PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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.23, at admin/class-knowledge-manager.php

10,341 lines 423.9 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 MxChat_Utils::extract_page_title($body_content) // the page's own title (plan d9ee66)
1867 );
1868
1869 if (is_wp_error($db_result)) {
1870 $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1871 set_transient('mxchat_admin_notice_error', $error_message, 30);
1872 } else {
1873 $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1874 set_transient('mxchat_admin_notice_success', $success_message, 30);
1875 }
1876
1877 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1878 exit;
1879 }
1880
1881
1882 public function mxchat_get_single_url_status() {
1883 $status = get_transient('mxchat_single_url_status');
1884 if (!$status) {
1885 return null;
1886 }
1887
1888 // Add human-readable time
1889 if (isset($status['timestamp'])) {
1890 $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1891 }
1892
1893 return $status;
1894 }
1895
1896 public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1897 if (!current_user_can('manage_options')) {
1898 return false;
1899 }
1900
1901 try {
1902 $sitemap_url = esc_url_raw($sitemap_url);
1903
1904 if (!$xml || !is_object($xml)) {
1905 throw new Exception(__('Invalid XML object provided', 'mxchat'));
1906 }
1907
1908 // Get bot-specific embedding API for validation
1909 $bot_options = $this->get_bot_options($bot_id);
1910 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1911
1912 // Test the embedding API before processing
1913 $test_phrase = "Test embedding generation for MxChat";
1914 $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1915
1916 if (is_string($test_result)) {
1917 throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1918 }
1919
1920 if (!is_array($test_result)) {
1921 throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1922 }
1923
1924 // Extract URLs from sitemap
1925 $urls = array();
1926 foreach ($xml->url as $url_element) {
1927 $url = esc_url_raw((string)$url_element->loc);
1928 if ($url) {
1929 $urls[] = array('url' => $url);
1930 }
1931 }
1932
1933 $total_urls = count($urls);
1934
1935 if ($total_urls < 1) {
1936 throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1937 }
1938
1939 // Create unique queue ID
1940 $queue_id = 'sitemap_' . md5($sitemap_url . time());
1941
1942 // Add URLs to queue
1943 $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1944
1945 if ($queued_count === 0) {
1946 throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1947 }
1948
1949 // Store queue metadata
1950 $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1951 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1952 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1953 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1954 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1955
1956 // Store queue ID in transient for status tracking
1957 set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
1958 set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
1959
1960 return 'queued';
1961
1962 } catch (Exception $e) {
1963 $error_message = $e->getMessage();
1964 //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
1965
1966 return $error_message;
1967 }
1968
1969 }
1970
1971 /**
1972 * Remove shortcode tags but preserve the content inside them
1973 * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
1974 *
1975 * @param string $content The content containing shortcodes
1976 * @return string Content with shortcode tags removed but inner content preserved
1977 */
1978 /**
1979 * Single-pass HTML entity decode for text entering the knowledge base.
1980 * The corpus should hold what a human reads: a stored `&amp;` consumes
1981 * extra tokens, distorts the vector away from the form a visitor's
1982 * question uses, and can be quoted back verbatim in an answer.
1983 * Deliberately NOT looped to a fixed point — a stored `&amp;amp;` is a
1984 * legitimate literal `&amp;` and must not collapse further (data loss).
1985 * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
1986 * paths call this at their output points so the treatment cannot drift.
1987 * (Plan d2c92e.)
1988 */
1989 private function mxchat_decode_entities_for_indexing($text) {
1990 return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1991 }
1992
1993 /**
1994 * Price lines for a product's indexed text, pinned to the store's BASE currency.
1995 *
1996 * The four product assembly paths each used to call get_woocommerce_currency_symbol()
1997 * with no argument, which resolves the currency active on the CURRENT request.
1998 * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
1999 * request, so whichever currency the store happened to be serving when an import ran
2000 * was frozen into every product it indexed. The amounts have the mirror problem: the
2001 * woocommerce_product_get_* filters convert prices in the 'view' context but not in
2002 * 'edit', so a converted amount could be paired with an unconverted symbol and produce
2003 * a price that is not merely wrong but incoherent.
2004 *
2005 * Base currency option + 'edit' context makes both halves agree and makes the output
2006 * independent of when the import ran. The currency CODE is emitted alongside the symbol
2007 * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
2008 * (Plan 7403ec.)
2009 */
2010 private function mxchat_product_price_lines($product) {
2011 if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
2012 return '';
2013 }
2014
2015 $currency = get_option('woocommerce_currency');
2016 $currency = is_string($currency) ? trim($currency) : '';
2017 $symbol = ($currency !== '')
2018 ? get_woocommerce_currency_symbol($currency)
2019 : get_woocommerce_currency_symbol();
2020 $symbol = $this->mxchat_decode_entities_for_indexing($symbol);
2021
2022 $regular_price = $product->get_regular_price('edit');
2023 $sale_price = $product->get_sale_price('edit');
2024 $price = $product->get_price('edit');
2025
2026 $lines = '';
2027
2028 if (!empty($regular_price)) {
2029 $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
2030 } elseif (!empty($price)) {
2031 $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
2032 }
2033
2034 if (!empty($sale_price) && $sale_price !== $regular_price) {
2035 $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
2036 }
2037
2038 if ($product->is_type('variable')) {
2039 list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
2040 if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
2041 $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
2042 . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
2043 }
2044 }
2045
2046 return $lines;
2047 }
2048
2049 /**
2050 * One indexed price amount, labelled with its currency code.
2051 *
2052 * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
2053 * is kept so a quoted price still reads naturally. Falls back to the old symbol-only
2054 * shape when WooCommerce has no base currency configured, and drops the parenthetical
2055 * when the symbol is absent or IS the code (several currencies have no distinct glyph).
2056 */
2057 private function mxchat_format_indexed_price($amount, $currency, $symbol) {
2058 $amount = (string) $amount;
2059
2060 if ($currency === '') {
2061 return $symbol . $amount;
2062 }
2063
2064 if ($symbol === '' || $symbol === $currency) {
2065 return $currency . ' ' . $amount;
2066 }
2067
2068 return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
2069 }
2070
2071 /**
2072 * Min/max variation price read from the variations themselves in 'edit' context.
2073 *
2074 * get_variation_price() reads WooCommerce's display price cache, which multi-currency
2075 * plugins populate with converted values — the same defect the rest of this helper
2076 * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
2077 * the store's own price formatting, and (null, null) when no variation carries a price.
2078 */
2079 private function mxchat_variation_price_range($product) {
2080 $min_raw = null;
2081 $max_raw = null;
2082 $min_val = null;
2083 $max_val = null;
2084
2085 $children = method_exists($product, 'get_children') ? $product->get_children() : array();
2086
2087 foreach ($children as $child_id) {
2088 $variation = wc_get_product($child_id);
2089 if (!$variation) {
2090 continue;
2091 }
2092 $raw = $variation->get_price('edit');
2093 if ($raw === '' || $raw === null) {
2094 continue;
2095 }
2096 $val = (float) $raw;
2097 if ($min_val === null || $val < $min_val) {
2098 $min_val = $val;
2099 $min_raw = $raw;
2100 }
2101 if ($max_val === null || $val > $max_val) {
2102 $max_val = $val;
2103 $max_raw = $raw;
2104 }
2105 }
2106
2107 return array($min_raw, $max_raw);
2108 }
2109
2110 private function strip_shortcode_tags_preserve_content($content) {
2111 // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
2112 // Content between tags is inherently preserved since only brackets are targeted
2113 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
2114 return ($result !== null) ? $result : $content;
2115 }
2116
2117 public function mxchat_sanitize_content_for_api($content) {
2118 //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
2119
2120 // Remove shortcode tags but PRESERVE content inside them
2121 $content = $this->strip_shortcode_tags_preserve_content($content);
2122
2123 // Remove script, style tags, and HTML comments
2124 $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
2125 $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
2126 $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
2127
2128 // Remove all HTML tags and decode HTML entities
2129 $content = wp_strip_all_tags($content);
2130 $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
2131
2132 // Normalize whitespace but preserve paragraph breaks
2133 // First, normalize line endings to \n
2134 $content = str_replace(["\r\n", "\r"], "\n", $content);
2135 // Replace multiple spaces/tabs with single space, but preserve newlines
2136 $content = preg_replace('/[ \t]+/', ' ', $content);
2137 // Replace 3+ newlines with 2 newlines (max 2 blank lines)
2138 $content = preg_replace('/\n{3,}/', "\n\n", $content);
2139 // Trim each line
2140 $lines = explode("\n", $content);
2141 $lines = array_map('trim', $lines);
2142 $content = implode("\n", $lines);
2143 // Final trim
2144 $content = trim($content);
2145
2146 // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
2147 $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
2148
2149 // Remove NULL bytes which can cause database errors
2150 $content = str_replace("\0", "", $content);
2151
2152 // Ensure valid UTF-8 encoding
2153 $content = wp_check_invalid_utf8($content);
2154
2155 // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
2156 // Counts CHARACTERS (/u), and never strips a run containing characters from a
2157 // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
2158 // where a normal paragraph is legitimately one unbroken run.
2159 $content = preg_replace_callback('/\S{300,}/u', function ($m) {
2160 return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
2161 }, $content);
2162
2163 // Remove emoji/symbol blocks only — not the whole supplementary plane, which
2164 // also holds CJK Extension B ideographs used in real Chinese/Japanese names.
2165 // A ZWJ (U+200D) BETWEEN stripped pictographs is consumed with them, so a
2166 // family sequence like 👨‍👩‍👧 leaves no invisible zero-width residue behind
2167 // (the joiner between NON-emoji characters — Hindi conjuncts — is untouched).
2168 $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}](?:\x{200D}[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}])*/u', '', $content);
2169
2170 // There is deliberately NO catch-all character allowlist here (plan 209e57;
2171 // one existed until 3.2.20). Every genuinely dangerous byte is already gone:
2172 // control characters, null bytes, invalid UTF-8 and the emoji blocks are all
2173 // stripped above. The allowlist's only remaining effect was to damage scripts
2174 // nobody thought to enumerate — Unicode Cf (Format) was missing, so it
2175 // replaced the zero-width joiner/non-joiner with spaces and silently split
2176 // Persian words (می‌روم → می روم) and broke Hindi conjuncts (क्‍ष → क् ष).
2177 // Do not add one back; the failure mode of an allowlist is exactly this.
2178
2179 // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
2180 // but cut on a character boundary so a multibyte char is never split mid-sequence)
2181 $max_length = 65000; // Just under MySQL TEXT field limit
2182 if (strlen($content) > $max_length) {
2183 $content = mb_strcut($content, 0, $max_length, 'UTF-8');
2184 }
2185
2186 //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
2187 return $content;
2188 }
2189 public function mxchat_extract_main_content($html) {
2190 if (empty($html)) {
2191 return '';
2192 }
2193 try {
2194 $dom = new DOMDocument;
2195 libxml_use_internal_errors(true); // Suppress HTML parsing errors
2196 @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2197 $xpath = new DOMXPath($dom);
2198
2199 // For debugging purposes
2200 $debugEnabled = true; // Set to true to enable debugging output
2201 $debug = function($message) use ($debugEnabled) {
2202 if ($debugEnabled) {
2203 //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
2204 }
2205 };
2206
2207 // Direct targeting for Gerow theme posts
2208 $post_text = $xpath->query('//div[contains(@class, "post-text")]');
2209 if ($post_text && $post_text->length > 0) {
2210 $debug("Found post-text directly");
2211 $content = '';
2212 foreach ($post_text as $node) {
2213 $content .= $dom->saveHTML($node);
2214 }
2215 if (!empty($content)) {
2216 $debug("Returning post-text content");
2217 return $content;
2218 }
2219 }
2220
2221 // Try to get the blog details content which contains the post-text
2222 $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
2223 if ($blog_details && $blog_details->length > 0) {
2224 $debug("Found blog-details-content");
2225 $content = '';
2226 foreach ($blog_details as $node) {
2227 $content .= $dom->saveHTML($node);
2228 }
2229 if (!empty($content)) {
2230 $debug("Returning blog-details-content");
2231 return $content;
2232 }
2233 }
2234
2235 // Try to get the article which contains the blog details
2236 $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
2237 if ($article && $article->length > 0) {
2238 $debug("Found article with blog-details-wrap");
2239 $content = '';
2240 foreach ($article as $node) {
2241 $content .= $dom->saveHTML($node);
2242 }
2243 if (!empty($content)) {
2244 $debug("Returning article content");
2245 return $content;
2246 }
2247 }
2248
2249 // Try even broader with the blog-item-wrap
2250 $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
2251 if ($blog_item && $blog_item->length > 0) {
2252 $debug("Found blog-item-wrap");
2253 $content = '';
2254 foreach ($blog_item as $node) {
2255 $content .= $dom->saveHTML($node);
2256 }
2257 if (!empty($content)) {
2258 $debug("Returning blog-item-wrap content");
2259 return $content;
2260 }
2261 }
2262
2263 // Specific Gerow theme path
2264 $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
2265 if ($gerow_path && $gerow_path->length > 0) {
2266 $debug("Found Gerow theme path to post-text");
2267 $content = '';
2268 foreach ($gerow_path as $node) {
2269 $content .= $dom->saveHTML($node);
2270 }
2271 if (!empty($content)) {
2272 $debug("Returning Gerow post-text content");
2273 return $content;
2274 }
2275 }
2276
2277 // Generic blog post selectors
2278 $selectors = [
2279 // Blog post specific selectors
2280 '//div[contains(@class, "post-text")]',
2281 '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
2282 '//div[contains(@class, "blog-details-content")]',
2283 '//article[contains(@class, "blog-details-wrap")]',
2284 '//div[contains(@class, "entry-content")]',
2285 '//div[contains(@class, "blog-content")]',
2286 '//div[contains(@class, "blog-item-wrap")]',
2287
2288 // More general content selectors
2289 '//div[contains(@class, "page__content")]',
2290 '//div[contains(@class, "elementor-widget-container")]',
2291 '//div[contains(@class, "elementor-text-editor")]',
2292 '//div[contains(@class, "elementor-widget-text-editor")]',
2293 '//*[contains(@class, "entry-content")]',
2294 '//*[contains(@class, "post-content")]',
2295 '//*[contains(@class, "article-content")]',
2296 '//*[@id="content"]',
2297 '//*[@id="main-content"]',
2298 '//section[contains(@class, "blog-area")]',
2299 '//article',
2300 '//main',
2301 '//div[contains(@class, "content")]'
2302 ];
2303
2304 // First handle Elementor content - get only leaf widget containers to avoid duplicates
2305 $debug("Checking for Elementor content");
2306 // Get widget containers that are direct children of widgets (not nested inside other widget containers)
2307 $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
2308 if ($elementor_widgets && $elementor_widgets->length > 0) {
2309 $debug("Found Elementor widgets");
2310 $seen_content = array(); // Track seen content to avoid duplicates
2311 $combined_content = '';
2312 foreach ($elementor_widgets as $widget) {
2313 $widget_content = $dom->saveHTML($widget);
2314 if (!empty($widget_content)) {
2315 // Create a hash of the content to detect duplicates
2316 $content_hash = md5($widget_content);
2317 if (!isset($seen_content[$content_hash])) {
2318 $seen_content[$content_hash] = true;
2319 $combined_content .= $widget_content;
2320 }
2321 }
2322 }
2323 if (!empty($combined_content)) {
2324 $debug("Returning Elementor content");
2325 return $combined_content;
2326 }
2327 }
2328
2329 // Try standard selectors one by one
2330 foreach ($selectors as $selector) {
2331 $debug("Trying selector: " . $selector);
2332 $nodes = $xpath->query($selector);
2333 if ($nodes && $nodes->length > 0) {
2334 $debug("Found " . $nodes->length . " matches for selector: " . $selector);
2335 // Only take the FIRST matching node to avoid duplicate content
2336 // (pages often have nested or multiple containers with same class)
2337 $content = $dom->saveHTML($nodes->item(0));
2338 if (!empty($content)) {
2339 $debug("Returning content from selector: " . $selector . " (first match only)");
2340 return $content;
2341 }
2342 }
2343 }
2344
2345 // Manual regex fallback for post-text if DOM methods fail
2346 $debug("Trying regex fallback");
2347 if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
2348 $debug("Found post-text via regex");
2349 return '<div class="post-text">' . $matches[1] . '</div>';
2350 }
2351
2352 // Try to extract the blog section as a whole
2353 $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
2354 if ($blog_section && $blog_section->length > 0) {
2355 $debug("Found blog-area section");
2356 $content = '';
2357 foreach ($blog_section as $node) {
2358 $content .= $dom->saveHTML($node);
2359 }
2360 if (!empty($content)) {
2361 $debug("Returning blog-area section content");
2362 return $content;
2363 }
2364 }
2365
2366 // Generic container selectors for non-CMS sites (like .asp pages)
2367 $debug("Trying generic container selectors");
2368 $generic_selectors = [
2369 '//div[@id="main"]',
2370 '//div[@id="wrapper"]',
2371 '//div[@id="page"]',
2372 '//div[@id="site-content"]',
2373 '//div[contains(@class, "main-content")]',
2374 '//div[contains(@class, "page-content")]',
2375 '//div[contains(@class, "site-content")]',
2376 ];
2377
2378 foreach ($generic_selectors as $selector) {
2379 $debug("Trying generic selector: " . $selector);
2380 $nodes = $xpath->query($selector);
2381 if ($nodes && $nodes->length > 0) {
2382 $content = $dom->saveHTML($nodes->item(0));
2383 if (!empty($content)) {
2384 $debug("Returning content from generic selector: " . $selector);
2385 return $content;
2386 }
2387 }
2388 }
2389
2390 // Paragraph-based content detection - find regions with substantial text
2391 $debug("Trying paragraph-based content detection");
2392 $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
2393 if ($paragraphs && $paragraphs->length >= 3) {
2394 $debug("Found " . $paragraphs->length . " substantial paragraphs");
2395 // Collect all substantial paragraphs and their content
2396 $paragraph_content = '';
2397 foreach ($paragraphs as $p) {
2398 $paragraph_content .= $dom->saveHTML($p) . "\n";
2399 }
2400 if (!empty($paragraph_content)) {
2401 $debug("Returning paragraph-based content");
2402 return $paragraph_content;
2403 }
2404 }
2405
2406 // Improved body fallback - strip nav/header/footer elements first
2407 $debug("Using improved body fallback");
2408 $body = $dom->getElementsByTagName('body');
2409 if ($body->length > 0) {
2410 // Clone the body to avoid modifying the original DOM
2411 $body_clone = $body->item(0)->cloneNode(true);
2412
2413 // Remove common non-content elements by tag name
2414 $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
2415 foreach ($remove_tags as $tag) {
2416 $elements = $body_clone->getElementsByTagName($tag);
2417 // Iterate backwards to safely remove elements
2418 for ($i = $elements->length - 1; $i >= 0; $i--) {
2419 $el = $elements->item($i);
2420 if ($el && $el->parentNode) {
2421 $el->parentNode->removeChild($el);
2422 }
2423 }
2424 }
2425
2426 // Remove elements with common non-content class names using XPath on the cloned body
2427 $temp_dom = new DOMDocument();
2428 @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
2429 $temp_xpath = new DOMXPath($temp_dom);
2430
2431 $remove_class_patterns = [
2432 '//*[contains(@class, "nav")]',
2433 '//*[contains(@class, "menu")]',
2434 '//*[contains(@class, "sidebar")]',
2435 '//*[contains(@class, "footer")]',
2436 '//*[contains(@class, "header")]',
2437 '//*[contains(@id, "nav")]',
2438 '//*[contains(@id, "menu")]',
2439 '//*[contains(@id, "sidebar")]',
2440 '//*[contains(@id, "footer")]',
2441 '//*[contains(@id, "header")]',
2442 ];
2443
2444 foreach ($remove_class_patterns as $pattern) {
2445 $elements = $temp_xpath->query($pattern);
2446 if ($elements) {
2447 for ($i = $elements->length - 1; $i >= 0; $i--) {
2448 $el = $elements->item($i);
2449 if ($el && $el->parentNode) {
2450 $el->parentNode->removeChild($el);
2451 }
2452 }
2453 }
2454 }
2455
2456 $cleaned_content = $temp_dom->saveHTML();
2457 if (!empty($cleaned_content)) {
2458 $debug("Returning cleaned body content");
2459 return $cleaned_content;
2460 }
2461 }
2462
2463 // Last resort: return the original HTML
2464 $debug("Returning original HTML");
2465 return $html;
2466 } catch (Exception $e) {
2467 //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
2468 return $html; // Return original HTML if parsing fails
2469 } finally {
2470 libxml_clear_errors();
2471 }
2472 }
2473 public function mxchat_get_sitemap_processing_status($sitemap_url) {
2474 $sitemap_url = esc_url_raw($sitemap_url);
2475 $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
2476 $status = get_transient($status_key);
2477
2478 if (!$status || !is_array($status)) {
2479 return false;
2480 }
2481
2482 // Auto-complete check: if all URLs are processed but status isn't complete
2483 if (isset($status['processed_urls']) && isset($status['total_urls']) &&
2484 $status['processed_urls'] >= $status['total_urls'] &&
2485 isset($status['status']) && $status['status'] !== 'complete' &&
2486 $status['status'] !== 'error') {
2487
2488 // Mark as complete
2489 $status['status'] = 'complete';
2490 $status['processed_urls'] = $status['total_urls']; // Ensure exact match
2491
2492 // Update the transient with the corrected status
2493 set_transient($status_key, $status, DAY_IN_SECONDS);
2494 }
2495
2496 return array(
2497 'total_urls' => absint($status['total_urls']),
2498 'processed_urls' => absint($status['processed_urls']),
2499 'failed_urls' => absint($status['failed_urls'] ?? 0),
2500 'percentage' => ($status['total_urls'] > 0)
2501 ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
2502 : 0,
2503 'status' => sanitize_text_field($status['status']),
2504 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
2505 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
2506 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
2507 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
2508 );
2509 }
2510
2511 public function mxchat_ajax_get_status_updates() {
2512 try {
2513 // Verify the request
2514 check_ajax_referer('mxchat_status_nonce', 'nonce');
2515
2516 // Get active queue IDs
2517 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2518 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2519
2520 $sitemap_status = false;
2521 $pdf_status = false;
2522
2523 // Get sitemap queue status
2524 if ($sitemap_queue_id) {
2525 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2526 }
2527
2528 // Get PDF queue status
2529 if ($pdf_queue_id) {
2530 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2531 }
2532
2533 $is_active_processing =
2534 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2535 ($pdf_status && $pdf_status['status'] === 'processing');
2536
2537 // Return JSON response with the status data
2538 wp_send_json(array(
2539 'pdf_status' => $pdf_status,
2540 'sitemap_status' => $sitemap_status,
2541 'is_processing' => $is_active_processing,
2542 'sitemap_queue_id' => $sitemap_queue_id,
2543 'pdf_queue_id' => $pdf_queue_id
2544 ));
2545
2546 } catch (Exception $e) {
2547 //error_log('MxChat Status Update Error: ' . $e->getMessage());
2548
2549 wp_send_json_error(array(
2550 'message' => 'Error getting status updates: ' . $e->getMessage(),
2551 'status' => 'error'
2552 ));
2553 }
2554 }
2555
2556 /**
2557 * Helper function to get queue status data
2558 */
2559 private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
2560 global $wpdb;
2561 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2562
2563 // Get counts by status
2564 $counts = $wpdb->get_results($wpdb->prepare(
2565 "SELECT status, COUNT(*) as count
2566 FROM $table_name
2567 WHERE queue_id = %s
2568 GROUP BY status",
2569 $queue_id
2570 ), OBJECT_K);
2571
2572 $total = 0;
2573 $completed = 0;
2574 $failed = 0;
2575 $processing = 0;
2576 $pending = 0;
2577
2578 foreach ($counts as $status => $data) {
2579 $count = absint($data->count);
2580 $total += $count;
2581
2582 switch ($status) {
2583 case 'completed':
2584 $completed = $count;
2585 break;
2586 case 'failed':
2587 $failed = $count;
2588 break;
2589 case 'processing':
2590 $processing = $count;
2591 break;
2592 case 'pending':
2593 $pending = $count;
2594 break;
2595 }
2596 }
2597
2598 if ($total === 0) {
2599 return false;
2600 }
2601
2602 // Calculate percentage
2603 $percentage = round((($completed + $failed) / $total) * 100);
2604
2605 // Get failed items details (limit to 50)
2606 $failed_items = array();
2607 if ($failed > 0) {
2608 $failed_results = $wpdb->get_results($wpdb->prepare(
2609 "SELECT item_type, item_data, error_message, attempts, completed_at
2610 FROM $table_name
2611 WHERE queue_id = %s
2612 AND status = 'failed'
2613 AND attempts >= max_attempts
2614 ORDER BY id DESC
2615 LIMIT 50",
2616 $queue_id
2617 ));
2618
2619 foreach ($failed_results as $item) {
2620 $data = json_decode($item->item_data, true);
2621 $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
2622
2623 $failed_items[] = array(
2624 'url' => $url,
2625 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
2626 'error' => $item->error_message,
2627 'retries' => $item->attempts,
2628 'time' => strtotime($item->completed_at)
2629 );
2630 }
2631 }
2632
2633 // Get queue metadata
2634 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
2635
2636 // Determine if queue is complete
2637 $is_complete = ($pending === 0 && $processing === 0);
2638
2639 // Get last update time
2640 $last_update = $wpdb->get_var($wpdb->prepare(
2641 "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
2642 FROM $table_name
2643 WHERE queue_id = %s",
2644 $queue_id
2645 ));
2646
2647 $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
2648
2649 // Format based on type
2650 if ($type === 'pdf') {
2651 return array(
2652 'total_pages' => $total,
2653 'processed_pages' => $completed + $failed,
2654 'failed_pages' => $failed,
2655 'percentage' => $percentage,
2656 'status' => $is_complete ? 'complete' : 'processing',
2657 'last_update' => $last_update_text,
2658 'failed_pages_list' => $failed_items,
2659 'pdf_url' => $source_url,
2660 'queue_id' => $queue_id
2661 );
2662 } else {
2663 return array(
2664 'total_urls' => $total,
2665 'processed_urls' => $completed + $failed,
2666 'failed_urls' => $failed,
2667 'percentage' => $percentage,
2668 'status' => $is_complete ? 'complete' : 'processing',
2669 'last_update' => $last_update_text,
2670 'failed_urls_list' => $failed_items,
2671 'sitemap_url' => $source_url,
2672 'queue_id' => $queue_id
2673 );
2674 }
2675 }
2676
2677 /**
2678 * Public method to get processing status for both sitemap and PDF queues
2679 * Used by admin pages to display processing status
2680 *
2681 * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2682 */
2683 public function mxchat_get_processing_statuses() {
2684 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2685 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2686
2687 $sitemap_status = false;
2688 $pdf_status = false;
2689
2690 if ($sitemap_queue_id) {
2691 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2692 }
2693
2694 if ($pdf_queue_id) {
2695 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2696 }
2697
2698 $is_processing =
2699 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2700 ($pdf_status && $pdf_status['status'] === 'processing');
2701
2702 return array(
2703 'sitemap_status' => $sitemap_status,
2704 'pdf_status' => $pdf_status,
2705 'is_processing' => $is_processing
2706 );
2707 }
2708
2709 /**
2710 * AJAX handler to get recent knowledge entries for real-time table updates
2711 * UPDATED: Now supports both WordPress DB and Pinecone data sources
2712 */
2713 public function ajax_mxchat_get_recent_entries() {
2714 check_ajax_referer('mxchat_entries_nonce', 'nonce');
2715
2716 if (!current_user_can('manage_options')) {
2717 wp_send_json_error(array('message' => 'Unauthorized'));
2718 return;
2719 }
2720
2721 global $wpdb;
2722 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2723
2724 // Get parameters
2725 $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2726 $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2727 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2728
2729 // Check if Pinecone is enabled for this bot
2730 $pinecone_manager = $this->mxchat_get_pinecone_manager();
2731 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2732 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2733 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2734
2735 if ($use_pinecone && $has_pinecone_api) {
2736 // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2737 // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2738 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2739 $total_count = $records['total'] ?? 0;
2740
2741 // For Pinecone, we don't return individual entries during polling
2742 // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2743 // We just return the updated count. Over 500 vectors that is the live
2744 // index / namespace vector count climbing during an import (plan dd6e10).
2745 wp_send_json_success(array(
2746 'entries' => array(),
2747 'total_count' => absint($total_count),
2748 'count_unit' => $records['count_unit'] ?? '',
2749 'max_id' => $last_id,
2750 'data_source' => 'pinecone'
2751 ));
2752 return;
2753 }
2754
2755 // WORDPRESS DB DATA SOURCE
2756 // Build query to get entries newer than last_id
2757 $where_clauses = array('1=1');
2758 $where_values = array();
2759
2760 if ($last_id > 0) {
2761 $where_clauses[] = 'id > %d';
2762 $where_values[] = $last_id;
2763 }
2764
2765 // Note: WordPress DB table doesn't have bot_id column
2766 // Multi-bot filtering is handled via Pinecone namespaces
2767
2768 $where_sql = implode(' AND ', $where_clauses);
2769
2770 // Get recent entries
2771 $query = "SELECT id, article_content, source_url, timestamp
2772 FROM $table_name
2773 WHERE $where_sql
2774 ORDER BY id DESC
2775 LIMIT %d";
2776
2777 $where_values[] = $limit;
2778
2779 $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2780
2781 // Get total count of GROUPED entries (by source_url) - matches pagination display
2782 // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2783 $total_count = $wpdb->get_var(
2784 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2785 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2786 );
2787
2788 // Format entries for response
2789 $formatted_entries = array();
2790 $preview_length = 150;
2791 foreach ($entries as $entry) {
2792 // Parse chunk metadata using the proper chunker method (same as initial page load)
2793 if (class_exists('MxChat_Chunker')) {
2794 $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2795 $display_content = $chunk_meta['text'];
2796 $chunk_metadata = $chunk_meta['metadata'];
2797 } else {
2798 $display_content = $entry->article_content;
2799 $chunk_metadata = array();
2800 }
2801
2802 $content_preview = mb_strlen($display_content) > $preview_length
2803 ? mb_substr($display_content, 0, $preview_length) . '...'
2804 : $display_content;
2805
2806 $formatted_entries[] = array(
2807 'id' => $entry->id,
2808 'preview' => esc_html($content_preview),
2809 'full_content' => wp_kses_post(wpautop($display_content)),
2810 'content_length' => mb_strlen($display_content),
2811 'preview_length' => $preview_length,
2812 'source_url' => $entry->source_url,
2813 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2814 'chunk_metadata' => $chunk_metadata,
2815 'bot_id' => $entry->bot_id ?? 'default',
2816 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2817 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2818 );
2819 }
2820
2821 wp_send_json_success(array(
2822 'entries' => $formatted_entries,
2823 'total_count' => absint($total_count),
2824 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2825 'data_source' => 'wordpress'
2826 ));
2827 }
2828
2829 /**
2830 * Get Pinecone total count from stats API
2831 * Helper function for ajax_mxchat_get_recent_entries
2832 */
2833 private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2834 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2835 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2836 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2837
2838 if (empty($api_key) || empty($host)) {
2839 return 0;
2840 }
2841
2842 // Document index (plan 362c31).
2843 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
2844 return MxChat_Pinecone_Documents::count(MxChat_Pinecone_Documents::cfg_from_options($pinecone_options));
2845 }
2846
2847 try {
2848 $stats_url = "https://{$host}/describe_index_stats";
2849
2850 $response = wp_remote_post($stats_url, array(
2851 'headers' => array(
2852 'Api-Key' => $api_key,
2853 'Content-Type' => 'application/json'
2854 ),
2855 'body' => '{}',
2856 'timeout' => 10
2857 ));
2858
2859 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2860 $body = wp_remote_retrieve_body($response);
2861 $stats_data = json_decode($body, true);
2862
2863 // If namespace is specified, get count from that specific namespace
2864 if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
2865 return intval($stats_data['namespaces'][$namespace]['vectorCount']);
2866 }
2867
2868 // If no namespace specified or namespace not found in response, use total
2869 return intval($stats_data['totalVectorCount'] ?? 0);
2870 }
2871
2872 return 0;
2873
2874 } catch (Exception $e) {
2875 return 0;
2876 }
2877 }
2878
2879 /**
2880 * AJAX handler to refresh Pinecone entries table via AJAX
2881 * Returns the table HTML for updating the UI without a full page reload
2882 */
2883 public function ajax_mxchat_refresh_pinecone_entries() {
2884 check_ajax_referer('mxchat_entries_nonce', 'nonce');
2885
2886 if (!current_user_can('manage_options')) {
2887 wp_send_json_error(array('message' => 'Unauthorized'));
2888 return;
2889 }
2890
2891 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2892 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2893 $per_page = 25;
2894 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2895 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2896
2897 // Get Pinecone manager and options
2898 $pinecone_manager = $this->mxchat_get_pinecone_manager();
2899 if (!$pinecone_manager) {
2900 wp_send_json_error(array('message' => 'Pinecone manager not available'));
2901 return;
2902 }
2903
2904 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
2905 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2906 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2907
2908 if (!$use_pinecone || empty($pinecone_api_key)) {
2909 wp_send_json_error(array('message' => 'Pinecone not configured'));
2910 return;
2911 }
2912
2913 // Fetch records from Pinecone
2914 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2915 $prompts = $records['data'] ?? array();
2916 $total_records = $records['total'] ?? 0;
2917
2918 // Preprocess Pinecone records — set chunk_metadata and display_content
2919 // (matches admin-knowledge-page.php preprocessing)
2920 foreach ($prompts as $prompt) {
2921 if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2922 $prompt->chunk_metadata = array(
2923 'chunk_index' => intval($prompt->chunk_index),
2924 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2925 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2926 'source_url' => $prompt->source_url ?? ''
2927 );
2928 $prompt->display_content = $prompt->article_content;
2929 } else {
2930 $prompt->chunk_metadata = array();
2931 $prompt->display_content = $prompt->article_content ?? '';
2932 }
2933 }
2934
2935 // Group prompts by source_url
2936 $grouped_prompts = array();
2937 foreach ($prompts as $prompt) {
2938 $source_url = '';
2939 if (!empty($prompt->chunk_metadata['source_url'])) {
2940 $source_url = $prompt->chunk_metadata['source_url'];
2941 } elseif (!empty($prompt->source_url)) {
2942 $source_url = $prompt->source_url;
2943 }
2944
2945 if (!empty($source_url)) {
2946 if (!isset($grouped_prompts[$source_url])) {
2947 $grouped_prompts[$source_url] = array();
2948 }
2949 $grouped_prompts[$source_url][] = $prompt;
2950 } else {
2951 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2952 }
2953 }
2954
2955 // Sort each group by chunk_index
2956 foreach ($grouped_prompts as $source_url => &$group) {
2957 usort($group, function($a, $b) {
2958 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2959 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2960 return $index_a - $index_b;
2961 });
2962 }
2963 unset($group);
2964
2965 // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
2966 ob_start();
2967 $display_index = 0;
2968 $current_page = $page;
2969 $data_source = 'pinecone';
2970 $current_bot_id = $bot_id;
2971 $preview_length = 150;
2972
2973 if (empty($grouped_prompts)) {
2974 echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2975 esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
2976 echo '</td></tr>';
2977 } else {
2978 foreach ($grouped_prompts as $source_url => $group) {
2979 $chunk_count = count($group);
2980 $first_prompt = $group[0];
2981 $display_index++;
2982
2983 if ($chunk_count > 1) {
2984 // Multiple chunks - show grouped row with expand button
2985 $group_id = 'group-' . md5($source_url);
2986 ?>
2987 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2988 class="mxchat-chunk-group-header"
2989 data-source="<?php echo esc_attr($data_source); ?>"
2990 data-group-id="<?php echo esc_attr($group_id); ?>"
2991 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2992 <td style="padding: 12px 16px; text-align: center;">
2993 <input type="checkbox"
2994 class="mxchat-entry-checkbox"
2995 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2996 data-source="<?php echo esc_attr($data_source); ?>"
2997 data-source-url="<?php echo esc_attr($source_url); ?>"
2998 data-is-group="true"
2999 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3000 </td>
3001 <td style="padding: 12px 16px; font-size: 13px;">
3002 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3003 </td>
3004 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3005 <div class="mxchat-chunk-group-info">
3006 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3007 <span class="dashicons dashicons-arrow-right-alt2"></span>
3008 </button>
3009 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3010 <span class="mxchat-chunk-preview">
3011 <?php
3012 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
3013 $content_preview = mb_substr($parent_content, 0, 100);
3014 echo esc_html($content_preview . '...');
3015 ?>
3016 </span>
3017 </div>
3018 </td>
3019 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3020 <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3021 <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3022 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3023 <?php esc_html_e('View Source', 'mxchat'); ?>
3024 </a>
3025 <?php else : ?>
3026 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3027 <?php endif; ?>
3028 </td>
3029 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3030 <button type="button"
3031 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3032 data-source-url="<?php echo esc_attr($source_url); ?>"
3033 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3034 data-data-source="<?php echo esc_attr($data_source); ?>"
3035 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3036 data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3037 title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3038 <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3039 </button>
3040 <button type="button"
3041 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3042 data-source-url="<?php echo esc_attr($source_url); ?>"
3043 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3044 data-data-source="<?php echo esc_attr($data_source); ?>"
3045 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3046 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3047 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3048 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3049 </button>
3050 <button type="button"
3051 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3052 data-source-url="<?php echo esc_attr($source_url); ?>"
3053 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3054 data-data-source="<?php echo esc_attr($data_source); ?>"
3055 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3056 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3057 style="color: var(--mxch-error);"
3058 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3059 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3060 </button>
3061 </td>
3062 </tr>
3063 <?php
3064 // Render hidden chunk rows
3065 foreach ($group as $chunk_index => $chunk) {
3066 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3067 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3068 $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
3069 $content_preview = mb_strlen($content) > $preview_length
3070 ? mb_substr($content, 0, $preview_length) . '...'
3071 : $content;
3072 ?>
3073 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3074 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3075 data-source="<?php echo esc_attr($data_source); ?>"
3076 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3077 <td style="padding: 12px 16px; text-align: center;">
3078 <!-- Checkbox column placeholder for chunks (managed by group) -->
3079 </td>
3080 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3081 <!-- Hidden ID column for chunks -->
3082 </td>
3083 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3084 <div class="mxchat-accordion-wrapper">
3085 <div class="mxchat-content-preview">
3086 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3087 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3088 </span>
3089 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3090 <?php if (mb_strlen($content) > $preview_length) : ?>
3091 <button class="mxchat-expand-toggle" type="button">
3092 <span class="dashicons dashicons-arrow-down-alt2"></span>
3093 </button>
3094 <?php endif; ?>
3095 </div>
3096 <div class="mxchat-content-full" style="display: none;">
3097 <div class="content-view">
3098 <?php
3099 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3100 echo '<div dir="rtl" lang="he" class="rtl-content">';
3101 echo wp_kses_post(wpautop($content));
3102 echo '</div>';
3103 } else {
3104 echo wp_kses_post(wpautop($content));
3105 }
3106 ?>
3107 </div>
3108 </div>
3109 </div>
3110 </td>
3111 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3112 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3113 </td>
3114 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3115 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3116 </td>
3117 </tr>
3118 <?php
3119 }
3120 } else {
3121 // Single entry - display normally with accordion
3122 $prompt = $first_prompt;
3123 $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
3124 $content_preview = mb_strlen($content) > $preview_length
3125 ? mb_substr($content, 0, $preview_length) . '...'
3126 : $content;
3127 ?>
3128 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3129 data-source="<?php echo esc_attr($data_source); ?>"
3130 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
3131 <td style="padding: 12px 16px; text-align: center;">
3132 <input type="checkbox"
3133 class="mxchat-entry-checkbox"
3134 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3135 data-source="<?php echo esc_attr($data_source); ?>"
3136 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3137 data-is-group="false"
3138 data-chunk-count="1">
3139 </td>
3140 <td style="padding: 12px 16px; font-size: 13px;">
3141 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3142 </td>
3143 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3144 <div class="mxchat-accordion-wrapper">
3145 <div class="mxchat-content-preview">
3146 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3147 <?php if (mb_strlen($content) > $preview_length) : ?>
3148 <button class="mxchat-expand-toggle" type="button">
3149 <span class="dashicons dashicons-arrow-down-alt2"></span>
3150 </button>
3151 <?php endif; ?>
3152 </div>
3153 <div class="mxchat-content-full" style="display: none;">
3154 <div class="content-view">
3155 <?php
3156 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3157 echo '<div dir="rtl" lang="he" class="rtl-content">';
3158 echo wp_kses_post(wpautop($content));
3159 echo '</div>';
3160 } else {
3161 echo wp_kses_post(wpautop($content));
3162 }
3163 ?>
3164 </div>
3165 </div>
3166 </div>
3167 </td>
3168 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3169 <?php
3170 $actual_source = $source_url;
3171 if (strpos($source_url, '_ungrouped_') === 0) {
3172 $actual_source = $prompt->source_url ?? '';
3173 }
3174 if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3175 <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3176 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3177 <?php esc_html_e('View', 'mxchat'); ?>
3178 </a>
3179 <?php else : ?>
3180 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3181 <?php endif; ?>
3182 </td>
3183 <td style="padding: 12px 16px; white-space: nowrap;">
3184 <button type="button"
3185 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3186 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3187 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3188 data-data-source="<?php echo esc_attr($data_source); ?>"
3189 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3190 data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3191 title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3192 <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3193 </button>
3194 <button type="button"
3195 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3196 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3197 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3198 data-data-source="<?php echo esc_attr($data_source); ?>"
3199 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3200 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3201 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3202 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3203 </button>
3204 <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);">
3205 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3206 </button>
3207 </td>
3208 </tr>
3209 <?php
3210 }
3211 }
3212 }
3213 $html = ob_get_clean();
3214
3215 // Generate pagination HTML for Pinecone
3216 $total_pages = ceil($total_records / $per_page);
3217 $count_unit = $records['count_unit'] ?? '';
3218 $cursor_pagination = !empty($records['cursor_pagination']);
3219 $has_next = !empty($records['has_next']);
3220 $pagination_html = '';
3221 if ($cursor_pagination) {
3222 // Plan dd6e10: cursor path — Previous / Next only, no numbered pages.
3223 $pagination_html = self::mxchat_cursor_pagination_html($page, $has_next, $search_query, $content_type_filter);
3224 } elseif ($total_pages > 1) {
3225 $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) . '">';
3226
3227 // Previous button
3228 if ($page > 1) {
3229 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3230 }
3231
3232 // Page numbers
3233 $start_page = max(1, $page - 2);
3234 $end_page = min($total_pages, $page + 2);
3235
3236 if ($start_page > 1) {
3237 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3238 if ($start_page > 2) {
3239 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3240 }
3241 }
3242
3243 for ($i = $start_page; $i <= $end_page; $i++) {
3244 if ($i == $page) {
3245 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3246 } else {
3247 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3248 }
3249 }
3250
3251 if ($end_page < $total_pages) {
3252 if ($end_page < $total_pages - 1) {
3253 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3254 }
3255 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3256 }
3257
3258 // Next button
3259 if ($page < $total_pages) {
3260 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3261 }
3262
3263 $pagination_html .= '</div>';
3264 }
3265
3266 wp_send_json_success(array(
3267 'html' => $html,
3268 'pagination_html' => $pagination_html,
3269 'total_count' => $total_records,
3270 'count_unit' => $count_unit,
3271 'total_pages' => $total_pages,
3272 'page' => $page,
3273 'per_page' => $per_page,
3274 'data_source' => 'pinecone'
3275 ));
3276 }
3277
3278 /**
3279 * Previous / Next markup for the Pinecone cursor path (plan dd6e10).
3280 *
3281 * Pinecone's /vectors/list has no random access, so numbered pages would be a
3282 * fiction; the page number here is only the cursor step, which the manager
3283 * maps to a stored paginationToken. Used by the page template on first render
3284 * and by the AJAX paginator, so the two can never drift.
3285 */
3286 public static function mxchat_cursor_pagination_html($page, $has_next, $search_query = '', $content_type_filter = '') {
3287 $page = max(1, absint($page));
3288 if ($page <= 1 && !$has_next) {
3289 return '';
3290 }
3291 $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) . '">';
3292 if ($page > 1) {
3293 $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3294 }
3295 $html .= '<span class="mxchat-page-current">' . esc_html($page) . '</span> ';
3296 if ($has_next) {
3297 $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3298 }
3299 $html .= '</div>';
3300 return $html;
3301 }
3302
3303 /**
3304 * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
3305 * Returns paginated entries without requiring a full page reload
3306 */
3307 public function ajax_mxchat_paginate_entries() {
3308 check_ajax_referer('mxchat_entries_nonce', 'nonce');
3309
3310 if (!current_user_can('manage_options')) {
3311 wp_send_json_error(array('message' => 'Unauthorized'));
3312 return;
3313 }
3314
3315 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
3316 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3317 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3318 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
3319 $per_page = 25;
3320
3321 // Check if Pinecone is enabled for this bot
3322 $pinecone_manager = $this->mxchat_get_pinecone_manager();
3323 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
3324 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3325 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
3326
3327 if ($use_pinecone && $has_pinecone_api) {
3328 // Delegate to Pinecone pagination handler (pass search params)
3329 $_POST['page'] = $page;
3330 $_POST['search'] = $search_query;
3331 $_POST['content_type'] = $content_type_filter;
3332 $this->ajax_mxchat_refresh_pinecone_entries();
3333 return;
3334 }
3335
3336 // WordPress DB pagination - MUST match initial page load logic exactly
3337 global $wpdb;
3338 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3339 $offset = ($page - 1) * $per_page;
3340
3341 // Build WHERE clause for search and content type filtering
3342 $where_clauses = array();
3343 $where_values = array();
3344
3345 if ($search_query) {
3346 $where_clauses[] = "article_content LIKE %s";
3347 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
3348 }
3349
3350 if ($content_type_filter) {
3351 switch ($content_type_filter) {
3352 case 'manual':
3353 $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
3354 break;
3355 case 'pdf':
3356 $where_clauses[] = "source_url LIKE '%.pdf'";
3357 break;
3358 case 'url':
3359 $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
3360 break;
3361 }
3362 }
3363
3364 $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
3365
3366 // Count grouped entries with filters applied
3367 if (!empty($where_values)) {
3368 $count_args = array_merge($where_values, $where_values);
3369 $count_query = $wpdb->prepare(
3370 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3371 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
3372 ...$count_args
3373 );
3374 $total_records = $wpdb->get_var($count_query);
3375 } else if (!empty($where_sql)) {
3376 // Content type filter only (no search), no prepared values needed
3377 $total_records = $wpdb->get_var(
3378 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3379 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
3380 );
3381 } else {
3382 // No filters
3383 $total_records = $wpdb->get_var(
3384 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3385 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
3386 );
3387 }
3388 $total_pages = ceil($total_records / $per_page);
3389
3390 // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
3391 if (!empty($where_values)) {
3392 $query_args = array_merge($where_values, array($per_page, $offset));
3393 $urls_query = $wpdb->prepare(
3394 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3395 {$where_sql}
3396 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3397 ...$query_args
3398 );
3399 } else if (!empty($where_sql)) {
3400 $urls_query = $wpdb->prepare(
3401 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3402 {$where_sql}
3403 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3404 $per_page, $offset
3405 );
3406 } else {
3407 $urls_query = $wpdb->prepare(
3408 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3409 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3410 $per_page, $offset
3411 );
3412 }
3413 $page_urls = $wpdb->get_results($urls_query);
3414
3415 // Step 2: Build list of source_urls to fetch
3416 $url_list = array();
3417 $url_order_map = array();
3418 $order_index = 0;
3419 foreach ($page_urls as $url_row) {
3420 $url = $url_row->source_url;
3421 $url_list[] = $url;
3422 $url_order_map[$url] = $order_index++;
3423 }
3424
3425 // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
3426 $prompts = array();
3427 if (!empty($url_list)) {
3428 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
3429 if ($search_query) {
3430 // Include search filter in the final fetch
3431 $prompts_query = $wpdb->prepare(
3432 "SELECT id, article_content, source_url, timestamp, role_restriction
3433 FROM {$table_name}
3434 WHERE source_url IN ($placeholders) AND article_content LIKE %s
3435 ORDER BY timestamp DESC",
3436 ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
3437 );
3438 } else {
3439 $prompts_query = $wpdb->prepare(
3440 "SELECT id, article_content, source_url, timestamp, role_restriction
3441 FROM {$table_name}
3442 WHERE source_url IN ($placeholders)
3443 ORDER BY timestamp DESC",
3444 $url_list
3445 );
3446 }
3447 $prompts = $wpdb->get_results($prompts_query);
3448 }
3449
3450 // Group prompts by source_url for chunk display
3451 $grouped_prompts = array();
3452 foreach ($prompts as $prompt) {
3453 $source_url = $prompt->source_url ?? '';
3454
3455 // Parse chunk metadata using the proper chunker method (same as initial page load)
3456 if (class_exists('MxChat_Chunker')) {
3457 $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
3458 $prompt->chunk_metadata = $chunk_meta['metadata'];
3459 $prompt->display_content = $chunk_meta['text'];
3460 } else {
3461 $prompt->chunk_metadata = array();
3462 $prompt->display_content = $prompt->article_content;
3463 }
3464
3465 if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
3466 if (!isset($grouped_prompts[$source_url])) {
3467 $grouped_prompts[$source_url] = array();
3468 }
3469 $grouped_prompts[$source_url][] = $prompt;
3470 } else {
3471 // Ungrouped entries
3472 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
3473 }
3474 }
3475
3476 // Sort groups by the original URL order (newest first)
3477 uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
3478 $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
3479 $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
3480 return $order_a - $order_b;
3481 });
3482
3483 // Sort each group internally by chunk_index
3484 foreach ($grouped_prompts as $source_url => &$group) {
3485 usort($group, function($a, $b) {
3486 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3487 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3488 return $index_a - $index_b;
3489 });
3490 }
3491 unset($group);
3492
3493 // Build HTML for the table rows
3494 ob_start();
3495 $display_index = 0;
3496 $current_page = $page;
3497 $data_source = 'wordpress';
3498 $current_bot_id = $bot_id;
3499 $preview_length = 150;
3500
3501 if (empty($grouped_prompts)) {
3502 echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3503 esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
3504 echo '</td></tr>';
3505 } else {
3506 foreach ($grouped_prompts as $source_url => $group) {
3507 $chunk_count = count($group);
3508 $first_prompt = $group[0];
3509 $display_index++;
3510
3511 if ($chunk_count > 1) {
3512 // Multiple chunks - show grouped row with expand button
3513 $group_id = 'group-' . md5($source_url);
3514 ?>
3515 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3516 class="mxchat-chunk-group-header"
3517 data-source="<?php echo esc_attr($data_source); ?>"
3518 data-group-id="<?php echo esc_attr($group_id); ?>"
3519 style="border-bottom: 1px solid var(--mxch-card-border);">
3520 <td style="padding: 12px 16px; text-align: center;">
3521 <input type="checkbox"
3522 class="mxchat-entry-checkbox"
3523 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3524 data-source="<?php echo esc_attr($data_source); ?>"
3525 data-source-url="<?php echo esc_attr($source_url); ?>"
3526 data-is-group="true"
3527 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3528 </td>
3529 <td style="padding: 12px 16px; font-size: 13px;">
3530 <?php echo esc_html($first_prompt->id); ?>
3531 </td>
3532 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3533 <div class="mxchat-chunk-group-info">
3534 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3535 <span class="dashicons dashicons-arrow-right-alt2"></span>
3536 </button>
3537 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3538 <span class="mxchat-chunk-preview">
3539 <?php
3540 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
3541 $content_preview = mb_substr($parent_content, 0, 100);
3542 echo esc_html($content_preview . '...');
3543 ?>
3544 </span>
3545 </div>
3546 </td>
3547 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3548 <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3549 <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3550 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3551 <?php esc_html_e('View Source', 'mxchat'); ?>
3552 </a>
3553 <?php else : ?>
3554 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3555 <?php endif; ?>
3556 </td>
3557 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3558 <?php if ($data_source !== 'pinecone') : ?>
3559 <button type="button"
3560 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3561 data-source-url="<?php echo esc_attr($source_url); ?>"
3562 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3563 data-data-source="<?php echo esc_attr($data_source); ?>"
3564 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3565 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3566 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3567 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3568 </button>
3569 <?php endif; ?>
3570 <button type="button"
3571 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3572 data-source-url="<?php echo esc_attr($source_url); ?>"
3573 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3574 data-data-source="<?php echo esc_attr($data_source); ?>"
3575 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3576 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3577 style="color: var(--mxch-error);"
3578 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3579 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3580 </button>
3581 </td>
3582 </tr>
3583 <?php
3584 // Render hidden chunk rows
3585 foreach ($group as $chunk_index => $chunk) {
3586 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3587 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3588 $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
3589 $content_preview = mb_strlen($content) > $preview_length
3590 ? mb_substr($content, 0, $preview_length) . '...'
3591 : $content;
3592 ?>
3593 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3594 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3595 data-source="<?php echo esc_attr($data_source); ?>"
3596 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3597 <td style="padding: 12px 16px; text-align: center;">
3598 <!-- Checkbox column placeholder for chunks (managed by group) -->
3599 </td>
3600 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3601 <!-- Hidden ID column for chunks -->
3602 </td>
3603 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3604 <div class="mxchat-accordion-wrapper">
3605 <div class="mxchat-content-preview">
3606 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3607 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3608 </span>
3609 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3610 <?php if (mb_strlen($content) > $preview_length) : ?>
3611 <button class="mxchat-expand-toggle" type="button">
3612 <span class="dashicons dashicons-arrow-down-alt2"></span>
3613 </button>
3614 <?php endif; ?>
3615 </div>
3616 <div class="mxchat-content-full" style="display: none;">
3617 <div class="content-view">
3618 <?php
3619 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3620 echo '<div dir="rtl" lang="he" class="rtl-content">';
3621 echo wp_kses_post(wpautop($content));
3622 echo '</div>';
3623 } else {
3624 echo wp_kses_post(wpautop($content));
3625 }
3626 ?>
3627 </div>
3628 </div>
3629 </div>
3630 </td>
3631 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3632 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3633 </td>
3634 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3635 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3636 </td>
3637 </tr>
3638 <?php
3639 }
3640 } else {
3641 // Single entry - display normally with accordion
3642 $prompt = $first_prompt;
3643 $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
3644 $content_preview = mb_strlen($content) > $preview_length
3645 ? mb_substr($content, 0, $preview_length) . '...'
3646 : $content;
3647 ?>
3648 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3649 data-source="<?php echo esc_attr($data_source); ?>"
3650 style="border-bottom: 1px solid var(--mxch-card-border);">
3651 <td style="padding: 12px 16px; text-align: center;">
3652 <input type="checkbox"
3653 class="mxchat-entry-checkbox"
3654 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3655 data-source="<?php echo esc_attr($data_source); ?>"
3656 data-source-url="<?php echo esc_attr($source_url); ?>"
3657 data-is-group="false">
3658 </td>
3659 <td style="padding: 12px 16px; font-size: 13px;">
3660 <?php echo esc_html($prompt->id); ?>
3661 </td>
3662 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3663 <div class="mxchat-accordion-wrapper">
3664 <div class="mxchat-content-preview">
3665 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3666 <?php if (mb_strlen($content) > $preview_length) : ?>
3667 <button class="mxchat-expand-toggle" type="button">
3668 <span class="dashicons dashicons-arrow-down-alt2"></span>
3669 </button>
3670 <?php endif; ?>
3671 </div>
3672 <div class="mxchat-content-full" style="display: none;">
3673 <div class="content-view">
3674 <?php
3675 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3676 echo '<div dir="rtl" lang="he" class="rtl-content">';
3677 echo wp_kses_post(wpautop($content));
3678 echo '</div>';
3679 } else {
3680 echo wp_kses_post(wpautop($content));
3681 }
3682 ?>
3683 </div>
3684 </div>
3685 </div>
3686 </td>
3687 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3688 <?php
3689 $actual_source = $source_url;
3690 if (strpos($source_url, '_ungrouped_') === 0) {
3691 $actual_source = $prompt->source_url ?? '';
3692 }
3693 if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3694 <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3695 <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3696 <?php esc_html_e('View', 'mxchat'); ?>
3697 </a>
3698 <?php else : ?>
3699 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3700 <?php endif; ?>
3701 </td>
3702 <td style="padding: 12px 16px; white-space: nowrap;">
3703 <button type="button"
3704 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3705 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3706 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3707 data-data-source="<?php echo esc_attr($data_source); ?>"
3708 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3709 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3710 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3711 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3712 </button>
3713 <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);">
3714 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3715 </button>
3716 </td>
3717 </tr>
3718 <?php
3719 }
3720 }
3721 }
3722 $html = ob_get_clean();
3723
3724 // Generate pagination HTML (include search/filter data for subsequent pages)
3725 $pagination_html = '';
3726 if ($total_pages > 1) {
3727 $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) . '">';
3728
3729 // Previous button
3730 if ($page > 1) {
3731 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3732 }
3733
3734 // Page numbers
3735 $start_page = max(1, $page - 2);
3736 $end_page = min($total_pages, $page + 2);
3737
3738 if ($start_page > 1) {
3739 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3740 if ($start_page > 2) {
3741 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3742 }
3743 }
3744
3745 for ($i = $start_page; $i <= $end_page; $i++) {
3746 if ($i == $page) {
3747 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3748 } else {
3749 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3750 }
3751 }
3752
3753 if ($end_page < $total_pages) {
3754 if ($end_page < $total_pages - 1) {
3755 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3756 }
3757 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3758 }
3759
3760 // Next button
3761 if ($page < $total_pages) {
3762 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3763 }
3764
3765 $pagination_html .= '</div>';
3766 }
3767
3768 wp_send_json_success(array(
3769 'html' => $html,
3770 'pagination_html' => $pagination_html,
3771 'total_count' => $total_records,
3772 'total_pages' => $total_pages,
3773 'page' => $page,
3774 'per_page' => $per_page,
3775 'data_source' => 'wordpress'
3776 ));
3777 }
3778
3779 /**
3780 * AJAX handler to detect available sitemaps on the site
3781 * Optimized for speed - only checks primary sitemap indexes first
3782 */
3783 public function ajax_mxchat_detect_sitemaps() {
3784 check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3785
3786 if (!current_user_can('manage_options')) {
3787 wp_send_json_error(array('message' => 'Unauthorized'));
3788 return;
3789 }
3790
3791 $site_url = get_site_url();
3792 $sitemaps = array();
3793 $found_index = false;
3794
3795 // Only check the main sitemap index files first (much faster)
3796 // These are the primary entry points that contain sub-sitemaps
3797 $primary_indexes = array(
3798 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3799 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3800 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3801 );
3802
3803 foreach ($primary_indexes as $path => $source) {
3804 $url = trailingslashit($site_url) . $path;
3805
3806 $response = wp_remote_head($url, array(
3807 'timeout' => 10,
3808 'sslverify' => false,
3809 'redirection' => 1,
3810 'user-agent' => mxchat_ingest_user_agent(),
3811 ));
3812
3813 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3814 // Found a sitemap index - parse it to get sub-sitemaps
3815 $sub_sitemaps = $this->parse_sitemap_index($url);
3816 if (!empty($sub_sitemaps)) {
3817 $sitemaps[] = array(
3818 'url' => $url,
3819 'type' => 'index',
3820 'source' => $source,
3821 'sub_sitemaps' => $sub_sitemaps
3822 );
3823 $found_index = true;
3824 // Found a valid index, no need to check others
3825 break;
3826 }
3827 }
3828 }
3829
3830 // If no sitemap index found, check for standalone sitemaps
3831 if (!$found_index) {
3832 $standalone_sitemaps = array(
3833 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3834 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3835 );
3836
3837 foreach ($standalone_sitemaps as $path => $info) {
3838 $url = trailingslashit($site_url) . $path;
3839
3840 $response = wp_remote_head($url, array(
3841 'timeout' => 2,
3842 'sslverify' => false
3843 ));
3844
3845 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3846 $sitemaps[] = array(
3847 'url' => $url,
3848 'type' => $info['type'],
3849 'source' => $info['source'],
3850 'url_count' => 0 // Skip URL count for speed
3851 );
3852 }
3853 }
3854 }
3855
3856 wp_send_json_success(array(
3857 'sitemaps' => $sitemaps,
3858 'site_url' => $site_url
3859 ));
3860 }
3861
3862 /**
3863 * Parse a sitemap index to get sub-sitemaps
3864 * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
3865 */
3866 private function parse_sitemap_index($url) {
3867 $sub_sitemaps = array();
3868
3869 $response = wp_remote_get($url, array(
3870 'timeout' => 30,
3871 'sslverify' => false,
3872 'user-agent' => mxchat_ingest_user_agent(),
3873 'headers' => array(
3874 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3875 ),
3876 ));
3877
3878 if (is_wp_error($response)) {
3879 return $sub_sitemaps;
3880 }
3881
3882 $body = wp_remote_retrieve_body($response);
3883 if (empty($body)) {
3884 return $sub_sitemaps;
3885 }
3886
3887 // Suppress XML errors
3888 libxml_use_internal_errors(true);
3889 $xml = simplexml_load_string($body);
3890 libxml_clear_errors();
3891
3892 if ($xml === false) {
3893 return $sub_sitemaps;
3894 }
3895
3896 // Check if it's a sitemap index (contains <sitemap> elements)
3897 if (isset($xml->sitemap)) {
3898 foreach ($xml->sitemap as $sitemap) {
3899 $loc = (string) $sitemap->loc;
3900 if (!empty($loc)) {
3901 // Try to determine the type from the URL
3902 $type = 'content';
3903 if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
3904 $type = 'taxonomy';
3905 } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
3906 $type = 'author';
3907 }
3908
3909 // Skip URL count - too slow to fetch for each sitemap
3910 $sub_sitemaps[] = array(
3911 'url' => $loc,
3912 'type' => $type,
3913 'url_count' => 0, // Don't fetch - takes too long
3914 'name' => basename(parse_url($loc, PHP_URL_PATH))
3915 );
3916 }
3917 }
3918 }
3919
3920 return $sub_sitemaps;
3921 }
3922
3923 /**
3924 * Get URL count from a sitemap
3925 */
3926 private function get_sitemap_url_count($url) {
3927 $response = wp_remote_get($url, array(
3928 'timeout' => 30,
3929 'sslverify' => false,
3930 'user-agent' => mxchat_ingest_user_agent(),
3931 'headers' => array(
3932 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3933 ),
3934 ));
3935
3936 if (is_wp_error($response)) {
3937 return 0;
3938 }
3939
3940 $body = wp_remote_retrieve_body($response);
3941 if (empty($body)) {
3942 return 0;
3943 }
3944
3945 // Count <url> or <loc> elements
3946 $count = preg_match_all('/<url>/i', $body, $matches);
3947 return $count ?: 0;
3948 }
3949
3950 /**
3951 * Get sitemaps declared in robots.txt
3952 */
3953 private function get_sitemaps_from_robots($site_url) {
3954 $sitemaps = array();
3955 $robots_url = trailingslashit($site_url) . 'robots.txt';
3956
3957 $response = wp_remote_get($robots_url, array(
3958 'timeout' => 15,
3959 'sslverify' => false,
3960 'user-agent' => mxchat_ingest_user_agent(),
3961 ));
3962
3963 if (is_wp_error($response)) {
3964 return $sitemaps;
3965 }
3966
3967 $body = wp_remote_retrieve_body($response);
3968 if (empty($body)) {
3969 return $sitemaps;
3970 }
3971
3972 // Find Sitemap: declarations
3973 if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
3974 foreach ($matches[1] as $sitemap_url) {
3975 $sitemap_url = trim($sitemap_url);
3976 if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
3977 $sitemaps[] = $sitemap_url;
3978 }
3979 }
3980 }
3981
3982 return $sitemaps;
3983 }
3984
3985 public function mxchat_stop_processing() {
3986 // Verify permissions
3987 if (!current_user_can('manage_options')) {
3988 wp_die(esc_html__('Unauthorized access', 'mxchat'));
3989 }
3990
3991 // Verify nonce
3992 check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
3993
3994 global $wpdb;
3995 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
3996
3997 // Get active queue IDs
3998 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
3999 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
4000
4001 // Delete all pending items from active queues
4002 if ($sitemap_queue_id) {
4003 $wpdb->delete(
4004 $table_name,
4005 array(
4006 'queue_id' => $sitemap_queue_id,
4007 'status' => 'pending'
4008 ),
4009 array('%s', '%s')
4010 );
4011
4012 delete_transient('mxchat_active_queue_sitemap');
4013 delete_transient('mxchat_last_sitemap_url');
4014 }
4015
4016 if ($pdf_queue_id) {
4017 // Get PDF path before deleting
4018 $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
4019
4020 $wpdb->delete(
4021 $table_name,
4022 array(
4023 'queue_id' => $pdf_queue_id,
4024 'status' => 'pending'
4025 ),
4026 array('%s', '%s')
4027 );
4028
4029 // Delete PDF file
4030 if ($pdf_path && file_exists($pdf_path)) {
4031 wp_delete_file($pdf_path);
4032 }
4033
4034 delete_transient('mxchat_active_queue_pdf');
4035 delete_transient('mxchat_last_pdf_url');
4036 }
4037
4038 // Redirect back with a success message
4039 set_transient('mxchat_admin_notice_success',
4040 esc_html__('Processing has been stopped successfully.', 'mxchat'),
4041 30
4042 );
4043 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
4044 exit;
4045 }
4046
4047 /**
4048 * Get content list for processing
4049 */
4050 public function ajax_mxchat_get_content_list() {
4051 // Verify the nonce
4052 check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
4053
4054 if (!current_user_can('manage_options')) {
4055 wp_send_json_error(__('Unauthorized access', 'mxchat'));
4056 }
4057
4058 $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
4059 $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
4060 $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
4061 $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
4062 $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
4063 $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
4064
4065 // Build query args
4066 $args = array(
4067 'posts_per_page' => $per_page,
4068 'paged' => $page,
4069 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
4070 'orderby' => 'date',
4071 'order' => 'DESC',
4072 );
4073
4074 // Supported media types, and the one-shot WHERE filter that scopes the MIME gate
4075 // to attachment rows when this query spans several post types (plan 01010a).
4076 $attachment_mimes = self::mxchat_supported_attachment_mime_types();
4077 $attachment_where = null;
4078
4079 // Handle post types - IMPROVED VERSION
4080 if ($post_type !== 'all') {
4081 $args['post_type'] = $post_type;
4082
4083 // Media is offered by the dropdown above (attachment is a public post type)
4084 // but could never return a row: attachments are stored with post_status
4085 // 'inherit' and this query asks for publish/draft/pending, so the list was
4086 // empty by construction. Force the status attachments actually use, and
4087 // offer only the file types we can extract text from (plan 01010a).
4088 if ($post_type === 'attachment') {
4089 $args['post_status'] = 'inherit';
4090 $args['post_mime_type'] = $attachment_mimes;
4091 }
4092 } else {
4093 // Get all available post types that might contain content
4094 $all_post_types = array();
4095
4096 // First get all public post types
4097 $public_types = get_post_types(array('public' => true), 'names');
4098 $all_post_types = array_merge($all_post_types, $public_types);
4099
4100 // Add common forum/community post types
4101 $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
4102 foreach ($forum_types as $forum_type) {
4103 if (post_type_exists($forum_type)) {
4104 $all_post_types[] = $forum_type;
4105 }
4106 }
4107
4108 // Add other commonly used post types
4109 $common_types = array('product', 'job_listing', 'event', 'portfolio');
4110 foreach ($common_types as $common_type) {
4111 if (post_type_exists($common_type)) {
4112 $all_post_types[] = $common_type;
4113 }
4114 }
4115
4116 // Remove duplicates and ensure we have at least some post types
4117 $all_post_types = array_unique($all_post_types);
4118
4119 if (empty($all_post_types)) {
4120 // Fallback to basic post types
4121 $all_post_types = array('post', 'page');
4122 }
4123
4124 // Attachments in the "All Content Types" view (plan 01010a). Two things have
4125 // to happen at once and neither can ride $args on its own:
4126 //
4127 // - 'inherit' has to join the status list, because WP_Query takes ONE status
4128 // set for the whole query. Only attachments and revisions use it, and
4129 // revisions are not in $all_post_types, so nothing else widens.
4130 // - the MIME gate CANNOT be $args['post_mime_type'] here — that clause is
4131 // applied to every row, and an ordinary post has an empty post_mime_type,
4132 // so it would empty the entire listing rather than filter the media half.
4133 // A scoped WHERE is the only shape that filters attachments alone.
4134 //
4135 // Media joins this view under the default Published filter and under All
4136 // Statuses; picking Drafts deliberately drops it, because a file has no draft
4137 // state and answering a draft filter with published media is a wrong answer.
4138 $wants_attachments = in_array('attachment', $all_post_types, true)
4139 && ($post_status === 'publish' || $post_status === 'all');
4140
4141 if ($wants_attachments) {
4142 $statuses = array_values(array_unique(array_merge((array) $args['post_status'], array('inherit'))));
4143 $args['post_status'] = $statuses;
4144
4145 global $wpdb;
4146 $mime_placeholders = implode(', ', array_fill(0, count($attachment_mimes), '%s'));
4147 $mime_clause = $wpdb->prepare(
4148 " AND ({$wpdb->posts}.post_type <> 'attachment' OR {$wpdb->posts}.post_mime_type IN ($mime_placeholders))",
4149 $attachment_mimes
4150 );
4151 $attachment_where = function ($where) use ($mime_clause) {
4152 return $where . $mime_clause;
4153 };
4154 add_filter('posts_where', $attachment_where);
4155 } else {
4156 $all_post_types = array_values(array_diff($all_post_types, array('attachment')));
4157 }
4158
4159 $args['post_type'] = $all_post_types;
4160
4161 // Debug logging to see what post types are being queried
4162 //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
4163 }
4164
4165 if (!empty($search)) {
4166 $args['s'] = $search;
4167 }
4168
4169 // Get processed data from storage
4170 $processed_data = array();
4171
4172 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4173 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4174
4175 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4176 // Get fresh data from Pinecone - no caching
4177 $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
4178 } else {
4179 // WordPress DB checking with better URL matching for all post types
4180 global $wpdb;
4181 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4182 $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
4183
4184 // Group items by source_url to count chunks
4185 $url_chunk_counts = array();
4186 $url_latest_timestamp = array();
4187 $url_first_id = array();
4188
4189 if (!empty($processed_items)) {
4190 foreach ($processed_items as $item) {
4191 $url = $item->source_url;
4192 if (empty($url)) continue;
4193
4194 // Count chunks per URL
4195 if (!isset($url_chunk_counts[$url])) {
4196 $url_chunk_counts[$url] = 0;
4197 $url_latest_timestamp[$url] = $item->timestamp;
4198 $url_first_id[$url] = $item->id;
4199 }
4200 $url_chunk_counts[$url]++;
4201
4202 // Track latest timestamp
4203 if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
4204 $url_latest_timestamp[$url] = $item->timestamp;
4205 }
4206 }
4207
4208 // Now build processed_data with chunk counts
4209 foreach ($url_chunk_counts as $url => $chunk_count) {
4210 $post_id = $this->mxchat_url_to_post_id_improved($url);
4211
4212 if ($post_id) {
4213 $processed_data[$post_id] = array(
4214 'db_id' => $url_first_id[$url],
4215 'timestamp' => $url_latest_timestamp[$url],
4216 'url' => $url,
4217 'source' => 'wordpress',
4218 'chunk_count' => $chunk_count
4219 );
4220 }
4221 }
4222 }
4223 }
4224
4225 // Get processed IDs as a simple array for in_array checks
4226 $processed_ids = array_keys($processed_data);
4227
4228 // Handle processed/unprocessed filter
4229 if ($processed_filter === 'processed' && !empty($processed_ids)) {
4230 $args['post__in'] = $processed_ids;
4231 } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
4232 $args['post__not_in'] = $processed_ids;
4233 }
4234
4235 // Run the query
4236 $query = new WP_Query($args);
4237
4238 // Scoped to this one query — leaving it attached would MIME-filter every later
4239 // WP_Query in the request.
4240 if ($attachment_where) {
4241 remove_filter('posts_where', $attachment_where);
4242 }
4243
4244 $content_items = array();
4245
4246 if ($query->have_posts()) {
4247 while ($query->have_posts()) {
4248 $query->the_post();
4249 $id = get_the_ID();
4250 $post_date = get_the_date();
4251 $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
4252 $word_count = str_word_count(strip_tags(get_the_content()));
4253
4254 $is_processed = in_array($id, $processed_ids);
4255 $processed_date = '';
4256 $db_record_id = 0;
4257 $data_source = 'none';
4258
4259 if ($is_processed && isset($processed_data[$id])) {
4260 $item_data = $processed_data[$id];
4261 $data_source = $item_data['source'];
4262
4263 if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
4264 // WordPress DB format
4265 $timestamp = strtotime($item_data['timestamp']);
4266 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4267 $db_record_id = $item_data['db_id'];
4268 } elseif ($data_source === 'pinecone') {
4269 // Pinecone format
4270 $processed_date = $item_data['processed_date'];
4271 $db_record_id = $item_data['db_id'];
4272 }
4273 }
4274
4275 // Get chunk count for this item
4276 $chunk_count = 0;
4277 if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
4278 $chunk_count = intval($processed_data[$id]['chunk_count']);
4279 }
4280
4281 // Media rows describe themselves differently (plan 01010a): the link has
4282 // to be the FILE, not the attachment page nobody wants; "attachment" and
4283 // "0 words" tell the user nothing, so the row carries the file type and
4284 // its size instead. Word count is deliberately NOT computed — that would
4285 // mean parsing every PDF in the library just to render a list.
4286 $permalink = get_permalink();
4287 $type_label = get_post_type();
4288 $size_label = '';
4289
4290 if (get_post_type() === 'attachment') {
4291 $file_url = wp_get_attachment_url($id);
4292 $permalink = $file_url ? $file_url : $permalink;
4293 $type_label = self::mxchat_attachment_type_label(get_post_mime_type($id));
4294
4295 $file_path = get_attached_file($id);
4296 $file_size = ($file_path && file_exists($file_path)) ? @filesize($file_path) : false;
4297 if ($file_size !== false) {
4298 $size_label = size_format($file_size, 0);
4299 }
4300
4301 if ($excerpt === '') {
4302 $excerpt = $file_url ? wp_basename($file_url) : '';
4303 }
4304 }
4305
4306 $content_items[] = array(
4307 'id' => $id,
4308 'title' => get_the_title(),
4309 'permalink' => $permalink,
4310 'date' => $post_date,
4311 'type' => get_post_type(),
4312 'type_label' => $type_label,
4313 'size_label' => $size_label,
4314 'status' => get_post_status(),
4315 'excerpt' => $excerpt,
4316 'word_count' => $word_count,
4317 'already_processed' => $is_processed,
4318 'processed_date' => $processed_date,
4319 'db_record_id' => $db_record_id,
4320 'data_source' => $data_source,
4321 'chunk_count' => $chunk_count
4322 );
4323 }
4324 wp_reset_postdata();
4325 }
4326
4327 $response = array(
4328 'items' => $content_items,
4329 'total' => $query->found_posts,
4330 'total_pages' => $query->max_num_pages,
4331 'current_page' => $page,
4332 'processed_count' => count($processed_ids)
4333 );
4334
4335 wp_send_json_success($response);
4336 exit;
4337 }
4338
4339
4340 /**
4341 * This function handles various WooCommerce URL formats and permalink structures
4342 */
4343 private function mxchat_url_to_post_id_improved($url) {
4344 // First try the standard WordPress function
4345 $post_id = url_to_postid($url);
4346
4347 if ($post_id > 0) {
4348 return $post_id;
4349 }
4350
4351 // Media entries are stored under the FILE url (…/wp-content/uploads/…), which is
4352 // not a permalink — url_to_postid() never resolves one, and every slug fallback
4353 // below excludes 'attachment' explicitly. Without this, an imported media file
4354 // renders as "Not In Knowledge Base" forever and re-imports as new every time
4355 // (plan 01010a). attachment_url_to_postid() matches on _wp_attached_file and
4356 // returns 0 for anything outside the uploads directory, so post permalinks are
4357 // unaffected.
4358 $attachment_id = (int) attachment_url_to_postid($url);
4359 if ($attachment_id > 0) {
4360 return $attachment_id;
4361 }
4362
4363 // If that fails, try more aggressive URL matching
4364 // Remove trailing slashes and query parameters for better matching
4365 $clean_url = rtrim($url, '/');
4366 $clean_url = strtok($clean_url, '?'); // Remove query parameters
4367
4368 // Try again with cleaned URL
4369 $post_id = url_to_postid($clean_url);
4370 if ($post_id > 0) {
4371 return $post_id;
4372 }
4373
4374 // For bbPress forum topics, try extracting slug from URL
4375 if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
4376 // Handle bbPress URLs: /forums/topic/topic-name/
4377 if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
4378 $topic_slug = $matches[1];
4379
4380 // Look up topic by slug
4381 $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
4382 if ($topic) {
4383 return $topic->ID;
4384 }
4385
4386 // Alternative method: query by post_name
4387 global $wpdb;
4388 $post_id = $wpdb->get_var($wpdb->prepare(
4389 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4390 $topic_slug
4391 ));
4392
4393 if ($post_id) {
4394 return intval($post_id);
4395 }
4396 }
4397
4398 // Handle simpler topic URLs: /topic/topic-name/
4399 if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
4400 $topic_slug = $matches[1];
4401
4402 global $wpdb;
4403 $post_id = $wpdb->get_var($wpdb->prepare(
4404 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4405 $topic_slug
4406 ));
4407
4408 if ($post_id) {
4409 return intval($post_id);
4410 }
4411 }
4412 }
4413
4414 // For WooCommerce products
4415 if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
4416 // Extract product slug from various URL formats
4417 $product_slug = '';
4418
4419 // Handle pretty permalinks: /product/product-name/
4420 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
4421 $product_slug = $matches[1];
4422 }
4423 // Handle query parameters: ?product=product-name
4424 elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
4425 $product_slug = $matches[1];
4426 }
4427
4428 if (!empty($product_slug)) {
4429 // Look up product by slug
4430 $product = get_page_by_path($product_slug, OBJECT, 'product');
4431 if ($product) {
4432 return $product->ID;
4433 }
4434
4435 // Alternative method: query by post_name
4436 global $wpdb;
4437 $post_id = $wpdb->get_var($wpdb->prepare(
4438 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
4439 $product_slug
4440 ));
4441
4442 if ($post_id) {
4443 return intval($post_id);
4444 }
4445 }
4446 }
4447
4448 // Generic approach: try to extract slug and match against all post types
4449 $parsed_url = wp_parse_url($clean_url);
4450 $path = $parsed_url['path'] ?? '';
4451
4452 if (!empty($path)) {
4453 // Get the last part of the path as potential slug
4454 $path_parts = array_filter(explode('/', trim($path, '/')));
4455 $potential_slug = end($path_parts);
4456
4457 if (!empty($potential_slug)) {
4458 global $wpdb;
4459
4460 // Try to find any post with this slug
4461 $post_id = $wpdb->get_var($wpdb->prepare(
4462 "SELECT ID FROM {$wpdb->posts}
4463 WHERE post_name = %s
4464 AND post_status IN ('publish', 'closed', 'private')
4465 AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
4466 ORDER BY CASE
4467 WHEN post_type = 'post' THEN 1
4468 WHEN post_type = 'page' THEN 2
4469 WHEN post_type = 'topic' THEN 3
4470 WHEN post_type = 'product' THEN 4
4471 ELSE 5
4472 END
4473 LIMIT 1",
4474 $potential_slug
4475 ));
4476
4477 if ($post_id) {
4478 return intval($post_id);
4479 }
4480 }
4481 }
4482
4483 // ADDITIONAL: Try direct database lookup by URL variations
4484 global $wpdb;
4485 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4486
4487 // Try variations of the URL (with/without trailing slash, http/https)
4488 $url_variations = array(
4489 $url,
4490 rtrim($url, '/'),
4491 $url . '/',
4492 str_replace('http://', 'https://', $url),
4493 str_replace('https://', 'http://', $url),
4494 str_replace('http://', 'https://', rtrim($url, '/')),
4495 str_replace('https://', 'http://', rtrim($url, '/'))
4496 );
4497
4498 // Remove duplicates
4499 $url_variations = array_unique($url_variations);
4500
4501 foreach ($url_variations as $variation) {
4502 $existing_record = $wpdb->get_row($wpdb->prepare(
4503 "SELECT id, source_url FROM $table_name WHERE source_url = %s",
4504 $variation
4505 ));
4506
4507 if ($existing_record) {
4508 // Try to get post ID from this stored URL
4509 $stored_post_id = url_to_postid($existing_record->source_url);
4510 if ($stored_post_id > 0) {
4511 return $stored_post_id;
4512 }
4513 }
4514 }
4515
4516 return 0; // No match found
4517 }
4518 /**
4519 * Process selected content via AJAX
4520 */
4521 public function ajax_mxchat_process_selected_content() {
4522 // Basic request validation
4523 if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
4524 wp_send_json_error('Invalid nonce');
4525 exit;
4526 }
4527
4528 if (!current_user_can('manage_options')) {
4529 wp_send_json_error('Unauthorized access');
4530 exit;
4531 }
4532
4533 // Get post IDs - safely parse the array
4534 $post_ids = array();
4535 if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
4536 foreach ($_POST['post_ids'] as $id) {
4537 $post_ids[] = absint($id);
4538 }
4539 }
4540
4541 if (empty($post_ids)) {
4542 wp_send_json_error('No content selected');
4543 exit;
4544 }
4545
4546 // Get bot_id from request
4547 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
4548
4549 // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
4550 // plan 11720c). The import modal shows a passive status line pointing
4551 // there; the old per-batch checkbox and its remembered default are gone.
4552 $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
4553
4554 // Process only ONE post at a time to avoid request size issues
4555 $post_id = reset($post_ids);
4556 $post = get_post($post_id);
4557
4558 if (!$post) {
4559 wp_send_json_error('Post not found');
4560 exit;
4561 }
4562
4563 // Media-library attachments take their own route (plan 01010a) — different source
4564 // URL (the file, not the attachment page), different body (an extractor, not
4565 // post_content), and a file that yields no text is refused with a reason instead
4566 // of stored empty. Sends its own response and exits.
4567 if ($post->post_type === 'attachment') {
4568 $this->mxchat_import_attachment($post, $bot_id);
4569 }
4570
4571 /**
4572 * Allow developers to modify post data before processing into the knowledge base.
4573 * Applied on BOTH content-preparation paths (this manual bulk import and the
4574 * auto-sync path in mxchat_handle_post_update) with the same signature, so a
4575 * callback registered once covers every indexing route. Purely additive —
4576 * zero behaviour change when unhooked.
4577 *
4578 * @param WP_Post $post The post about to be indexed.
4579 * @param string $bot_id Bot context for this import.
4580 */
4581 $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
4582 if (!($post instanceof WP_Post)) {
4583 $post = get_post($post_id); // defend against a bad callback return
4584 }
4585
4586 // Assemble the indexable text via the shared post-kind assembler (a3d60c).
4587 // Bulk import reads raw post fields, has always included product custom tabs,
4588 // and passes the install-level ACF→PDF option.
4589 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
4590 'read_display' => false,
4591 'extract_acf_pdfs' => $extract_acf_pdfs,
4592 'include_product_tabs' => true,
4593 ));
4594 $content = $prepared['content'];
4595 $pdf_extracted_count = $prepared['pdf_extracted_count'];
4596
4597 // Debug logging for WordPress Import content
4598 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4599 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4600 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
4601 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4602
4603 // Note: Removed 10,000 char limit - chunking now handles large content properly
4604
4605 // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
4606 $bot_options = $this->get_bot_options($bot_id);
4607 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4608
4609 $preflight = MxChat_Utils::embedding_preflight($options);
4610 if (!$preflight['ok']) {
4611 MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4612 wp_send_json_error($preflight['reason']);
4613 exit;
4614 }
4615 $api_key = $preflight['api_key'];
4616
4617 $source_url = get_permalink($post_id);
4618 $vector_id = md5($source_url); // Vector ID for Pinecone
4619
4620 // Check for existing content in bot-specific storage
4621 $is_update = false;
4622
4623 // Get bot-specific Pinecone configuration
4624 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4625 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
4626
4627 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
4628 // Check Pinecone for this bot
4629 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
4630 if (isset($pinecone_data[$post_id])) {
4631 $is_update = true;
4632 }
4633 } else {
4634 // Check WordPress DB (same as before since it's shared)
4635 global $wpdb;
4636 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4637 $existing_record = $wpdb->get_row($wpdb->prepare(
4638 "SELECT id FROM $table_name WHERE source_url = %s",
4639 $source_url
4640 ));
4641
4642 if ($existing_record) {
4643 $is_update = true;
4644 }
4645 }
4646
4647 // UPDATED 2.5.6: Determine content type based on post_type
4648 $post_type = $post->post_type;
4649 $content_type = 'content'; // Default fallback
4650
4651 // Map WordPress post types to content types
4652 switch ($post_type) {
4653 case 'post':
4654 $content_type = 'post';
4655 break;
4656 case 'page':
4657 $content_type = 'page';
4658 break;
4659 case 'product':
4660 $content_type = 'product';
4661 break;
4662 default:
4663 // For custom post types, use the post type name
4664 $content_type = sanitize_key($post_type);
4665 break;
4666 }
4667
4668 // Use the centralized utility function with bot_id and content_type
4669 $result = MxChat_Utils::submit_content_to_db(
4670 $content,
4671 $source_url,
4672 $api_key,
4673 $vector_id,
4674 $bot_id,
4675 $content_type
4676 );
4677
4678 if (is_wp_error($result)) {
4679 MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
4680 wp_send_json_error('Storage failed: ' . $result->get_error_message());
4681 exit;
4682 }
4683
4684 // Automatically apply role restriction based on tags
4685 $this->apply_role_restriction_to_post($post_id, $source_url);
4686
4687 $operation_type = $is_update ? 'update' : 'new';
4688
4689 // Count ACF fields for debugging
4690 $acf_field_count = $prepared['acf_fields_found'];
4691
4692 // Success response with minimal data
4693 wp_send_json_success(array(
4694 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4695 'post_id' => $post_id,
4696 'title' => $post->post_title,
4697 'operation_type' => $operation_type,
4698 'vector_id' => $vector_id,
4699 'acf_fields_found' => $acf_field_count,
4700 'pdf_extracted_count' => (int) $pdf_extracted_count,
4701 'content_preview' => substr($content, 0, 100) . '...',
4702 'bot_id' => $bot_id
4703 ));
4704 exit;
4705 }
4706
4707 private function apply_role_restriction_to_post($post_id, $source_url) {
4708 $highest_role = 'public';
4709
4710 // Tag-derived restriction. Tags only exist on posts, which is why pages
4711 // could never be restricted from the Role Restrictions screen (plan 6ab002).
4712 $mappings = get_option('mxchat_tag_role_mappings', array());
4713 if (!empty($mappings)) {
4714 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4715 if (!empty($post_tags)) {
4716 foreach ($post_tags as $tag_slug) {
4717 if (isset($mappings[$tag_slug])) {
4718 $highest_role = self::mxchat_stricter_role($highest_role, $mappings[$tag_slug]);
4719 }
4720 }
4721 }
4722 }
4723
4724 // Per-entry restriction set on the post itself, which works for pages and
4725 // every other public post type. The stricter of the two wins.
4726 $meta_role = get_post_meta($post_id, '_mxchat_kb_role_restriction', true);
4727 if (is_string($meta_role) && $meta_role !== '') {
4728 $highest_role = self::mxchat_stricter_role($highest_role, $meta_role);
4729 }
4730
4731 // Nothing restricts this entry — leave it public.
4732 if ($highest_role === 'public' || $highest_role === '') {
4733 return;
4734 }
4735
4736 // Update the role restriction in the database
4737 global $wpdb;
4738
4739 // Check if using Pinecone
4740 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4741 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4742
4743 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4744 // Update Pinecone role restriction
4745 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4746 $vector_id = md5($source_url);
4747
4748 $wpdb->replace(
4749 $roles_table,
4750 array(
4751 'vector_id' => $vector_id,
4752 'role_restriction' => $highest_role,
4753 'updated_at' => current_time('mysql')
4754 ),
4755 array('%s', '%s', '%s')
4756 );
4757 } else {
4758 // Update WordPress DB
4759 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4760
4761 $wpdb->update(
4762 $table_name,
4763 array('role_restriction' => $highest_role),
4764 array('source_url' => $source_url),
4765 array('%s'),
4766 array('%s')
4767 );
4768 }
4769
4770 // The entry's restriction just changed — keep the OpenAI Vector Store
4771 // mirror consistent: non-public pulls the file (file_search has no
4772 // per-role filtering), public re-mirrors it (plan 15b5c6).
4773 if (class_exists('MxChat_Vectorstore_Manager')) {
4774 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
4775 }
4776 }
4777
4778 public function mxchat_get_public_post_types() {
4779 // Get all public post types
4780 $post_types = get_post_types(array('public' => true), 'objects');
4781 $post_type_options = array();
4782
4783 foreach ($post_types as $post_type) {
4784 $post_type_options[$post_type->name] = $post_type->label;
4785 }
4786
4787 // Also include common forum/community post types that might not be marked as public
4788 $additional_types = array(
4789 'topic' => 'Forum Topics (bbPress)',
4790 'reply' => 'Forum Replies (bbPress)',
4791 'forum' => 'Forums (bbPress)',
4792 'wpforo_topic' => 'wpForo Topics',
4793 'wpforo_post' => 'wpForo Posts'
4794 );
4795
4796 foreach ($additional_types as $type_name => $type_label) {
4797 if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4798 $post_type_options[$type_name] = $type_label;
4799 }
4800 }
4801
4802 return $post_type_options;
4803 }
4804
4805 /**
4806 * Retrieves processed content from Pinecone API
4807 */
4808 public function mxchat_get_pinecone_processed_content($pinecone_options) {
4809 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4810 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4811
4812 if (empty($api_key) || empty($host)) {
4813 return array();
4814 }
4815
4816 $pinecone_data = array();
4817
4818 try {
4819 // Always get fresh data from Pinecone
4820 $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4821
4822 // Method 2: Final fallback - try stats endpoint (if available)
4823 if (empty($pinecone_data)) {
4824 $stats_url = "https://{$host}/describe_index_stats";
4825
4826 $response = wp_remote_post($stats_url, array(
4827 'headers' => array(
4828 'Api-Key' => $api_key,
4829 'Content-Type' => 'application/json'
4830 ),
4831 'body' => json_encode(array()),
4832 'timeout' => 30
4833 ));
4834
4835 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4836 $body = wp_remote_retrieve_body($response);
4837 $stats_data = json_decode($body, true);
4838 }
4839 }
4840
4841 } catch (Exception $e) {
4842 // Log error but return fresh data only
4843 }
4844
4845 return $pinecone_data;
4846 }
4847 public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
4848 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4849 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4850 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4851
4852 if (empty($api_key) || empty($host) || empty($vector_ids)) {
4853 return array();
4854 }
4855
4856 try {
4857 // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
4858 // repeated in the query string (ids=a&ids=b — http_build_query would
4859 // emit ids[0]=a); the old POST here was answered 200-with-an-empty-body,
4860 // which read as "nothing indexed". Chunked at 100 ids to stay well
4861 // under the measured HTTP 414 URL-length boundary.
4862 $vectors = array();
4863 // Document index (plan 362c31): documents/fetch instead of the GET loop (same id => vector shape).
4864 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
4865 $vectors = MxChat_Pinecone_Documents::fetch(array_values($vector_ids), MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
4866 } else
4867 foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
4868 $fetch_query = array();
4869 foreach ($chunk as $fetch_vid) {
4870 $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
4871 }
4872 if (!empty($namespace)) {
4873 $fetch_query[] = 'namespace=' . rawurlencode($namespace);
4874 }
4875
4876 $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
4877 'headers' => array(
4878 'Api-Key' => $api_key,
4879 'accept' => 'application/json'
4880 ),
4881 'timeout' => 30
4882 ));
4883
4884 if (is_wp_error($response)) {
4885 error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
4886 continue;
4887 }
4888
4889 if (wp_remote_retrieve_response_code($response) !== 200) {
4890 error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
4891 continue;
4892 }
4893
4894 $data = json_decode(wp_remote_retrieve_body($response), true);
4895 if (isset($data['vectors']) && is_array($data['vectors'])) {
4896 $vectors += $data['vectors'];
4897 }
4898 }
4899
4900 if (empty($vectors)) {
4901 return array();
4902 }
4903
4904 $processed_data = array();
4905
4906 foreach ($vectors as $vector_id => $vector_data) {
4907 $metadata = $vector_data['metadata'] ?? array();
4908 $source_url = $metadata['source_url'] ?? '';
4909
4910 if (!empty($source_url)) {
4911 $post_id = url_to_postid($source_url);
4912 // A media entry is stored under its FILE url, which url_to_postid() cannot
4913 // resolve — without this the Pinecone read reports every imported
4914 // attachment as unprocessed while the WordPress-DB read (which goes
4915 // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
4916 if (!$post_id) {
4917 $post_id = (int) attachment_url_to_postid($source_url);
4918 }
4919 if ($post_id) {
4920 $created_at = $metadata['created_at'] ?? '';
4921 $processed_date = 'Recently';
4922
4923 if (!empty($created_at)) {
4924 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4925 if ($timestamp) {
4926 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4927 }
4928 }
4929
4930 $processed_data[$post_id] = array(
4931 'db_id' => $vector_id,
4932 'processed_date' => $processed_date,
4933 'url' => $source_url,
4934 'source' => 'pinecone',
4935 'timestamp' => $timestamp ?? current_time('timestamp')
4936 );
4937 }
4938 }
4939 }
4940
4941 //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
4942 return $processed_data;
4943
4944 } catch (Exception $e) {
4945 //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
4946 return array();
4947 }
4948 }
4949
4950 /**
4951 * Get embedding dimensions based on the selected model.
4952 */
4953 private function mxchat_get_embedding_dimensions() {
4954 $options = get_option('mxchat_options', array());
4955 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4956
4957 $model_dimensions = array(
4958 'text-embedding-ada-002' => 1536,
4959 'text-embedding-3-small' => 1536,
4960 'text-embedding-3-large' => 3072,
4961 'voyage-2' => 1024,
4962 'voyage-large-2' => 1536,
4963 'voyage-3-large' => 2048,
4964 'gemini-embedding-001' => 1536,
4965 );
4966
4967 if (strpos($selected_model, 'voyage-3-large') === 0) {
4968 $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
4969 return intval($custom_dimensions);
4970 }
4971
4972 if (strpos($selected_model, 'gemini-embedding') === 0) {
4973 $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
4974 return intval($custom_dimensions);
4975 }
4976
4977 return $model_dimensions[$selected_model] ?? 1536;
4978 }
4979
4980 /**
4981 * Scan Pinecone for processed content
4982 */
4983 public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4984 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4985 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4986 // plan 793b82: this scan was namespace-blind — on a namespaced setup it
4987 // surveyed the default namespace and reported the wrong content as indexed.
4988 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4989
4990 if (empty($api_key) || empty($host)) {
4991 return array();
4992 }
4993
4994 try {
4995 // Use multiple random vectors to get better coverage
4996 $all_matches = array();
4997 $seen_ids = array();
4998
4999 // Get correct dimensions for the configured embedding model
5000 $dimensions = $this->mxchat_get_embedding_dimensions();
5001
5002 // Try 3 different random vectors to get better coverage
5003 for ($i = 0; $i < 3; $i++) {
5004 $query_url = "https://{$host}/query";
5005
5006 // Generate a random unit vector instead of zeros
5007 $random_vector = array();
5008 for ($j = 0; $j < $dimensions; $j++) {
5009 $random_vector[] = (rand(-1000, 1000) / 1000.0);
5010 }
5011
5012 // Normalize the vector to unit length
5013 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
5014 if ($magnitude > 0) {
5015 $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
5016 }
5017
5018 $query_data = array(
5019 'includeMetadata' => true,
5020 'includeValues' => false,
5021 'topK' => 10000,
5022 'vector' => $random_vector
5023 );
5024
5025 if (!empty($namespace)) {
5026 $query_data['namespace'] = $namespace;
5027 }
5028
5029 // Document index (plan 362c31): same random-vector sweep through documents/search.
5030 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
5031 $doc_hits = MxChat_Pinecone_Documents::search_dense($random_vector, 10000, null, MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
5032 foreach ((is_wp_error($doc_hits) ? array() : $doc_hits) as $match) {
5033 $match_id = $match['id'] ?? '';
5034 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5035 $all_matches[] = $match;
5036 $seen_ids[$match_id] = true;
5037 }
5038 }
5039 continue;
5040 }
5041
5042 $response = wp_remote_post($query_url, array(
5043 'headers' => array(
5044 'Api-Key' => $api_key,
5045 'Content-Type' => 'application/json'
5046 ),
5047 'body' => json_encode($query_data),
5048 'timeout' => 30
5049 ));
5050
5051 if (is_wp_error($response)) {
5052 continue;
5053 }
5054
5055 $response_code = wp_remote_retrieve_response_code($response);
5056
5057 if ($response_code !== 200) {
5058 continue;
5059 }
5060
5061 $body = wp_remote_retrieve_body($response);
5062 $data = json_decode($body, true);
5063
5064 if (isset($data['matches'])) {
5065 foreach ($data['matches'] as $match) {
5066 $match_id = $match['id'] ?? '';
5067 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5068 $all_matches[] = $match;
5069 $seen_ids[$match_id] = true;
5070 }
5071 }
5072 }
5073 }
5074
5075 // Convert matches to processed data format, grouping by URL to count chunks
5076 $processed_data = array();
5077 $url_chunk_counts = array();
5078
5079 foreach ($all_matches as $match) {
5080 $metadata = $match['metadata'] ?? array();
5081 $source_url = $metadata['source_url'] ?? '';
5082 $match_id = $match['id'] ?? '';
5083
5084 if (!empty($source_url) && !empty($match_id)) {
5085 $post_id = url_to_postid($source_url);
5086 // A media entry is stored under its FILE url, which url_to_postid() cannot
5087 // resolve — without this the Pinecone read reports every imported
5088 // attachment as unprocessed while the WordPress-DB read (which goes
5089 // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
5090 if (!$post_id) {
5091 $post_id = (int) attachment_url_to_postid($source_url);
5092 }
5093 if ($post_id) {
5094 // Count chunks per post_id
5095 if (!isset($url_chunk_counts[$post_id])) {
5096 $url_chunk_counts[$post_id] = 0;
5097 }
5098 $url_chunk_counts[$post_id]++;
5099
5100 $created_at = $metadata['created_at'] ?? '';
5101 $processed_date = 'Recently';
5102
5103 if (!empty($created_at)) {
5104 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
5105 if ($timestamp) {
5106 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
5107 }
5108 }
5109
5110 // Only store if not already set, or update with newer timestamp
5111 if (!isset($processed_data[$post_id]) ||
5112 ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
5113 $processed_data[$post_id] = array(
5114 'db_id' => $match_id,
5115 'processed_date' => $processed_date,
5116 'url' => $source_url,
5117 'source' => 'pinecone',
5118 'timestamp' => $timestamp ?? current_time('timestamp')
5119 );
5120 }
5121 }
5122 }
5123 }
5124
5125 // Add chunk counts to processed data
5126 foreach ($url_chunk_counts as $post_id => $chunk_count) {
5127 if (isset($processed_data[$post_id])) {
5128 $processed_data[$post_id]['chunk_count'] = $chunk_count;
5129 }
5130 }
5131
5132 return $processed_data;
5133
5134 } catch (Exception $e) {
5135 return array();
5136 }
5137 }
5138 /**
5139 * Generate embeddings from input text for MXChat with bot support
5140 */
5141 private function mxchat_generate_embedding($text, $bot_id = 'default') {
5142 // Enable detailed logging for debugging
5143 //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
5144 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
5145
5146 // Get bot-specific options
5147 $bot_options = $this->get_bot_options($bot_id);
5148 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
5149
5150 // Opt-in: when the custom provider is selected for embeddings, index through
5151 // the same custom endpoint the query path uses so stored vectors and query
5152 // vectors share a model. Returns the vector array on success, or an error
5153 // string on failure (this function's existing failure contract).
5154 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5155 if (!class_exists('MxChat_Utils')) {
5156 require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
5157 }
5158 return MxChat_Utils::generate_embedding_custom($text, $options);
5159 }
5160
5161 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5162 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
5163
5164 // Determine provider and endpoint
5165 if (strpos($selected_model, 'voyage') === 0) {
5166 $api_key = $options['voyage_api_key'] ?? '';
5167 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5168 $provider_name = 'Voyage AI';
5169 //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
5170 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5171 $api_key = $options['gemini_api_key'] ?? '';
5172 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5173 $provider_name = 'Google Gemini';
5174 //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
5175 } else {
5176 $api_key = $options['api_key'] ?? '';
5177 $endpoint = 'https://api.openai.com/v1/embeddings';
5178 $provider_name = 'OpenAI';
5179 //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
5180 }
5181
5182 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
5183
5184 if (empty($api_key)) {
5185 $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
5186 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
5187 return $error_message;
5188 }
5189
5190 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
5191 $estimated_tokens = ceil(str_word_count($text) / 0.75);
5192 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
5193
5194 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
5195 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
5196 // Consider truncating text here
5197 }
5198
5199 // Prepare request body based on provider
5200 if (strpos($selected_model, 'gemini-embedding') === 0) {
5201 // Gemini API format
5202 $request_body = array(
5203 'model' => 'models/' . $selected_model,
5204 'content' => array(
5205 'parts' => array(
5206 array('text' => $text)
5207 )
5208 )
5209 );
5210
5211 // Set output dimensionality to 1536 for consistency with other models
5212 $request_body['outputDimensionality'] = 1536;
5213 } else {
5214 // OpenAI/Voyage API format
5215 $request_body = array(
5216 'model' => $selected_model,
5217 'input' => $text
5218 );
5219
5220 // Add output_dimension for voyage-3-large model
5221 if ($selected_model === 'voyage-3-large') {
5222 $request_body['output_dimension'] = 2048;
5223 }
5224 }
5225
5226 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
5227
5228 // Prepare headers based on provider
5229 if (strpos($selected_model, 'gemini-embedding') === 0) {
5230 // Gemini uses API key as query parameter
5231 $endpoint .= '?key=' . $api_key;
5232 $headers = array(
5233 'Content-Type' => 'application/json'
5234 );
5235 } else {
5236 // OpenAI/Voyage use Bearer token
5237 $headers = array(
5238 'Authorization' => 'Bearer ' . $api_key,
5239 'Content-Type' => 'application/json'
5240 );
5241 }
5242
5243 // Make API request
5244 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
5245 $response = wp_remote_post($endpoint, array(
5246 'body' => wp_json_encode($request_body),
5247 'headers' => $headers,
5248 'timeout' => 60 // Increased timeout for large inputs
5249 ));
5250
5251 // Handle wp_remote_post errors
5252 if (is_wp_error($response)) {
5253 $error_message = $response->get_error_message();
5254 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
5255 return 'Connection error: ' . $error_message;
5256 }
5257
5258 // Get and check HTTP response code
5259 $http_code = wp_remote_retrieve_response_code($response);
5260 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
5261
5262 if ($http_code !== 200) {
5263 $error_body = wp_remote_retrieve_body($response);
5264 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
5265
5266 // Try to parse error for more details
5267 $error_json = json_decode($error_body, true);
5268 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
5269 $error_type = $error_json['error']['type'] ?? 'unknown';
5270 $error_message = $error_json['error']['message'] ?? 'No message';
5271 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
5272 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
5273
5274 // Keep the provider's own diagnostic — a restricted-key 401 names the
5275 // exact missing scope, and replacing it with "check your API key" sent
5276 // a customer to regenerate two keys (plan 46b596). Same shape as
5277 // MxChat_Utils::embedding_failure_error() so both ingestion paths read
5278 // identically. Key never appears in provider messages, but scrub anyway.
5279 if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
5280 if (is_string($api_key) && $api_key !== '') {
5281 $error_message = str_replace($api_key, '[redacted]', $error_message);
5282 }
5283 $error_message = sprintf(
5284 'Embedding failed (%s, HTTP %d): %s',
5285 $selected_model,
5286 $http_code,
5287 substr($error_message, 0, 300)
5288 );
5289 }
5290
5291 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5292 return $error_message;
5293 }
5294
5295 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
5296 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5297 return $error_message;
5298 }
5299
5300 // Parse response body
5301 $response_body = wp_remote_retrieve_body($response);
5302 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
5303
5304 $response_data = json_decode($response_body, true);
5305
5306 if (json_last_error() !== JSON_ERROR_NONE) {
5307 $error = json_last_error_msg();
5308 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
5309 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
5310 return "Failed to parse API response: $error";
5311 }
5312
5313 // Handle different response formats based on provider
5314 if (strpos($selected_model, 'gemini-embedding') === 0) {
5315 // Gemini API response format
5316 if (isset($response_data['embedding']['values'])) {
5317 $embedding_dimensions = count($response_data['embedding']['values']);
5318 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
5319
5320 // Check if embedding dimensions are as expected (should be 1536)
5321 if ($embedding_dimensions !== 1536) {
5322 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
5323 }
5324
5325 return $response_data['embedding']['values'];
5326 } else {
5327 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
5328 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5329
5330 if (isset($response_data['error'])) {
5331 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
5332 //error_log('[MXCHAT-EMBED] ' . $error_message);
5333 return $error_message;
5334 }
5335
5336 $error_message = "Invalid Gemini API response format: No embedding found";
5337 //error_log('[MXCHAT-EMBED] ' . $error_message);
5338 return $error_message;
5339 }
5340 } else {
5341 // OpenAI/Voyage API response format
5342 if (isset($response_data['data'][0]['embedding'])) {
5343 $embedding_dimensions = count($response_data['data'][0]['embedding']);
5344 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
5345
5346 // Check if embedding dimensions are as expected
5347 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
5348 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
5349 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
5350 }
5351
5352 return $response_data['data'][0]['embedding'];
5353 } else {
5354 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
5355 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5356
5357 if (isset($response_data['error'])) {
5358 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
5359 //error_log('[MXCHAT-EMBED] ' . $error_message);
5360 return $error_message;
5361 }
5362
5363 $error_message = "Invalid API response format: No embedding found";
5364 //error_log('[MXCHAT-EMBED] ' . $error_message);
5365 return $error_message;
5366 }
5367 }
5368 }
5369
5370 /**
5371 * Get bot-specific options for multi-bot functionality
5372 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
5373 */
5374 private function get_bot_options($bot_id = 'default') {
5375 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
5376
5377 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5378 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
5379 return array();
5380 }
5381
5382 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
5383
5384 if (!empty($bot_options)) {
5385 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
5386 if (isset($bot_options['similarity_threshold'])) {
5387 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
5388 }
5389 }
5390
5391 return is_array($bot_options) ? $bot_options : array();
5392 }
5393
5394 /**
5395 * Get bot-specific Pinecone configuration
5396 * Used in the knowledge retrieval functions
5397 */
5398 // Also add debugging to your get_bot_pinecone_config function
5399 private function get_bot_pinecone_config($bot_id = 'default') {
5400 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
5401
5402 // If default bot or multi-bot add-on not active, use default Pinecone config
5403 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5404 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
5405 $addon_options = get_option('mxchat_pinecone_addon_options', array());
5406 $config = array(
5407 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
5408 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
5409 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
5410 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
5411 );
5412 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
5413 return $config;
5414 }
5415
5416 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
5417
5418 // Hook for multi-bot add-on to provide bot-specific Pinecone config
5419 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
5420
5421 if (!empty($bot_pinecone_config)) {
5422 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
5423 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
5424 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
5425 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
5426 } else {
5427 //error_log("MXCHAT DEBUG: Filter returned empty config!");
5428 }
5429
5430 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
5431 }
5432
5433
5434 public function mxchat_ajax_dismiss_completed_status() {
5435 try {
5436 // Verify the request
5437 check_ajax_referer('mxchat_status_nonce', 'nonce');
5438
5439 if (!current_user_can('manage_options')) {
5440 wp_send_json_error('Unauthorized access');
5441 exit;
5442 }
5443
5444 $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
5445
5446 if ($card_type === 'pdf') {
5447 // Clear PDF status
5448 $pdf_url = get_transient('mxchat_last_pdf_url');
5449 if ($pdf_url) {
5450 delete_transient('mxchat_pdf_status_' . md5($pdf_url));
5451 delete_transient('mxchat_last_pdf_url');
5452 }
5453 } elseif ($card_type === 'sitemap') {
5454 // Clear sitemap status
5455 $sitemap_url = get_transient('mxchat_last_sitemap_url');
5456 if ($sitemap_url) {
5457 delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
5458 delete_transient('mxchat_last_sitemap_url');
5459 }
5460 }
5461
5462 wp_send_json_success(array('message' => 'Status dismissed successfully'));
5463
5464 } catch (Exception $e) {
5465 wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
5466 }
5467 }
5468
5469 /**
5470 * Render completed status cards on page load
5471 * This ensures completed processing status persists through page refreshes
5472 */
5473 public function mxchat_render_completed_status_cards() {
5474 $output = '';
5475
5476 // Check for completed PDF status
5477 $pdf_url = get_transient('mxchat_last_pdf_url');
5478 if ($pdf_url) {
5479 $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
5480 if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
5481 $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
5482 }
5483 }
5484
5485 // Check for completed sitemap status
5486 $sitemap_url = get_transient('mxchat_last_sitemap_url');
5487 if ($sitemap_url) {
5488 $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
5489 if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
5490 $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
5491 }
5492 }
5493
5494 return $output;
5495 }
5496
5497 /**
5498 * Render PDF status card HTML
5499 */
5500 private function mxchat_render_pdf_status_card($status, $pdf_url) {
5501 $html = '<div class="mxchat-status-card" data-card-type="pdf">';
5502 $html .= '<div class="mxchat-status-header">';
5503 $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
5504
5505 // Add dismiss button for completed status
5506 if ($status['status'] === 'complete' || $status['status'] === 'error') {
5507 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5508 }
5509
5510 // Process Batch button for processing status
5511 if ($status['status'] === 'processing') {
5512 $html .= '<button type="button" class="mxchat-manual-batch-btn"
5513 data-process-type="pdf"
5514 data-url="' . esc_attr($pdf_url) . '">
5515 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5516 }
5517
5518 // Add status badges
5519 if ($status['status'] === 'error') {
5520 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5521 } elseif ($status['status'] === 'complete') {
5522 if ($status['failed_pages'] > 0) {
5523 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5524 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
5525 } else {
5526 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5527 }
5528 }
5529
5530 $html .= '</div>'; // End header
5531
5532 // Progress bar
5533 $html .= '<div class="mxchat-progress-bar">';
5534 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5535 $html .= '</div>';
5536
5537 // Status details
5538 $html .= '<div class="mxchat-status-details">';
5539 $html .= '<p>' . sprintf(
5540 esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
5541 $status['processed_pages'],
5542 $status['total_pages'],
5543 $status['percentage']
5544 ) . '</p>';
5545
5546 // Show failed pages count if any
5547 if ($status['failed_pages'] > 0) {
5548 $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
5549 }
5550
5551 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5552 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5553
5554 // Add completion summary if available AND it's an array
5555 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5556 $summary = $status['completion_summary'];
5557 $html .= '<div class="mxchat-completion-summary">';
5558 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5559 $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
5560 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
5561 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
5562 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5563 $html .= '</div>';
5564 }
5565
5566 // Add failed pages list if any AND it's an array
5567 if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
5568 $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
5569 }
5570
5571 // Add error message if any
5572 if (isset($status['error']) && !empty($status['error'])) {
5573 $html .= '<div class="mxchat-error-notice">';
5574 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5575 $html .= '</div>';
5576 }
5577
5578 $html .= '</div>'; // End details
5579 $html .= '</div>'; // End card
5580
5581 return $html;
5582 }
5583 /**
5584 * Render sitemap status card HTML
5585 */
5586 private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
5587 $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
5588 $html .= '<div class="mxchat-status-header">';
5589 $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
5590
5591 // Add dismiss button for completed status
5592 if ($status['status'] === 'complete' || $status['status'] === 'error') {
5593 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5594 }
5595
5596 // Process Batch button for processing status
5597 if ($status['status'] === 'processing') {
5598 $html .= '<button type="button" class="mxchat-manual-batch-btn"
5599 data-process-type="sitemap"
5600 data-url="' . esc_attr($sitemap_url) . '">
5601 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5602 }
5603
5604 // Add status badges
5605 if ($status['status'] === 'error') {
5606 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5607 } elseif ($status['status'] === 'complete') {
5608 if ($status['failed_urls'] > 0) {
5609 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5610 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
5611 } else {
5612 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5613 }
5614 }
5615
5616 $html .= '</div>'; // End header
5617
5618 // Progress bar
5619 $html .= '<div class="mxchat-progress-bar">';
5620 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5621 $html .= '</div>';
5622
5623 // Status details
5624 $html .= '<div class="mxchat-status-details">';
5625 $html .= '<p>' . sprintf(
5626 esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
5627 $status['processed_urls'],
5628 $status['total_urls'],
5629 $status['percentage']
5630 ) . '</p>';
5631
5632 // Show failed URLs count if any
5633 if ($status['failed_urls'] > 0) {
5634 $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
5635 }
5636
5637 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5638 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5639
5640 // Add completion summary if available AND it's an array
5641 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5642 $summary = $status['completion_summary'];
5643 $html .= '<div class="mxchat-completion-summary">';
5644 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5645 $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
5646 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
5647 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
5648 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5649 $html .= '</div>';
5650 }
5651
5652 // Add error messages if any (but not the failed URLs list)
5653 if (!empty($status['error']) || !empty($status['last_error'])) {
5654 $html .= '<div class="mxchat-error-notice">';
5655
5656 if (!empty($status['error'])) {
5657 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5658 }
5659
5660 if (!empty($status['last_error'])) {
5661 $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
5662 }
5663
5664 $html .= '</div>';
5665 }
5666
5667 $html .= '</div>'; // End details
5668 $html .= '</div>'; // End card
5669
5670 return $html;
5671 }
5672
5673
5674 /**
5675 * Render failed pages list
5676 */
5677 private function mxchat_render_failed_pages_list($failed_pages_list) {
5678 // Validate that $failed_pages_list is an array and not empty
5679 if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
5680 return '';
5681 }
5682
5683 $html = '<div class="mxchat-error-notice">';
5684 $html .= '<div class="mxchat-failed-pages-container">';
5685 $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
5686 $html .= '<details>';
5687 $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
5688 $html .= '<div class="mxchat-failed-pages-list">';
5689
5690 // Create table for failed pages
5691 $html .= '<table class="widefat striped">';
5692 $html .= '<thead><tr>';
5693 $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
5694 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5695 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5696 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5697 $html .= '</tr></thead><tbody>';
5698
5699 // Sort failed pages by most recent
5700 $sorted_failed_pages = $failed_pages_list;
5701 usort($sorted_failed_pages, function($a, $b) {
5702 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5703 });
5704
5705 foreach ($sorted_failed_pages as $item) {
5706 // Ensure $item is an array before accessing its elements
5707 if (!is_array($item)) {
5708 continue;
5709 }
5710
5711 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5712 $html .= '<tr>';
5713 $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
5714 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5715 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5716 $html .= '<td>' . esc_html($time_ago) . '</td>';
5717 $html .= '</tr>';
5718 }
5719
5720 $html .= '</tbody></table>';
5721 $html .= '</div></details></div></div>';
5722
5723 return $html;
5724 }
5725
5726 /**
5727 * Render failed URLs list
5728 */
5729 private function mxchat_render_failed_urls_list($failed_urls_list) {
5730 // Validate that $failed_urls_list is an array and not empty
5731 if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5732 return '';
5733 }
5734
5735 $html = '<div class="mxchat-failed-urls-container">';
5736 $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5737 $html .= '<details>';
5738 $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5739 $html .= '<div class="mxchat-failed-urls-list">';
5740
5741 // Create table for failed URLs
5742 $html .= '<table class="widefat striped">';
5743 $html .= '<thead><tr>';
5744 $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5745 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5746 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5747 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5748 $html .= '</tr></thead><tbody>';
5749
5750 // Sort failed URLs by most recent
5751 $sorted_failed_urls = $failed_urls_list;
5752 usort($sorted_failed_urls, function($a, $b) {
5753 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5754 });
5755
5756 // Show up to 50 failed URLs
5757 $display_urls = array_slice($sorted_failed_urls, 0, 50);
5758
5759 foreach ($display_urls as $item) {
5760 // Ensure $item is an array before accessing its elements
5761 if (!is_array($item)) {
5762 continue;
5763 }
5764
5765 $url = $item['url'] ?? '';
5766 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5767
5768 // Truncate URL for display
5769 $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5770
5771 $html .= '<tr>';
5772 $html .= '<td style="word-break: break-all;">';
5773 if (!empty($url)) {
5774 $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5775 } else {
5776 $html .= esc_html__('Unknown URL', 'mxchat');
5777 }
5778 $html .= '</td>';
5779 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5780 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5781 $html .= '<td>' . esc_html($time_ago) . '</td>';
5782 $html .= '</tr>';
5783 }
5784
5785 $html .= '</tbody></table>';
5786
5787 if (count($failed_urls_list) > 50) {
5788 $html .= '<div class="mxchat-failed-urls-more">+ ' .
5789 (count($failed_urls_list) - 50) .
5790 ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5791 }
5792
5793 $html .= '</div></details></div>';
5794
5795 return $html;
5796 }
5797
5798 /**
5799 * Get all ACF fields for a specific post, excluding any fields the user has disabled
5800 */
5801 public function mxchat_get_acf_fields_for_post($post_id) {
5802 if (!function_exists('get_field_objects')) {
5803 return array();
5804 }
5805
5806 // Field OBJECTS, not get_fields(): exclusion matches on the field KEY
5807 // (unique per field) rather than the name (shared across groups — plan
5808 // 30e81f). ACF's own get_fields() is implemented as get_field_objects()
5809 // reduced to name => value, so the un-excluded reduction below is the
5810 // identical shape and order the previous get_fields() call produced.
5811 $field_objects = get_field_objects($post_id);
5812 if (!$field_objects || !is_array($field_objects)) {
5813 return array();
5814 }
5815
5816 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5817 if (!is_array($excluded_fields)) {
5818 $excluded_fields = array();
5819 }
5820
5821 $fields = array();
5822 foreach ($field_objects as $field_name => $field_object) {
5823 if (!empty($excluded_fields)) {
5824 $field_key = isset($field_object['key']) ? $field_object['key'] : '';
5825 // Legacy name entries stay honored: a stored name whose group was
5826 // inactive at migration time still excludes every field wearing it.
5827 if (in_array($field_key, $excluded_fields, true) || in_array($field_name, $excluded_fields, true)) {
5828 continue;
5829 }
5830 }
5831 $fields[$field_name] = isset($field_object['value']) ? $field_object['value'] : null;
5832 }
5833
5834 return $fields;
5835 }
5836
5837 /**
5838 * Get all registered ACF field groups and their fields for the settings UI
5839 */
5840 public function mxchat_get_all_acf_fields() {
5841 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5842 return array();
5843 }
5844
5845 // Keyed by GROUP KEY, not title (titles are not unique), and each field
5846 // entry carries its ACF field key — the unique identifier every toggle,
5847 // save, and index-time exclusion now runs on (plans 30e81f / bf57e0).
5848 $all_fields = array();
5849 $field_groups = acf_get_field_groups();
5850
5851 if (!empty($field_groups)) {
5852 foreach ($field_groups as $group) {
5853 $group_fields = acf_get_fields($group['key']);
5854 if (!empty($group_fields)) {
5855 $entry = array(
5856 'title' => $group['title'],
5857 'fields' => array(),
5858 );
5859 foreach ($group_fields as $field) {
5860 $entry['fields'][] = array(
5861 'key' => $field['key'],
5862 'name' => $field['name'],
5863 'label' => $field['label'],
5864 'type' => $field['type']
5865 );
5866 }
5867 $all_fields[$group['key']] = $entry;
5868 }
5869 }
5870 }
5871
5872 return $all_fields;
5873 }
5874
5875 /**
5876 * Get whitelisted custom post meta for a given post
5877 * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
5878 */
5879 public function mxchat_get_whitelisted_post_meta($post_id) {
5880 $whitelist = get_option('mxchat_custom_meta_whitelist', '');
5881
5882 if (empty($whitelist)) {
5883 return array();
5884 }
5885
5886 // Parse the whitelist - one meta key per line
5887 $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
5888
5889 if (empty($meta_keys)) {
5890 return array();
5891 }
5892
5893 $result = array();
5894
5895 foreach ($meta_keys as $key) {
5896 // Skip empty keys
5897 if (empty($key)) {
5898 continue;
5899 }
5900
5901 $value = get_post_meta($post_id, $key, true);
5902
5903 // Only include non-empty string values
5904 if (!empty($value) && is_string($value)) {
5905 $result[$key] = $value;
5906 } elseif (!empty($value) && is_array($value)) {
5907 // Handle array values by joining them
5908 $flat_value = $this->mxchat_flatten_meta_array($value);
5909 if (!empty($flat_value)) {
5910 $result[$key] = $flat_value;
5911 }
5912 }
5913 }
5914
5915 return $result;
5916 }
5917
5918 /**
5919 * Flatten array meta values into a readable string
5920 */
5921 private function mxchat_flatten_meta_array($array, $depth = 0) {
5922 if ($depth > 3) {
5923 return ''; // Prevent infinite recursion
5924 }
5925
5926 $parts = array();
5927
5928 foreach ($array as $key => $value) {
5929 if (is_string($value) && !empty($value)) {
5930 $parts[] = $value;
5931 } elseif (is_array($value)) {
5932 $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
5933 if (!empty($nested)) {
5934 $parts[] = $nested;
5935 }
5936 }
5937 }
5938
5939 return implode(', ', $parts);
5940 }
5941
5942 /**
5943 * Format ACF field values for content extraction
5944 */
5945 public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
5946 if (empty($value)) {
5947 return '';
5948 }
5949
5950 // Handle WP_Post objects first (THIS IS THE KEY FIX)
5951 if ($value instanceof WP_Post) {
5952 return $value->post_title ?: '';
5953 }
5954
5955 // Handle other WP objects
5956 if (is_object($value)) {
5957 if (isset($value->post_title)) {
5958 return $value->post_title;
5959 } elseif (isset($value->display_name)) {
5960 return $value->display_name;
5961 } elseif (isset($value->name)) {
5962 return $value->name;
5963 } elseif (method_exists($value, '__toString')) {
5964 try {
5965 return (string) $value;
5966 } catch (Exception $e) {
5967 return '';
5968 }
5969 }
5970 // For any other objects, return empty string
5971 return '';
5972 }
5973
5974 // Handle different ACF field types
5975 if (is_array($value)) {
5976 // Check if it's an image/file field
5977 if (isset($value['url'])) {
5978 // Image field - return alt text, title, or caption
5979 if (!empty($value['alt'])) {
5980 return $value['alt'];
5981 } elseif (!empty($value['title'])) {
5982 return $value['title'];
5983 } elseif (!empty($value['caption'])) {
5984 return $value['caption'];
5985 } else {
5986 return ''; // Don't include just the URL
5987 }
5988 }
5989
5990 // Check if it's a post object or relationship field
5991 if (isset($value['post_title'])) {
5992 return $value['post_title'];
5993 }
5994
5995 // Check if it's a user field
5996 if (isset($value['display_name'])) {
5997 return $value['display_name'];
5998 }
5999
6000 // Check if it's a taxonomy term
6001 if (isset($value['name']) && isset($value['taxonomy'])) {
6002 return $value['name'];
6003 }
6004
6005 // Check if it's a select field with label
6006 if (isset($value['label'])) {
6007 return $value['label'];
6008 }
6009
6010 // Check for repeater field or flexible content
6011 if (is_numeric(key($value))) {
6012 $sub_values = array();
6013 foreach ($value as $sub_item) {
6014 if (is_array($sub_item)) {
6015 // For repeater/flexible content, extract text values
6016 $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
6017 if (!empty($sub_text)) {
6018 $sub_values[] = $sub_text;
6019 }
6020 } elseif ($sub_item instanceof WP_Post) {
6021 // Handle WP_Post objects in arrays
6022 $sub_values[] = $sub_item->post_title ?: '';
6023 } else {
6024 $sub_values[] = (string) $sub_item;
6025 }
6026 }
6027 return implode(', ', array_filter($sub_values));
6028 }
6029
6030 // For other arrays, try to extract meaningful text
6031 $text_values = array();
6032 foreach ($value as $key => $val) {
6033 if (is_string($val) && !empty(trim($val))) {
6034 $text_values[] = trim($val);
6035 } elseif ($val instanceof WP_Post) {
6036 // Handle WP_Post objects in associative arrays
6037 $text_values[] = $val->post_title ?: '';
6038 } elseif (is_array($val) && isset($val['post_title'])) {
6039 $text_values[] = $val['post_title'];
6040 } elseif (is_array($val) && isset($val['name'])) {
6041 $text_values[] = $val['name'];
6042 }
6043 }
6044
6045 return implode(', ', array_filter($text_values));
6046 }
6047
6048 // Handle boolean values
6049 if (is_bool($value)) {
6050 return $value ? 'Yes' : 'No';
6051 }
6052
6053 // Handle numeric values
6054 if (is_numeric($value)) {
6055 return (string) $value;
6056 }
6057
6058 // Handle string values
6059 if (is_string($value)) {
6060 return trim($value);
6061 }
6062
6063 // For anything else that we can't handle, return empty string
6064 // This prevents the "Object could not be converted to string" error
6065 return '';
6066 }
6067
6068 /**
6069 * Extract text from complex ACF array structures
6070 */
6071 private function mxchat_extract_text_from_acf_array($array) {
6072 if (!is_array($array)) {
6073 return '';
6074 }
6075
6076 $text_parts = array();
6077
6078 foreach ($array as $key => $value) {
6079 if (is_string($value) && !empty(trim($value))) {
6080 // Skip keys that are likely to be IDs or technical values
6081 if (!is_numeric($value) || strlen($value) > 10) {
6082 $text_parts[] = trim($value);
6083 }
6084 } elseif ($value instanceof WP_Post) {
6085 // Handle WP_Post objects
6086 $text_parts[] = $value->post_title ?: '';
6087 } elseif (is_array($value)) {
6088 if (isset($value['post_title'])) {
6089 $text_parts[] = $value['post_title'];
6090 } elseif (isset($value['name'])) {
6091 $text_parts[] = $value['name'];
6092 } elseif (isset($value['label'])) {
6093 $text_parts[] = $value['label'];
6094 }
6095 } elseif (is_object($value)) {
6096 // Handle other objects safely
6097 if (isset($value->post_title)) {
6098 $text_parts[] = $value->post_title;
6099 } elseif (isset($value->name)) {
6100 $text_parts[] = $value->name;
6101 } elseif (isset($value->display_name)) {
6102 $text_parts[] = $value->display_name;
6103 }
6104 }
6105 }
6106
6107 return implode(', ', array_filter($text_parts));
6108 }
6109
6110 /**
6111 * Walk an ACF field value tree and collect attachment IDs for any value that
6112 * resolves to a PDF in the WordPress media library. Handles the three shapes
6113 * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
6114 * plain URL string), and recurses through repeater/group/flexible content.
6115 *
6116 * @param mixed $value The ACF field value (any depth)
6117 * @param array $out Accumulator (passed by reference) for attachment IDs
6118 * @param int $depth Recursion guard
6119 */
6120 private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
6121 if ($depth > 6) {
6122 return; // prevent runaway recursion on circular/very-deep structures
6123 }
6124
6125 if (empty($value)) {
6126 return;
6127 }
6128
6129 // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
6130 if (is_array($value)) {
6131 // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
6132 $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
6133 if ($looks_like_attachment) {
6134 $att_id = 0;
6135 if (!empty($value['ID']) && is_numeric($value['ID'])) {
6136 $att_id = (int) $value['ID'];
6137 } elseif (!empty($value['id']) && is_numeric($value['id'])) {
6138 $att_id = (int) $value['id'];
6139 } elseif (!empty($value['url']) && is_string($value['url'])) {
6140 $att_id = (int) attachment_url_to_postid($value['url']);
6141 }
6142
6143 $is_pdf = false;
6144 if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
6145 $is_pdf = true;
6146 } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
6147 $is_pdf = true;
6148 } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
6149 $is_pdf = true;
6150 } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
6151 $is_pdf = true;
6152 }
6153
6154 if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
6155 $out[] = $att_id;
6156 }
6157 // An array node that represents one attachment doesn't contain other
6158 // attachments inside it — done with this branch.
6159 return;
6160 }
6161
6162 // Recurse: repeater rows, flexible-content layouts, groups, etc.
6163 foreach ($value as $sub) {
6164 $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
6165 }
6166 return;
6167 }
6168
6169 // Plain numeric attachment ID (ACF File field set to "Return: ID")
6170 if (is_numeric($value)) {
6171 $att_id = (int) $value;
6172 if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6173 $out[] = $att_id;
6174 }
6175 return;
6176 }
6177
6178 // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
6179 if (is_string($value)) {
6180 $trimmed = trim($value);
6181 if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
6182 $att_id = (int) attachment_url_to_postid($trimmed);
6183 if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6184 $out[] = $att_id;
6185 }
6186 }
6187 return;
6188 }
6189 }
6190
6191 /**
6192 * Heuristic: does this URL/string look like a PDF reference?
6193 * Tolerates query strings and fragments (#page=2).
6194 */
6195 private function mxchat_url_looks_like_pdf($url) {
6196 if (!is_string($url) || $url === '') {
6197 return false;
6198 }
6199 // Strip query + fragment before checking extension
6200 $path = preg_replace('/[?#].*$/', '', $url);
6201 return (bool) preg_match('/\.pdf$/i', $path);
6202 }
6203
6204 /**
6205 * Extract text from a PDF attachment by ID using the bundled Smalot parser.
6206 * Reads the file directly from disk via get_attached_file (no HTTP fetch).
6207 * Result is cached on the attachment as post_meta keyed by file mtime so we
6208 * only parse the same PDF once unless the file changes on disk.
6209 *
6210 * @param int $attachment_id
6211 * @return string Extracted plain text, or '' on failure.
6212 */
6213 private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
6214 $attachment_id = (int) $attachment_id;
6215 if ($attachment_id <= 0) {
6216 return '';
6217 }
6218 if (get_post_mime_type($attachment_id) !== 'application/pdf') {
6219 return '';
6220 }
6221
6222 $pdf_path = get_attached_file($attachment_id);
6223 if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
6224 return '';
6225 }
6226
6227 // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
6228 // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
6229 $default_max_bytes = 25 * 1024 * 1024;
6230 $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
6231 if ($max_bytes > 0) {
6232 $file_size = @filesize($pdf_path);
6233 if ($file_size !== false && $file_size > $max_bytes) {
6234 error_log(sprintf(
6235 '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
6236 $attachment_id,
6237 basename($pdf_path),
6238 $file_size,
6239 $max_bytes
6240 ));
6241 return '';
6242 }
6243 }
6244
6245 $mtime = @filemtime($pdf_path);
6246 $cache_meta_key = '_mxchat_acf_pdf_text_v1';
6247 $cached = get_post_meta($attachment_id, $cache_meta_key, true);
6248 if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
6249 return (string) $cached['text'];
6250 }
6251
6252 $text = '';
6253 try {
6254 if (function_exists('mxchat_load_pdf_parser')) {
6255 mxchat_load_pdf_parser();
6256 }
6257 if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
6258 return '';
6259 }
6260 $parser = new \Smalot\PdfParser\Parser();
6261 $pdf = $parser->parseFile($pdf_path);
6262 $pages = $pdf->getPages();
6263 $page_texts = array();
6264 $acf_page_num = 0;
6265 foreach ($pages as $page) {
6266 $acf_page_num++;
6267 $page_text = '';
6268 try {
6269 $page_text = $page->getText();
6270 } catch (\Exception $e) {
6271 $page_text = '';
6272 }
6273 if (!empty($page_text)) {
6274 $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num);
6275 $page_texts[] = $page_text;
6276 }
6277 }
6278 $text = trim(implode("\n\n", $page_texts));
6279 } catch (\Exception $e) {
6280 error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
6281 return '';
6282 } catch (\Throwable $e) {
6283 error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
6284 return '';
6285 }
6286
6287 // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
6288 // The chunker downstream will still split this into multiple vectors.
6289 $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
6290 if ($max_len > 0 && strlen($text) > $max_len) {
6291 $text = substr($text, 0, $max_len);
6292 }
6293
6294 update_post_meta($attachment_id, $cache_meta_key, array(
6295 'mtime' => (int) $mtime,
6296 'text' => $text,
6297 ));
6298
6299 return $text;
6300 }
6301
6302 /**
6303 * The media-library MIME types the knowledge base can actually read (plan 01010a).
6304 *
6305 * The importer's post-type dropdown is built from get_post_types(['public' => true]),
6306 * and 'attachment' is public — so Media has always been OFFERED as a source. What it
6307 * could never do is return anything, and the fix is deliberately narrower than the
6308 * offer: only files one of the THREE extractors already shipping in this plugin can
6309 * turn into text belong on that list. Images, audio and video are not listed at all
6310 * rather than listed and greyed out, because "nothing to import here" is a clearer
6311 * answer than a row you cannot select.
6312 *
6313 * text/markdown has no entry in WordPress's default wp_get_mime_types(), so a .md
6314 * file is only ever in the library on a site that added the mapping itself; it stays
6315 * on the list for those sites and costs nothing on the rest.
6316 *
6317 * @return array MIME type strings.
6318 */
6319 public static function mxchat_supported_attachment_mime_types() {
6320 /**
6321 * Filter the attachment MIME types offered by the WordPress Content importer.
6322 *
6323 * Adding a type here makes it listable and importable — mxchat_extract_attachment_text()
6324 * reads anything text/* as plain text, so a text-shaped MIME works out of the box.
6325 * A binary format needs its own extractor.
6326 *
6327 * @param array $mime_types Supported MIME types.
6328 */
6329 return (array) apply_filters('mxchat_kb_attachment_mime_types', array(
6330 'application/pdf',
6331 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
6332 'text/plain',
6333 'text/markdown',
6334 ));
6335 }
6336
6337 /**
6338 * Short human label for a supported attachment MIME type ("PDF", "Word", ...).
6339 * Used in the importer list, where "attachment" as a type and "0 words" as a size
6340 * would tell the user nothing about what they are about to import.
6341 */
6342 public static function mxchat_attachment_type_label($mime_type) {
6343 $labels = array(
6344 'application/pdf' => __('PDF', 'mxchat'),
6345 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => __('Word', 'mxchat'),
6346 'text/plain' => __('Text', 'mxchat'),
6347 'text/markdown' => __('Markdown', 'mxchat'),
6348 );
6349
6350 return isset($labels[$mime_type]) ? $labels[$mime_type] : __('Document', 'mxchat');
6351 }
6352
6353 /**
6354 * Extract indexable text from a media-library attachment (plan 01010a).
6355 *
6356 * Wiring, not parsing: every extractor here already ships and is already used
6357 * elsewhere — the Smalot path via mxchat_extract_pdf_text_by_attachment_id() (ACF
6358 * PDFs), MXChat_Word_Handler::extract_docx_text() and the plain-text read (both from
6359 * Document Upload, which is also where the character-estimated page cap comes from).
6360 * No new parser was added for this.
6361 *
6362 * Returns a reason instead of an empty string on failure so the caller can say WHY a
6363 * file was skipped; an attachment that yields no text must never be stored as an
6364 * empty entry.
6365 *
6366 * @param int $attachment_id
6367 * @return array{text: string, reason: string} reason is '' on success.
6368 */
6369 private function mxchat_extract_attachment_text($attachment_id) {
6370 $attachment_id = (int) $attachment_id;
6371 $mime_type = get_post_mime_type($attachment_id);
6372
6373 if (!in_array($mime_type, self::mxchat_supported_attachment_mime_types(), true)) {
6374 return array(
6375 'text' => '',
6376 'reason' => __('Unsupported file type — only PDF, Word, text and Markdown files can be imported.', 'mxchat'),
6377 );
6378 }
6379
6380 // PDF: the shared extractor owns its own size cap (25 MB raw, filterable), its
6381 // 50,000-character output cap, the RTL normaliser and the mtime-keyed cache, so
6382 // re-importing the same PDF does not re-parse it.
6383 if ($mime_type === 'application/pdf') {
6384 $text = $this->mxchat_extract_pdf_text_by_attachment_id($attachment_id);
6385 $text = trim((string) $text);
6386
6387 if ($text === '') {
6388 return array(
6389 'text' => '',
6390 'reason' => __('No extractable text — the PDF is empty, scanned images only, or larger than the size limit.', 'mxchat'),
6391 );
6392 }
6393
6394 return array('text' => $text, 'reason' => '');
6395 }
6396
6397 $file_path = get_attached_file($attachment_id);
6398 if (empty($file_path) || !file_exists($file_path) || !is_readable($file_path)) {
6399 return array(
6400 'text' => '',
6401 'reason' => __('The file could not be read from disk.', 'mxchat'),
6402 );
6403 }
6404
6405 if ($mime_type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
6406 $text = MXChat_Word_Handler::extract_docx_text($file_path);
6407 if ($text === false) {
6408 return array(
6409 'text' => '',
6410 'reason' => __('The Word file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
6411 );
6412 }
6413 $text = trim((string) $text);
6414 } else {
6415 // .txt / .md read as-is, the same way Document Upload reads them —
6416 // Markdown deliberately keeps its syntax, because headings are useful
6417 // retrieval signal.
6418 $text = (string) file_get_contents($file_path);
6419 $text = wp_check_invalid_utf8($text);
6420 $text = trim($text);
6421 }
6422
6423 if ($text === '') {
6424 return array(
6425 'text' => '',
6426 'reason' => __('The file contains no readable text.', 'mxchat'),
6427 );
6428 }
6429
6430 // Same cap Document Upload applies to the same three formats: the pdf_max_pages
6431 // setting, estimated by CHARACTERS (~2500/page) because the .docx cleaner
6432 // collapses newlines and a paragraph count reads 1 for any Word file. Importing
6433 // is synchronous, so an unbounded document risks a timeout.
6434 $options = get_option('mxchat_options', array());
6435 $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
6436 $estimated_pages = (int) ceil(strlen($text) / 2500);
6437 if ($max_pages > 0 && $estimated_pages > $max_pages) {
6438 return array(
6439 'text' => '',
6440 'reason' => sprintf(
6441 /* translators: 1: estimated page count, 2: configured page limit */
6442 __('Too large (about %1$d pages; the limit is %2$d). Split the file, or raise the PDF max pages setting.', 'mxchat'),
6443 $estimated_pages,
6444 $max_pages
6445 ),
6446 );
6447 }
6448
6449 return array('text' => $text, 'reason' => '');
6450 }
6451
6452 /**
6453 * Assemble the stored text for an attachment: title first, then the extracted body.
6454 * Mirrors the post assembler's shape (title on its own line, blank line, content) so
6455 * a media entry retrieves on its filename/title the same way a post does.
6456 */
6457 private function mxchat_build_attachment_content($post, $extracted_text) {
6458 $title = $this->mxchat_decode_entities_for_indexing($post->post_title);
6459 $parts = array($title);
6460
6461 // The media modal's Caption and Description fields are real, editable metadata
6462 // and are frequently the only human-written context a file has.
6463 $caption = trim(wp_strip_all_tags((string) $post->post_excerpt));
6464 if ($caption !== '') {
6465 $parts[] = $this->mxchat_decode_entities_for_indexing($caption);
6466 }
6467
6468 $description = trim(wp_strip_all_tags((string) $post->post_content));
6469 if ($description !== '') {
6470 $parts[] = $this->mxchat_decode_entities_for_indexing($description);
6471 }
6472
6473 $parts[] = $extracted_text;
6474
6475 return implode("\n\n", array_filter($parts, function ($part) {
6476 return $part !== '';
6477 }));
6478 }
6479
6480 /**
6481 * Import one media-library attachment into the knowledge base — the attachment
6482 * branch of ajax_mxchat_process_selected_content (plan 01010a).
6483 *
6484 * Separate from the post path rather than folded into it because almost every step
6485 * differs: the source URL is the FILE url (get_permalink() on an attachment returns
6486 * its attachment page, which is not what anyone means by "where this document
6487 * lives"), the body comes from an extractor instead of post_content, and a file that
6488 * yields no text has to be refused with a reason rather than stored empty.
6489 *
6490 * Sends its own JSON response and exits, exactly like its caller.
6491 */
6492 private function mxchat_import_attachment($post, $bot_id) {
6493 $post_id = (int) $post->ID;
6494
6495 $extracted = $this->mxchat_extract_attachment_text($post_id);
6496 if ($extracted['text'] === '') {
6497 wp_send_json_error(sprintf(
6498 /* translators: 1: attachment title, 2: reason the file was skipped */
6499 __('Skipped "%1$s": %2$s', 'mxchat'),
6500 $post->post_title,
6501 $extracted['reason']
6502 ));
6503 exit;
6504 }
6505
6506 $bot_options = $this->get_bot_options($bot_id);
6507 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6508
6509 $preflight = MxChat_Utils::embedding_preflight($options);
6510 if (!$preflight['ok']) {
6511 MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (attachment import)');
6512 wp_send_json_error($preflight['reason']);
6513 exit;
6514 }
6515
6516 $source_url = wp_get_attachment_url($post_id);
6517 if (empty($source_url)) {
6518 wp_send_json_error(__('The file has no URL — it may be missing from the uploads folder.', 'mxchat'));
6519 exit;
6520 }
6521
6522 $vector_id = md5($source_url);
6523 $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6524
6525 // "Already imported" read, same two-backend split the post path uses.
6526 $is_update = false;
6527 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6528 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
6529
6530 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
6531 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
6532 if (isset($pinecone_data[$post_id])) {
6533 $is_update = true;
6534 }
6535 } else {
6536 global $wpdb;
6537 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6538 $existing_record = $wpdb->get_row($wpdb->prepare(
6539 "SELECT id FROM $table_name WHERE source_url = %s",
6540 $source_url
6541 ));
6542 if ($existing_record) {
6543 $is_update = true;
6544 }
6545 }
6546
6547 // content_type 'attachment' — NOT 'document'. The Knowledge screen's type filter
6548 // is built from public post types, so 'attachment' is exactly what its "Media"
6549 // option filters on; 'document' (Document Upload's label) would file these under
6550 // an option that dropdown does not offer.
6551 $result = MxChat_Utils::submit_content_to_db(
6552 $content,
6553 $source_url,
6554 $preflight['api_key'],
6555 $vector_id,
6556 $bot_id,
6557 'attachment'
6558 );
6559
6560 if (is_wp_error($result)) {
6561 MxChat_Admin::mxchat_log_debug('storage_error', 'Attachment storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
6562 wp_send_json_error('Storage failed: ' . $result->get_error_message());
6563 exit;
6564 }
6565
6566 // Attachments carry no tags, so the tag half of this is always a no-op — the
6567 // per-entry dropdown and the MxChat meta box on the attachment's own edit screen
6568 // are what restrict a media entry (plan 6ab002).
6569 $this->apply_role_restriction_to_post($post_id, $source_url);
6570
6571 $this->mxchat_remember_attachment_signature($post_id, $source_url, $content);
6572
6573 wp_send_json_success(array(
6574 'message' => $is_update ? 'Content updated successfully' : 'Content processed successfully',
6575 'post_id' => $post_id,
6576 'title' => $post->post_title,
6577 'operation_type' => $is_update ? 'update' : 'new',
6578 'vector_id' => $vector_id,
6579 'acf_fields_found' => 0,
6580 'pdf_extracted_count' => (get_post_mime_type($post_id) === 'application/pdf') ? 1 : 0,
6581 'content_preview' => substr($content, 0, 100) . '...',
6582 'bot_id' => $bot_id
6583 ));
6584 exit;
6585 }
6586
6587 /**
6588 * Is media auto-sync switched on? The toggle is the generic per-post-type one the
6589 * Advanced Custom Post Sync Settings list already renders for Media (attachment is a
6590 * public post type), so there is no new setting and no new UI for it.
6591 */
6592 private function mxchat_attachment_auto_sync_enabled() {
6593 return get_option('mxchat_auto_sync_attachment') === '1';
6594 }
6595
6596 /**
6597 * Record what we last indexed for an attachment: the URL it was stored under and a
6598 * hash of the stored text.
6599 *
6600 * This exists because 'edit_attachment' is noisy — it fires on every metadata touch,
6601 * including ones that change nothing we index (alt text, regenerating thumbnails).
6602 * Without a signature, each of those would pay for a fresh embedding call. The stored
6603 * URL is also what lets a file REPLACEMENT clean up after itself: the old URL's
6604 * vectors are deleted before the new ones are written, instead of being orphaned.
6605 */
6606 private function mxchat_remember_attachment_signature($attachment_id, $source_url, $content) {
6607 update_post_meta((int) $attachment_id, '_mxchat_kb_attachment_sig', array(
6608 'url' => (string) $source_url,
6609 'hash' => md5((string) $content),
6610 ));
6611 }
6612
6613 /**
6614 * Auto-sync an attachment on upload or edit (add_attachment / edit_attachment).
6615 *
6616 * Silent by design, like the post auto-sync path: a media upload is not a knowledge
6617 * base action from the user's point of view, so a file we cannot read is skipped
6618 * without an admin notice. The manual importer is where a skip gets a visible reason.
6619 */
6620 public function mxchat_handle_attachment_change($attachment_id) {
6621 $attachment_id = (int) $attachment_id;
6622
6623 if (!$this->mxchat_attachment_auto_sync_enabled()) {
6624 return;
6625 }
6626
6627 $post = get_post($attachment_id);
6628 if (!$post || $post->post_type !== 'attachment') {
6629 return;
6630 }
6631
6632 if (!in_array(get_post_mime_type($attachment_id), self::mxchat_supported_attachment_mime_types(), true)) {
6633 return;
6634 }
6635
6636 $source_url = wp_get_attachment_url($attachment_id);
6637 if (empty($source_url)) {
6638 return;
6639 }
6640
6641 $extracted = $this->mxchat_extract_attachment_text($attachment_id);
6642 if ($extracted['text'] === '') {
6643 return;
6644 }
6645
6646 $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6647 $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6648
6649 if (is_array($signature) && isset($signature['url'], $signature['hash'])) {
6650 // Nothing we index changed — don't pay for another embedding.
6651 if ($signature['url'] === $source_url && $signature['hash'] === md5($content)) {
6652 return;
6653 }
6654 // The file was replaced under a new URL. md5(old_url) vectors would survive
6655 // as an entry citing a file that no longer exists there, so clear them first.
6656 if ($signature['url'] !== $source_url && $signature['url'] !== '') {
6657 MxChat_Utils::delete_chunks_for_url($signature['url'], 'default');
6658 }
6659 }
6660
6661 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6662 if (!$preflight['ok']) {
6663 return;
6664 }
6665
6666 $result = MxChat_Utils::submit_content_to_db(
6667 $content,
6668 $source_url,
6669 $preflight['api_key'],
6670 md5($source_url),
6671 'default',
6672 'attachment'
6673 );
6674
6675 if (is_wp_error($result)) {
6676 return;
6677 }
6678
6679 $this->apply_role_restriction_to_post($attachment_id, $source_url);
6680 $this->mxchat_remember_attachment_signature($attachment_id, $source_url, $content);
6681 }
6682
6683 /**
6684 * Remove an attachment's knowledge entry when the file is deleted.
6685 *
6686 * Fires on 'delete_attachment', which is the ONLY delete hook an attachment gets —
6687 * wp_delete_post() hands attachments to wp_delete_attachment() before firing
6688 * before_delete_post, so mxchat_handle_post_delete never runs for one.
6689 *
6690 * Gated on the same auto-sync toggle as the post delete path, for the same reason it
6691 * is there: with auto-sync off, MxChat does not act on content changes by itself.
6692 */
6693 public function mxchat_handle_attachment_delete($attachment_id) {
6694 $attachment_id = (int) $attachment_id;
6695
6696 if (!$this->mxchat_attachment_auto_sync_enabled()) {
6697 return;
6698 }
6699
6700 // Still resolvable — this hook fires before the row and the file are removed.
6701 $source_url = wp_get_attachment_url($attachment_id);
6702 $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6703
6704 $urls = array();
6705 if (!empty($source_url)) {
6706 $urls[] = $source_url;
6707 }
6708 // A replaced-then-deleted file can still have vectors under the URL it was
6709 // last indexed at, if that differs from where it lives now.
6710 if (is_array($signature) && !empty($signature['url'])) {
6711 $urls[] = $signature['url'];
6712 }
6713
6714 foreach (array_unique($urls) as $url) {
6715 MxChat_Utils::delete_chunks_for_url($url, 'default');
6716 }
6717 }
6718
6719 /**
6720 * Handle ACF save - fires after ACF fields are saved
6721 * This ensures ACF field data is available when syncing to knowledge base
6722 */
6723 public function mxchat_handle_acf_save($post_id) {
6724 // Skip if not a valid post
6725 if (!$post_id || $post_id === 'options') {
6726 return;
6727 }
6728
6729 // Skip autosaves and revisions
6730 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6731 return;
6732 }
6733
6734 $post = get_post($post_id);
6735 if (!$post) {
6736 return;
6737 }
6738
6739 $post_type = $post->post_type;
6740
6741 // Check if sync is enabled for this post type
6742 $should_sync = false;
6743
6744 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6745 $should_sync = true;
6746 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6747 $should_sync = true;
6748 } else if ($post_type === 'product' && class_exists('WooCommerce')) {
6749 // WooCommerce products - check if WooCommerce integration is enabled
6750 $options = get_option('mxchat_options', array());
6751 if (isset($options['enable_woocommerce_integration']) &&
6752 ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
6753 $should_sync = true;
6754 }
6755 } else {
6756 // Check custom post types
6757 $option_name = 'mxchat_auto_sync_' . $post_type;
6758 if (get_option($option_name) === '1') {
6759 $should_sync = true;
6760 }
6761 }
6762
6763 if (!$should_sync) {
6764 return;
6765 }
6766
6767 // Only process published posts
6768 if ($post->post_status !== 'publish') {
6769 return;
6770 }
6771
6772 // Check if this post has any ACF fields - if not, no need to re-sync
6773 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6774 if (empty($acf_fields)) {
6775 return;
6776 }
6777
6778 // Use a transient to prevent duplicate processing (post_updated may have already run)
6779 $transient_key = 'mxchat_acf_synced_' . $post_id;
6780 if (get_transient($transient_key)) {
6781 return;
6782 }
6783 set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
6784
6785 // Re-run the sync with ACF data now available
6786 // We pass $update=true since this is effectively an update with ACF data
6787 $this->mxchat_handle_post_update($post_id, $post, true);
6788 }
6789
6790 public function mxchat_handle_post_update($post_id, $post, $update) {
6791 // The in-flight-update marker has done its job the moment post_updated runs; drop it
6792 // before any early return so it can never outlive its own save (a failed $wpdb->update
6793 // inside wp_insert_post returns after pre_post_update but before the transition).
6794 unset($this->pending_post_update[$post_id]);
6795
6796 // Basic validation checks
6797 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6798 return;
6799 }
6800
6801 $post_type = $post->post_type;
6802
6803 // Check if sync is enabled for this post type
6804 $should_sync = false;
6805
6806 // Check built-in post types first
6807 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6808 $should_sync = true;
6809 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6810 $should_sync = true;
6811 } else {
6812 // Check custom post types
6813 $option_name = 'mxchat_auto_sync_' . $post_type;
6814 if (get_option($option_name) === '1') {
6815 $should_sync = true;
6816 }
6817 }
6818
6819 if (!$should_sync) {
6820 return;
6821 }
6822
6823 // Check if we have stored the previous status and URL in our transients
6824 $previous_status_key = 'mxchat_prev_status_' . $post_id;
6825 $previous_status = get_transient($previous_status_key);
6826
6827 $previous_url_key = 'mxchat_prev_url_' . $post_id;
6828 $previous_url = get_transient($previous_url_key);
6829
6830 // If the post was previously published but is now not published, remove from knowledge base
6831 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
6832 // Use the stored URL from when it was published, or fall back to current permalink
6833 $source_url = $previous_url ?: get_permalink($post_id);
6834
6835 // mxchat_handle_status_transition already deleted for this post earlier in this
6836 // request (it fires first inside wp_insert_post); skip the redundant round-trip.
6837 if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
6838 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6839 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6840 }
6841
6842 // Clean up the transients and exit early
6843 delete_transient($previous_status_key);
6844 delete_transient($previous_url_key);
6845 return;
6846 }
6847
6848 // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
6849 // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
6850 if ($post->post_status === 'publish' && !empty($previous_url)) {
6851 $current_url = get_permalink($post_id);
6852 if ($current_url && $current_url !== $previous_url) {
6853 MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
6854 }
6855 }
6856
6857 // Store the current status for next time (if this is an update)
6858 if ($update) {
6859 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
6860
6861 // If the post is currently published, also store its URL
6862 if ($post->post_status === 'publish') {
6863 $current_url = get_permalink($post_id);
6864 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
6865 }
6866 }
6867
6868 // Only process currently published content for adding/updating.
6869 // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
6870 // already indexed this post earlier in this request (editor publishes fire
6871 // transition_post_status first, then post_updated) — skip the duplicate embed.
6872 // Consume-once: the flag is cleared when honoured, so a LATER save of the same
6873 // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
6874 if ($post->post_status === 'publish') {
6875 if (!empty($this->transition_indexed_posts[$post_id])) {
6876 unset($this->transition_indexed_posts[$post_id]);
6877 } else {
6878 $this->mxchat_index_published_post($post_id, $post);
6879 }
6880 }
6881
6882 // Clean up the stored previous status if not used above
6883 if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6884 delete_transient($previous_status_key);
6885 delete_transient($previous_url_key);
6886 }
6887 }
6888
6889 /**
6890 * Index a published post into the knowledge base: preprocessing filter, content
6891 * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
6892 * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
6893 * upsert, then tag-based role restriction.
6894 *
6895 * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
6896 * transition_post_status arrival edge (mxchat_handle_status_transition), so
6897 * scheduled publishes (wp_publish_post) and direct status=publish inserts index
6898 * identically to editor saves (plan 3055e1). Pure extraction of the former
6899 * publish branch — body indentation retained to keep the diff reviewable.
6900 */
6901 private function mxchat_index_published_post($post_id, $post) {
6902 $post_type = $post->post_type;
6903
6904 // WooCommerce products are owned by the WC-object assembler (plan a3d60c):
6905 // whenever WooCommerce is active AND the integration is enabled, every
6906 // product save also fires save_post_product, which queues
6907 // mxchat_store_product_embedding on shutdown — and that writer runs LAST,
6908 // overwriting the same md5(permalink) row this path would write. Assembling
6909 // and embedding the product here was pure duplicate spend (measured: two
6910 // embedding calls per product save, second one wins). Skip ONLY under the
6911 // exact conditions the shutdown writer runs — same option read as its own
6912 // gate — because with the integration off (or WooCommerce inactive) this
6913 // path is the sole product indexer and must keep working.
6914 if ($post_type === 'product'
6915 && class_exists('WooCommerce')
6916 && isset($this->options['enable_woocommerce_integration'])
6917 && in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6918 return;
6919 }
6920
6921 // Get the source URL
6922 $source_url = get_permalink($post_id);
6923
6924 // A draft published programmatically (wp_publish_post) can reach this
6925 // point with an EMPTY post_name — wp_insert_post skips slug generation
6926 // for draft/pending — and get_permalink() then resolves to the bare
6927 // site root. A knowledge row keyed to the homepage cites the wrong URL
6928 // and answers homepage questions with this post's body, so refuse to
6929 // write it; the post indexes correctly on its next save, once the slug
6930 // exists. The empty-post_name test is what keeps a legitimate static
6931 // front page (which has a slug but a root permalink) indexable.
6932 // (Plan d138c4.)
6933 if ('' === $post->post_name
6934 && untrailingslashit($source_url) === untrailingslashit(home_url())) {
6935 return;
6936 }
6937
6938 /**
6939 * Allow developers to modify post data before processing into the knowledge base.
6940 * Same filter and signature as the manual bulk-import path
6941 * (ajax_mxchat_process_selected_content), so a callback registered once covers
6942 * every indexing route. Purely additive — zero behaviour change when unhooked.
6943 * Auto-sync runs under the 'default' bot context, matching the rest of this
6944 * function.
6945 *
6946 * @param WP_Post $post The post about to be indexed.
6947 * @param string $bot_id Bot context ('default' on auto-sync).
6948 */
6949 $post = apply_filters('mxchat_before_process_post', $post, 'default');
6950 if (!($post instanceof WP_Post)) {
6951 $post = get_post($post_id); // defend against a bad callback return
6952 }
6953
6954 // Assemble the indexable text via the shared post-kind assembler (a3d60c),
6955 // reading from the FILTERED post object — not re-fetched by ID, which would
6956 // discard it. Auto-sync reads content/excerpt in its historical
6957 // get_post_field() display context, never appended product custom tabs
6958 // (its product branch is reachable only with the WooCommerce integration
6959 // off), and gates ACF→PDF extraction behind its own opt-in option —
6960 // default OFF, because re-parsing every ACF PDF on every editor save is
6961 // expensive and most sites don't want it (the 25 MB size cap lives in the
6962 // shared extractor either way).
6963 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
6964 'read_display' => true,
6965 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
6966 'include_product_tabs' => false,
6967 ));
6968 $final_content = $prepared['content'];
6969
6970 // Embedding decision — custom-provider-aware. Gating on a cloud API key
6971 // here silently killed auto-sync on keyless custom-embeddings sites,
6972 // because generate_embedding() routes custom FIRST and never needs the
6973 // key (plan cbd5fd). Silent-return shape preserved.
6974 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6975 if (!$preflight['ok']) {
6976 return;
6977 }
6978 $api_key = $preflight['api_key'];
6979
6980 // Use the centralized utility function for storage
6981 $result = MxChat_Utils::submit_content_to_db(
6982 $final_content,
6983 $source_url,
6984 $api_key,
6985 md5($source_url) // Vector ID for Pinecone
6986 );
6987
6988 // After successful storage, apply role restriction based on tags
6989 if (!is_wp_error($result)) {
6990 $this->apply_role_restriction_to_post($post_id, $source_url);
6991 }
6992 }
6993
6994 /**
6995 * Shared post-fields content assembler (plan a3d60c) — the ONE body behind both
6996 * post-kind ingestion paths: manual bulk import (ajax_mxchat_process_selected_content)
6997 * and auto-sync (mxchat_index_published_post). Behavior-preserving extraction; the
6998 * measured per-caller differences ride $args instead of living as drifting copies:
6999 *
7000 * 'read_display' bool Auto-sync historically reads content/excerpt via
7001 * get_post_field() in its default 'display' context
7002 * (the post_content / post_excerpt display filters
7003 * fire); bulk import reads the raw properties. Inert
7004 * on a stock install — preserved per-path, not converged.
7005 * 'extract_acf_pdfs' bool Each caller passes its OWN option (bulk:
7006 * mxchat_acf_pdf_extraction; auto-sync:
7007 * mxchat_auto_sync_acf_pdfs) — the two-option design
7008 * is deliberate (plan 11720c). Gates BOTH the PDF-id
7009 * collection walk and the extraction loop; the ids are
7010 * only ever read inside the extraction branch, so
7011 * gating collection is output-identical on every install.
7012 * 'include_product_tabs' bool The bulk path has always appended yikes_woo custom
7013 * tabs to product content; the auto-sync product branch
7014 * (reachable only with the WooCommerce integration off)
7015 * never did. Preserved per-path — converging it would be
7016 * a behavior change, recorded on the plan instead.
7017 *
7018 * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
7019 * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
7020 */
7021 private function mxchat_prepare_post_content_for_indexing($post_id, $post, $args) {
7022 $read_display = !empty($args['read_display']);
7023 $extract_acf_pdfs = !empty($args['extract_acf_pdfs']);
7024 $include_product_tabs = !empty($args['include_product_tabs']);
7025
7026 // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
7027 // convert_chars and prepends the "Protected:" / "Private:" display chrome.
7028 // The knowledge base stores facts, not display strings. Entity decode at
7029 // output time (single-pass, shared helper) — a stored `&amp;` embeds worse
7030 // than `&` and gets quoted back to visitors (d2c92e).
7031 $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n";
7032
7033 $raw_excerpt = $read_display ? get_post_field('post_excerpt', $post) : $post->post_excerpt;
7034 $raw_content = $read_display ? get_post_field('post_content', $post) : $post->post_content;
7035
7036 // Add short description if it exists (WooCommerce products use post_excerpt for short description)
7037 // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
7038 // empty, and testing the raw value emitted a bare "Short Description: " label
7039 // with no value after it. trim() only in the TEST — the emitted value is
7040 // untouched, so a populated excerpt is byte-identical to before. A
7041 // whitespace-only excerpt is an empty excerpt and must not produce a labelled
7042 // line with nothing after it.
7043 $clean_excerpt = $this->strip_shortcode_tags_preserve_content($raw_excerpt);
7044 if (trim($clean_excerpt) !== '') {
7045 $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
7046 }
7047
7048 // Main content — remove shortcode tags but preserve content inside them, then
7049 // strip tags (don't use 'the_content' filter as it may re-add shortcodes).
7050 $clean_content = $this->strip_shortcode_tags_preserve_content($raw_content);
7051 $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
7052
7053 // WooCommerce product enrichment (post-fields kind). The WC-object assembler
7054 // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
7055 // integration is on; this branch serves the bulk import (all configurations)
7056 // and auto-sync with the integration off.
7057 if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
7058 $product = wc_get_product($post_id);
7059
7060 if ($product) {
7061 $content .= "\n";
7062 $content .= $this->mxchat_woo_product_summary_lines($product);
7063 }
7064
7065 if ($include_product_tabs) {
7066 $content .= $this->mxchat_woo_custom_tabs_text($post_id);
7067 }
7068 }
7069
7070 // For custom post types like job_listing, include additional fields
7071 if (get_post_type($post_id) === 'job_listing') {
7072 // Add job-specific meta if available
7073 $job_location = get_post_meta($post_id, '_job_location', true);
7074 if (!empty($job_location)) {
7075 $content .= "\n\nLocation: " . $job_location;
7076 }
7077
7078 // Get job type terms
7079 $job_types = get_the_terms($post_id, 'job_listing_type');
7080 if (!empty($job_types) && !is_wp_error($job_types)) {
7081 $types = array();
7082 foreach ($job_types as $type) {
7083 $types[] = $type->name;
7084 }
7085 $content .= "\n\nJob Type: " . implode(', ', $types);
7086 }
7087
7088 // Get company name if available
7089 $company_name = get_post_meta($post_id, '_company_name', true);
7090 if (!empty($company_name)) {
7091 $content .= "\n\nCompany: " . $company_name;
7092 }
7093 }
7094
7095 // ADD ACF FIELDS SUPPORT
7096 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
7097 $pdf_extracted_count = 0;
7098 if (!empty($acf_fields)) {
7099 $acf_content_parts = array();
7100 $pdf_attachment_ids = array();
7101
7102 foreach ($acf_fields as $field_name => $field_value) {
7103 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
7104
7105 if (!empty($formatted_value)) {
7106 // Both separators: a hyphenated ACF name should read as words.
7107 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
7108 $acf_content_parts[] = $field_label . ": " . $formatted_value;
7109 }
7110
7111 // Walk this field's value tree for any PDF attachment references and
7112 // queue them for extraction — only when this caller's PDF option is on.
7113 if ($extract_acf_pdfs) {
7114 $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
7115 }
7116 }
7117
7118 if (!empty($acf_content_parts)) {
7119 $content .= "\n\n" . implode("\n", $acf_content_parts);
7120 }
7121
7122 // Extract text from each unique PDF found in ACF fields and append as a labeled section
7123 if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
7124 $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
7125 $pdf_sections = array();
7126 foreach ($pdf_attachment_ids as $att_id) {
7127 $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
7128 if (!empty($pdf_text)) {
7129 $pdf_title = get_the_title($att_id);
7130 $pdf_url = wp_get_attachment_url($att_id);
7131 $header = 'PDF Attachment';
7132 if (!empty($pdf_title)) {
7133 $header .= ': ' . $pdf_title;
7134 }
7135 if (!empty($pdf_url)) {
7136 $header .= ' (' . $pdf_url . ')';
7137 }
7138 $pdf_sections[] = $header . "\n" . $pdf_text;
7139 $pdf_extracted_count++;
7140 }
7141 }
7142 if (!empty($pdf_sections)) {
7143 $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
7144 }
7145 }
7146 }
7147
7148 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
7149 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
7150 if (!empty($custom_meta)) {
7151 $meta_content_parts = array();
7152
7153 foreach ($custom_meta as $meta_key => $meta_value) {
7154 // Convert meta key to readable label
7155 $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
7156 $meta_content_parts[] = $meta_label . ": " . $meta_value;
7157 }
7158
7159 if (!empty($meta_content_parts)) {
7160 $content .= "\n\n" . implode("\n", $meta_content_parts);
7161 }
7162 }
7163
7164 return array(
7165 'content' => $content,
7166 'acf_fields_found' => count($acf_fields),
7167 'pdf_extracted_count' => $pdf_extracted_count,
7168 );
7169 }
7170
7171 /**
7172 * Shared WC-object product assembler (plan a3d60c) — the ONE body behind the two
7173 * WooCommerce-object ingestion paths: the auto-sync product writer
7174 * (mxchat_store_product_embedding) and the URL/sitemap product import
7175 * (mxchat_extract_woocommerce_product_content). Assembles from the WC_Product,
7176 * the authoritative source for product rows (scope decision on the plan).
7177 */
7178 private function mxchat_prepare_product_content_for_indexing($product) {
7179 $title = $product->get_name();
7180 $description = $product->get_description();
7181 $short_description = $product->get_short_description();
7182
7183 // Format content consistently
7184 $content = $title . "\n\n";
7185
7186 if (!empty($short_description)) {
7187 $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7188 }
7189
7190 if (!empty($description)) {
7191 $content .= wp_strip_all_tags($description) . "\n\n";
7192 }
7193
7194 $content .= $this->mxchat_woo_product_summary_lines($product);
7195 $content .= $this->mxchat_woo_custom_tabs_text($product->get_id());
7196
7197 return $content;
7198 }
7199
7200 /**
7201 * Pricing + SKU + categories lines for a product — shared by both assembler kinds
7202 * (the post-fields product enrichment and the WC-object assembler).
7203 */
7204 private function mxchat_woo_product_summary_lines($product) {
7205 // Add pricing information (base-currency pinned — see mxchat_product_price_lines)
7206 $lines = $this->mxchat_product_price_lines($product);
7207
7208 $sku = $product->get_sku();
7209 if (!empty($sku)) {
7210 $lines .= "SKU: " . $sku . "\n";
7211 }
7212
7213 // Get product categories
7214 $categories = wp_get_post_terms($product->get_id(), 'product_cat', array('fields' => 'names'));
7215 if (!empty($categories) && !is_wp_error($categories)) {
7216 $lines .= "Categories: " . implode(', ', $categories) . "\n";
7217 }
7218
7219 return $lines;
7220 }
7221
7222 /**
7223 * Custom Product Tabs text (supports "Custom Product Tabs for WooCommerce" by
7224 * Code Parrots) — direct tabs plus applied reusable/saved tabs. The ONE copy of
7225 * the yikes_woo logic; three sites carried byte-identical clones before a3d60c.
7226 */
7227 private function mxchat_woo_custom_tabs_text($product_id) {
7228 $text = '';
7229
7230 $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7231 if (!empty($custom_tabs) && is_array($custom_tabs)) {
7232 foreach ($custom_tabs as $tab) {
7233 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7234 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7235
7236 if (!empty($tab_title) && !empty($tab_content)) {
7237 $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7238 }
7239 }
7240 }
7241
7242 // Also check for reusable/saved tabs applied to this product
7243 $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7244 if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7245 $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7246 if (!empty($saved_tabs) && is_array($saved_tabs)) {
7247 foreach ($applied_saved_tabs as $saved_tab_id) {
7248 if (isset($saved_tabs[$saved_tab_id])) {
7249 $tab = $saved_tabs[$saved_tab_id];
7250 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7251 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7252
7253 if (!empty($tab_title) && !empty($tab_content)) {
7254 $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7255 }
7256 }
7257 }
7258 }
7259 }
7260
7261 return $text;
7262 }
7263
7264 /**
7265 * Store the post status and URL before update to detect status transitions
7266 * This runs before the post is actually updated in the database
7267 */
7268 public function mxchat_store_pre_update_status($post_id, $data) {
7269 // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
7270 // this request and can consume the arrival-edge guard (plan a664f3).
7271 $this->pending_post_update[$post_id] = true;
7272
7273 // Get the current post from database (before update)
7274 $current_post = get_post($post_id);
7275
7276 if ($current_post) {
7277 // Store the current status temporarily
7278 $status_key = 'mxchat_prev_status_' . $post_id;
7279 set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
7280
7281 // If the post is currently published, also store its URL
7282 if ($current_post->post_status === 'publish') {
7283 $url_key = 'mxchat_prev_url_' . $post_id;
7284 $current_url = get_permalink($post_id);
7285 set_transient($url_key, $current_url, HOUR_IN_SECONDS);
7286 }
7287 }
7288 }
7289
7290 /**
7291 * Whether auto-sync is enabled for a post type (mirrors the checks used by the
7292 * update/delete handlers; kept as one helper so new call sites cannot drift).
7293 */
7294 private function mxchat_is_auto_sync_enabled($post_type) {
7295 if ($post_type === 'post') {
7296 return get_option('mxchat_auto_sync_posts') === '1';
7297 }
7298 if ($post_type === 'page') {
7299 return get_option('mxchat_auto_sync_pages') === '1';
7300 }
7301 return get_option('mxchat_auto_sync_' . $post_type) === '1';
7302 }
7303
7304 /**
7305 * Remove a post's vectors the moment it leaves 'publish', using the authoritative
7306 * old status core passes to transition_post_status — no transient involved (plan 816fb1).
7307 *
7308 * Covers status changes that never route through wp_update_post (scheduled-expiry
7309 * plugins and others that flip post_status directly and call wp_transition_post_status),
7310 * where neither pre_post_update nor post_updated fires and the old detection missed.
7311 */
7312 public function mxchat_handle_status_transition($new_status, $old_status, $post) {
7313 if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
7314 return;
7315 }
7316
7317 // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
7318 // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
7319 // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
7320 // auto-sync ADD path alone misses them. Editor publishes also pass through here;
7321 // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
7322 // a second time in the same request.
7323 if ($new_status === 'publish' && $old_status !== 'publish') {
7324 if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
7325 $this->mxchat_index_published_post($post->ID, $post);
7326
7327 // Arm the double-fire guard ONLY when a post_updated is actually coming to
7328 // consume it (plan a664f3). Two publish paths never fire post_updated at all:
7329 // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
7330 // call check_and_publish_future_post() makes for scheduled posts. Arming the
7331 // guard unconditionally left it set with nothing to consume it, so the NEXT
7332 // update of that post was swallowed entirely: zero embed calls, no knowledge
7333 // -base row, silently. Consume-once on this side too, so a guard can never
7334 // outlive the single save it was armed for.
7335 if (!empty($this->pending_post_update[$post->ID])) {
7336 unset($this->pending_post_update[$post->ID]);
7337 $this->transition_indexed_posts[$post->ID] = true;
7338 }
7339 }
7340 return;
7341 }
7342
7343 // Only the publish -> not-publish edge matters here.
7344 if ($old_status !== 'publish' || $new_status === 'publish') {
7345 return;
7346 }
7347 // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
7348 // resolution; skip to avoid a second network round-trip per trash.
7349 if ($new_status === 'trash') {
7350 return;
7351 }
7352 if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
7353 return;
7354 }
7355
7356 $urls = array();
7357
7358 // The DB may already hold the new status when this fires, so get_permalink() on the
7359 // live post could build a draft-style URL whose md5 misses the stored vector IDs.
7360 // Reconstruct the published permalink from a clone instead.
7361 $published_clone = clone $post;
7362 $published_clone->post_status = 'publish';
7363 $published_url = get_permalink($published_clone);
7364 if ($published_url) {
7365 $urls[] = $published_url;
7366 }
7367
7368 // Honour the pre-update capture when present (covers a slug change in the same save).
7369 $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
7370 if (!empty($previous_url)) {
7371 $urls[] = $previous_url;
7372 }
7373
7374 foreach (array_unique($urls) as $url) {
7375 MxChat_Utils::delete_chunks_for_url($url, 'default');
7376 }
7377
7378 if (!empty($urls)) {
7379 $this->transition_deleted_posts[$post->ID] = true;
7380 }
7381 }
7382
7383 /**
7384 * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
7385 * trashed, or made private before the transition_post_status handler existed.
7386 *
7387 * Walks every auto-synced post type's non-published posts, reconstructs each one's
7388 * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
7389 * Deletion is idempotent, so never-indexed posts are a cheap no-op.
7390 *
7391 * ## OPTIONS
7392 *
7393 * [--dry-run]
7394 * : Report what would be removed without deleting anything.
7395 *
7396 * ## EXAMPLES
7397 *
7398 * wp mxchat prune-unpublished --dry-run
7399 * wp mxchat prune-unpublished
7400 */
7401 public function cli_prune_unpublished($args, $assoc_args) {
7402 global $wpdb;
7403 $dry_run = !empty($assoc_args['dry-run']);
7404 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7405
7406 $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
7407 $synced_types = array();
7408 foreach ($candidate_types as $type) {
7409 if ($this->mxchat_is_auto_sync_enabled($type)) {
7410 $synced_types[] = $type;
7411 }
7412 }
7413 if (empty($synced_types)) {
7414 WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
7415 return;
7416 }
7417
7418 $scanned = 0;
7419 $pruned = 0;
7420 $paged = 1;
7421 do {
7422 $query = new WP_Query(array(
7423 'post_type' => $synced_types,
7424 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
7425 'posts_per_page' => 100,
7426 'paged' => $paged,
7427 'fields' => 'ids',
7428 ));
7429 foreach ($query->posts as $post_id) {
7430 $post = get_post($post_id);
7431 if (!$post) {
7432 continue;
7433 }
7434 $scanned++;
7435
7436 // Rebuild the permalink the post had while published: publish-status clone,
7437 // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
7438 $clone = clone $post;
7439 $clone->post_status = 'publish';
7440 if (substr($clone->post_name, -9) === '__trashed') {
7441 $clone->post_name = substr($clone->post_name, 0, -9);
7442 }
7443 $url = get_permalink($clone);
7444 if (!$url) {
7445 continue;
7446 }
7447
7448 // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
7449 // reads 0 but the delete below still routes to Pinecone and is idempotent.
7450 $local_rows = (int) $wpdb->get_var($wpdb->prepare(
7451 "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
7452 ));
7453
7454 if ($dry_run) {
7455 if ($local_rows > 0) {
7456 WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7457 $pruned += $local_rows;
7458 }
7459 continue;
7460 }
7461
7462 MxChat_Utils::delete_chunks_for_url($url, 'default');
7463 if ($local_rows > 0) {
7464 WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7465 $pruned += $local_rows;
7466 }
7467 }
7468 $more = $paged < $query->max_num_pages;
7469 $paged++;
7470 } while ($more);
7471
7472 WP_CLI::success(sprintf(
7473 '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
7474 $dry_run ? 'Would remove' : 'Removed',
7475 $pruned,
7476 $scanned,
7477 ' (Pinecone-mode deletions are not counted locally.)'
7478 ));
7479 }
7480
7481 /**
7482 * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual
7483 * (reversed) order before the RTL normalizer existed. 32bf9e fixed new
7484 * imports only; this fixes rows already in the table without the customer
7485 * having to re-source and re-upload the original PDFs (plan d1e6f7).
7486 *
7487 * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a
7488 * row is a candidate exactly when the normalizer would change it, so the
7489 * import-time heuristic and the repair heuristic can never disagree.
7490 * Repaired rows are RE-EMBEDDED — the stored vector was computed over
7491 * reversed text and is as broken as the text — so a wet run calls the
7492 * embedding provider once per repaired row on the site's API key. Runs
7493 * beyond 25 rows therefore require --yes.
7494 *
7495 * Scope notes:
7496 * - Scans the WordPress knowledge table. Pinecone-mode entries live in
7497 * Pinecone, not this table, and are not scanned; if a scanned row's bot
7498 * ALSO has Pinecone enabled (hybrid drift), the repaired entry is
7499 * re-submitted through the normal import path so the md5-keyed Pinecone
7500 * vector is replaced too.
7501 * - Knowledge rows do not carry a bot id; --bot only selects whose
7502 * embedding configuration (model + key) is used for re-embedding.
7503 * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled
7504 * normalization gets detections of zero, not surprise rewrites.
7505 * - The metadata header the PDF importer stores before the text separator
7506 * is preserved byte-identical; only the text segment is repaired.
7507 *
7508 * ## OPTIONS
7509 *
7510 * [--dry-run]
7511 * : List the rows that would be repaired without changing anything.
7512 *
7513 * [--bot=<id>]
7514 * : Embedding configuration to use for re-embedding. Default: default.
7515 *
7516 * [--all-content]
7517 * : Scan every row containing right-to-left text, not just rows with PDF
7518 * provenance (a page anchor in the source URL, or pdf content type).
7519 *
7520 * [--yes]
7521 * : Proceed even when more than 25 rows need re-embedding (API cost gate).
7522 *
7523 * ## EXAMPLES
7524 *
7525 * wp mxchat rtl-repair --dry-run
7526 * wp mxchat rtl-repair
7527 * wp mxchat rtl-repair --all-content --yes
7528 */
7529 public function cli_rtl_repair($args, $assoc_args) {
7530 global $wpdb;
7531 $dry_run = !empty($assoc_args['dry-run']);
7532 $all = !empty($assoc_args['all-content']);
7533 $yes = !empty($assoc_args['yes']);
7534 $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default';
7535 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7536
7537 // Detection pass — no API calls. Walk the table in id batches so a large
7538 // knowledge base never loads at once.
7539 $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';
7540 $candidates = array();
7541 $scanned = 0;
7542 $last_id = 0;
7543 do {
7544 if ($all) {
7545 $rows = $wpdb->get_results($wpdb->prepare(
7546 "SELECT id, article_content, source_url, content_type FROM {$table}
7547 WHERE id > %d ORDER BY id ASC LIMIT 200",
7548 $last_id
7549 ));
7550 } else {
7551 $rows = $wpdb->get_results($wpdb->prepare(
7552 "SELECT id, article_content, source_url, content_type FROM {$table}
7553 WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf')
7554 ORDER BY id ASC LIMIT 200",
7555 $last_id,
7556 '%' . $wpdb->esc_like('#page=') . '%'
7557 ));
7558 }
7559 foreach ($rows as $row) {
7560 $last_id = (int) $row->id;
7561 $scanned++;
7562 $content = (string) $row->article_content;
7563 if (!preg_match($rtl_re, $content)) {
7564 continue;
7565 }
7566 list($header, $text) = $this->mxchat_rtl_repair_split($content);
7567 $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id);
7568 if (is_string($normalized) && $normalized !== $text) {
7569 $candidates[] = array(
7570 'id' => (int) $row->id,
7571 'source_url' => (string) $row->source_url,
7572 'content_type' => (string) $row->content_type,
7573 'new_content' => $header . $normalized,
7574 );
7575 }
7576 }
7577 } while (count($rows) === 200);
7578
7579 WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates)));
7580 if (empty($candidates)) {
7581 WP_CLI::success('No reversed RTL rows found — nothing to repair.');
7582 return;
7583 }
7584
7585 foreach ($candidates as $c) {
7586 WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url']));
7587 }
7588 if ($dry_run) {
7589 WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates)));
7590 return;
7591 }
7592
7593 // Cost gate: re-embedding spends the customer's API budget.
7594 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)));
7595 if (count($candidates) > 25 && !$yes) {
7596 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)));
7597 }
7598
7599 $bot_options = $this->get_bot_options($bot_id);
7600 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7601 $preflight = MxChat_Utils::embedding_preflight($options);
7602 if (!$preflight['ok']) {
7603 WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']);
7604 }
7605 $api_key = $preflight['api_key'];
7606
7607 $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id);
7608 $repaired = 0;
7609 $failed = 0;
7610 foreach ($candidates as $c) {
7611 $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id);
7612 if (!is_array($vector)) {
7613 $failed++;
7614 $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed';
7615 // Text and vector must stay consistent: never write repaired text
7616 // beside the stale reversed-text vector.
7617 WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason));
7618 continue;
7619 }
7620 $wpdb->update(
7621 $table,
7622 array(
7623 'article_content' => $c['new_content'],
7624 'embedding_vector' => maybe_serialize($vector),
7625 ),
7626 array('id' => $c['id']),
7627 array('%s', '%s'),
7628 array('%d')
7629 );
7630 $repaired++;
7631 if (class_exists('MxChat_Admin')) {
7632 MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array(
7633 'row_id' => $c['id'],
7634 'source_url' => $c['source_url'],
7635 'bot' => $bot_id,
7636 ));
7637 }
7638 // Hybrid drift: the bot indexes into Pinecone but this row sat in the
7639 // WP table — push the repaired entry through the normal import path so
7640 // the md5(source_url)-keyed Pinecone vector is replaced as well.
7641 if ($pinecone_hybrid) {
7642 MxChat_Utils::submit_content_to_db(
7643 $c['new_content'],
7644 $c['source_url'],
7645 $api_key,
7646 null,
7647 $bot_id,
7648 $c['content_type'] !== '' ? $c['content_type'] : 'pdf'
7649 );
7650 }
7651 }
7652
7653 WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned));
7654 }
7655
7656 /**
7657 * Split a stored KB row into (metadata header incl. separator, text segment).
7658 * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text —
7659 * repair must touch only the text and keep the header byte-identical.
7660 */
7661 private function mxchat_rtl_repair_split($content) {
7662 $sep = "\n---\n";
7663 $pos = strpos($content, $sep);
7664 if ($pos !== false && $pos > 0 && $content[0] === '{') {
7665 $maybe_json = substr($content, 0, $pos);
7666 if (json_decode($maybe_json) !== null) {
7667 return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep)));
7668 }
7669 }
7670 return array('', $content);
7671 }
7672
7673 /**
7674 * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for
7675 * the repair CLI's hybrid-drift check.
7676 */
7677 private function mxchat_rtl_repair_pinecone_enabled($bot_id) {
7678 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
7679 $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
7680 if (!empty($cfg)) {
7681 return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']);
7682 }
7683 }
7684 $po = get_option('mxchat_pinecone_addon_options');
7685 return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0'
7686 && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']);
7687 }
7688
7689 public function mxchat_handle_post_delete($post_id) {
7690 // Get post data before it's deleted
7691 $post = get_post($post_id);
7692
7693 // Basic validation
7694 if (!$post || wp_is_post_revision($post_id)) {
7695 return;
7696 }
7697
7698 $post_type = $post->post_type;
7699
7700 // Check if sync is enabled for this post type
7701 $should_sync = false;
7702
7703 // Check built-in post types first
7704 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
7705 $should_sync = true;
7706 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
7707 $should_sync = true;
7708 } else {
7709 // Check custom post types
7710 $option_name = 'mxchat_auto_sync_' . $post_type;
7711 if (get_option($option_name) === '1') {
7712 $should_sync = true;
7713 }
7714 }
7715
7716 if (!$should_sync) {
7717 return;
7718 }
7719
7720 // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
7721 // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
7722 // real vector IDs stored under the original URL.
7723 $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
7724 if (!$source_url) {
7725 //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
7726 return;
7727 }
7728
7729 // Use chunk-aware deletion (handles both chunked and non-chunked content)
7730 $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
7731
7732 if (is_wp_error($delete_result)) {
7733 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
7734 }
7735
7736 delete_transient('mxchat_prev_url_' . $post_id);
7737 delete_transient('mxchat_prev_status_' . $post_id);
7738 }
7739
7740 /**
7741 * Resolve the source URL for a post being trashed/deleted.
7742 *
7743 * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
7744 * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
7745 * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
7746 * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
7747 */
7748 private function mxchat_resolve_pre_trash_url($post_id) {
7749 $previous_url = get_transient('mxchat_prev_url_' . $post_id);
7750 if (!empty($previous_url)) {
7751 return $previous_url;
7752 }
7753
7754 $current = get_permalink($post_id);
7755 if (!$current) {
7756 return '';
7757 }
7758 return preg_replace('#__trashed(/?)$#', '$1', $current);
7759 }
7760
7761
7762
7763 public function mxchat_handle_product_change($post_id, $post, $update) {
7764 if ($post->post_type !== 'product') {
7765 return;
7766 }
7767
7768 if ($post->post_status === 'publish') {
7769 add_action('shutdown', function() use ($post_id) {
7770 $product = wc_get_product($post_id);
7771 if ($product) {
7772 $this->mxchat_store_product_embedding($product);
7773 }
7774 });
7775 }
7776 }
7777
7778 /**
7779 * Store WooCommerce product embeddings
7780 */
7781 private function mxchat_store_product_embedding($product) {
7782 if (!isset($this->options['enable_woocommerce_integration']) ||
7783 !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
7784 return;
7785 }
7786
7787 $source_url = get_permalink($product->get_id());
7788 $product_id = $product->get_id();
7789
7790 // Build product content via the shared WC-object assembler (a3d60c) — this
7791 // writer owns product rows whenever the integration is on.
7792 $content = $this->mxchat_prepare_product_content_for_indexing($product);
7793
7794 // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
7795 // shape preserved.
7796 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
7797 if (!$preflight['ok']) {
7798 //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
7799 return;
7800 }
7801 $api_key = $preflight['api_key'];
7802
7803 // Use the centralized utility function for storage
7804 $result = MxChat_Utils::submit_content_to_db(
7805 $content,
7806 $source_url,
7807 $api_key,
7808 md5($source_url) // Vector ID for Pinecone
7809 );
7810
7811 // After successful storage, apply role restriction based on tags
7812 if (!is_wp_error($result)) {
7813 $this->apply_role_restriction_to_post($product_id, $source_url);
7814 }
7815
7816 if (is_wp_error($result)) {
7817 //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
7818 }
7819 }
7820
7821 public function mxchat_handle_product_delete($post_id) {
7822 if (get_post_type($post_id) !== 'product') {
7823 return;
7824 }
7825
7826 $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
7827 if (!$source_url) {
7828 return;
7829 }
7830
7831 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
7832 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
7833
7834 delete_transient('mxchat_prev_url_' . $post_id);
7835 delete_transient('mxchat_prev_status_' . $post_id);
7836 }
7837
7838 /**
7839 * Handle individual Pinecone content deletion
7840 */
7841 public function mxchat_handle_pinecone_prompt_delete() {
7842 // Check permissions
7843 if (!current_user_can('manage_options')) {
7844 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
7845 }
7846
7847 // Verify nonce
7848 if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
7849 wp_die(esc_html__('Security check failed.', 'mxchat'));
7850 }
7851
7852 $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
7853
7854 if (empty($vector_id)) {
7855 set_transient('mxchat_admin_notice_error',
7856 esc_html__('Invalid vector ID.', 'mxchat'),
7857 30
7858 );
7859 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
7860 exit;
7861 }
7862
7863 // Get Pinecone settings
7864 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7865 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7866
7867 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
7868 set_transient('mxchat_admin_notice_error',
7869 esc_html__('Pinecone is not properly configured.', 'mxchat'),
7870 30
7871 );
7872 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
7873 exit;
7874 }
7875
7876 // Delete from Pinecone
7877 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7878 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
7879 $vector_id,
7880 $pinecone_options['mxchat_pinecone_api_key'],
7881 $pinecone_options['mxchat_pinecone_host'],
7882 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
7883 );
7884
7885 if ($result['success']) {
7886 // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6);
7887 // a chunk vector id reduces to its base entry there.
7888 if (class_exists('MxChat_Vectorstore_Manager')) {
7889 MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, 'default');
7890 }
7891 // No cache clearing needed since we removed caching
7892 set_transient('mxchat_admin_notice_success',
7893 esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
7894 30
7895 );
7896 } else {
7897 set_transient('mxchat_admin_notice_error',
7898 esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
7899 30
7900 );
7901 }
7902
7903 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
7904 exit;
7905 }
7906 /**
7907 * Handle individual Pinecone content deletion via AJAX
7908 */
7909 public function ajax_mxchat_delete_pinecone_prompt() {
7910 // Verify nonce and permissions
7911 if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
7912 wp_send_json_error('Invalid nonce');
7913 exit;
7914 }
7915
7916 if (!current_user_can('manage_options')) {
7917 wp_send_json_error('Unauthorized access');
7918 exit;
7919 }
7920
7921 $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
7922 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
7923
7924 if (empty($vector_id)) {
7925 wp_send_json_error('Missing vector ID');
7926 exit;
7927 }
7928
7929 // Get bot-specific Pinecone settings
7930 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7931 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
7932
7933 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7934
7935 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
7936 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
7937 exit;
7938 }
7939
7940 // Delete from the correct Pinecone index and namespace
7941 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
7942 $vector_id,
7943 $pinecone_options['mxchat_pinecone_api_key'],
7944 $pinecone_options['mxchat_pinecone_host'],
7945 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
7946 );
7947
7948 if ($result['success']) {
7949 // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6)
7950 if (class_exists('MxChat_Vectorstore_Manager')) {
7951 MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, $bot_id);
7952 }
7953 // No cache clearing needed since we removed caching
7954 wp_send_json_success(array(
7955 'message' => 'Entry deleted successfully from Pinecone',
7956 'vector_id' => $vector_id,
7957 'bot_id' => $bot_id
7958 ));
7959 } else {
7960 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
7961 wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
7962 }
7963
7964 exit;
7965 }
7966
7967 /**
7968 * Handle deletion of all chunks for a given source URL via AJAX
7969 * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
7970 */
7971 public function ajax_mxchat_delete_chunks_by_url() {
7972 // Verify nonce and permissions
7973 if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
7974 wp_send_json_error('Invalid nonce');
7975 exit;
7976 }
7977
7978 if (!current_user_can('manage_options')) {
7979 wp_send_json_error('Unauthorized access');
7980 exit;
7981 }
7982
7983 $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
7984 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
7985 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
7986
7987 if (empty($source_url)) {
7988 wp_send_json_error('Missing source URL');
7989 exit;
7990 }
7991
7992 // Generate the base vector ID from the source URL (same as how chunks are created)
7993 $base_vector_id = md5($source_url);
7994
7995 if ($data_source === 'pinecone') {
7996 // Get bot-specific Pinecone settings (same as working delete function)
7997 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7998 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
7999
8000 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
8001
8002 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
8003 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
8004 exit;
8005 }
8006
8007 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
8008 $host = $pinecone_options['mxchat_pinecone_host'];
8009 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
8010
8011 // Document index (plan 362c31): base id + chunk ids through the documents API.
8012 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
8013 $docs_cfg = MxChat_Pinecone_Documents::cfg_from_options($pinecone_options);
8014 $doc_ids = array_merge(array($base_vector_id), MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $docs_cfg));
8015 $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_ids, $docs_cfg);
8016 if (is_wp_error($doc_result)) {
8017 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete documents from Pinecone: ' . $doc_result->get_error_message(), array('source_url' => $source_url));
8018 wp_send_json_error('Failed to delete from Pinecone: ' . $doc_result->get_error_message());
8019 exit;
8020 }
8021 if (class_exists('MxChat_Vectorstore_Manager')) {
8022 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8023 }
8024 wp_send_json_success(array(
8025 'message' => 'All chunks deleted successfully from Pinecone',
8026 'source_url' => $source_url,
8027 'deleted_count' => count($doc_ids)
8028 ));
8029 exit;
8030 }
8031
8032 // Collect all vector IDs to delete
8033 $vectors_to_delete = array();
8034
8035 // Add the original single-vector ID (for non-chunked content)
8036 $vectors_to_delete[] = $base_vector_id;
8037
8038 // Use Pinecone list API to find all chunk vectors with this prefix
8039 // NOTE: Pinecone List API is a GET request with query parameters, not POST
8040 $prefix = $base_vector_id . '_chunk_';
8041
8042 $query_params = array(
8043 'prefix' => $prefix,
8044 'limit' => 100
8045 );
8046
8047 if (!empty($namespace)) {
8048 $query_params['namespace'] = $namespace;
8049 }
8050
8051 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
8052
8053 $list_response = wp_remote_get($list_url, array(
8054 'headers' => array(
8055 'Api-Key' => $api_key,
8056 'accept' => 'application/json'
8057 ),
8058 'timeout' => 30
8059 ));
8060
8061 if (!is_wp_error($list_response)) {
8062 $list_body_response = wp_remote_retrieve_body($list_response);
8063 $list_data = json_decode($list_body_response, true);
8064 if (!empty($list_data['vectors'])) {
8065 foreach ($list_data['vectors'] as $vector) {
8066 if (isset($vector['id'])) {
8067 $vectors_to_delete[] = $vector['id'];
8068 }
8069 }
8070 }
8071 }
8072
8073 if (empty($vectors_to_delete)) {
8074 // Entry already gone from Pinecone — still clear any mirrored
8075 // Vector Store file so it can't outlive the entry (plan 15b5c6).
8076 if (class_exists('MxChat_Vectorstore_Manager')) {
8077 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8078 }
8079 wp_send_json_success(array(
8080 'message' => 'No vectors found to delete',
8081 'source_url' => $source_url
8082 ));
8083 exit;
8084 }
8085
8086 // Delete all vectors using the same endpoint as the working function
8087 $delete_url = "https://{$host}/vectors/delete";
8088
8089 $delete_body = array(
8090 'ids' => $vectors_to_delete
8091 );
8092
8093 if (!empty($namespace)) {
8094 $delete_body['namespace'] = $namespace;
8095 }
8096
8097 $delete_response = wp_remote_post($delete_url, array(
8098 'headers' => array(
8099 'Api-Key' => $api_key,
8100 'accept' => 'application/json',
8101 'content-type' => 'application/json'
8102 ),
8103 'body' => wp_json_encode($delete_body),
8104 'timeout' => 30
8105 ));
8106
8107 if (is_wp_error($delete_response)) {
8108 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
8109 wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
8110 exit;
8111 }
8112
8113 $response_code = wp_remote_retrieve_response_code($delete_response);
8114
8115 if ($response_code !== 200) {
8116 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
8117 wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
8118 exit;
8119 }
8120
8121 // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
8122 if (class_exists('MxChat_Vectorstore_Manager')) {
8123 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8124 }
8125
8126 wp_send_json_success(array(
8127 'message' => 'All chunks deleted successfully from Pinecone',
8128 'source_url' => $source_url,
8129 'deleted_count' => count($vectors_to_delete)
8130 ));
8131
8132 } else {
8133 // WordPress database deletion
8134 global $wpdb;
8135 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8136
8137 $result = $wpdb->delete(
8138 $table_name,
8139 array('source_url' => $source_url),
8140 array('%s')
8141 );
8142
8143 if ($result === false) {
8144 MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
8145 wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
8146 exit;
8147 }
8148
8149 // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
8150 if (class_exists('MxChat_Vectorstore_Manager')) {
8151 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
8152 }
8153
8154 wp_send_json_success(array(
8155 'message' => 'All chunks deleted successfully from database',
8156 'source_url' => $source_url,
8157 'deleted_count' => $result
8158 ));
8159 }
8160
8161 exit;
8162 }
8163
8164 /**
8165 * Handle individual WordPress database content deletion via AJAX
8166 * Mirrors the Pinecone delete handler but for WordPress database entries
8167 */
8168 public function ajax_mxchat_delete_wordpress_prompt() {
8169 // Verify nonce and permissions
8170 if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
8171 wp_send_json_error('Invalid nonce');
8172 exit;
8173 }
8174
8175 if (!current_user_can('manage_options')) {
8176 wp_send_json_error('Unauthorized access');
8177 exit;
8178 }
8179
8180 $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
8181
8182 if (empty($entry_id)) {
8183 wp_send_json_error('Missing entry ID');
8184 exit;
8185 }
8186
8187 global $wpdb;
8188 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8189
8190 // Capture the identity BEFORE the row disappears — needed to mirror the
8191 // change into the Vector Store (plan 15b5c6).
8192 $source_url = $wpdb->get_var($wpdb->prepare(
8193 "SELECT source_url FROM {$table_name} WHERE id = %d",
8194 $entry_id
8195 ));
8196
8197 // Clear cache for this entry
8198 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
8199
8200 // Delete from database
8201 $result = $wpdb->delete(
8202 $table_name,
8203 array('id' => $entry_id),
8204 array('%d')
8205 );
8206
8207 if ($result !== false) {
8208 // Mirror to the Vector Store: if sibling rows remain (this was one
8209 // chunk of a larger entry) the entry's file is REFRESHED from what's
8210 // left; if none remain, the file is removed.
8211 if (!empty($source_url) && class_exists('MxChat_Vectorstore_Manager')) {
8212 $remaining = (int) $wpdb->get_var($wpdb->prepare(
8213 "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
8214 $source_url
8215 ));
8216 if ($remaining > 0) {
8217 MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, '', 'default');
8218 } else {
8219 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, 'default');
8220 }
8221 }
8222
8223 wp_send_json_success(array(
8224 'message' => 'Entry deleted successfully',
8225 'entry_id' => $entry_id
8226 ));
8227 } else {
8228 wp_send_json_error('Failed to delete entry from database');
8229 }
8230
8231 exit;
8232 }
8233
8234 /**
8235 * Handle bulk deletion of knowledge entries via AJAX
8236 * Supports both Pinecone and WordPress database entries
8237 */
8238 public function ajax_mxchat_bulk_delete_knowledge() {
8239 // Verify nonce and permissions
8240 if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
8241 wp_send_json_error('Invalid nonce');
8242 exit;
8243 }
8244
8245 if (!current_user_can('manage_options')) {
8246 wp_send_json_error('Unauthorized access');
8247 exit;
8248 }
8249
8250 $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
8251 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
8252
8253 if (empty($entries) || !is_array($entries)) {
8254 wp_send_json_error('No entries provided');
8255 exit;
8256 }
8257
8258 // Extend execution time — bulk Pinecone operations can take a while
8259 if (function_exists('set_time_limit')) {
8260 set_time_limit(120);
8261 }
8262
8263 $success_ids = array();
8264 $failed_ids = array();
8265 $errors = array();
8266
8267 global $wpdb;
8268 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8269
8270 // Get Pinecone manager for Pinecone deletions
8271 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
8272 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
8273 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
8274
8275 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
8276 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
8277 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
8278 // Document index (plan 362c31): list + delete go through the documents API below.
8279 $bulk_docs = class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options);
8280 $bulk_docs_cfg = $bulk_docs ? MxChat_Pinecone_Documents::cfg_from_options($pinecone_options) : array();
8281
8282 // =============================================
8283 // PHASE 1: Collect all Pinecone vector IDs
8284 // and separate WordPress entries
8285 // =============================================
8286 $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
8287 $wordpress_entries = array(); // entries for WordPress DB deletion
8288 $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
8289 $vs_mirror_urls = array(); // Vector Store mirror: URLs to delete (plan 15b5c6)
8290 $vs_mirror_keys = array(); // Vector Store mirror: bare vector ids to delete
8291
8292 foreach ($entries as $entry) {
8293 $entry_id = sanitize_text_field($entry['id'] ?? '');
8294 $source = sanitize_text_field($entry['source'] ?? 'wordpress');
8295 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
8296 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
8297
8298 if (empty($entry_id)) {
8299 continue;
8300 }
8301
8302 if ($source === 'pinecone') {
8303 if (!$use_pinecone || empty($api_key)) {
8304 $failed_ids[] = $entry_id;
8305 $errors[] = "Pinecone not configured for entry: $entry_id";
8306 continue;
8307 }
8308
8309 $pinecone_entry_ids[] = $entry_id;
8310
8311 if ($is_group && !empty($source_url)) {
8312 // Grouped/chunked entry: collect base ID + chunk IDs via List API
8313 $base_vector_id = md5($source_url);
8314 $all_vector_ids[] = $base_vector_id;
8315
8316 if ($bulk_docs) {
8317 $all_vector_ids = array_merge($all_vector_ids, MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $bulk_docs_cfg));
8318 } else {
8319 $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
8320 if (!empty($namespace)) {
8321 $list_url .= '&namespace=' . rawurlencode($namespace);
8322 }
8323 $list_response = wp_remote_get($list_url, array(
8324 'headers' => array(
8325 'Api-Key' => $api_key,
8326 'accept' => 'application/json'
8327 ),
8328 'timeout' => 30
8329 ));
8330
8331 if (!is_wp_error($list_response)) {
8332 $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
8333 if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
8334 foreach ($list_body['vectors'] as $vector) {
8335 if (isset($vector['id'])) {
8336 $all_vector_ids[] = $vector['id'];
8337 }
8338 }
8339 }
8340 }
8341 } // end classic (vector index) list
8342 } else {
8343 // Single entry: the entry_id IS the vector ID
8344 $all_vector_ids[] = $entry_id;
8345 }
8346
8347 if (!empty($source_url)) {
8348 $vs_mirror_urls[] = $source_url;
8349 } else {
8350 $vs_mirror_keys[] = $entry_id;
8351 }
8352 } else {
8353 $wordpress_entries[] = $entry;
8354 }
8355 }
8356
8357 // =============================================
8358 // PHASE 2: Single batch delete to Pinecone
8359 // =============================================
8360 if (!empty($all_vector_ids)) {
8361 $all_vector_ids = array_values(array_unique($all_vector_ids));
8362 $pinecone_success = true;
8363 $batches = array_chunk($all_vector_ids, 100);
8364
8365 // Document index (plan 362c31): one documents/delete call for every id.
8366 if ($bulk_docs) {
8367 $doc_result = MxChat_Pinecone_Documents::delete_ids($all_vector_ids, $bulk_docs_cfg);
8368 if (is_wp_error($doc_result)) {
8369 $pinecone_success = false;
8370 $errors[] = 'Pinecone batch deletion failed: ' . $doc_result->get_error_message();
8371 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete (document index) failed: ' . $doc_result->get_error_message());
8372 }
8373 } else
8374 foreach ($batches as $batch) {
8375 $delete_body = array('ids' => $batch);
8376 if (!empty($namespace)) {
8377 $delete_body['namespace'] = $namespace;
8378 }
8379 $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
8380 'headers' => array(
8381 'Api-Key' => $api_key,
8382 'accept' => 'application/json',
8383 'content-type' => 'application/json'
8384 ),
8385 'body' => wp_json_encode($delete_body),
8386 'timeout' => 60
8387 ));
8388
8389 if (is_wp_error($delete_response)) {
8390 $pinecone_success = false;
8391 $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
8392 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
8393 } else {
8394 $response_code = wp_remote_retrieve_response_code($delete_response);
8395 if ($response_code !== 200) {
8396 $pinecone_success = false;
8397 $response_body = wp_remote_retrieve_body($delete_response);
8398 $errors[] = "Pinecone API error (HTTP $response_code)";
8399 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
8400 }
8401 }
8402 }
8403
8404 // Mark all pinecone entries based on batch result
8405 foreach ($pinecone_entry_ids as $eid) {
8406 if ($pinecone_success) {
8407 $success_ids[] = $eid;
8408 } else {
8409 $failed_ids[] = $eid;
8410 }
8411 }
8412
8413 // Mirror the removals to the OpenAI Vector Store (plan 15b5c6)
8414 if ($pinecone_success && class_exists('MxChat_Vectorstore_Manager')) {
8415 foreach (array_unique($vs_mirror_urls) as $vs_url) {
8416 MxChat_Vectorstore_Manager::sync_delete_entry($vs_url, $bot_id);
8417 }
8418 foreach (array_unique($vs_mirror_keys) as $vs_key) {
8419 MxChat_Vectorstore_Manager::sync_delete_by_key($vs_key, $bot_id);
8420 }
8421 }
8422 }
8423
8424 // =============================================
8425 // PHASE 3: WordPress database deletions
8426 // =============================================
8427 foreach ($wordpress_entries as $entry) {
8428 $entry_id = sanitize_text_field($entry['id'] ?? '');
8429 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
8430 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
8431
8432 if (empty($entry_id)) {
8433 continue;
8434 }
8435
8436 try {
8437 if ($is_group && !empty($source_url)) {
8438 $result = $wpdb->delete(
8439 $table_name,
8440 array('source_url' => $source_url),
8441 array('%s')
8442 );
8443 $row_url = $source_url;
8444 } else {
8445 // Identity captured pre-delete for the Vector Store mirror
8446 $row_url = $wpdb->get_var($wpdb->prepare(
8447 "SELECT source_url FROM {$table_name} WHERE id = %d",
8448 intval($entry_id)
8449 ));
8450 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
8451 $result = $wpdb->delete(
8452 $table_name,
8453 array('id' => intval($entry_id)),
8454 array('%d')
8455 );
8456 }
8457
8458 if ($result !== false) {
8459 $success_ids[] = $entry_id;
8460 // Mirror to the Vector Store: refresh the entry's file when
8461 // sibling chunk rows survive, remove it when none do.
8462 if (!empty($row_url) && class_exists('MxChat_Vectorstore_Manager')) {
8463 $remaining = (int) $wpdb->get_var($wpdb->prepare(
8464 "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
8465 $row_url
8466 ));
8467 if ($remaining > 0) {
8468 MxChat_Vectorstore_Manager::sync_upsert_entry($row_url, '', $bot_id);
8469 } else {
8470 MxChat_Vectorstore_Manager::sync_delete_entry($row_url, $bot_id);
8471 }
8472 }
8473 } else {
8474 $failed_ids[] = $entry_id;
8475 $errors[] = "Database error for entry: $entry_id";
8476 }
8477 } catch (Exception $e) {
8478 $failed_ids[] = $entry_id;
8479 $errors[] = $e->getMessage();
8480 }
8481 }
8482
8483 wp_send_json_success(array(
8484 'success_ids' => $success_ids,
8485 'failed_ids' => $failed_ids,
8486 'errors' => $errors,
8487 'total_processed' => count($success_ids) + count($failed_ids)
8488 ));
8489
8490 exit;
8491 }
8492
8493 /**
8494 * Get the roles offered for a knowledge-entry restriction.
8495 *
8496 * Built from the site's ACTUAL roles (wp_roles()->get_names()) rather than a
8497 * hardcoded five, so roles added by User Role Editor / Members / a membership
8498 * plugin can be restricted to. The two virtual levels stay first, and the
8499 * built-in five keep their existing keys AND their existing labels, so
8500 * restrictions already stored on entries continue to mean what they meant.
8501 */
8502 public function mxchat_get_role_options() {
8503 return self::mxchat_role_options();
8504 }
8505
8506 /**
8507 * The same list, callable without an instance.
8508 *
8509 * The constructor registers hooks, so anything outside this class that needs
8510 * the role list (the post meta box) must not instantiate one just to read it.
8511 */
8512 public static function mxchat_role_options() {
8513 // Existing labels, in the existing order. A built-in that a site has
8514 // removed is simply not offered.
8515 $builtin_labels = array(
8516 'subscriber' => __('Subscribers & Above', 'mxchat'),
8517 'contributor' => __('Contributors & Above', 'mxchat'),
8518 'author' => __('Authors & Above', 'mxchat'),
8519 'editor' => __('Editors & Above', 'mxchat'),
8520 'administrator' => __('Administrators Only', 'mxchat'),
8521 );
8522
8523 $options = array(
8524 'public' => __('Public (Everyone)', 'mxchat'),
8525 'logged_in' => __('Logged In Users', 'mxchat'),
8526 );
8527
8528 $site_roles = function_exists('wp_roles') ? wp_roles()->get_names() : array();
8529 if (empty($site_roles) || !is_array($site_roles)) {
8530 // No roles readable (very early boot): fall back to the built-in set so
8531 // the dropdown is never empty.
8532 return array_merge($options, $builtin_labels);
8533 }
8534
8535 foreach ($builtin_labels as $slug => $label) {
8536 if (isset($site_roles[$slug])) {
8537 $options[$slug] = $label;
8538 }
8539 }
8540
8541 // Everything else the site defines, alphabetical by display name.
8542 $custom = array();
8543 foreach ($site_roles as $slug => $name) {
8544 if (!isset($builtin_labels[$slug])) {
8545 $custom[$slug] = translate_user_role($name);
8546 }
8547 }
8548 asort($custom);
8549
8550 return array_merge($options, $custom);
8551 }
8552
8553 /**
8554 * Check if the current user may see content carrying a role restriction.
8555 *
8556 * Access is granted when the user holds the required role outright, or holds
8557 * a role whose capabilities are a superset of the required role's. That keeps
8558 * the built-in "& Above" ladder exactly as it was — administrator's caps
8559 * cover editor's, editor's cover author's, and so on down to subscriber —
8560 * while working for any custom role, which a hardcoded five-name hierarchy
8561 * could not.
8562 *
8563 * The old hierarchy scored an unknown role 0, so restricting an entry TO a
8564 * custom role silently granted it to every logged-in user. Fails closed now.
8565 */
8566 public function mxchat_user_has_content_access($role_restriction) {
8567 // Public content is always accessible
8568 if ($role_restriction === 'public' || empty($role_restriction)) {
8569 return true;
8570 }
8571
8572 // Check if user is logged in for logged_in restriction
8573 if ($role_restriction === 'logged_in') {
8574 return is_user_logged_in();
8575 }
8576
8577 // If not logged in, no access to role-restricted content
8578 if (!is_user_logged_in()) {
8579 return false;
8580 }
8581
8582 $user = wp_get_current_user();
8583 $user_roles = (array) $user->roles;
8584
8585 if (empty($user_roles)) {
8586 return false;
8587 }
8588
8589 // Holding the required role is always enough.
8590 if (in_array($role_restriction, $user_roles, true)) {
8591 return true;
8592 }
8593
8594 // Site administrators keep the blanket access the old level-5 ladder gave
8595 // them. This also stops a restriction naming a since-deleted role from
8596 // locking the owner out of their own content.
8597 if (current_user_can('manage_options')) {
8598 return true;
8599 }
8600
8601 foreach ($user_roles as $user_role) {
8602 if (self::mxchat_role_covers($user_role, $role_restriction)) {
8603 return true;
8604 }
8605 }
8606
8607 return false;
8608 }
8609
8610 /**
8611 * Does $candidate_role grant everything $required_role grants?
8612 *
8613 * Compared on capabilities, not names. Memoised per request because this runs
8614 * once per retrieved knowledge row.
8615 *
8616 * A required role that does not exist, or that grants nothing at all, is NOT
8617 * treated as covered by anything — an empty capability set is a superset of
8618 * itself for every role on the site, which would quietly make the restriction
8619 * meaningless. Those cases fall back to the exact-role check in the caller.
8620 *
8621 * @param string $candidate_role Role slug the user holds.
8622 * @param string $required_role Role slug the entry is restricted to.
8623 * @return bool
8624 */
8625 private static function mxchat_role_covers($candidate_role, $required_role) {
8626 static $cache = array();
8627
8628 $key = $candidate_role . '|' . $required_role;
8629 if (isset($cache[$key])) {
8630 return $cache[$key];
8631 }
8632
8633 $cache[$key] = false;
8634
8635 if (!function_exists('wp_roles')) {
8636 return false;
8637 }
8638
8639 $roles = wp_roles();
8640 $candidate = $roles->get_role($candidate_role);
8641 $required = $roles->get_role($required_role);
8642
8643 if (!$candidate || !$required) {
8644 return false;
8645 }
8646
8647 $required_caps = self::mxchat_real_caps($required);
8648 $candidate_caps = self::mxchat_real_caps($candidate);
8649
8650 if (empty($required_caps)) {
8651 return false;
8652 }
8653
8654 foreach (array_keys($required_caps) as $cap) {
8655 if (empty($candidate_caps[$cap])) {
8656 return false;
8657 }
8658 }
8659
8660 $cache[$key] = true;
8661 return true;
8662 }
8663
8664 /**
8665 * A role's granted capabilities, minus the vestigial user-level ones.
8666 *
8667 * WordPress still stamps level_0 ... level_10 onto its built-in roles for
8668 * back-compat with user levels, deprecated since 3.0. They carry no meaning
8669 * and a hand-built custom role usually has none of them — so comparing them
8670 * makes a role that genuinely does more than a subscriber fail to cover
8671 * subscriber, purely because it lacks level_0. Measured on this box: a
8672 * Teacher role with read/edit_posts/delete_posts/upload_files was denied
8673 * subscriber-restricted content for exactly that reason.
8674 *
8675 * @param WP_Role $role
8676 * @return array Capability name => true, for granted non-level capabilities.
8677 */
8678 /**
8679 * Of two role restrictions, the one that admits FEWER people.
8680 *
8681 * 'public' loses to everything; 'logged_in' loses to any real role. Between
8682 * two real roles the comparison is the same capability-superset test the
8683 * access check uses: if A's capabilities cover B's, then everyone who
8684 * satisfies A also satisfies B, so A is the stricter requirement.
8685 *
8686 * Two unrelated custom roles are genuinely incomparable — neither covers the
8687 * other. In that case $b wins, because callers pass the per-entry choice as
8688 * $b: an owner who set the dropdown on the page itself meant it.
8689 *
8690 * @param string $a Current winner (may be 'public').
8691 * @param string $b Challenger.
8692 * @return string
8693 */
8694 private static function mxchat_stricter_role($a, $b) {
8695 $rank = array('public' => 0, 'logged_in' => 1);
8696
8697 if ($b === '' || $b === null) { return $a; }
8698 if ($a === '' || $a === null) { return $b; }
8699 if ($a === $b) { return $a; }
8700
8701 $a_virtual = isset($rank[$a]);
8702 $b_virtual = isset($rank[$b]);
8703
8704 if ($a_virtual && $b_virtual) {
8705 return $rank[$b] > $rank[$a] ? $b : $a;
8706 }
8707 // A real role always outranks 'public' and 'logged_in'.
8708 if ($a_virtual) { return $b; }
8709 if ($b_virtual) { return $a; }
8710
8711 // Both are real roles: whichever covers the other is the stricter one.
8712 if (self::mxchat_role_covers($a, $b)) { return $a; }
8713 if (self::mxchat_role_covers($b, $a)) { return $b; }
8714
8715 // Incomparable — the explicit per-entry choice wins.
8716 return $b;
8717 }
8718
8719 private static function mxchat_real_caps($role) {
8720 $caps = array();
8721 foreach ((array) $role->capabilities as $cap => $granted) {
8722 if (!$granted) {
8723 continue;
8724 }
8725 if (preg_match('/^level_\d+$/', $cap)) {
8726 continue;
8727 }
8728 $caps[$cap] = true;
8729 }
8730 return $caps;
8731 }
8732
8733 /**
8734 * Handle role restriction updates via AJAX
8735 * Removed cache clearing call since we removed caching
8736 */
8737 public function ajax_mxchat_update_role_restriction() {
8738 // Verify nonce and permissions
8739 if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
8740 wp_send_json_error('Invalid nonce');
8741 exit;
8742 }
8743
8744 if (!current_user_can('manage_options')) {
8745 wp_send_json_error('Unauthorized access');
8746 exit;
8747 }
8748
8749 $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
8750 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
8751 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
8752
8753 if (empty($entry_id)) {
8754 wp_send_json_error('Invalid entry ID');
8755 exit;
8756 }
8757
8758 // Get knowledge manager instance to validate role restriction
8759 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
8760 $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
8761 if (!in_array($role_restriction, $valid_roles)) {
8762 wp_send_json_error('Invalid role restriction');
8763 exit;
8764 }
8765
8766 global $wpdb;
8767
8768 if ($data_source === 'pinecone') {
8769 // Handle Pinecone role restriction (stored separately in WordPress table)
8770 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
8771
8772 // Use REPLACE to insert or update the role restriction
8773 $result = $wpdb->replace(
8774 $roles_table,
8775 array(
8776 'vector_id' => $entry_id,
8777 'role_restriction' => $role_restriction,
8778 'updated_at' => current_time('mysql')
8779 ),
8780 array('%s', '%s', '%s')
8781 );
8782
8783 // No cache clearing needed since we removed caching
8784
8785 } else {
8786 // Handle WordPress database role restriction (existing functionality)
8787 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8788
8789 $result = $wpdb->update(
8790 $table_name,
8791 array('role_restriction' => $role_restriction),
8792 array('id' => absint($entry_id)),
8793 array('%s'),
8794 array('%d')
8795 );
8796 }
8797
8798 if ($result === false) {
8799 wp_send_json_error('Database update failed: ' . $wpdb->last_error);
8800 exit;
8801 }
8802
8803 // Keep the OpenAI Vector Store mirror consistent with the new restriction
8804 // (plan 15b5c6): non-public pulls the mirrored file, public re-mirrors.
8805 if (class_exists('MxChat_Vectorstore_Manager')) {
8806 if ($data_source === 'pinecone') {
8807 if ($role_restriction !== 'public') {
8808 MxChat_Vectorstore_Manager::sync_delete_by_key($entry_id, 'default');
8809 }
8810 // Public again: Pinecone-mode content isn't held locally, so the
8811 // entry re-mirrors on its next save/import rather than here.
8812 } else {
8813 $row_url = $wpdb->get_var($wpdb->prepare(
8814 "SELECT source_url FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
8815 absint($entry_id)
8816 ));
8817 if (!empty($row_url)) {
8818 MxChat_Vectorstore_Manager::handle_role_change($row_url, 'default', $role_restriction);
8819 }
8820 }
8821 }
8822
8823 wp_send_json_success(array(
8824 'message' => 'Role restriction updated successfully',
8825 'role_restriction' => $role_restriction,
8826 'data_source' => $data_source,
8827 'entry_id' => $entry_id
8828 ));
8829 exit;
8830 }
8831
8832 // ========================================
8833 // ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
8834 // Add these to your MxChat_Knowledge_Manager class
8835 // ========================================
8836
8837 /**
8838 * Initialize role-based content hooks
8839 * Add this call to your __construct() or mxchat_init_hooks() method
8840 */
8841 private function mxchat_init_role_hooks() {
8842 // AJAX handlers for tag-role mappings
8843 add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
8844 add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
8845 add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
8846 add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
8847
8848 // Hook to automatically update role restrictions when tags are added/removed
8849 add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
8850
8851 // Hook to apply role restrictions on auto-sync
8852 add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
8853 }
8854
8855 /**
8856 * Add tag-role mapping via AJAX
8857 */
8858 public function ajax_add_tag_role_mapping() {
8859 // Verify nonce and permissions
8860 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
8861
8862 if (!current_user_can('manage_options')) {
8863 wp_send_json_error('Unauthorized access');
8864 exit;
8865 }
8866
8867 $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
8868 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
8869
8870 if (empty($tag_input)) {
8871 wp_send_json_error('Please enter a tag name or slug');
8872 exit;
8873 }
8874
8875 // Validate role restriction
8876 $valid_roles = array_keys($this->mxchat_get_role_options());
8877 if (!in_array($role_restriction, $valid_roles)) {
8878 wp_send_json_error('Invalid role restriction');
8879 exit;
8880 }
8881
8882 // Resolve the tag by slug first, then fall back to its display name, so users can
8883 // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
8884 // labeled by name but previously validated by slug only, producing the confusing
8885 // "Tag does not exist in WordPress" error when a real tag's name was typed.)
8886 $term = get_term_by('slug', $tag_input, 'post_tag');
8887 if (!$term) {
8888 $term = get_term_by('name', $tag_input, 'post_tag');
8889 }
8890 if (!$term) {
8891 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.');
8892 exit;
8893 }
8894
8895 // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
8896 // compares against each post's tag slugs, so the stored key must be a slug,
8897 // never the raw (possibly display-name) input.
8898 $tag_slug = $term->slug;
8899
8900 // Get existing mappings
8901 $mappings = get_option('mxchat_tag_role_mappings', array());
8902
8903 // Check if mapping already exists
8904 if (isset($mappings[$tag_slug])) {
8905 wp_send_json_error('Mapping for this tag already exists');
8906 exit;
8907 }
8908
8909 // Add new mapping
8910 $mappings[$tag_slug] = $role_restriction;
8911 update_option('mxchat_tag_role_mappings', $mappings);
8912
8913 wp_send_json_success(array(
8914 'message' => 'Tag-role mapping added successfully',
8915 'tag_slug' => $tag_slug,
8916 'role_restriction' => $role_restriction
8917 ));
8918 exit;
8919 }
8920
8921 /**
8922 * Delete tag-role mapping via AJAX
8923 */
8924 public function ajax_delete_tag_role_mapping() {
8925 // Verify nonce and permissions
8926 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
8927
8928 if (!current_user_can('manage_options')) {
8929 wp_send_json_error('Unauthorized access');
8930 exit;
8931 }
8932
8933 $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
8934
8935 if (empty($tag_slug)) {
8936 wp_send_json_error('Tag slug is required');
8937 exit;
8938 }
8939
8940 // Get existing mappings
8941 $mappings = get_option('mxchat_tag_role_mappings', array());
8942
8943 // Check if mapping exists
8944 if (!isset($mappings[$tag_slug])) {
8945 wp_send_json_error('Mapping does not exist');
8946 exit;
8947 }
8948
8949 // Remove mapping
8950 unset($mappings[$tag_slug]);
8951 update_option('mxchat_tag_role_mappings', $mappings);
8952
8953 wp_send_json_success(array(
8954 'message' => 'Tag-role mapping deleted successfully',
8955 'tag_slug' => $tag_slug
8956 ));
8957 exit;
8958 }
8959
8960 /**
8961 * Get all tag-role mappings via AJAX
8962 */
8963 public function ajax_get_tag_role_mappings() {
8964 // Verify nonce and permissions
8965 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
8966
8967 if (!current_user_can('manage_options')) {
8968 wp_send_json_error('Unauthorized access');
8969 exit;
8970 }
8971
8972 // Get mappings
8973 $mappings = get_option('mxchat_tag_role_mappings', array());
8974 $role_options = $this->mxchat_get_role_options();
8975
8976 $formatted_mappings = array();
8977
8978 foreach ($mappings as $tag_slug => $role_restriction) {
8979 // Get tag object
8980 $term = get_term_by('slug', $tag_slug, 'post_tag');
8981
8982 // Count posts with this tag
8983 $post_count = 0;
8984 if ($term) {
8985 $post_count = $term->count;
8986 }
8987
8988 $formatted_mappings[] = array(
8989 'tag_slug' => $tag_slug,
8990 'role_restriction' => $role_restriction,
8991 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
8992 'post_count' => $post_count
8993 );
8994 }
8995
8996 wp_send_json_success(array(
8997 'mappings' => $formatted_mappings
8998 ));
8999 exit;
9000 }
9001
9002 /**
9003 * Bulk update role restrictions for all existing content with mapped tags
9004 */
9005 public function ajax_bulk_update_tag_roles() {
9006 // Verify nonce and permissions
9007 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
9008
9009 if (!current_user_can('manage_options')) {
9010 wp_send_json_error('Unauthorized access');
9011 exit;
9012 }
9013
9014 // Get mappings
9015 $mappings = get_option('mxchat_tag_role_mappings', array());
9016
9017 if (empty($mappings)) {
9018 wp_send_json_error('No tag-role mappings found');
9019 exit;
9020 }
9021
9022 global $wpdb;
9023
9024 // Check if using Pinecone
9025 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9026 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9027
9028 $updated_count = 0;
9029 $details = array();
9030
9031 foreach ($mappings as $tag_slug => $role_restriction) {
9032 // Get all posts with this tag
9033 $posts = get_posts(array(
9034 'tag' => $tag_slug,
9035 'post_type' => 'any',
9036 'posts_per_page' => -1,
9037 'fields' => 'ids',
9038 'post_status' => 'publish'
9039 ));
9040
9041 if (empty($posts)) {
9042 continue;
9043 }
9044
9045 $tag_updated = 0;
9046
9047 foreach ($posts as $post_id) {
9048 $source_url = get_permalink($post_id);
9049 if (!$source_url) {
9050 continue;
9051 }
9052
9053 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
9054 // Update Pinecone role restriction
9055 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9056 $vector_id = md5($source_url);
9057
9058 $result = $wpdb->replace(
9059 $roles_table,
9060 array(
9061 'vector_id' => $vector_id,
9062 'role_restriction' => $role_restriction,
9063 'updated_at' => current_time('mysql')
9064 ),
9065 array('%s', '%s', '%s')
9066 );
9067 } else {
9068 // Update WordPress DB
9069 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9070
9071 $result = $wpdb->update(
9072 $table_name,
9073 array('role_restriction' => $role_restriction),
9074 array('source_url' => $source_url),
9075 array('%s'),
9076 array('%s')
9077 );
9078 }
9079
9080 if ($result !== false) {
9081 $tag_updated++;
9082 $updated_count++;
9083 }
9084 }
9085
9086 if ($tag_updated > 0) {
9087 $details[] = sprintf(
9088 'Tag "%s" (%s): %d posts updated',
9089 $tag_slug,
9090 $role_restriction,
9091 $tag_updated
9092 );
9093 }
9094 }
9095
9096 wp_send_json_success(array(
9097 'message' => 'Bulk update completed',
9098 'updated_count' => $updated_count,
9099 'tags_processed' => count($mappings),
9100 'details' => $details
9101 ));
9102 exit;
9103 }
9104
9105 /**
9106 * Handle tag changes on posts (when tags are added or removed)
9107 */
9108 public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
9109 // Only process post tags
9110 if ($taxonomy !== 'post_tag') {
9111 return;
9112 }
9113
9114 // Get tag-role mappings
9115 $mappings = get_option('mxchat_tag_role_mappings', array());
9116
9117 if (empty($mappings)) {
9118 return;
9119 }
9120
9121 // Get the post's URL
9122 $source_url = get_permalink($object_id);
9123 if (!$source_url) {
9124 return;
9125 }
9126
9127 // Determine the highest role restriction based on tags
9128 $highest_role = 'public';
9129 $role_hierarchy = array(
9130 'public' => 0,
9131 'logged_in' => 1,
9132 'subscriber' => 2,
9133 'contributor' => 3,
9134 'author' => 4,
9135 'editor' => 5,
9136 'administrator' => 6
9137 );
9138
9139 // Get all current tags for the post
9140 $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
9141
9142 // Find the highest role restriction among the tags
9143 foreach ($current_tags as $tag_slug) {
9144 if (isset($mappings[$tag_slug])) {
9145 $role = $mappings[$tag_slug];
9146 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
9147 $highest_role = $role;
9148 }
9149 }
9150 }
9151
9152 // Update the role restriction in the database
9153 global $wpdb;
9154
9155 // Check if using Pinecone
9156 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9157 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9158
9159 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
9160 // Update Pinecone role restriction
9161 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9162 $vector_id = md5($source_url);
9163
9164 $wpdb->replace(
9165 $roles_table,
9166 array(
9167 'vector_id' => $vector_id,
9168 'role_restriction' => $highest_role,
9169 'updated_at' => current_time('mysql')
9170 ),
9171 array('%s', '%s', '%s')
9172 );
9173 } else {
9174 // Update WordPress DB
9175 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9176
9177 $wpdb->update(
9178 $table_name,
9179 array('role_restriction' => $highest_role),
9180 array('source_url' => $source_url),
9181 array('%s'),
9182 array('%s')
9183 );
9184 }
9185
9186 // The entry's restriction just changed — keep the OpenAI Vector Store
9187 // mirror consistent: non-public pulls the file (file_search has no
9188 // per-role filtering), public re-mirrors it (plan 15b5c6).
9189 if (class_exists('MxChat_Vectorstore_Manager')) {
9190 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
9191 }
9192 }
9193
9194 /**
9195 * Apply role restriction after content is stored (for auto-sync)
9196 */
9197 public function apply_role_restriction_after_storage($post_id, $source_url) {
9198 // Get tag-role mappings
9199 $mappings = get_option('mxchat_tag_role_mappings', array());
9200
9201 if (empty($mappings)) {
9202 return;
9203 }
9204
9205 // Get all tags for the post
9206 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
9207
9208 if (empty($post_tags)) {
9209 return;
9210 }
9211
9212 // Determine the highest role restriction based on tags
9213 $highest_role = 'public';
9214 $role_hierarchy = array(
9215 'public' => 0,
9216 'logged_in' => 1,
9217 'subscriber' => 2,
9218 'contributor' => 3,
9219 'author' => 4,
9220 'editor' => 5,
9221 'administrator' => 6
9222 );
9223
9224 foreach ($post_tags as $tag_slug) {
9225 if (isset($mappings[$tag_slug])) {
9226 $role = $mappings[$tag_slug];
9227 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
9228 $highest_role = $role;
9229 }
9230 }
9231 }
9232
9233 // If no restricted tags found, return (leave as public)
9234 if ($highest_role === 'public') {
9235 return;
9236 }
9237
9238 // Update the role restriction
9239 global $wpdb;
9240
9241 // Check if using Pinecone
9242 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9243 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9244
9245 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
9246 // Update Pinecone role restriction
9247 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
9248 $vector_id = md5($source_url);
9249
9250 $wpdb->replace(
9251 $roles_table,
9252 array(
9253 'vector_id' => $vector_id,
9254 'role_restriction' => $highest_role,
9255 'updated_at' => current_time('mysql')
9256 ),
9257 array('%s', '%s', '%s')
9258 );
9259 } else {
9260 // Update WordPress DB
9261 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9262
9263 $wpdb->update(
9264 $table_name,
9265 array('role_restriction' => $highest_role),
9266 array('source_url' => $source_url),
9267 array('%s'),
9268 array('%s')
9269 );
9270 }
9271
9272 // The entry's restriction just changed — keep the OpenAI Vector Store
9273 // mirror consistent: non-public pulls the file (file_search has no
9274 // per-role filtering), public re-mirrors it (plan 15b5c6).
9275 if (class_exists('MxChat_Vectorstore_Manager')) {
9276 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
9277 }
9278 }
9279
9280
9281 // ========================================
9282 // HELPER METHODS
9283 // ========================================
9284
9285 /**
9286 * Check if user has required permissions for content processing
9287 */
9288 private function mxchat_check_user_permissions() {
9289 if (!current_user_can('manage_options')) {
9290 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
9291 }
9292 }
9293
9294 /**
9295 * Validate nonce for security
9296 */
9297 private function mxchat_validate_nonce($nonce_name, $nonce_action) {
9298 if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
9299 wp_die(esc_html__('Security check failed.', 'mxchat'));
9300 }
9301 }
9302
9303 /**
9304 * Get embedding API credentials
9305 */
9306 private function mxchat_get_embedding_credentials() {
9307 $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
9308
9309 if (strpos($embedding_model, 'text-embedding-') !== false) {
9310 return array(
9311 'type' => 'openai',
9312 'api_key' => $this->options['api_key'] ?? ''
9313 );
9314 } elseif (strpos($embedding_model, 'voyage-') !== false) {
9315 return array(
9316 'type' => 'voyage',
9317 'api_key' => $this->options['voyage_api_key'] ?? ''
9318 );
9319 } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
9320 return array(
9321 'type' => 'gemini',
9322 'api_key' => $this->options['gemini_api_key'] ?? ''
9323 );
9324 }
9325
9326 return array('type' => 'unknown', 'api_key' => '');
9327 }
9328
9329 /**
9330 * Log processing errors
9331 */
9332 private function mxchat_log_processing_error($operation, $error_message) {
9333 //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
9334 }
9335
9336 /**
9337 * Set admin notice transient
9338 */
9339 private function mxchat_set_admin_notice($type, $message) {
9340 set_transient("mxchat_admin_notice_{$type}", $message, 30);
9341 }
9342
9343 /**
9344 * Get Pinecone manager instance for vector operations
9345 */
9346 private function mxchat_get_pinecone_manager() {
9347 return MxChat_Pinecone_Manager::get_instance();
9348 }
9349
9350
9351 // ========================================
9352 // DATABASE QUEUE TABLE MANAGEMENT
9353 // ========================================
9354
9355 /**
9356 * Create queue table on plugin activation
9357 * Call this from your plugin activation hook
9358 */
9359 public function mxchat_create_queue_table() {
9360 global $wpdb;
9361
9362 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9363 $charset_collate = $wpdb->get_charset_collate();
9364
9365 $sql = "CREATE TABLE IF NOT EXISTS $table_name (
9366 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
9367 queue_id varchar(64) NOT NULL,
9368 item_type varchar(20) NOT NULL,
9369 item_data longtext NOT NULL,
9370 status varchar(20) NOT NULL DEFAULT 'pending',
9371 bot_id varchar(50) NOT NULL DEFAULT 'default',
9372 priority int(11) NOT NULL DEFAULT 0,
9373 attempts int(11) NOT NULL DEFAULT 0,
9374 max_attempts int(11) NOT NULL DEFAULT 3,
9375 error_message text DEFAULT NULL,
9376 created_at datetime NOT NULL,
9377 started_at datetime DEFAULT NULL,
9378 completed_at datetime DEFAULT NULL,
9379 PRIMARY KEY (id),
9380 KEY queue_id (queue_id),
9381 KEY status (status),
9382 KEY item_type (item_type),
9383 KEY priority (priority)
9384 ) $charset_collate;";
9385
9386 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
9387 dbDelta($sql);
9388
9389 // Also create a meta table for queue metadata
9390 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
9391
9392 $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
9393 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
9394 queue_id varchar(64) NOT NULL,
9395 meta_key varchar(255) NOT NULL,
9396 meta_value longtext,
9397 PRIMARY KEY (id),
9398 KEY queue_id (queue_id),
9399 KEY meta_key (meta_key)
9400 ) $charset_collate;";
9401
9402 dbDelta($meta_sql);
9403 }
9404
9405 /**
9406 * Add items to the processing queue
9407 *
9408 * @param string $queue_id Unique identifier for this queue batch
9409 * @param string $item_type Type of item (url, pdf_page)
9410 * @param array $items Array of items to queue
9411 * @param string $bot_id Bot ID for processing
9412 * @return int Number of items queued
9413 */
9414 private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
9415 global $wpdb;
9416 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9417
9418 $queued_count = 0;
9419 $priority = 0;
9420
9421 foreach ($items as $item) {
9422 $result = $wpdb->insert(
9423 $table_name,
9424 array(
9425 'queue_id' => $queue_id,
9426 'item_type' => $item_type,
9427 'item_data' => wp_json_encode($item),
9428 'status' => 'pending',
9429 'bot_id' => $bot_id,
9430 'priority' => $priority,
9431 'attempts' => 0,
9432 'max_attempts' => 3,
9433 'created_at' => current_time('mysql')
9434 ),
9435 array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
9436 );
9437
9438 if ($result) {
9439 $queued_count++;
9440 }
9441
9442 $priority++; // Process in order
9443 }
9444
9445 return $queued_count;
9446 }
9447
9448 /**
9449 * Store queue metadata (total counts, source URL, etc.)
9450 */
9451 private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
9452 global $wpdb;
9453 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
9454
9455 // Check if meta exists
9456 $existing = $wpdb->get_var($wpdb->prepare(
9457 "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
9458 $queue_id,
9459 $meta_key
9460 ));
9461
9462 if ($existing) {
9463 // Update
9464 $wpdb->update(
9465 $meta_table,
9466 array('meta_value' => maybe_serialize($meta_value)),
9467 array('queue_id' => $queue_id, 'meta_key' => $meta_key),
9468 array('%s'),
9469 array('%s', '%s')
9470 );
9471 } else {
9472 // Insert
9473 $wpdb->insert(
9474 $meta_table,
9475 array(
9476 'queue_id' => $queue_id,
9477 'meta_key' => $meta_key,
9478 'meta_value' => maybe_serialize($meta_value)
9479 ),
9480 array('%s', '%s', '%s')
9481 );
9482 }
9483 }
9484
9485 /**
9486 * Get queue metadata
9487 */
9488 private function mxchat_get_queue_meta($queue_id, $meta_key) {
9489 global $wpdb;
9490 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
9491
9492 $value = $wpdb->get_var($wpdb->prepare(
9493 "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
9494 $queue_id,
9495 $meta_key
9496 ));
9497
9498 return maybe_unserialize($value);
9499 }
9500
9501 // ========================================
9502 // AJAX QUEUE PROCESSING HANDLERS
9503 // ========================================
9504
9505 /**
9506 * AJAX: Get next item from queue to process
9507 */
9508 public function ajax_mxchat_get_next_queue_item() {
9509 // Verify nonce and permissions
9510 check_ajax_referer('mxchat_queue_nonce', 'nonce');
9511
9512 if (!current_user_can('manage_options')) {
9513 wp_send_json_error('Unauthorized access');
9514 }
9515
9516 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
9517
9518 if (empty($queue_id)) {
9519 wp_send_json_error('Missing queue ID');
9520 }
9521
9522 global $wpdb;
9523 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9524
9525 // Get next pending item with retry logic for failed items
9526 $next_item = $wpdb->get_row($wpdb->prepare(
9527 "SELECT * FROM $table_name
9528 WHERE queue_id = %s
9529 AND status IN ('pending', 'failed')
9530 AND attempts < max_attempts
9531 ORDER BY priority ASC, id ASC
9532 LIMIT 1",
9533 $queue_id
9534 ));
9535
9536 if (!$next_item) {
9537 // No more items - queue complete
9538 wp_send_json_success(array(
9539 'complete' => true,
9540 'message' => 'Queue processing complete'
9541 ));
9542 }
9543
9544 // Mark item as processing
9545 $wpdb->update(
9546 $table_name,
9547 array(
9548 'status' => 'processing',
9549 'started_at' => current_time('mysql'),
9550 'attempts' => $next_item->attempts + 1
9551 ),
9552 array('id' => $next_item->id),
9553 array('%s', '%s', '%d'),
9554 array('%d')
9555 );
9556
9557 wp_send_json_success(array(
9558 'complete' => false,
9559 'item' => array(
9560 'id' => $next_item->id,
9561 'type' => $next_item->item_type,
9562 'data' => json_decode($next_item->item_data, true),
9563 'bot_id' => $next_item->bot_id,
9564 'attempt' => $next_item->attempts + 1
9565 )
9566 ));
9567 }
9568
9569 /**
9570 * AJAX: Process a single queue item
9571 */
9572 public function ajax_mxchat_process_queue_item() {
9573 // Verify nonce and permissions
9574 check_ajax_referer('mxchat_queue_nonce', 'nonce');
9575
9576 if (!current_user_can('manage_options')) {
9577 wp_send_json_error('Unauthorized access');
9578 }
9579
9580 $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
9581 $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
9582 $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
9583 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9584
9585 if (empty($item_id) || empty($item_type)) {
9586 wp_send_json_error('Missing item data');
9587 }
9588
9589 global $wpdb;
9590 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
9591
9592 // Process based on item type
9593 try {
9594 set_time_limit(60); // Give processing 60 seconds
9595
9596 $result = false;
9597 $error_message = '';
9598
9599 // Read item directly from DB to get queue_id and preserve special chars in item_data
9600 // (POST round-trip through JS mangles characters like apostrophes in URLs)
9601 $db_item = $wpdb->get_row($wpdb->prepare(
9602 "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
9603 $item_id
9604 ));
9605 $item_queue_id = $db_item ? $db_item->queue_id : '';
9606 if ($db_item && !empty($db_item->item_data)) {
9607 $db_data = json_decode($db_item->item_data, true);
9608 if (is_array($db_data)) {
9609 $item_data = $db_data;
9610 }
9611 }
9612
9613 switch ($item_type) {
9614 case 'url':
9615 $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
9616 break;
9617
9618 case 'pdf_page':
9619 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
9620 break;
9621
9622 default:
9623 throw new Exception('Unknown item type: ' . $item_type);
9624 }
9625
9626 if (is_wp_error($result)) {
9627 $error_code = $result->get_error_code();
9628 // Content errors (empty page, sanitization) are permanent — retrying won't help
9629 $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
9630 if (in_array($error_code, $permanent_codes)) {
9631 // Mark as permanently failed — set attempts = max_attempts so it won't be retried
9632 $current_item = $wpdb->get_row($wpdb->prepare(
9633 "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
9634 ));
9635 $wpdb->update(
9636 $table_name,
9637 array(
9638 'status' => 'failed',
9639 'error_message' => $result->get_error_message(),
9640 'attempts' => $current_item ? $current_item->max_attempts : 3
9641 ),
9642 array('id' => $item_id),
9643 array('%s', '%s', '%d'),
9644 array('%d')
9645 );
9646 wp_send_json_error(array(
9647 'message' => $result->get_error_message(),
9648 'permanent_failure' => true,
9649 'item_id' => $item_id
9650 ));
9651 return;
9652 }
9653 throw new Exception($result->get_error_message());
9654 }
9655
9656 if ($result === false) {
9657 throw new Exception('Processing returned false - item may be empty or invalid');
9658 }
9659
9660 // Mark as completed
9661 $wpdb->update(
9662 $table_name,
9663 array(
9664 'status' => 'completed',
9665 'completed_at' => current_time('mysql'),
9666 'error_message' => null
9667 ),
9668 array('id' => $item_id),
9669 array('%s', '%s', '%s'),
9670 array('%d')
9671 );
9672
9673 wp_send_json_success(array(
9674 'processed' => true,
9675 'item_id' => $item_id,
9676 'message' => 'Item processed successfully'
9677 ));
9678
9679 } catch (Exception $e) {
9680 $error_message = $e->getMessage();
9681
9682 // Get current attempt count
9683 $item = $wpdb->get_row($wpdb->prepare(
9684 "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
9685 $item_id
9686 ));
9687
9688 // Check if we've exhausted retries
9689 if ($item && $item->attempts >= $item->max_attempts) {
9690 // Permanently failed
9691 $wpdb->update(
9692 $table_name,
9693 array(
9694 'status' => 'failed',
9695 'error_message' => $error_message
9696 ),
9697 array('id' => $item_id),
9698 array('%s', '%s'),
9699 array('%d')
9700 );
9701
9702 wp_send_json_error(array(
9703 'message' => 'Item failed after maximum attempts: ' . $error_message,
9704 'permanent_failure' => true,
9705 'item_id' => $item_id
9706 ));
9707 } else {
9708 // Mark for retry
9709 $wpdb->update(
9710 $table_name,
9711 array(
9712 'status' => 'failed',
9713 'error_message' => $error_message
9714 ),
9715 array('id' => $item_id),
9716 array('%s', '%s'),
9717 array('%d')
9718 );
9719
9720 wp_send_json_error(array(
9721 'message' => 'Item processing failed, will retry: ' . $error_message,
9722 'can_retry' => true,
9723 'item_id' => $item_id,
9724 'attempts' => $item ? $item->attempts : 0
9725 ));
9726 }
9727 }
9728 }
9729
9730 /**
9731 * Process a URL from the queue
9732 */
9733 private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
9734 $url = isset($item_data['url']) ? $item_data['url'] : '';
9735
9736 if (empty($url)) {
9737 return new WP_Error('invalid_url', 'URL is empty');
9738 }
9739
9740 // Get bot-specific embedding decision early (needed for both paths) —
9741 // custom-provider-aware (plan cbd5fd). Error code preserved.
9742 $bot_options = $this->get_bot_options($bot_id);
9743 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
9744
9745 $preflight = MxChat_Utils::embedding_preflight($options);
9746 if (!$preflight['ok']) {
9747 return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
9748 }
9749 $api_key = $preflight['api_key'];
9750
9751 // Check if this is a WooCommerce product URL and WooCommerce is active
9752 $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
9753 $content_type = $is_product_url ? 'product' : 'url';
9754
9755 // Try to get WooCommerce product data if it's a product URL
9756 if ($is_product_url && class_exists('WooCommerce')) {
9757 $product_content = $this->mxchat_extract_woocommerce_product_content($url);
9758
9759 if (!empty($product_content)) {
9760 // Successfully extracted WooCommerce product data with pricing
9761 $result = MxChat_Utils::submit_content_to_db(
9762 $product_content,
9763 $url,
9764 $api_key,
9765 null,
9766 $bot_id,
9767 'product'
9768 );
9769 return $result;
9770 }
9771 // If WooCommerce extraction failed, fall through to HTML extraction
9772 }
9773
9774 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
9775 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
9776 $response = wp_remote_get($url, array(
9777 'timeout' => $is_likely_pdf ? 120 : 30,
9778 'redirection' => 5,
9779 'user-agent' => mxchat_ingest_user_agent(),
9780 ));
9781
9782 if (is_wp_error($response)) {
9783 return $response;
9784 }
9785
9786 $response_code = wp_remote_retrieve_response_code($response);
9787 if ($response_code !== 200) {
9788 return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
9789 }
9790
9791 // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
9792 if ($this->mxchat_is_pdf_url($url, $response)) {
9793 return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
9794 }
9795
9796 $html = wp_remote_retrieve_body($response);
9797
9798 if (empty($html)) {
9799 return new WP_Error('empty_response', 'Empty response body');
9800 }
9801
9802 // Extract and sanitize content
9803 $content = $this->mxchat_extract_main_content($html);
9804 $sanitized = $this->mxchat_sanitize_content_for_api($content);
9805
9806 if (empty($sanitized)) {
9807 // Not an error - just no content found (maybe a redirect or empty page)
9808 return false;
9809 }
9810
9811 // Submit to database with content_type (+ the page's own title, plan d9ee66)
9812 $result = MxChat_Utils::submit_content_to_db(
9813 $sanitized,
9814 $url,
9815 $api_key,
9816 null,
9817 $bot_id,
9818 $content_type,
9819 MxChat_Utils::extract_page_title($html)
9820 );
9821
9822 return $result;
9823 }
9824
9825 /**
9826 * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
9827 * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
9828 * and adds pdf_page items to the same queue so they process with full progress tracking.
9829 */
9830 private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
9831 set_time_limit(120); // PDFs need extra time for download + parsing
9832
9833 $upload_dir = wp_upload_dir();
9834 $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
9835 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
9836
9837 $response_body = wp_remote_retrieve_body($response);
9838 if (empty($response_body)) {
9839 return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
9840 }
9841
9842 if (!wp_mkdir_p(dirname($pdf_path))) {
9843 return new WP_Error('dir_error', 'Failed to create upload directory');
9844 }
9845
9846 file_put_contents($pdf_path, $response_body);
9847
9848 if (!file_exists($pdf_path)) {
9849 return new WP_Error('save_error', 'Failed to save PDF file');
9850 }
9851
9852 try {
9853 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
9854
9855 if ($total_pages === false || $total_pages < 1) {
9856 wp_delete_file($pdf_path);
9857 return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
9858 }
9859
9860 // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
9861 $pages = array();
9862 for ($i = 1; $i <= $total_pages; $i++) {
9863 $pages[] = array(
9864 'pdf_path' => $pdf_path,
9865 'pdf_url' => $pdf_url,
9866 'page_number' => $i,
9867 'total_pages' => $total_pages
9868 );
9869 }
9870
9871 // Add pdf_page items to the SAME queue so the JS picks them up automatically
9872 if (!empty($queue_id)) {
9873 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
9874 } else {
9875 // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
9876 $new_queue_id = 'pdf_' . md5($pdf_url . time());
9877 $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
9878 $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
9879 $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
9880 $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
9881 $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
9882 $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
9883 $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
9884 }
9885
9886 if ($queued_count === 0) {
9887 wp_delete_file($pdf_path);
9888 return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
9889 }
9890
9891 // Return true so the original URL item is marked complete
9892 // The new pdf_page items will be processed in subsequent batches
9893 return true;
9894
9895 } catch (Exception $e) {
9896 if (file_exists($pdf_path)) {
9897 wp_delete_file($pdf_path);
9898 }
9899 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
9900 }
9901 }
9902
9903 /**
9904 * Legacy: Process a PDF URL inline during sitemap queue processing.
9905 * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
9906 */
9907 private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
9908 set_time_limit(120); // PDFs need more time — downloading + parsing all pages
9909
9910 $upload_dir = wp_upload_dir();
9911 $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
9912 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
9913
9914 $response_body = wp_remote_retrieve_body($response);
9915 if (empty($response_body)) {
9916 return new WP_Error('empty_pdf', 'Empty PDF response');
9917 }
9918
9919 if (!wp_mkdir_p(dirname($pdf_path))) {
9920 return new WP_Error('dir_error', 'Failed to create upload directory');
9921 }
9922
9923 file_put_contents($pdf_path, $response_body);
9924
9925 if (!file_exists($pdf_path)) {
9926 return new WP_Error('save_error', 'Failed to save PDF file');
9927 }
9928
9929 try {
9930 mxchat_load_pdf_parser();
9931 $parser = new \Smalot\PdfParser\Parser();
9932 $pdf = $parser->parseFile($pdf_path);
9933 $pages = $pdf->getPages();
9934 $total_pages = count($pages);
9935
9936 if ($total_pages < 1) {
9937 wp_delete_file($pdf_path);
9938 return new WP_Error('no_pages', 'PDF has no pages');
9939 }
9940
9941 $processed = 0;
9942 $skipped_pages = array();
9943
9944 for ($i = 0; $i < $total_pages; $i++) {
9945 $page_num = $i + 1;
9946 $text = $pages[$i]->getText();
9947 $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num);
9948 if (empty($text)) {
9949 $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
9950 continue;
9951 }
9952
9953 $sanitized = $this->mxchat_sanitize_content_for_api($text);
9954 if (empty($sanitized)) {
9955 $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
9956 continue;
9957 }
9958
9959 $metadata = array(
9960 'document_type' => 'pdf',
9961 'total_pages' => $total_pages,
9962 'current_page' => $page_num,
9963 'source_url' => $pdf_url,
9964 );
9965
9966 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
9967 $page_url = esc_url($pdf_url . '#page=' . $page_num);
9968
9969 MxChat_Utils::submit_content_to_db(
9970 $content_with_metadata,
9971 $page_url,
9972 $api_key,
9973 null,
9974 $bot_id,
9975 'pdf'
9976 );
9977
9978 $processed++;
9979 }
9980
9981 // Clean up the temp PDF file
9982 wp_delete_file($pdf_path);
9983
9984 if (!empty($skipped_pages)) {
9985 error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
9986 }
9987
9988 return $processed > 0 ? true : false;
9989
9990 } catch (Exception $e) {
9991 if (file_exists($pdf_path)) {
9992 wp_delete_file($pdf_path);
9993 }
9994 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
9995 }
9996 }
9997
9998 /**
9999 * Extract WooCommerce product content including pricing
10000 *
10001 * @param string $url The product URL
10002 * @return string|false Product content with pricing, or false if not found
10003 */
10004 private function mxchat_extract_woocommerce_product_content($url) {
10005 // Try to get product ID from URL
10006 $product_id = url_to_postid($url);
10007
10008 // If url_to_postid fails, try to extract from URL pattern
10009 if (!$product_id) {
10010 $product_slug = '';
10011
10012 // Handle pretty permalinks: /product/product-name/
10013 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
10014 $product_slug = $matches[1];
10015 }
10016
10017 if (!empty($product_slug)) {
10018 $product_post = get_page_by_path($product_slug, OBJECT, 'product');
10019 if ($product_post) {
10020 $product_id = $product_post->ID;
10021 }
10022 }
10023 }
10024
10025 if (!$product_id) {
10026 return false;
10027 }
10028
10029 // Get WooCommerce product object
10030 $product = wc_get_product($product_id);
10031
10032 if (!$product) {
10033 return false;
10034 }
10035
10036 // Build product content via the shared WC-object assembler (a3d60c) — same
10037 // body as the auto-sync product writer, so the two paths can never drift.
10038 $content = $this->mxchat_prepare_product_content_for_indexing($product);
10039
10040 return $this->mxchat_sanitize_content_for_api($content);
10041 }
10042
10043 /**
10044 * Process a PDF page from the queue
10045 */
10046 private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
10047 $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
10048 $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
10049 $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
10050 $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
10051
10052 if (empty($pdf_path) || !file_exists($pdf_path)) {
10053 return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
10054 }
10055
10056 if ($page_number < 1) {
10057 return new WP_Error('invalid_page', 'Invalid page number');
10058 }
10059
10060 try {
10061 mxchat_load_pdf_parser();
10062 $parser = new \Smalot\PdfParser\Parser();
10063 $pdf = $parser->parseFile($pdf_path);
10064 $pages = $pdf->getPages();
10065
10066 if (!isset($pages[$page_number - 1])) {
10067 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
10068 }
10069
10070 $text = $pages[$page_number - 1]->getText();
10071 $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number);
10072
10073 if (empty($text)) {
10074 return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
10075 }
10076
10077 $sanitized = $this->mxchat_sanitize_content_for_api($text);
10078
10079 if (empty($sanitized)) {
10080 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');
10081 }
10082
10083 // Create metadata
10084 $metadata = array(
10085 'document_type' => 'pdf',
10086 'total_pages' => $total_pages,
10087 'current_page' => $page_number,
10088 'source_url' => $pdf_url
10089 );
10090
10091 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
10092 $page_url = esc_url($pdf_url . "#page=" . $page_number);
10093
10094 // Get bot-specific embedding decision — custom-provider-aware
10095 // (plan cbd5fd). Error code preserved.
10096 $bot_options = $this->get_bot_options($bot_id);
10097 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
10098
10099 $preflight = MxChat_Utils::embedding_preflight($options);
10100 if (!$preflight['ok']) {
10101 return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
10102 }
10103 $api_key = $preflight['api_key'];
10104
10105 // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
10106 $result = MxChat_Utils::submit_content_to_db(
10107 $content_with_metadata,
10108 $page_url,
10109 $api_key,
10110 null,
10111 $bot_id,
10112 'pdf'
10113 );
10114
10115 return $result;
10116
10117 } catch (Exception $e) {
10118 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
10119 }
10120 }
10121
10122 /**
10123 * AJAX: Get queue processing status
10124 */
10125 public function ajax_mxchat_get_queue_status() {
10126 // Verify nonce and permissions
10127 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10128
10129 if (!current_user_can('manage_options')) {
10130 wp_send_json_error('Unauthorized access');
10131 }
10132
10133 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10134
10135 if (empty($queue_id)) {
10136 wp_send_json_error('Missing queue ID');
10137 }
10138
10139 global $wpdb;
10140 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10141
10142 // Get counts by status
10143 $counts = $wpdb->get_results($wpdb->prepare(
10144 "SELECT status, COUNT(*) as count
10145 FROM $table_name
10146 WHERE queue_id = %s
10147 GROUP BY status",
10148 $queue_id
10149 ), OBJECT_K);
10150
10151 $total = 0;
10152 $completed = 0;
10153 $failed = 0;
10154 $processing = 0;
10155 $pending = 0;
10156
10157 foreach ($counts as $status => $data) {
10158 $count = absint($data->count);
10159 $total += $count;
10160
10161 switch ($status) {
10162 case 'completed':
10163 $completed = $count;
10164 break;
10165 case 'failed':
10166 $failed = $count;
10167 break;
10168 case 'processing':
10169 $processing = $count;
10170 break;
10171 case 'pending':
10172 $pending = $count;
10173 break;
10174 }
10175 }
10176
10177 // Calculate percentage
10178 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
10179
10180 // Get failed items details (include all failed items, not just those that exhausted retries)
10181 $failed_items = array();
10182 if ($failed > 0) {
10183 $failed_items = $wpdb->get_results($wpdb->prepare(
10184 "SELECT item_type, item_data, error_message, attempts
10185 FROM $table_name
10186 WHERE queue_id = %s
10187 AND status = 'failed'
10188 ORDER BY id DESC
10189 LIMIT 50",
10190 $queue_id
10191 ));
10192 }
10193
10194 // Get queue metadata
10195 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
10196 $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
10197
10198 // Determine if queue is complete
10199 $is_complete = ($pending === 0 && $processing === 0);
10200
10201 wp_send_json_success(array(
10202 'queue_id' => $queue_id,
10203 'queue_type' => $queue_type,
10204 'source_url' => $source_url,
10205 'total' => $total,
10206 'completed' => $completed,
10207 'failed' => $failed,
10208 'processing' => $processing,
10209 'pending' => $pending,
10210 'percentage' => $percentage,
10211 'is_complete' => $is_complete,
10212 'failed_items' => $failed_items,
10213 'status' => $is_complete ? 'complete' : 'processing'
10214 ));
10215 }
10216
10217 /**
10218 * AJAX: Clear completed queue
10219 */
10220 public function ajax_mxchat_clear_queue() {
10221 // Verify nonce and permissions
10222 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10223
10224 if (!current_user_can('manage_options')) {
10225 wp_send_json_error('Unauthorized access');
10226 }
10227
10228 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10229
10230 if (empty($queue_id)) {
10231 wp_send_json_error('Missing queue ID');
10232 }
10233
10234 global $wpdb;
10235 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10236 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10237
10238 // Delete queue items
10239 $wpdb->delete(
10240 $table_name,
10241 array('queue_id' => $queue_id),
10242 array('%s')
10243 );
10244
10245 // Delete queue metadata
10246 $wpdb->delete(
10247 $meta_table,
10248 array('queue_id' => $queue_id),
10249 array('%s')
10250 );
10251
10252 wp_send_json_success(array(
10253 'message' => 'Queue cleared successfully'
10254 ));
10255 }
10256
10257 /**
10258 * AJAX: Retry failed items in queue
10259 */
10260 public function ajax_mxchat_retry_failed() {
10261 // Verify nonce and permissions
10262 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10263
10264 if (!current_user_can('manage_options')) {
10265 wp_send_json_error('Unauthorized access');
10266 }
10267
10268 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10269
10270 if (empty($queue_id)) {
10271 wp_send_json_error('Missing queue ID');
10272 }
10273
10274 global $wpdb;
10275 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10276
10277 // Reset failed items to pending and reset attempt count
10278 $updated = $wpdb->update(
10279 $table_name,
10280 array(
10281 'status' => 'pending',
10282 'attempts' => 0,
10283 'error_message' => null
10284 ),
10285 array(
10286 'queue_id' => $queue_id,
10287 'status' => 'failed'
10288 ),
10289 array('%s', '%d', '%s'),
10290 array('%s', '%s')
10291 );
10292
10293 wp_send_json_success(array(
10294 'message' => 'Reset ' . $updated . ' failed items for retry',
10295 'reset_count' => $updated
10296 ));
10297 }
10298
10299
10300 public function ajax_mxchat_mark_queue_complete() {
10301 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10302
10303 if (!current_user_can('manage_options')) {
10304 wp_send_json_error('Unauthorized access');
10305 }
10306
10307 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10308
10309 if (empty($queue_id)) {
10310 wp_send_json_error('Missing queue ID');
10311 }
10312
10313 // Clear active queue transients
10314 if (strpos($queue_id, 'sitemap_') === 0) {
10315 delete_transient('mxchat_active_queue_sitemap');
10316 } else if (strpos($queue_id, 'pdf_') === 0) {
10317 delete_transient('mxchat_active_queue_pdf');
10318 }
10319
10320 wp_send_json_success(array('message' => 'Queue marked as complete'));
10321 }
10322
10323
10324 // ========================================
10325 // STATIC ACCESS METHODS
10326 // ========================================
10327
10328 /**
10329 * Get singleton instance
10330 */
10331 public static function get_instance() {
10332 static $instance = null;
10333 if ($instance === null) {
10334 $instance = new self();
10335 }
10336 return $instance;
10337 }
10338 }
10339
10340 // Initialize the Knowledge manager
10341 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();