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

11,624 lines 484.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 // One command for the entries that five import fixes left behind:
117 // wp mxchat kb-reimport (plan 89739b)
118 WP_CLI::add_command('mxchat kb-reimport', array($this, 'cli_kb_reimport'));
119 }
120 add_action('wp_ajax_mxchat_kb_reimport', array($this, 'ajax_mxchat_kb_reimport'));
121
122 add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
123
124 // WooCommerce product hooks (if WooCommerce is active)
125 if (class_exists('WooCommerce')) {
126 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
127 add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
128 add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
129 add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
130 }
131 }
132
133 /**
134 * Get current options (refreshed)
135 */
136 private function mxchat_get_options() {
137 if (empty($this->options)) {
138 $this->options = get_option('mxchat_options', array());
139 }
140 return $this->options;
141 }
142
143
144 // ========================================
145 // MAIN CONTENT SUBMISSION HANDLERS
146 // ========================================
147
148 public function mxchat_handle_content_submission() {
149 // Check if the form was submitted and the user has permission.
150 if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
151 return;
152 }
153
154 // Verify the nonce.
155 $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
156 if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
157 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
158 }
159
160 // Sanitize the inputs.
161 // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
162 $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
163 $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
164
165 // Get bot_id from form submission
166 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
167
168 // Get bot-specific options and API key
169 $bot_options = $this->get_bot_options($bot_id);
170 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
171
172 // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
173 $preflight = MxChat_Utils::embedding_preflight($options);
174 if (!$preflight['ok']) {
175 set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
176 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
177 exit;
178 }
179 $api_key = $preflight['api_key'];
180
181 // Use centralized utility function with bot_id
182 $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
183
184 if (is_wp_error($result)) {
185 set_transient('mxchat_admin_notice_error',
186 esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
187 30
188 );
189 } else {
190 set_transient('mxchat_admin_notice_success',
191 esc_html__('Content successfully submitted!', 'mxchat'),
192 30
193 );
194 }
195
196 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
197 exit;
198 }
199
200 /**
201 * Handle the "YouTube" KB import source (admin-post form submission).
202 *
203 * Per-video description mode:
204 * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
205 * If no usable transcript, index the metadata anyway, tell the admin,
206 * and bounce back with the manual box pre-filled (never fail silently).
207 * - manual: the admin's own description is what gets indexed; metadata rides along.
208 *
209 * The row is stored with content_type 'youtube' and source_url = the canonical
210 * watch URL, so re-importing the same video UPDATES the entry (source_url
211 * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
212 * "augment a metadata-only entry" path.
213 */
214 public function mxchat_handle_youtube_submission() {
215 if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
216 wp_die(esc_html__('Unauthorized access', 'mxchat'));
217 }
218
219 check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
220
221 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
222
223 $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
224 $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
225
226 if (empty($video_id)) {
227 set_transient('mxchat_admin_notice_error',
228 esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
229 30
230 );
231 wp_safe_redirect(esc_url($redirect_url));
232 exit;
233 }
234
235 $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
236
237 $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
238 $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
239
240 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
241
242 // Resolve the embedding decision exactly like the sibling handlers —
243 // custom-provider-aware (plan cbd5fd).
244 $bot_options = $this->get_bot_options($bot_id);
245 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
246
247 $preflight = MxChat_Utils::embedding_preflight($options);
248 if (!$preflight['ok']) {
249 set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
250 wp_safe_redirect(esc_url($redirect_url));
251 exit;
252 }
253 $api_key = $preflight['api_key'];
254
255 // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
256 // manual mode it enriches the indexed text with the real title/channel.
257 $meta = $this->mxchat_fetch_youtube_oembed($video_id);
258 $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
259 $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
260
261 $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
262 if ($video_channel !== '') {
263 $header_lines .= 'Channel: ' . $video_channel . "\n";
264 }
265 $header_lines .= 'URL: ' . $canonical_url . "\n\n";
266
267 $transcript_missing = false;
268
269 if ($description_mode === 'manual') {
270 if ($manual_description === '') {
271 set_transient('mxchat_admin_notice_error',
272 esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
273 30
274 );
275 wp_safe_redirect(esc_url($redirect_url));
276 exit;
277 }
278 $indexed_text = $header_lines . $manual_description;
279 } else {
280 $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
281
282 if (strlen($transcript) >= 200) {
283 $indexed_text = $header_lines . $transcript;
284 } else {
285 // Graceful fallback: captions disabled / blocked / no speech. Auto
286 // reliably gets metadata; it does NOT guarantee a transcript.
287 $transcript_missing = true;
288
289 if ($video_title === '' && $video_channel === '') {
290 // Both halves failed — nothing meaningful to index.
291 set_transient('mxchat_admin_notice_error',
292 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'),
293 30
294 );
295 wp_safe_redirect(esc_url($redirect_url));
296 exit;
297 }
298
299 $indexed_text = $header_lines . sprintf(
300 /* translators: 1: video title, 2: channel name */
301 __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
302 $video_title !== '' ? $video_title : $canonical_url,
303 $video_channel !== '' ? $video_channel : 'YouTube'
304 );
305 }
306 }
307
308 $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
309
310 if (is_wp_error($result)) {
311 set_transient('mxchat_admin_notice_error',
312 esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
313 30
314 );
315 wp_safe_redirect(esc_url($redirect_url));
316 exit;
317 }
318
319 if ($transcript_missing) {
320 set_transient('mxchat_admin_notice_success',
321 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'),
322 30
323 );
324 // Bounce back with prefill args so the page reopens the YouTube form in
325 // manual mode with the URL + fetched title ready to augment.
326 $redirect_url = add_query_arg(array(
327 'mxchat_yt_prefill' => '1',
328 'yt_url' => rawurlencode($canonical_url),
329 'yt_title' => rawurlencode($video_title),
330 ), $redirect_url);
331 } else {
332 set_transient('mxchat_admin_notice_success',
333 esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
334 30
335 );
336 }
337
338 wp_safe_redirect(esc_url_raw($redirect_url));
339 exit;
340 }
341
342 /**
343 * Fetch YouTube oEmbed metadata for a video (no API key required).
344 * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
345 */
346 private function mxchat_fetch_youtube_oembed($video_id) {
347 $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
348 $response = wp_remote_get($oembed_url, array('timeout' => 15));
349 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
350 return array();
351 }
352 $data = json_decode(wp_remote_retrieve_body($response), true);
353 return is_array($data) ? $data : array();
354 }
355
356 /**
357 * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
358 * YouTube's unofficial timedtext route (the caption track list embedded in the
359 * watch page), which YouTube has broken before and will break again. Every
360 * failure mode returns '' so a break degrades to the metadata-only import path
361 * instead of erroring the whole submission. Do not let anything in here throw.
362 */
363 private function mxchat_fetch_youtube_transcript($video_id) {
364 $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
365
366 // First try the honest ingest UA; some responses omit the player config for
367 // bot UAs, so retry once with a browser UA before giving up.
368 $user_agents = array(
369 mxchat_ingest_user_agent(),
370 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
371 );
372
373 $tracks = array();
374 foreach ($user_agents as $ua) {
375 $response = wp_remote_get($watch_url, array(
376 'timeout' => 20,
377 'user-agent' => $ua,
378 ));
379 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
380 continue;
381 }
382 $body = wp_remote_retrieve_body($response);
383 if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
384 continue;
385 }
386 $decoded = json_decode($m[1], true);
387 if (is_array($decoded) && !empty($decoded)) {
388 $tracks = $decoded;
389 break;
390 }
391 }
392
393 if (empty($tracks)) {
394 return '';
395 }
396
397 // Prefer an English track, else take the first offered.
398 $chosen = null;
399 foreach ($tracks as $track) {
400 if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
401 $chosen = $track;
402 break;
403 }
404 }
405 if ($chosen === null) {
406 $chosen = $tracks[0];
407 }
408 if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
409 return '';
410 }
411
412 $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
413 if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
414 return '';
415 }
416 $xml = wp_remote_retrieve_body($timedtext);
417 if (!is_string($xml) || strpos($xml, '<text') === false) {
418 return '';
419 }
420
421 // <text start=".." dur="..">caption</text> — strip tags, decode the
422 // double-encoded entities timedtext ships, collapse whitespace.
423 $text = preg_replace('/<[^>]+>/', ' ', $xml);
424 $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
425 $text = trim(preg_replace('/\s+/u', ' ', $text));
426
427 return $text;
428 }
429
430 public function mxchat_is_pdf_url($url, $response) {
431 $content_type = wp_remote_retrieve_header($response, 'content-type');
432 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
433
434 // Check Content-Disposition header for .pdf filename (Google Drive sends this)
435 $disposition = wp_remote_retrieve_header($response, 'content-disposition');
436 $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
437
438 return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
439 }
440
441
442 public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
443 if (!current_user_can('manage_options')) {
444 return false;
445 }
446
447 $pdf_url = esc_url_raw($pdf_url);
448 $upload_dir = wp_upload_dir();
449
450 if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
451 return false;
452 }
453
454 $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
455 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
456
457 $response_body = wp_remote_retrieve_body($response);
458 if (empty($response_body)) {
459 return false;
460 }
461
462 if (!wp_mkdir_p(dirname($pdf_path))) {
463 return false;
464 }
465
466 try {
467 file_put_contents($pdf_path, $response_body);
468
469 if (!file_exists($pdf_path)) {
470 throw new Exception(__('Failed to save PDF file', 'mxchat'));
471 }
472
473 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
474
475 if ($total_pages === false || $total_pages < 1) {
476 throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
477 }
478
479 // Create unique queue ID
480 $queue_id = 'pdf_' . md5($pdf_url . time());
481
482 // Create array of pages to process
483 $pages = array();
484 for ($i = 1; $i <= $total_pages; $i++) {
485 $pages[] = array(
486 'pdf_path' => $pdf_path,
487 'pdf_url' => $pdf_url,
488 'page_number' => $i,
489 'total_pages' => $total_pages
490 );
491 }
492
493 // Add pages to queue
494 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
495
496 if ($queued_count === 0) {
497 wp_delete_file($pdf_path);
498 throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
499 }
500
501 // Store queue metadata
502 $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
503 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
504 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
505 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
506 $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
507 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
508
509 // Store queue ID in transient for status tracking
510 set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
511 set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
512
513 return 'queued';
514
515 } catch (Exception $e) {
516 if (file_exists($pdf_path)) {
517 wp_delete_file($pdf_path);
518 }
519 return $e->getMessage();
520 }
521 }
522
523 /**
524 * Handle direct PDF file upload from the knowledge base page
525 */
526 public function mxchat_handle_pdf_file_submission() {
527 if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
528 wp_die(esc_html__('Unauthorized access', 'mxchat'));
529 }
530
531 check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
532
533 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
534
535 // Validate file upload
536 if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
537 $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
538 $error_messages = array(
539 UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
540 UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
541 UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
542 UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
543 UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
544 UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
545 );
546 $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
547 set_transient('mxchat_admin_notice_error', $error_msg, 30);
548 wp_safe_redirect(esc_url($redirect_url));
549 exit;
550 }
551
552 $file = $_FILES['pdf_file'];
553
554 // Validate MIME type
555 $finfo = finfo_open(FILEINFO_MIME_TYPE);
556 $mime_type = finfo_file($finfo, $file['tmp_name']);
557 finfo_close($finfo);
558
559 if ($mime_type !== 'application/pdf') {
560 set_transient('mxchat_admin_notice_error',
561 esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
562 30
563 );
564 wp_safe_redirect(esc_url($redirect_url));
565 exit;
566 }
567
568 // Validate extension
569 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
570 if ($ext !== 'pdf') {
571 set_transient('mxchat_admin_notice_error',
572 esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
573 30
574 );
575 wp_safe_redirect(esc_url($redirect_url));
576 exit;
577 }
578
579 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
580 $original_filename = sanitize_file_name($file['name']);
581
582 $upload_dir = wp_upload_dir();
583 if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
584 set_transient('mxchat_admin_notice_error',
585 esc_html__('WordPress upload directory is not writable.', 'mxchat'),
586 30
587 );
588 wp_safe_redirect(esc_url($redirect_url));
589 exit;
590 }
591
592 $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
593 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
594
595 if (!wp_mkdir_p(dirname($pdf_path))) {
596 set_transient('mxchat_admin_notice_error',
597 esc_html__('Failed to create upload directory.', 'mxchat'),
598 30
599 );
600 wp_safe_redirect(esc_url($redirect_url));
601 exit;
602 }
603
604 // Move uploaded file
605 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
606 set_transient('mxchat_admin_notice_error',
607 esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
608 30
609 );
610 wp_safe_redirect(esc_url($redirect_url));
611 exit;
612 }
613
614 try {
615 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
616
617 if ($total_pages === false || $total_pages < 1) {
618 throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
619 }
620
621 // Use original filename as the source identifier
622 $source_label = 'upload://' . $original_filename;
623
624 // Optional page the PDF is cited as (plans 025ea2 / dcc19d). Kept
625 // beside the file's upload:// identity, which every page carries, so a
626 // re-upload still replaces the pages; leaving the field empty on a
627 // re-upload clears an earlier page.
628 MxChat_Utils::set_upload_source_url(
629 $source_label,
630 $bot_id,
631 isset($_POST['source_page_url']) ? wp_unslash($_POST['source_page_url']) : ''
632 );
633
634 $queue_id = 'pdf_' . md5($source_label . time());
635
636 $pages = array();
637 for ($i = 1; $i <= $total_pages; $i++) {
638 $pages[] = array(
639 'pdf_path' => $pdf_path,
640 'pdf_url' => $source_label,
641 'page_number' => $i,
642 'total_pages' => $total_pages,
643 );
644 }
645
646 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
647
648 if ($queued_count === 0) {
649 wp_delete_file($pdf_path);
650 throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
651 }
652
653 $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
654 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
655 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
656 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
657 $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
658 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
659
660 set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
661 set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
662
663 set_transient('mxchat_admin_notice_success',
664 sprintf(
665 esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
666 esc_html($original_filename),
667 $total_pages
668 ),
669 30
670 );
671
672 } catch (Exception $e) {
673 if (file_exists($pdf_path)) {
674 wp_delete_file($pdf_path);
675 }
676 set_transient('mxchat_admin_notice_error',
677 esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
678 30
679 );
680 }
681
682 wp_safe_redirect(esc_url($redirect_url));
683 exit;
684 }
685
686 /**
687 * Handle direct document upload (.docx / .txt / .md) from the knowledge base
688 * page (plan 0485e5). Unlike PDF Upload there is no per-page queue: the text
689 * extracts in one pass and routes through submit_content_to_db, whose chunker
690 * takes over for long content. The uploaded file is read from the PHP temp
691 * file and never persisted — only its extracted text enters the KB.
692 *
693 * Source identity matches PDF Upload's scheme: upload://<filename>, stable
694 * across re-uploads so a re-import REPLACES (delete_chunks_for_url + upsert
695 * per identity) instead of duplicating.
696 */
697 public function mxchat_handle_document_file_submission() {
698 if (!isset($_POST['submit_document_file']) || !current_user_can('manage_options')) {
699 wp_die(esc_html__('Unauthorized access', 'mxchat'));
700 }
701
702 check_admin_referer('mxchat_submit_document_file_action', 'mxchat_submit_document_file_nonce');
703
704 $redirect_url = admin_url('admin.php?page=mxchat-prompts');
705
706 if (empty($_FILES['document_file']) || $_FILES['document_file']['error'] !== UPLOAD_ERR_OK) {
707 $error_code = isset($_FILES['document_file']['error']) ? $_FILES['document_file']['error'] : UPLOAD_ERR_NO_FILE;
708 $error_messages = array(
709 UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
710 UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
711 UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
712 UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a document.', 'mxchat'),
713 UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
714 UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
715 );
716 $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
717 set_transient('mxchat_admin_notice_error', $error_msg, 30);
718 wp_safe_redirect(esc_url($redirect_url));
719 exit;
720 }
721
722 $file = $_FILES['document_file'];
723 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
724
725 $finfo = finfo_open(FILEINFO_MIME_TYPE);
726 $mime_type = finfo_file($finfo, $file['tmp_name']);
727 finfo_close($finfo);
728
729 // Per-extension MIME expectations. finfo commonly reports .docx as
730 // application/zip (it IS a Zip container) and .md as plain text.
731 $mime_ok = false;
732 if ($ext === 'docx') {
733 $mime_ok = in_array($mime_type, array(
734 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
735 'application/zip',
736 ), true);
737 } elseif ($ext === 'txt' || $ext === 'md') {
738 $mime_ok = (strpos((string) $mime_type, 'text/') === 0);
739 }
740
741 if (!$mime_ok) {
742 set_transient('mxchat_admin_notice_error',
743 esc_html__('Invalid or unreadable document. Accepted types: .docx, .txt, .md.', 'mxchat'),
744 30
745 );
746 wp_safe_redirect(esc_url($redirect_url));
747 exit;
748 }
749
750 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
751 $original_filename = sanitize_file_name($file['name']);
752
753 // ---- Extract text (ONE extractor for .docx — the word handler's) ----
754 if ($ext === 'docx') {
755 $text = MXChat_Word_Handler::extract_docx_text($file['tmp_name']);
756 if ($text === false) {
757 set_transient('mxchat_admin_notice_error',
758 esc_html__('The .docx file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
759 30
760 );
761 wp_safe_redirect(esc_url($redirect_url));
762 exit;
763 }
764 } else {
765 // .txt / .md read as-is. Markdown keeps its syntax on purpose —
766 // headings are useful retrieval signal.
767 $text = (string) file_get_contents($file['tmp_name']);
768 $text = wp_check_invalid_utf8($text);
769 $text = trim($text);
770 }
771
772 if ($text === '') {
773 set_transient('mxchat_admin_notice_error',
774 esc_html__('The uploaded document contains no readable text.', 'mxchat'),
775 30
776 );
777 wp_safe_redirect(esc_url($redirect_url));
778 exit;
779 }
780
781 // Size cap — same pdf_max_pages setting the PDF/toolbar paths use, but
782 // estimated by CHARACTERS (~2500/page): the .docx cleaner collapses all
783 // newlines to spaces, so a paragraph count reads 1 for any Word file.
784 // Processing is synchronous — an unbounded document risks a timeout.
785 $options = get_option('mxchat_options', array());
786 $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
787 $estimated_pages = (int) ceil(strlen($text) / 2500);
788 if ($estimated_pages > $max_pages) {
789 set_transient('mxchat_admin_notice_error',
790 sprintf(
791 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'),
792 $estimated_pages,
793 $max_pages
794 ),
795 30
796 );
797 wp_safe_redirect(esc_url($redirect_url));
798 exit;
799 }
800
801 // Embedding API key — bot-aware, same shape as the direct-content handler.
802 $api_key = '';
803 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
804 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
805 $api_key = $bot_options['api_key'] ?? '';
806 }
807 if (empty($api_key)) {
808 $api_key = $options['api_key'] ?? '';
809 }
810
811 // Stable identity — PDF Upload's scheme. A re-upload of the same filename
812 // replaces: clear old chunks first (covers a doc shrinking below the chunk
813 // threshold, where the single-vector path would not clean them), then
814 // submit — the chunked path re-deletes harmlessly.
815 $source_label = 'upload://' . $original_filename;
816 MxChat_Utils::delete_chunks_for_url($source_label, $bot_id);
817 $result = MxChat_Utils::submit_content_to_db($text, $source_label, $api_key, null, $bot_id, 'document');
818
819 // Optional page the document is cited as (plan 025ea2). Stored beside the
820 // entry, not as its identity; an empty field on a re-upload clears it.
821 if (!is_wp_error($result)) {
822 MxChat_Utils::set_upload_source_url(
823 $source_label,
824 $bot_id,
825 isset($_POST['source_page_url']) ? wp_unslash($_POST['source_page_url']) : ''
826 );
827 }
828
829 if (is_wp_error($result)) {
830 set_transient('mxchat_admin_notice_error',
831 esc_html__('Failed to import the document: ', 'mxchat') . esc_html($result->get_error_message()),
832 30
833 );
834 } else {
835 set_transient('mxchat_admin_notice_success',
836 sprintf(
837 esc_html__('Document "%s" imported into the knowledge base.', 'mxchat'),
838 esc_html($original_filename)
839 ),
840 30
841 );
842 }
843
844 wp_safe_redirect(esc_url($redirect_url));
845 exit;
846 }
847
848 /**
849 * Validate PDF and count pages with multiple parser attempts
850 */
851 private function mxchat_validate_and_count_pdf_pages($pdf_path) {
852 // Method 1: Try with Smalot PDF Parser (your current method)
853 try {
854 mxchat_load_pdf_parser();
855 $parser = new \Smalot\PdfParser\Parser();
856 $pdf = $parser->parseFile($pdf_path);
857 $pages = $pdf->getPages();
858 $page_count = count($pages);
859
860 if ($page_count > 0) {
861 //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
862 return $page_count;
863 }
864 } catch (Exception $e) {
865 //error_log('Smalot PDF parser failed: ' . $e->getMessage());
866 }
867
868 // Method 2: Try with pdfinfo command (if available)
869 if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
870 try {
871 $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
872 $output = shell_exec($command);
873
874 if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
875 $page_count = intval($matches[1]);
876 if ($page_count > 0) {
877 //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
878 return $page_count;
879 }
880 }
881 } catch (Exception $e) {
882 //error_log('pdfinfo command failed: ' . $e->getMessage());
883 }
884 }
885
886 // Method 3: Try to repair PDF and parse again
887 try {
888 $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
889 if ($repaired_path && $repaired_path !== $pdf_path) {
890 mxchat_load_pdf_parser();
891 $parser = new \Smalot\PdfParser\Parser();
892 $pdf = $parser->parseFile($repaired_path);
893 $pages = $pdf->getPages();
894 $page_count = count($pages);
895
896 if ($page_count > 0) {
897 // Replace original with repaired version
898 copy($repaired_path, $pdf_path);
899 unlink($repaired_path);
900 //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
901 return $page_count;
902 }
903
904 // Clean up repaired file if it didn't work
905 unlink($repaired_path);
906 }
907 } catch (Exception $e) {
908 //error_log('PDF repair attempt failed: ' . $e->getMessage());
909 }
910
911 // Method 4: Manual PDF structure analysis (basic page count)
912 try {
913 $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
914 if ($page_count > 0) {
915 //error_log('PDF page count determined manually: ' . $page_count . ' pages');
916 return $page_count;
917 }
918 } catch (Exception $e) {
919 //error_log('Manual PDF analysis failed: ' . $e->getMessage());
920 }
921
922 //error_log('All PDF parsing methods failed for: ' . $pdf_path);
923 return false;
924 }
925
926 /**
927 * Check if shell_exec is disabled
928 */
929 private function mxchat_is_shell_disabled() {
930 $disabled = explode(',', ini_get('disable_functions'));
931 return in_array('shell_exec', $disabled);
932 }
933
934 /**
935 * Attempt to repair PDF using basic methods
936 */
937 private function mxchat_attempt_pdf_repair($pdf_path) {
938 try {
939 $content = file_get_contents($pdf_path);
940 if (!$content) {
941 return false;
942 }
943
944 // Check if PDF starts with proper header
945 if (substr($content, 0, 4) !== '%PDF') {
946 // Try to find PDF header in the content
947 $header_pos = strpos($content, '%PDF');
948 if ($header_pos !== false && $header_pos < 1024) {
949 // Remove junk before PDF header
950 $content = substr($content, $header_pos);
951 $repaired_path = $pdf_path . '.repaired';
952 file_put_contents($repaired_path, $content);
953 return $repaired_path;
954 }
955 }
956
957 // Check for EOF marker
958 $content = rtrim($content);
959 if (!preg_match('/%%EOF\s*$/', $content)) {
960 // Add EOF marker if missing
961 $content .= "\n%%EOF";
962 $repaired_path = $pdf_path . '.repaired';
963 file_put_contents($repaired_path, $content);
964 return $repaired_path;
965 }
966
967 } catch (Exception $e) {
968 //error_log('PDF repair error: ' . $e->getMessage());
969 }
970
971 return false;
972 }
973
974 /**
975 * Manual PDF page counting by analyzing PDF structure
976 */
977 private function mxchat_manual_pdf_page_count($pdf_path) {
978 try {
979 $content = file_get_contents($pdf_path);
980 if (!$content) {
981 return 0;
982 }
983
984 // Method 1: Count /Type /Page objects
985 $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
986 if ($page_count > 0) {
987 return $page_count;
988 }
989
990 // Method 2: Look for /Count in pages object
991 if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
992 return intval($matches[1]);
993 }
994
995 // Method 3: Count page references
996 $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
997 if ($page_count > 0) {
998 return $page_count;
999 }
1000
1001 } catch (Exception $e) {
1002 //error_log('Manual PDF analysis error: ' . $e->getMessage());
1003 }
1004
1005 return 0;
1006 }
1007
1008
1009 public function mxchat_save_inline_prompt() {
1010 // DEBUG: Log what we're receiving
1011 //error_log('=== MXCHAT DEBUG ===');
1012 //error_log('POST data: ' . print_r($_POST, true));
1013 //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
1014
1015 // Check for nonce security
1016 check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
1017
1018 // If we get here, nonce passed
1019 //error_log('Nonce verification PASSED');
1020
1021 // Verify permissions
1022 if (!current_user_can('manage_options')) {
1023 wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
1024 return;
1025 }
1026
1027 global $wpdb;
1028 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1029
1030 // Validate and sanitize input data
1031 $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
1032 $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
1033 $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
1034
1035 if ($prompt_id > 0 && !empty($article_content)) {
1036 // Re-generate the embedding vector for the updated content
1037 $embedding_vector = $this->mxchat_generate_embedding($article_content);
1038 if (is_array($embedding_vector)) {
1039 // Serialize the embedding vector before storing it
1040 $embedding_vector_serialized = serialize($embedding_vector);
1041 // Update the prompt in the database
1042 $updated = $wpdb->update(
1043 $table_name,
1044 array(
1045 'article_content' => $article_content,
1046 'embedding_vector' => $embedding_vector_serialized,
1047 'source_url' => $article_url,
1048 ),
1049 array('id' => $prompt_id),
1050 array('%s', '%s', '%s'),
1051 array('%d')
1052 );
1053 if ($updated !== false) {
1054 wp_send_json_success();
1055 } else {
1056 MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
1057 wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
1058 }
1059 } else {
1060 MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
1061 wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
1062 }
1063 } else {
1064 wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
1065 }
1066 }
1067
1068
1069 /**
1070 * AJAX: Get full content for editing — reassembles chunks if needed.
1071 * Works for both WordPress DB and Pinecone entries.
1072 */
1073 /**
1074 * Sanitize a knowledge entry's source_url from an AJAX request WITHOUT destroying
1075 * its identity. sanitize_text_field() strips percent-encoded octets (%20, %D7%A9…),
1076 * so a percent-encoded URL — every non-ASCII permalink — would md5 to a DIFFERENT
1077 * id than the one it was stored under: reads miss the entry and saves write an
1078 * orphan copy while the original keeps its stale text. URLs get esc_url_raw
1079 * (identity-preserving, matches what import stored); non-URL keys (mxchat://,
1080 * _ungrouped_) keep the old sanitizer.
1081 */
1082 private function sanitize_entry_source_url( $raw ) {
1083 $raw = trim( (string) $raw );
1084 if ( preg_match( '#^https?://#i', $raw ) ) {
1085 return esc_url_raw( $raw );
1086 }
1087 return sanitize_text_field( $raw );
1088 }
1089
1090 public function ajax_mxchat_get_entry_content() {
1091 check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1092
1093 if ( ! current_user_can('manage_options') ) {
1094 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1095 }
1096
1097 $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1098 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1099 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1100 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1101
1102 if ( $data_source === 'pinecone' ) {
1103 // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1104 // destroy them, so re-read the raw value for this branch only.
1105 $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1106 // Pinecone: fetch vectors by source_url, reassemble chunks
1107 $content = $this->get_pinecone_entry_content( $source_url, $vector_id, $bot_id );
1108 } else {
1109 // WordPress DB
1110 $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
1111 }
1112
1113 if ( is_wp_error( $content ) ) {
1114 wp_send_json_error( array( 'message' => $content->get_error_message() ) );
1115 }
1116
1117 wp_send_json_success( $content );
1118 }
1119
1120 /**
1121 * Get content from WordPress DB — reassembles chunks by source_url.
1122 */
1123 private function get_wordpress_entry_content( $source_url, $entry_id ) {
1124 global $wpdb;
1125 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1126
1127 // If we have a source_url, check for chunks
1128 if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
1129 $rows = $wpdb->get_results( $wpdb->prepare(
1130 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1131 $source_url
1132 ) );
1133
1134 if ( $rows && count( $rows ) > 1 ) {
1135 // Multiple rows = chunked. Reassemble.
1136 $chunks = array();
1137 foreach ( $rows as $row ) {
1138 $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1139 $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1140 $chunks[ $index ] = $parsed['text'];
1141 }
1142 ksort( $chunks );
1143 return array(
1144 'content' => implode( "\n\n", $chunks ),
1145 'source_url' => $source_url,
1146 'is_chunked' => true,
1147 'chunk_count' => count( $chunks ),
1148 'content_type' => $rows[0]->content_type,
1149 );
1150 } elseif ( $rows && count( $rows ) === 1 ) {
1151 $parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
1152 return array(
1153 'content' => $parsed['text'],
1154 'source_url' => $source_url,
1155 'entry_id' => $rows[0]->id,
1156 'is_chunked' => false,
1157 'content_type' => $rows[0]->content_type,
1158 );
1159 }
1160 }
1161
1162 // Fallback: fetch by ID
1163 if ( $entry_id > 0 ) {
1164 $row = $wpdb->get_row( $wpdb->prepare(
1165 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1166 $entry_id
1167 ) );
1168 if ( $row ) {
1169 $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1170 return array(
1171 'content' => $parsed['text'],
1172 'source_url' => $row->source_url,
1173 'entry_id' => $row->id,
1174 'is_chunked' => false,
1175 'content_type' => $row->content_type,
1176 );
1177 }
1178 }
1179
1180 return new WP_Error( 'not_found', 'Entry not found.' );
1181 }
1182
1183 /**
1184 * Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
1185 */
1186 private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
1187 if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1188 return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
1189 }
1190
1191 // Get Pinecone config
1192 if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1193 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1194 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1195 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1196 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1197 } else {
1198 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1199 $api_key = $bot_config['api_key'] ?? '';
1200 $host = $bot_config['host'] ?? '';
1201 $namespace = $bot_config['namespace'] ?? '';
1202 }
1203
1204 if ( empty($host) || empty($api_key) ) {
1205 return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
1206 }
1207
1208 // Document index (plan 362c31): same ids, read through the documents API.
1209 if ( class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index( $bot_id ) ) {
1210 $docs_cfg = array( 'api_key' => $api_key, 'host' => $host, 'namespace' => $namespace );
1211 if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1212 $doc_ids = array( $entry_id );
1213 } else {
1214 $doc_ids = array_merge( array( md5( $source_url ) ), MxChat_Pinecone_Documents::list_ids( md5( $source_url ) . '_chunk_', $docs_cfg ) );
1215 }
1216 $docs = MxChat_Pinecone_Documents::fetch( $doc_ids, $docs_cfg, array( 'text', 'chunk_index', 'type' ) );
1217 if ( empty($docs) ) {
1218 return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1219 }
1220 $doc_chunks = array();
1221 $doc_type = 'content';
1222 foreach ( $docs as $doc ) {
1223 $doc_meta = $doc['metadata'] ?? array();
1224 $doc_chunks[ intval( $doc_meta['chunk_index'] ?? 0 ) ] = $doc_meta['text'] ?? '';
1225 $doc_type = $doc_meta['type'] ?? 'content';
1226 }
1227 ksort( $doc_chunks );
1228 return array(
1229 'content' => implode( "\n\n", $doc_chunks ),
1230 'source_url' => $source_url,
1231 'is_chunked' => count($doc_chunks) > 1,
1232 'chunk_count' => count($doc_chunks),
1233 'content_type' => $doc_type,
1234 );
1235 }
1236
1237 // Manual entries carry no source_url (their vector id is a minted manual_* string,
1238 // not md5 of anything the row can hand us) — fetch the exact vector instead.
1239 // '_ungrouped_' is the table view's synthetic display key for such rows.
1240 if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1241 $vector_ids = array( $entry_id );
1242 } else {
1243 // List vectors with the source_url prefix
1244 $base_id = md5( $source_url );
1245 $vector_ids = array( $base_id );
1246
1247 // Find chunk vectors
1248 // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1249 // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1250 $list_url = "https://{$host}/vectors/list";
1251 $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1252 if ( ! empty($namespace) ) {
1253 $list_params['namespace'] = $namespace;
1254 }
1255
1256 $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1257 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1258 'timeout' => 15,
1259 ) );
1260
1261 if ( ! is_wp_error($list_resp) ) {
1262 $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1263 if ( ! empty($list_data['vectors']) ) {
1264 foreach ( $list_data['vectors'] as $v ) {
1265 $vector_ids[] = $v['id'];
1266 }
1267 }
1268 }
1269 }
1270
1271 // Fetch vectors with metadata
1272 // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1273 // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1274 // the query string explicitly.
1275 $fetch_query = array();
1276 foreach ( $vector_ids as $fetch_vid ) {
1277 $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1278 }
1279 if ( ! empty($namespace) ) {
1280 $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1281 }
1282
1283 $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1284 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1285 'timeout' => 15,
1286 ) );
1287
1288 if ( is_wp_error($fetch_resp) ) {
1289 return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
1290 }
1291
1292 $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1293 $vectors = $fetch_data['vectors'] ?? array();
1294
1295 if ( empty($vectors) ) {
1296 return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1297 }
1298
1299 // Reassemble chunks
1300 $chunks = array();
1301 $content_type = 'content';
1302 foreach ( $vectors as $vid => $vector ) {
1303 $meta = $vector['metadata'] ?? array();
1304 $text = $meta['text'] ?? '';
1305 $index = $meta['chunk_index'] ?? 0;
1306 $content_type = $meta['type'] ?? 'content';
1307 $chunks[ intval($index) ] = $text;
1308 }
1309 ksort( $chunks );
1310
1311 return array(
1312 'content' => implode( "\n\n", $chunks ),
1313 'source_url' => $source_url,
1314 'is_chunked' => count($chunks) > 1,
1315 'chunk_count' => count($chunks),
1316 'content_type' => $content_type,
1317 );
1318 }
1319
1320 /**
1321 * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1322 * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1323 * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1324 */
1325 public function ajax_mxchat_inspect_entry() {
1326 check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1327
1328 if ( ! current_user_can('manage_options') ) {
1329 wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1330 }
1331
1332 // Same identity-preserving read the Edit path uses: sanitize_text_field()
1333 // strips percent-encoded octets, so a non-ASCII permalink would look up a
1334 // different string (WordPress DB) and md5 to a different id (Pinecone).
1335 $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1336 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1337 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1338 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1339
1340 if ( $data_source === 'pinecone' ) {
1341 // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1342 // destroy them, so re-read the raw value for this branch only.
1343 $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1344 $result = $this->inspect_pinecone_entry( $source_url, $vector_id, $bot_id );
1345 } else {
1346 $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1347 }
1348
1349 if ( is_wp_error( $result ) ) {
1350 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1351 }
1352
1353 wp_send_json_success( $result );
1354 }
1355
1356 /**
1357 * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1358 * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1359 * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1360 */
1361 private function inspect_wordpress_entry( $source_url, $entry_id ) {
1362 global $wpdb;
1363 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1364
1365 $rows = array();
1366
1367 // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1368 // Direct Content entries (the spec's manual-entry case), which share one
1369 // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1370 // display key (invented by the table view for rows with no source_url) is
1371 // excluded; those fall through to the entry_id lookup below.
1372 if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1373 $rows = $wpdb->get_results( $wpdb->prepare(
1374 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1375 $source_url
1376 ) );
1377 }
1378
1379 // Fallback / manual "Direct Content" entries: fetch the single row by id.
1380 if ( empty( $rows ) && $entry_id > 0 ) {
1381 $row = $wpdb->get_row( $wpdb->prepare(
1382 "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1383 $entry_id
1384 ) );
1385 if ( $row ) {
1386 $rows = array( $row );
1387 }
1388 }
1389
1390 if ( empty( $rows ) ) {
1391 return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1392 }
1393
1394 $chunks = array();
1395 $content_type = '';
1396 foreach ( $rows as $row ) {
1397 $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1398 $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1399 $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1400 $content_type = $row->content_type;
1401 $chunks[] = array(
1402 'index' => $index,
1403 'text' => $text,
1404 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1405 'row_id' => intval( $row->id ),
1406 );
1407 }
1408
1409 usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1410
1411 $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1412
1413 return array(
1414 'store' => 'wordpress',
1415 'source_url' => $source_url,
1416 'content_type' => $content_type,
1417 'is_chunked' => count( $chunks ) > 1,
1418 'chunk_count' => count( $chunks ),
1419 'assembled' => $assembled,
1420 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1421 'chunks' => array_values( $chunks ),
1422 // WP-DB storage carries no separate vector metadata; surface that fact
1423 // rather than letting the owner guess (the spec's taxonomy question).
1424 'metadata' => array(),
1425 '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'),
1426 );
1427 }
1428
1429 /**
1430 * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1431 * but keeps each vector's text + metadata instead of imploding, so the owner can
1432 * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1433 * are present per chunk. READ-ONLY.
1434 */
1435 private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1436 if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1437 return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1438 }
1439
1440 if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1441 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1442 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1443 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1444 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1445 } else {
1446 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1447 $api_key = $bot_config['api_key'] ?? '';
1448 $host = $bot_config['host'] ?? '';
1449 $namespace = $bot_config['namespace'] ?? '';
1450 }
1451
1452 if ( empty($host) || empty($api_key) ) {
1453 return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1454 }
1455
1456 // Document index (plan 362c31): the documents API hands back the same
1457 // id => {metadata} map the classic fetch does, so the inspection below is shared.
1458 // Manual entries carry no source_url (their vector id is a minted manual_* string,
1459 // not md5 of anything the row can hand us) — fetch the exact vector instead, the
1460 // way get_pinecone_entry_content() does. '_ungrouped_' is the table view's
1461 // synthetic display key for such rows.
1462 $exact_id = ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) );
1463
1464 if ( class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index( $bot_id ) ) {
1465 $docs_cfg = array( 'api_key' => $api_key, 'host' => $host, 'namespace' => $namespace );
1466 if ( $exact_id ) {
1467 $vector_ids = array( $entry_id );
1468 } else {
1469 $vector_ids = array_merge( array( md5( $source_url ) ), MxChat_Pinecone_Documents::list_ids( md5( $source_url ) . '_chunk_', $docs_cfg ) );
1470 }
1471 $vectors = MxChat_Pinecone_Documents::fetch( $vector_ids, $docs_cfg );
1472 if ( empty($vectors) ) {
1473 return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1474 }
1475 } else {
1476 if ( $exact_id ) {
1477 $vector_ids = array( $entry_id );
1478 } else {
1479 $base_id = md5( $source_url );
1480 $vector_ids = array( $base_id );
1481
1482 // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1483 // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1484 $list_url = "https://{$host}/vectors/list";
1485 $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1486 if ( ! empty($namespace) ) {
1487 $list_params['namespace'] = $namespace;
1488 }
1489
1490 $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1491 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1492 'timeout' => 15,
1493 ) );
1494
1495 if ( ! is_wp_error($list_resp) ) {
1496 $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1497 if ( ! empty($list_data['vectors']) ) {
1498 foreach ( $list_data['vectors'] as $v ) {
1499 $vector_ids[] = $v['id'];
1500 }
1501 }
1502 }
1503 } // end md5(source_url) + chunk-prefix ids
1504
1505 // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1506 // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1507 // the query string explicitly.
1508 $fetch_query = array();
1509 foreach ( $vector_ids as $fetch_vid ) {
1510 $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1511 }
1512 if ( ! empty($namespace) ) {
1513 $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1514 }
1515
1516 $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1517 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1518 'timeout' => 15,
1519 ) );
1520
1521 if ( is_wp_error($fetch_resp) ) {
1522 return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1523 }
1524
1525 $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1526 $vectors = $fetch_data['vectors'] ?? array();
1527
1528 if ( empty($vectors) ) {
1529 return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1530 }
1531 } // end classic (vector index) read
1532
1533 // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1534 // what is (and is NOT) stored per vector.
1535 $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1536 $chunks = array();
1537 $content_type = '';
1538 foreach ( $vectors as $vid => $vector ) {
1539 $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1540 $text = $meta['text'] ?? '';
1541 $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1542 $content_type = $meta['type'] ?? $content_type;
1543
1544 $clean_meta = array();
1545 foreach ( $meta_fields as $field ) {
1546 if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1547 $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1548 }
1549 }
1550
1551 $chunks[] = array(
1552 'index' => $index,
1553 'text' => $text,
1554 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1555 'vector_id' => (string) $vid,
1556 'metadata' => $clean_meta,
1557 );
1558 }
1559
1560 usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1561
1562 $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1563
1564 return array(
1565 'store' => 'pinecone',
1566 'source_url' => $source_url,
1567 'content_type' => $content_type,
1568 'is_chunked' => count( $chunks ) > 1,
1569 'chunk_count' => count( $chunks ),
1570 'assembled' => $assembled,
1571 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1572 'chunks' => array_values( $chunks ),
1573 'metadata' => array(),
1574 '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'),
1575 );
1576 }
1577
1578 /**
1579 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1580 * Works for both WordPress DB and Pinecone entries.
1581 */
1582 public function ajax_mxchat_save_entry_content() {
1583 check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1584
1585 if ( ! current_user_can('manage_options') ) {
1586 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1587 }
1588
1589 $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
1590 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1591 $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1592 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1593 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1594 $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
1595
1596 if ( empty($content) ) {
1597 wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
1598 }
1599
1600 // Get the embedding API key
1601 $options = get_option('mxchat_options', array());
1602 $api_key = '';
1603
1604 if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
1605 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1606 $api_key = $bot_options['api_key'] ?? '';
1607 }
1608 if ( empty($api_key) ) {
1609 $api_key = $options['api_key'] ?? '';
1610 }
1611
1612 if ( $data_source === 'pinecone' ) {
1613 // Pinecone branch. The WP-DB manual-entry delete below must never run here:
1614 // Pinecone ids are strings, and absint() on a digit-leading md5 hash would
1615 // yield a real (unrelated) WP row id.
1616 $raw_vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1617 $is_manual_single = empty($source_url) || strpos($source_url, '_ungrouped_') === 0;
1618 $is_manual_chunked = strpos($source_url, 'mxchat://') === 0;
1619
1620 if ( $is_manual_single || $is_manual_chunked ) {
1621 // Manual content: remove the old vectors first, then store as fresh manual
1622 // content — submit_content_to_db mints a new unique identity (manual_* id
1623 // for a single vector, an mxchat:// chunk prefix if it now chunks).
1624 if ( $is_manual_chunked ) {
1625 // Minted identity: base + chunk vectors share the md5(mxchat://...) prefix.
1626 MxChat_Utils::delete_chunks_for_url( $source_url, $bot_id );
1627 } elseif ( ! empty($raw_vector_id) && class_exists('MxChat_Pinecone_Manager') ) {
1628 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
1629 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options( $bot_id );
1630 if ( ! empty($pinecone_options['mxchat_pinecone_api_key']) && ! empty($pinecone_options['mxchat_pinecone_host']) ) {
1631 $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
1632 $raw_vector_id,
1633 $pinecone_options['mxchat_pinecone_api_key'],
1634 $pinecone_options['mxchat_pinecone_host'],
1635 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
1636 );
1637 }
1638 }
1639 $result = MxChat_Utils::submit_content_to_db( $content, '', $api_key, null, $bot_id, $content_type );
1640 } else {
1641 // URL-sourced entry: identity is md5(source_url). submit_content_to_db
1642 // handles delete-old-chunks → re-chunk → re-embed → store, and sweeps
1643 // stale chunk vectors when the content now fits in a single vector.
1644 $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, md5($source_url), $bot_id, $content_type );
1645 }
1646 } else {
1647 global $wpdb;
1648 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1649
1650 // If source_url is empty but we have an entry_id, look it up
1651 if ( empty($source_url) && $entry_id > 0 ) {
1652 $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1653 if ( $row && ! empty($row->source_url) ) {
1654 $source_url = $row->source_url;
1655 }
1656 }
1657
1658 // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1659 // so submit_content_to_db creates a replacement instead of a duplicate
1660 // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1661 $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1662 if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1663 $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1664 // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1665 // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1666 if ( $is_legacy_manual ) {
1667 $source_url = '';
1668 }
1669 }
1670
1671 // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1672 $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1673
1674 // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1675 $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1676 }
1677
1678 if ( is_wp_error($result) ) {
1679 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1680 }
1681
1682 wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
1683 }
1684
1685 public function mxchat_get_pdf_processing_status($pdf_url) {
1686 $pdf_url = esc_url_raw($pdf_url);
1687 $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
1688
1689 if (!$status || !is_array($status)) {
1690 return false;
1691 }
1692
1693 // Check for stalled processing (no updates for 5 minutes)
1694 if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
1695 $status['status'] = 'error';
1696 $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
1697
1698 // Save the updated status
1699 set_transient(
1700 sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
1701 array_map('sanitize_text_field', $status),
1702 DAY_IN_SECONDS
1703 );
1704 }
1705
1706 $result = array(
1707 'total_pages' => absint($status['total_pages']),
1708 'processed_pages' => absint($status['processed_pages']),
1709 'failed_pages' => absint($status['failed_pages'] ?? 0),
1710 'percentage' => ($status['total_pages'] > 0)
1711 ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
1712 : 0,
1713 'status' => sanitize_text_field($status['status']),
1714 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1715 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
1716 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
1717 );
1718
1719 // Add error message if present
1720 if (isset($status['error']) && !empty($status['error'])) {
1721 $result['error'] = sanitize_text_field($status['error']);
1722 }
1723
1724 return $result;
1725 }
1726
1727
1728 public function mxchat_handle_sitemap_submission() {
1729 // Check if the form was submitted and verify permissions
1730 if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
1731 wp_die(esc_html__('Unauthorized access', 'mxchat'));
1732 }
1733
1734 // Verify nonce
1735 check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
1736
1737 // Validate URL
1738 if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
1739 set_transient('mxchat_admin_notice_error',
1740 esc_html__('Please provide a valid URL.', 'mxchat'),
1741 30
1742 );
1743 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1744 exit;
1745 }
1746
1747 $submitted_url = esc_url_raw($_POST['sitemap_url']);
1748
1749 // Convert Google Drive sharing URLs to direct download URLs
1750 if ( strpos($submitted_url, 'drive.google.com') !== false ) {
1751 $file_id = '';
1752 if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
1753 $file_id = $m[1];
1754 } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
1755 $file_id = $m[1];
1756 }
1757 if ( ! empty($file_id) ) {
1758 $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
1759 }
1760 }
1761
1762 // Get bot_id from form submission
1763 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1764
1765 // Get bot-specific options and validate the embedding decision —
1766 // custom-provider-aware (plan cbd5fd).
1767 $bot_options = $this->get_bot_options($bot_id);
1768 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1769
1770 $preflight = MxChat_Utils::embedding_preflight($options);
1771 if (!$preflight['ok']) {
1772 set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
1773 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1774 exit;
1775 }
1776 $api_key = $preflight['api_key'];
1777
1778 // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1779 // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1780 // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1781 // from the site's own media library, which route through this same call).
1782 // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1783 // the browser-only Accept-Language fingerprint is dropped so it stays
1784 // coherent with a bot identity.
1785 $response = wp_remote_get($submitted_url, array(
1786 'timeout' => 30,
1787 'sslverify' => false,
1788 'user-agent' => mxchat_ingest_user_agent(),
1789 'headers' => array(
1790 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1791 ),
1792 ));
1793
1794 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1795 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1796 set_transient('mxchat_admin_notice_error',
1797 sprintf(
1798 esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1799 esc_html($error_message)
1800 ),
1801 30
1802 );
1803 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1804 exit;
1805 }
1806
1807 $content_type = wp_remote_retrieve_header($response, 'content-type');
1808 $body_content = wp_remote_retrieve_body($response);
1809
1810 if (empty($body_content)) {
1811 set_transient('mxchat_admin_notice_error',
1812 esc_html__('Empty response received from URL.', 'mxchat'),
1813 30
1814 );
1815 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1816 exit;
1817 }
1818
1819 // Handle PDF URL
1820 if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1821 $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1822
1823 if ($result === 'queued') {
1824 set_transient('mxchat_admin_notice_success',
1825 esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1826 30
1827 );
1828 } else {
1829 set_transient('mxchat_admin_notice_error',
1830 esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1831 30
1832 );
1833 }
1834
1835 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1836 exit;
1837 }
1838
1839 // Handle Sitemap XML
1840 if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1841 libxml_use_internal_errors(true);
1842 $xml = simplexml_load_string($body_content);
1843 $xml_errors = libxml_get_errors();
1844 libxml_clear_errors();
1845
1846 if ($xml === false || !empty($xml_errors)) {
1847 set_transient('mxchat_admin_notice_error',
1848 esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1849 30
1850 );
1851 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1852 exit;
1853 }
1854
1855 $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1856
1857 if ($result === 'queued') {
1858 set_transient('mxchat_admin_notice_success',
1859 esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1860 30
1861 );
1862 } else {
1863 // Surface the reason the handler already computed (embedding pre-flight,
1864 // empty sitemap, queue failure). The old message pointed at the status
1865 // area, which is empty on this path — nothing was ever queued.
1866 if (is_string($result) && $result !== '') {
1867 set_transient('mxchat_admin_notice_error',
1868 esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
1869 30
1870 );
1871 } else {
1872 set_transient('mxchat_admin_notice_error',
1873 esc_html__('Failed to queue sitemap processing.', 'mxchat'),
1874 30
1875 );
1876 }
1877 }
1878
1879 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1880 exit;
1881 }
1882
1883 // Handle Regular URL (single page)
1884 $page_content = $this->mxchat_extract_main_content($body_content);
1885 $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1886
1887 //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1888 //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1889 //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1890 //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1891
1892 if (empty($sanitized_content)) {
1893 set_transient('mxchat_admin_notice_error',
1894 esc_html__('No valid content found on the provided URL.', 'mxchat'),
1895 30
1896 );
1897 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1898 exit;
1899 }
1900
1901 // For single URLs, process immediately using submit_content_to_db
1902 // This handles chunking automatically for large content
1903 $db_result = MxChat_Utils::submit_content_to_db(
1904 $sanitized_content,
1905 $submitted_url,
1906 $api_key,
1907 null,
1908 $bot_id,
1909 'url', // content_type
1910 MxChat_Utils::extract_page_title($body_content) // the page's own title (plan d9ee66)
1911 );
1912
1913 if (is_wp_error($db_result)) {
1914 $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1915 set_transient('mxchat_admin_notice_error', $error_message, 30);
1916 } else {
1917 $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1918 set_transient('mxchat_admin_notice_success', $success_message, 30);
1919 }
1920
1921 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1922 exit;
1923 }
1924
1925
1926 public function mxchat_get_single_url_status() {
1927 $status = get_transient('mxchat_single_url_status');
1928 if (!$status) {
1929 return null;
1930 }
1931
1932 // Add human-readable time
1933 if (isset($status['timestamp'])) {
1934 $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1935 }
1936
1937 return $status;
1938 }
1939
1940 public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1941 if (!current_user_can('manage_options')) {
1942 return false;
1943 }
1944
1945 try {
1946 $sitemap_url = esc_url_raw($sitemap_url);
1947
1948 if (!$xml || !is_object($xml)) {
1949 throw new Exception(__('Invalid XML object provided', 'mxchat'));
1950 }
1951
1952 // Get bot-specific embedding API for validation
1953 $bot_options = $this->get_bot_options($bot_id);
1954 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1955
1956 // Test the embedding API before processing
1957 $test_phrase = "Test embedding generation for MxChat";
1958 $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1959
1960 if (is_string($test_result)) {
1961 throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1962 }
1963
1964 if (!is_array($test_result)) {
1965 throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1966 }
1967
1968 // Extract URLs from sitemap
1969 $urls = array();
1970 foreach ($xml->url as $url_element) {
1971 $url = esc_url_raw((string)$url_element->loc);
1972 if ($url) {
1973 $urls[] = array('url' => $url);
1974 }
1975 }
1976
1977 $total_urls = count($urls);
1978
1979 if ($total_urls < 1) {
1980 throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1981 }
1982
1983 // Create unique queue ID
1984 $queue_id = 'sitemap_' . md5($sitemap_url . time());
1985
1986 // Add URLs to queue
1987 $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1988
1989 if ($queued_count === 0) {
1990 throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1991 }
1992
1993 // Store queue metadata
1994 $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1995 $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1996 $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1997 $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1998 $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1999
2000 // Store queue ID in transient for status tracking
2001 set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
2002 set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
2003
2004 return 'queued';
2005
2006 } catch (Exception $e) {
2007 $error_message = $e->getMessage();
2008 //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
2009
2010 return $error_message;
2011 }
2012
2013 }
2014
2015 /**
2016 * Remove shortcode tags but preserve the content inside them
2017 * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
2018 *
2019 * @param string $content The content containing shortcodes
2020 * @return string Content with shortcode tags removed but inner content preserved
2021 */
2022 /**
2023 * Single-pass HTML entity decode for text entering the knowledge base.
2024 * The corpus should hold what a human reads: a stored `&amp;` consumes
2025 * extra tokens, distorts the vector away from the form a visitor's
2026 * question uses, and can be quoted back verbatim in an answer.
2027 * Deliberately NOT looped to a fixed point — a stored `&amp;amp;` is a
2028 * legitimate literal `&amp;` and must not collapse further (data loss).
2029 * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
2030 * paths call this at their output points so the treatment cannot drift.
2031 * (Plan d2c92e.)
2032 */
2033 private function mxchat_decode_entities_for_indexing($text) {
2034 return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2035 }
2036
2037 /**
2038 * Price lines for a product's indexed text, pinned to the store's BASE currency.
2039 *
2040 * The four product assembly paths each used to call get_woocommerce_currency_symbol()
2041 * with no argument, which resolves the currency active on the CURRENT request.
2042 * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
2043 * request, so whichever currency the store happened to be serving when an import ran
2044 * was frozen into every product it indexed. The amounts have the mirror problem: the
2045 * woocommerce_product_get_* filters convert prices in the 'view' context but not in
2046 * 'edit', so a converted amount could be paired with an unconverted symbol and produce
2047 * a price that is not merely wrong but incoherent.
2048 *
2049 * Base currency option + 'edit' context makes both halves agree and makes the output
2050 * independent of when the import ran. The currency CODE is emitted alongside the symbol
2051 * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
2052 * (Plan 7403ec.)
2053 */
2054 private function mxchat_product_price_lines($product) {
2055 if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
2056 return '';
2057 }
2058
2059 $currency = get_option('woocommerce_currency');
2060 $currency = is_string($currency) ? trim($currency) : '';
2061 $symbol = ($currency !== '')
2062 ? get_woocommerce_currency_symbol($currency)
2063 : get_woocommerce_currency_symbol();
2064 $symbol = $this->mxchat_decode_entities_for_indexing($symbol);
2065
2066 $regular_price = $product->get_regular_price('edit');
2067 $sale_price = $product->get_sale_price('edit');
2068 $price = $product->get_price('edit');
2069
2070 $lines = '';
2071
2072 if (!empty($regular_price)) {
2073 $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
2074 } elseif (!empty($price)) {
2075 $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
2076 }
2077
2078 if (!empty($sale_price) && $sale_price !== $regular_price) {
2079 $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
2080 }
2081
2082 if ($product->is_type('variable')) {
2083 list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
2084 if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
2085 $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
2086 . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
2087 }
2088 }
2089
2090 return $lines;
2091 }
2092
2093 /**
2094 * One indexed price amount, labelled with its currency code.
2095 *
2096 * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
2097 * is kept so a quoted price still reads naturally. Falls back to the old symbol-only
2098 * shape when WooCommerce has no base currency configured, and drops the parenthetical
2099 * when the symbol is absent or IS the code (several currencies have no distinct glyph).
2100 */
2101 private function mxchat_format_indexed_price($amount, $currency, $symbol) {
2102 $amount = (string) $amount;
2103
2104 if ($currency === '') {
2105 return $symbol . $amount;
2106 }
2107
2108 if ($symbol === '' || $symbol === $currency) {
2109 return $currency . ' ' . $amount;
2110 }
2111
2112 return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
2113 }
2114
2115 /**
2116 * Min/max variation price read from the variations themselves in 'edit' context.
2117 *
2118 * get_variation_price() reads WooCommerce's display price cache, which multi-currency
2119 * plugins populate with converted values — the same defect the rest of this helper
2120 * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
2121 * the store's own price formatting, and (null, null) when no variation carries a price.
2122 */
2123 private function mxchat_variation_price_range($product) {
2124 $min_raw = null;
2125 $max_raw = null;
2126 $min_val = null;
2127 $max_val = null;
2128
2129 $children = method_exists($product, 'get_children') ? $product->get_children() : array();
2130
2131 foreach ($children as $child_id) {
2132 $variation = wc_get_product($child_id);
2133 if (!$variation) {
2134 continue;
2135 }
2136 $raw = $variation->get_price('edit');
2137 if ($raw === '' || $raw === null) {
2138 continue;
2139 }
2140 $val = (float) $raw;
2141 if ($min_val === null || $val < $min_val) {
2142 $min_val = $val;
2143 $min_raw = $raw;
2144 }
2145 if ($max_val === null || $val > $max_val) {
2146 $max_val = $val;
2147 $max_raw = $raw;
2148 }
2149 }
2150
2151 return array($min_raw, $max_raw);
2152 }
2153
2154 private function strip_shortcode_tags_preserve_content($content) {
2155 // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
2156 // Content between tags is inherently preserved since only brackets are targeted
2157 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
2158 return ($result !== null) ? $result : $content;
2159 }
2160
2161 /**
2162 * Text a page pulls in by REFERENCE, resolved by lookup, never rendered (plan 735943).
2163 *
2164 * A synced pattern is stored as a self-closing block comment that names a
2165 * wp_block post (wp:block with a ref), and theme block shortcodes such as
2166 * WoodMart's HTML Block name another post by id. The assembler never runs
2167 * the_content (that would execute every shortcode on the site inside a bulk
2168 * admin request), so both references stripped to nothing and the chatbot
2169 * never saw text the visitor reads on the page. Each reference is replaced
2170 * with the referenced post's own post_content when that post exists, is
2171 * published and has no password; the usual shortcode strip and tag strip
2172 * then run over the inlined markup exactly as over the page's own.
2173 *
2174 * Guards: depth 3 (a pattern may hold a pattern, which may hold one more),
2175 * the chain of ids being inlined ends a self-reference or a cycle, and the
2176 * text inlined per page is capped (mxchat_reference_inline_cap, 200,000
2177 * characters). A reference that does not resolve is left in place and is
2178 * removed by the strips exactly as before.
2179 *
2180 * The shortcode map is filterable (mxchat_reference_shortcodes): shortcode
2181 * name => the attribute that carries the post id. Default: html_block / id.
2182 *
2183 * @param string $content Raw post_content.
2184 * @param int $depth 0 at the page itself.
2185 * @param array $chain Ids being inlined on the current chain (cycle guard).
2186 * @param int $budget Characters still allowed on this page.
2187 * @return string
2188 */
2189 private function mxchat_inline_referenced_content($content, $depth = 0, $chain = array(), &$budget = null) {
2190 if (!is_string($content) || $content === '') {
2191 return (string) $content;
2192 }
2193 if ($budget === null) {
2194 $budget = max(0, (int) apply_filters('mxchat_reference_inline_cap', 200000));
2195 }
2196 if ($depth >= 3 || $budget <= 0) {
2197 return $content;
2198 }
2199
2200 $self = $this;
2201 $resolve = function ($id) use ($self, $depth, $chain, &$budget) {
2202 $id = (int) $id;
2203 if ($id <= 0 || in_array($id, $chain, true) || $budget <= 0) {
2204 return null;
2205 }
2206 $ref = get_post($id);
2207 if (!($ref instanceof WP_Post) || $ref->post_status !== 'publish' || (string) $ref->post_password !== '') {
2208 return null;
2209 }
2210 $text = (string) $ref->post_content;
2211 if (strlen($text) > $budget) {
2212 $text = function_exists('mb_strcut') ? mb_strcut($text, 0, $budget) : substr($text, 0, $budget);
2213 }
2214 $budget -= strlen($text);
2215 $next = $chain;
2216 $next[] = $id;
2217 $r = new ReflectionMethod($self, 'mxchat_inline_referenced_content');
2218 $r->setAccessible(true);
2219 return "\n" . $r->invokeArgs($self, array($text, $depth + 1, $next, &$budget)) . "\n";
2220 };
2221
2222 // 1. Synced patterns: <!-- wp:block {"ref":123} /--> (pattern overrides keep the ref).
2223 if (strpos($content, 'wp:block') !== false) {
2224 $content = preg_replace_callback('/<!--\s*wp:block\s+(\{.*?\})\s*\/-->/s', function ($m) use ($resolve) {
2225 $attrs = json_decode($m[1], true);
2226 $id = (is_array($attrs) && isset($attrs['ref'])) ? (int) $attrs['ref'] : 0;
2227 $text = $id ? $resolve($id) : null;
2228 return ($text === null) ? $m[0] : $text;
2229 }, $content);
2230 }
2231
2232 // 2. Reference shortcodes: [html_block id="123"] and whatever the filter adds.
2233 if (strpos($content, '[') !== false) {
2234 $map = apply_filters('mxchat_reference_shortcodes', array('html_block' => 'id'));
2235 foreach ((array) $map as $tag => $attr) {
2236 if (!is_string($tag) || !is_string($attr) || !preg_match('/^[A-Za-z0-9_-]+$/', $tag) || !preg_match('/^[A-Za-z0-9_-]+$/', $attr)) {
2237 continue;
2238 }
2239 if (strpos($content, '[' . $tag) === false) {
2240 continue;
2241 }
2242 $content = preg_replace_callback('/\[' . preg_quote($tag, '/') . '(\s[^\]]*)?\]/', function ($m) use ($resolve, $attr) {
2243 $atts = shortcode_parse_atts(isset($m[1]) ? trim(rtrim(trim($m[1]), '/')) : '');
2244 $id = (is_array($atts) && isset($atts[$attr])) ? (int) $atts[$attr] : 0;
2245 $text = $id ? $resolve($id) : null;
2246 return ($text === null) ? $m[0] : $text;
2247 }, $content);
2248 }
2249 }
2250
2251 return $content;
2252 }
2253
2254 public function mxchat_sanitize_content_for_api($content) {
2255 //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
2256
2257 // Remove shortcode tags but PRESERVE content inside them
2258 $content = $this->strip_shortcode_tags_preserve_content($content);
2259
2260 // Remove script, style tags, and HTML comments
2261 $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
2262 $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
2263 $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
2264
2265 // Remove all HTML tags and decode HTML entities
2266 $content = wp_strip_all_tags($content);
2267 $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
2268
2269 // Normalize whitespace but preserve paragraph breaks
2270 // First, normalize line endings to \n
2271 $content = str_replace(["\r\n", "\r"], "\n", $content);
2272 // Replace multiple spaces/tabs with single space, but preserve newlines
2273 $content = preg_replace('/[ \t]+/', ' ', $content);
2274 // Replace 3+ newlines with 2 newlines (max 2 blank lines)
2275 $content = preg_replace('/\n{3,}/', "\n\n", $content);
2276 // Trim each line
2277 $lines = explode("\n", $content);
2278 $lines = array_map('trim', $lines);
2279 $content = implode("\n", $lines);
2280 // Final trim
2281 $content = trim($content);
2282
2283 // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
2284 $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
2285
2286 // Remove NULL bytes which can cause database errors
2287 $content = str_replace("\0", "", $content);
2288
2289 // Ensure valid UTF-8 encoding
2290 $content = wp_check_invalid_utf8($content);
2291
2292 // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
2293 // Counts CHARACTERS (/u), and never strips a run containing characters from a
2294 // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
2295 // where a normal paragraph is legitimately one unbroken run.
2296 $content = preg_replace_callback('/\S{300,}/u', function ($m) {
2297 return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
2298 }, $content);
2299
2300 // Remove emoji/symbol blocks only — not the whole supplementary plane, which
2301 // also holds CJK Extension B ideographs used in real Chinese/Japanese names.
2302 // A ZWJ (U+200D) BETWEEN stripped pictographs is consumed with them, so a
2303 // family sequence like 👨‍👩‍👧 leaves no invisible zero-width residue behind
2304 // (the joiner between NON-emoji characters — Hindi conjuncts — is untouched).
2305 $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}](?:\x{200D}[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}])*/u', '', $content);
2306
2307 // There is deliberately NO catch-all character allowlist here (plan 209e57;
2308 // one existed until 3.2.20). Every genuinely dangerous byte is already gone:
2309 // control characters, null bytes, invalid UTF-8 and the emoji blocks are all
2310 // stripped above. The allowlist's only remaining effect was to damage scripts
2311 // nobody thought to enumerate — Unicode Cf (Format) was missing, so it
2312 // replaced the zero-width joiner/non-joiner with spaces and silently split
2313 // Persian words (می‌روم → می روم) and broke Hindi conjuncts (क्‍ष → क् ष).
2314 // Do not add one back; the failure mode of an allowlist is exactly this.
2315
2316 // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
2317 // but cut on a character boundary so a multibyte char is never split mid-sequence)
2318 $max_length = 65000; // Just under MySQL TEXT field limit
2319 if (strlen($content) > $max_length) {
2320 $content = mb_strcut($content, 0, $max_length, 'UTF-8');
2321 }
2322
2323 //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
2324 return $content;
2325 }
2326 public function mxchat_extract_main_content($html) {
2327 if (empty($html)) {
2328 return '';
2329 }
2330 try {
2331 $dom = new DOMDocument;
2332 libxml_use_internal_errors(true); // Suppress HTML parsing errors
2333 @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2334 $xpath = new DOMXPath($dom);
2335
2336 // For debugging purposes
2337 $debugEnabled = true; // Set to true to enable debugging output
2338 $debug = function($message) use ($debugEnabled) {
2339 if ($debugEnabled) {
2340 //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
2341 }
2342 };
2343
2344 // Direct targeting for Gerow theme posts
2345 $post_text = $xpath->query('//div[contains(@class, "post-text")]');
2346 if ($post_text && $post_text->length > 0) {
2347 $debug("Found post-text directly");
2348 $content = '';
2349 foreach ($post_text as $node) {
2350 $content .= $dom->saveHTML($node);
2351 }
2352 if (!empty($content)) {
2353 $debug("Returning post-text content");
2354 return $content;
2355 }
2356 }
2357
2358 // Try to get the blog details content which contains the post-text
2359 $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
2360 if ($blog_details && $blog_details->length > 0) {
2361 $debug("Found blog-details-content");
2362 $content = '';
2363 foreach ($blog_details as $node) {
2364 $content .= $dom->saveHTML($node);
2365 }
2366 if (!empty($content)) {
2367 $debug("Returning blog-details-content");
2368 return $content;
2369 }
2370 }
2371
2372 // Try to get the article which contains the blog details
2373 $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
2374 if ($article && $article->length > 0) {
2375 $debug("Found article with blog-details-wrap");
2376 $content = '';
2377 foreach ($article as $node) {
2378 $content .= $dom->saveHTML($node);
2379 }
2380 if (!empty($content)) {
2381 $debug("Returning article content");
2382 return $content;
2383 }
2384 }
2385
2386 // Try even broader with the blog-item-wrap
2387 $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
2388 if ($blog_item && $blog_item->length > 0) {
2389 $debug("Found blog-item-wrap");
2390 $content = '';
2391 foreach ($blog_item as $node) {
2392 $content .= $dom->saveHTML($node);
2393 }
2394 if (!empty($content)) {
2395 $debug("Returning blog-item-wrap content");
2396 return $content;
2397 }
2398 }
2399
2400 // Specific Gerow theme path
2401 $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
2402 if ($gerow_path && $gerow_path->length > 0) {
2403 $debug("Found Gerow theme path to post-text");
2404 $content = '';
2405 foreach ($gerow_path as $node) {
2406 $content .= $dom->saveHTML($node);
2407 }
2408 if (!empty($content)) {
2409 $debug("Returning Gerow post-text content");
2410 return $content;
2411 }
2412 }
2413
2414 // Generic blog post selectors
2415 $selectors = [
2416 // Blog post specific selectors
2417 '//div[contains(@class, "post-text")]',
2418 '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
2419 '//div[contains(@class, "blog-details-content")]',
2420 '//article[contains(@class, "blog-details-wrap")]',
2421 '//div[contains(@class, "entry-content")]',
2422 '//div[contains(@class, "blog-content")]',
2423 '//div[contains(@class, "blog-item-wrap")]',
2424
2425 // More general content selectors
2426 '//div[contains(@class, "page__content")]',
2427 '//div[contains(@class, "elementor-widget-container")]',
2428 '//div[contains(@class, "elementor-text-editor")]',
2429 '//div[contains(@class, "elementor-widget-text-editor")]',
2430 '//*[contains(@class, "entry-content")]',
2431 '//*[contains(@class, "post-content")]',
2432 '//*[contains(@class, "article-content")]',
2433 '//*[@id="content"]',
2434 '//*[@id="main-content"]',
2435 '//section[contains(@class, "blog-area")]',
2436 '//article',
2437 '//main',
2438 '//div[contains(@class, "content")]'
2439 ];
2440
2441 // First handle Elementor content - get only leaf widget containers to avoid duplicates
2442 $debug("Checking for Elementor content");
2443 // Get widget containers that are direct children of widgets (not nested inside other widget containers)
2444 $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
2445 if ($elementor_widgets && $elementor_widgets->length > 0) {
2446 $debug("Found Elementor widgets");
2447 $seen_content = array(); // Track seen content to avoid duplicates
2448 $combined_content = '';
2449 foreach ($elementor_widgets as $widget) {
2450 $widget_content = $dom->saveHTML($widget);
2451 if (!empty($widget_content)) {
2452 // Create a hash of the content to detect duplicates
2453 $content_hash = md5($widget_content);
2454 if (!isset($seen_content[$content_hash])) {
2455 $seen_content[$content_hash] = true;
2456 $combined_content .= $widget_content;
2457 }
2458 }
2459 }
2460 if (!empty($combined_content)) {
2461 $debug("Returning Elementor content");
2462 return $combined_content;
2463 }
2464 }
2465
2466 // Try standard selectors one by one
2467 foreach ($selectors as $selector) {
2468 $debug("Trying selector: " . $selector);
2469 $nodes = $xpath->query($selector);
2470 if ($nodes && $nodes->length > 0) {
2471 $debug("Found " . $nodes->length . " matches for selector: " . $selector);
2472 // Only take the FIRST matching node to avoid duplicate content
2473 // (pages often have nested or multiple containers with same class)
2474 $content = $dom->saveHTML($nodes->item(0));
2475 if (!empty($content)) {
2476 $debug("Returning content from selector: " . $selector . " (first match only)");
2477 return $content;
2478 }
2479 }
2480 }
2481
2482 // Manual regex fallback for post-text if DOM methods fail
2483 $debug("Trying regex fallback");
2484 if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
2485 $debug("Found post-text via regex");
2486 return '<div class="post-text">' . $matches[1] . '</div>';
2487 }
2488
2489 // Try to extract the blog section as a whole
2490 $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
2491 if ($blog_section && $blog_section->length > 0) {
2492 $debug("Found blog-area section");
2493 $content = '';
2494 foreach ($blog_section as $node) {
2495 $content .= $dom->saveHTML($node);
2496 }
2497 if (!empty($content)) {
2498 $debug("Returning blog-area section content");
2499 return $content;
2500 }
2501 }
2502
2503 // Generic container selectors for non-CMS sites (like .asp pages)
2504 $debug("Trying generic container selectors");
2505 $generic_selectors = [
2506 '//div[@id="main"]',
2507 '//div[@id="wrapper"]',
2508 '//div[@id="page"]',
2509 '//div[@id="site-content"]',
2510 '//div[contains(@class, "main-content")]',
2511 '//div[contains(@class, "page-content")]',
2512 '//div[contains(@class, "site-content")]',
2513 ];
2514
2515 foreach ($generic_selectors as $selector) {
2516 $debug("Trying generic selector: " . $selector);
2517 $nodes = $xpath->query($selector);
2518 if ($nodes && $nodes->length > 0) {
2519 $content = $dom->saveHTML($nodes->item(0));
2520 if (!empty($content)) {
2521 $debug("Returning content from generic selector: " . $selector);
2522 return $content;
2523 }
2524 }
2525 }
2526
2527 // Paragraph-based content detection - find regions with substantial text
2528 $debug("Trying paragraph-based content detection");
2529 $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
2530 if ($paragraphs && $paragraphs->length >= 3) {
2531 $debug("Found " . $paragraphs->length . " substantial paragraphs");
2532 // Collect all substantial paragraphs and their content
2533 $paragraph_content = '';
2534 foreach ($paragraphs as $p) {
2535 $paragraph_content .= $dom->saveHTML($p) . "\n";
2536 }
2537 if (!empty($paragraph_content)) {
2538 $debug("Returning paragraph-based content");
2539 return $paragraph_content;
2540 }
2541 }
2542
2543 // Improved body fallback - strip nav/header/footer elements first
2544 $debug("Using improved body fallback");
2545 $body = $dom->getElementsByTagName('body');
2546 if ($body->length > 0) {
2547 // Clone the body to avoid modifying the original DOM
2548 $body_clone = $body->item(0)->cloneNode(true);
2549
2550 // Remove common non-content elements by tag name
2551 $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
2552 foreach ($remove_tags as $tag) {
2553 $elements = $body_clone->getElementsByTagName($tag);
2554 // Iterate backwards to safely remove elements
2555 for ($i = $elements->length - 1; $i >= 0; $i--) {
2556 $el = $elements->item($i);
2557 if ($el && $el->parentNode) {
2558 $el->parentNode->removeChild($el);
2559 }
2560 }
2561 }
2562
2563 // Remove elements with common non-content class names using XPath on the cloned body
2564 $temp_dom = new DOMDocument();
2565 @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
2566 $temp_xpath = new DOMXPath($temp_dom);
2567
2568 $remove_class_patterns = [
2569 '//*[contains(@class, "nav")]',
2570 '//*[contains(@class, "menu")]',
2571 '//*[contains(@class, "sidebar")]',
2572 '//*[contains(@class, "footer")]',
2573 '//*[contains(@class, "header")]',
2574 '//*[contains(@id, "nav")]',
2575 '//*[contains(@id, "menu")]',
2576 '//*[contains(@id, "sidebar")]',
2577 '//*[contains(@id, "footer")]',
2578 '//*[contains(@id, "header")]',
2579 ];
2580
2581 foreach ($remove_class_patterns as $pattern) {
2582 $elements = $temp_xpath->query($pattern);
2583 if ($elements) {
2584 for ($i = $elements->length - 1; $i >= 0; $i--) {
2585 $el = $elements->item($i);
2586 if ($el && $el->parentNode) {
2587 $el->parentNode->removeChild($el);
2588 }
2589 }
2590 }
2591 }
2592
2593 $cleaned_content = $temp_dom->saveHTML();
2594 if (!empty($cleaned_content)) {
2595 $debug("Returning cleaned body content");
2596 return $cleaned_content;
2597 }
2598 }
2599
2600 // Last resort: return the original HTML
2601 $debug("Returning original HTML");
2602 return $html;
2603 } catch (Exception $e) {
2604 //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
2605 return $html; // Return original HTML if parsing fails
2606 } finally {
2607 libxml_clear_errors();
2608 }
2609 }
2610 public function mxchat_get_sitemap_processing_status($sitemap_url) {
2611 $sitemap_url = esc_url_raw($sitemap_url);
2612 $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
2613 $status = get_transient($status_key);
2614
2615 if (!$status || !is_array($status)) {
2616 return false;
2617 }
2618
2619 // Auto-complete check: if all URLs are processed but status isn't complete
2620 if (isset($status['processed_urls']) && isset($status['total_urls']) &&
2621 $status['processed_urls'] >= $status['total_urls'] &&
2622 isset($status['status']) && $status['status'] !== 'complete' &&
2623 $status['status'] !== 'error') {
2624
2625 // Mark as complete
2626 $status['status'] = 'complete';
2627 $status['processed_urls'] = $status['total_urls']; // Ensure exact match
2628
2629 // Update the transient with the corrected status
2630 set_transient($status_key, $status, DAY_IN_SECONDS);
2631 }
2632
2633 return array(
2634 'total_urls' => absint($status['total_urls']),
2635 'processed_urls' => absint($status['processed_urls']),
2636 'failed_urls' => absint($status['failed_urls'] ?? 0),
2637 'percentage' => ($status['total_urls'] > 0)
2638 ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
2639 : 0,
2640 'status' => sanitize_text_field($status['status']),
2641 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
2642 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
2643 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
2644 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
2645 );
2646 }
2647
2648 public function mxchat_ajax_get_status_updates() {
2649 try {
2650 // Verify the request
2651 check_ajax_referer('mxchat_status_nonce', 'nonce');
2652
2653 // Get active queue IDs
2654 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2655 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2656
2657 $sitemap_status = false;
2658 $pdf_status = false;
2659
2660 // Get sitemap queue status
2661 if ($sitemap_queue_id) {
2662 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2663 }
2664
2665 // Get PDF queue status
2666 if ($pdf_queue_id) {
2667 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2668 }
2669
2670 $is_active_processing =
2671 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2672 ($pdf_status && $pdf_status['status'] === 'processing');
2673
2674 // Return JSON response with the status data
2675 wp_send_json(array(
2676 'pdf_status' => $pdf_status,
2677 'sitemap_status' => $sitemap_status,
2678 'is_processing' => $is_active_processing,
2679 'sitemap_queue_id' => $sitemap_queue_id,
2680 'pdf_queue_id' => $pdf_queue_id
2681 ));
2682
2683 } catch (Exception $e) {
2684 //error_log('MxChat Status Update Error: ' . $e->getMessage());
2685
2686 wp_send_json_error(array(
2687 'message' => 'Error getting status updates: ' . $e->getMessage(),
2688 'status' => 'error'
2689 ));
2690 }
2691 }
2692
2693 /**
2694 * Helper function to get queue status data
2695 */
2696 private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
2697 global $wpdb;
2698 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2699
2700 // Get counts by status
2701 $counts = $wpdb->get_results($wpdb->prepare(
2702 "SELECT status, COUNT(*) as count
2703 FROM $table_name
2704 WHERE queue_id = %s
2705 GROUP BY status",
2706 $queue_id
2707 ), OBJECT_K);
2708
2709 $total = 0;
2710 $completed = 0;
2711 $failed = 0;
2712 $processing = 0;
2713 $pending = 0;
2714
2715 foreach ($counts as $status => $data) {
2716 $count = absint($data->count);
2717 $total += $count;
2718
2719 switch ($status) {
2720 case 'completed':
2721 $completed = $count;
2722 break;
2723 case 'failed':
2724 $failed = $count;
2725 break;
2726 case 'processing':
2727 $processing = $count;
2728 break;
2729 case 'pending':
2730 $pending = $count;
2731 break;
2732 }
2733 }
2734
2735 if ($total === 0) {
2736 return false;
2737 }
2738
2739 // Calculate percentage
2740 $percentage = round((($completed + $failed) / $total) * 100);
2741
2742 // Get failed items details (limit to 50)
2743 $failed_items = array();
2744 if ($failed > 0) {
2745 $failed_results = $wpdb->get_results($wpdb->prepare(
2746 "SELECT item_type, item_data, error_message, attempts, completed_at
2747 FROM $table_name
2748 WHERE queue_id = %s
2749 AND status = 'failed'
2750 AND attempts >= max_attempts
2751 ORDER BY id DESC
2752 LIMIT 50",
2753 $queue_id
2754 ));
2755
2756 foreach ($failed_results as $item) {
2757 $data = json_decode($item->item_data, true);
2758 $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
2759
2760 $failed_items[] = array(
2761 'url' => $url,
2762 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
2763 'error' => $item->error_message,
2764 'retries' => $item->attempts,
2765 'time' => strtotime($item->completed_at)
2766 );
2767 }
2768 }
2769
2770 // Get queue metadata
2771 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
2772
2773 // Determine if queue is complete
2774 $is_complete = ($pending === 0 && $processing === 0);
2775
2776 // Get last update time
2777 $last_update = $wpdb->get_var($wpdb->prepare(
2778 "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
2779 FROM $table_name
2780 WHERE queue_id = %s",
2781 $queue_id
2782 ));
2783
2784 $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
2785
2786 // Format based on type
2787 if ($type === 'pdf') {
2788 return array(
2789 'total_pages' => $total,
2790 'processed_pages' => $completed + $failed,
2791 'failed_pages' => $failed,
2792 'percentage' => $percentage,
2793 'status' => $is_complete ? 'complete' : 'processing',
2794 'last_update' => $last_update_text,
2795 'failed_pages_list' => $failed_items,
2796 'pdf_url' => $source_url,
2797 'queue_id' => $queue_id
2798 );
2799 } else {
2800 return array(
2801 'total_urls' => $total,
2802 'processed_urls' => $completed + $failed,
2803 'failed_urls' => $failed,
2804 'percentage' => $percentage,
2805 'status' => $is_complete ? 'complete' : 'processing',
2806 'last_update' => $last_update_text,
2807 'failed_urls_list' => $failed_items,
2808 'sitemap_url' => $source_url,
2809 'queue_id' => $queue_id
2810 );
2811 }
2812 }
2813
2814 /**
2815 * Public method to get processing status for both sitemap and PDF queues
2816 * Used by admin pages to display processing status
2817 *
2818 * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2819 */
2820 public function mxchat_get_processing_statuses() {
2821 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2822 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2823
2824 $sitemap_status = false;
2825 $pdf_status = false;
2826
2827 if ($sitemap_queue_id) {
2828 $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2829 }
2830
2831 if ($pdf_queue_id) {
2832 $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2833 }
2834
2835 $is_processing =
2836 ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2837 ($pdf_status && $pdf_status['status'] === 'processing');
2838
2839 return array(
2840 'sitemap_status' => $sitemap_status,
2841 'pdf_status' => $pdf_status,
2842 'is_processing' => $is_processing
2843 );
2844 }
2845
2846 /**
2847 * AJAX handler to get recent knowledge entries for real-time table updates
2848 * UPDATED: Now supports both WordPress DB and Pinecone data sources
2849 */
2850 public function ajax_mxchat_get_recent_entries() {
2851 check_ajax_referer('mxchat_entries_nonce', 'nonce');
2852
2853 if (!current_user_can('manage_options')) {
2854 wp_send_json_error(array('message' => 'Unauthorized'));
2855 return;
2856 }
2857
2858 global $wpdb;
2859 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2860
2861 // Get parameters
2862 $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2863 $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2864 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2865
2866 // Check if Pinecone is enabled for this bot
2867 $pinecone_manager = $this->mxchat_get_pinecone_manager();
2868 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2869 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2870 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2871
2872 if ($use_pinecone && $has_pinecone_api) {
2873 // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2874 // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2875 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2876 $total_count = $records['total'] ?? 0;
2877
2878 // For Pinecone, we don't return individual entries during polling
2879 // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2880 // We just return the updated count. Over 500 vectors that is the live
2881 // index / namespace vector count climbing during an import (plan dd6e10).
2882 wp_send_json_success(array(
2883 'entries' => array(),
2884 'total_count' => absint($total_count),
2885 'count_unit' => $records['count_unit'] ?? '',
2886 'max_id' => $last_id,
2887 'data_source' => 'pinecone'
2888 ));
2889 return;
2890 }
2891
2892 // WORDPRESS DB DATA SOURCE
2893 // Build query to get entries newer than last_id
2894 $where_clauses = array('1=1');
2895 $where_values = array();
2896
2897 if ($last_id > 0) {
2898 $where_clauses[] = 'id > %d';
2899 $where_values[] = $last_id;
2900 }
2901
2902 // Note: WordPress DB table doesn't have bot_id column
2903 // Multi-bot filtering is handled via Pinecone namespaces
2904
2905 $where_sql = implode(' AND ', $where_clauses);
2906
2907 // Get recent entries
2908 $query = "SELECT id, article_content, source_url, timestamp
2909 FROM $table_name
2910 WHERE $where_sql
2911 ORDER BY id DESC
2912 LIMIT %d";
2913
2914 $where_values[] = $limit;
2915
2916 $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2917
2918 // Get total count of GROUPED entries (by source_url) - matches pagination display
2919 // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2920 $total_count = $wpdb->get_var(
2921 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2922 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2923 );
2924
2925 // Format entries for response
2926 $formatted_entries = array();
2927 $preview_length = 150;
2928 foreach ($entries as $entry) {
2929 // Parse chunk metadata using the proper chunker method (same as initial page load)
2930 if (class_exists('MxChat_Chunker')) {
2931 $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2932 $display_content = $chunk_meta['text'];
2933 $chunk_metadata = $chunk_meta['metadata'];
2934 } else {
2935 $display_content = $entry->article_content;
2936 $chunk_metadata = array();
2937 }
2938
2939 $content_preview = mb_strlen($display_content) > $preview_length
2940 ? mb_substr($display_content, 0, $preview_length) . '...'
2941 : $display_content;
2942
2943 $formatted_entries[] = array(
2944 'id' => $entry->id,
2945 'preview' => esc_html($content_preview),
2946 'full_content' => wp_kses_post(wpautop($display_content)),
2947 'content_length' => mb_strlen($display_content),
2948 'preview_length' => $preview_length,
2949 'source_url' => $entry->source_url,
2950 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0 && !MxChat_Utils::is_upload_source($entry->source_url),
2951 'source_html' => self::mxchat_source_cell_html($entry->source_url, $entry->bot_id ?? 'default', false),
2952 'chunk_metadata' => $chunk_metadata,
2953 'bot_id' => $entry->bot_id ?? 'default',
2954 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2955 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2956 );
2957 }
2958
2959 wp_send_json_success(array(
2960 'entries' => $formatted_entries,
2961 'total_count' => absint($total_count),
2962 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2963 'data_source' => 'wordpress'
2964 ));
2965 }
2966
2967 /**
2968 * Get Pinecone total count from stats API
2969 * Helper function for ajax_mxchat_get_recent_entries
2970 */
2971 private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2972 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2973 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2974 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2975
2976 if (empty($api_key) || empty($host)) {
2977 return 0;
2978 }
2979
2980 // Document index (plan 362c31).
2981 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
2982 return MxChat_Pinecone_Documents::count(MxChat_Pinecone_Documents::cfg_from_options($pinecone_options));
2983 }
2984
2985 try {
2986 $stats_url = "https://{$host}/describe_index_stats";
2987
2988 $response = wp_remote_post($stats_url, array(
2989 'headers' => array(
2990 'Api-Key' => $api_key,
2991 'Content-Type' => 'application/json'
2992 ),
2993 'body' => '{}',
2994 'timeout' => 10
2995 ));
2996
2997 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2998 $body = wp_remote_retrieve_body($response);
2999 $stats_data = json_decode($body, true);
3000
3001 // If namespace is specified, get count from that specific namespace
3002 if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
3003 return intval($stats_data['namespaces'][$namespace]['vectorCount']);
3004 }
3005
3006 // If no namespace specified or namespace not found in response, use total
3007 return intval($stats_data['totalVectorCount'] ?? 0);
3008 }
3009
3010 return 0;
3011
3012 } catch (Exception $e) {
3013 return 0;
3014 }
3015 }
3016
3017 /**
3018 * AJAX handler to refresh Pinecone entries table via AJAX
3019 * Returns the table HTML for updating the UI without a full page reload
3020 */
3021 public function ajax_mxchat_refresh_pinecone_entries() {
3022 check_ajax_referer('mxchat_entries_nonce', 'nonce');
3023
3024 if (!current_user_can('manage_options')) {
3025 wp_send_json_error(array('message' => 'Unauthorized'));
3026 return;
3027 }
3028
3029 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
3030 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3031 $per_page = 25;
3032 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3033 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
3034
3035 // Get Pinecone manager and options
3036 $pinecone_manager = $this->mxchat_get_pinecone_manager();
3037 if (!$pinecone_manager) {
3038 wp_send_json_error(array('message' => 'Pinecone manager not available'));
3039 return;
3040 }
3041
3042 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
3043 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3044 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3045
3046 if (!$use_pinecone || empty($pinecone_api_key)) {
3047 wp_send_json_error(array('message' => 'Pinecone not configured'));
3048 return;
3049 }
3050
3051 // Fetch records from Pinecone
3052 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
3053 $prompts = $records['data'] ?? array();
3054 $total_records = $records['total'] ?? 0;
3055
3056 // Preprocess Pinecone records — set chunk_metadata and display_content
3057 // (matches admin-knowledge-page.php preprocessing)
3058 foreach ($prompts as $prompt) {
3059 if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
3060 $prompt->chunk_metadata = array(
3061 'chunk_index' => intval($prompt->chunk_index),
3062 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
3063 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
3064 'source_url' => $prompt->source_url ?? ''
3065 );
3066 $prompt->display_content = $prompt->article_content;
3067 } else {
3068 $prompt->chunk_metadata = array();
3069 $prompt->display_content = $prompt->article_content ?? '';
3070 }
3071 }
3072
3073 // Group prompts by source_url
3074 $grouped_prompts = array();
3075 foreach ($prompts as $prompt) {
3076 $source_url = '';
3077 if (!empty($prompt->chunk_metadata['source_url'])) {
3078 $source_url = $prompt->chunk_metadata['source_url'];
3079 } elseif (!empty($prompt->source_url)) {
3080 $source_url = $prompt->source_url;
3081 }
3082
3083 if (!empty($source_url)) {
3084 if (!isset($grouped_prompts[$source_url])) {
3085 $grouped_prompts[$source_url] = array();
3086 }
3087 $grouped_prompts[$source_url][] = $prompt;
3088 } else {
3089 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
3090 }
3091 }
3092
3093 // Sort each group by chunk_index
3094 foreach ($grouped_prompts as $source_url => &$group) {
3095 usort($group, function($a, $b) {
3096 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3097 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3098 return $index_a - $index_b;
3099 });
3100 }
3101 unset($group);
3102
3103 // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
3104 ob_start();
3105 $display_index = 0;
3106 $current_page = $page;
3107 $data_source = 'pinecone';
3108 $current_bot_id = $bot_id;
3109 $preview_length = 150;
3110
3111 if (empty($grouped_prompts)) {
3112 echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3113 esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
3114 echo '</td></tr>';
3115 } else {
3116 foreach ($grouped_prompts as $source_url => $group) {
3117 $chunk_count = count($group);
3118 $first_prompt = $group[0];
3119 $display_index++;
3120
3121 if ($chunk_count > 1) {
3122 // Multiple chunks - show grouped row with expand button
3123 $group_id = 'group-' . md5($source_url);
3124 ?>
3125 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3126 class="mxchat-chunk-group-header"
3127 data-source="<?php echo esc_attr($data_source); ?>"
3128 data-group-id="<?php echo esc_attr($group_id); ?>"
3129 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
3130 <td style="padding: 12px 16px; text-align: center;">
3131 <input type="checkbox"
3132 class="mxchat-entry-checkbox"
3133 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3134 data-source="<?php echo esc_attr($data_source); ?>"
3135 data-source-url="<?php echo esc_attr($source_url); ?>"
3136 data-is-group="true"
3137 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3138 </td>
3139 <td style="padding: 12px 16px; font-size: 13px;">
3140 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3141 </td>
3142 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3143 <div class="mxchat-chunk-group-info">
3144 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3145 <span class="dashicons dashicons-arrow-right-alt2"></span>
3146 </button>
3147 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3148 <span class="mxchat-chunk-preview">
3149 <?php
3150 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
3151 $content_preview = mb_substr($parent_content, 0, 100);
3152 echo esc_html($content_preview . '...');
3153 ?>
3154 </span>
3155 </div>
3156 </td>
3157 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3158 <?php echo self::mxchat_source_cell_html($source_url, $current_bot_id, true); ?>
3159 </td>
3160 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3161 <button type="button"
3162 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3163 data-source-url="<?php echo esc_attr($source_url); ?>"
3164 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3165 data-data-source="<?php echo esc_attr($data_source); ?>"
3166 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3167 data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3168 title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3169 <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3170 </button>
3171 <button type="button"
3172 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3173 data-source-url="<?php echo esc_attr($source_url); ?>"
3174 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3175 data-data-source="<?php echo esc_attr($data_source); ?>"
3176 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3177 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3178 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3179 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3180 </button>
3181 <button type="button"
3182 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3183 data-source-url="<?php echo esc_attr($source_url); ?>"
3184 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3185 data-data-source="<?php echo esc_attr($data_source); ?>"
3186 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3187 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3188 style="color: var(--mxch-error);"
3189 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3190 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3191 </button>
3192 </td>
3193 </tr>
3194 <?php
3195 // Render hidden chunk rows
3196 foreach ($group as $chunk_index => $chunk) {
3197 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3198 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3199 $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
3200 $content_preview = mb_strlen($content) > $preview_length
3201 ? mb_substr($content, 0, $preview_length) . '...'
3202 : $content;
3203 ?>
3204 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3205 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3206 data-source="<?php echo esc_attr($data_source); ?>"
3207 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3208 <td style="padding: 12px 16px; text-align: center;">
3209 <!-- Checkbox column placeholder for chunks (managed by group) -->
3210 </td>
3211 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3212 <!-- Hidden ID column for chunks -->
3213 </td>
3214 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3215 <div class="mxchat-accordion-wrapper">
3216 <div class="mxchat-content-preview">
3217 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3218 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3219 </span>
3220 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3221 <?php if (mb_strlen($content) > $preview_length) : ?>
3222 <button class="mxchat-expand-toggle" type="button">
3223 <span class="dashicons dashicons-arrow-down-alt2"></span>
3224 </button>
3225 <?php endif; ?>
3226 </div>
3227 <div class="mxchat-content-full" style="display: none;">
3228 <div class="content-view">
3229 <?php
3230 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3231 echo '<div dir="rtl" lang="he" class="rtl-content">';
3232 echo wp_kses_post(wpautop($content));
3233 echo '</div>';
3234 } else {
3235 echo wp_kses_post(wpautop($content));
3236 }
3237 ?>
3238 </div>
3239 </div>
3240 </div>
3241 </td>
3242 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3243 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3244 </td>
3245 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3246 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3247 </td>
3248 </tr>
3249 <?php
3250 }
3251 } else {
3252 // Single entry - display normally with accordion
3253 $prompt = $first_prompt;
3254 $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
3255 $content_preview = mb_strlen($content) > $preview_length
3256 ? mb_substr($content, 0, $preview_length) . '...'
3257 : $content;
3258 ?>
3259 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3260 data-source="<?php echo esc_attr($data_source); ?>"
3261 style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
3262 <td style="padding: 12px 16px; text-align: center;">
3263 <input type="checkbox"
3264 class="mxchat-entry-checkbox"
3265 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3266 data-source="<?php echo esc_attr($data_source); ?>"
3267 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3268 data-is-group="false"
3269 data-chunk-count="1">
3270 </td>
3271 <td style="padding: 12px 16px; font-size: 13px;">
3272 <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
3273 </td>
3274 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3275 <div class="mxchat-accordion-wrapper">
3276 <div class="mxchat-content-preview">
3277 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3278 <?php if (mb_strlen($content) > $preview_length) : ?>
3279 <button class="mxchat-expand-toggle" type="button">
3280 <span class="dashicons dashicons-arrow-down-alt2"></span>
3281 </button>
3282 <?php endif; ?>
3283 </div>
3284 <div class="mxchat-content-full" style="display: none;">
3285 <div class="content-view">
3286 <?php
3287 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3288 echo '<div dir="rtl" lang="he" class="rtl-content">';
3289 echo wp_kses_post(wpautop($content));
3290 echo '</div>';
3291 } else {
3292 echo wp_kses_post(wpautop($content));
3293 }
3294 ?>
3295 </div>
3296 </div>
3297 </div>
3298 </td>
3299 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3300 <?php
3301 $actual_source = $source_url;
3302 if (strpos($source_url, '_ungrouped_') === 0) {
3303 $actual_source = $prompt->source_url ?? '';
3304 }
3305 echo self::mxchat_source_cell_html($actual_source, $current_bot_id, false);
3306 ?>
3307 </td>
3308 <td style="padding: 12px 16px; white-space: nowrap;">
3309 <button type="button"
3310 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3311 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3312 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3313 data-data-source="<?php echo esc_attr($data_source); ?>"
3314 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3315 data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3316 title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3317 <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3318 </button>
3319 <button type="button"
3320 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3321 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3322 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3323 data-data-source="<?php echo esc_attr($data_source); ?>"
3324 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3325 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3326 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3327 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3328 </button>
3329 <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);">
3330 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3331 </button>
3332 </td>
3333 </tr>
3334 <?php
3335 }
3336 }
3337 }
3338 $html = ob_get_clean();
3339
3340 // Generate pagination HTML for Pinecone
3341 $total_pages = ceil($total_records / $per_page);
3342 $count_unit = $records['count_unit'] ?? '';
3343 $cursor_pagination = !empty($records['cursor_pagination']);
3344 $has_next = !empty($records['has_next']);
3345 $pagination_html = '';
3346 if ($cursor_pagination) {
3347 // Plan dd6e10: cursor path — Previous / Next only, no numbered pages.
3348 $pagination_html = self::mxchat_cursor_pagination_html($page, $has_next, $search_query, $content_type_filter);
3349 } elseif ($total_pages > 1) {
3350 $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) . '">';
3351
3352 // Previous button
3353 if ($page > 1) {
3354 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3355 }
3356
3357 // Page numbers
3358 $start_page = max(1, $page - 2);
3359 $end_page = min($total_pages, $page + 2);
3360
3361 if ($start_page > 1) {
3362 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3363 if ($start_page > 2) {
3364 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3365 }
3366 }
3367
3368 for ($i = $start_page; $i <= $end_page; $i++) {
3369 if ($i == $page) {
3370 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3371 } else {
3372 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3373 }
3374 }
3375
3376 if ($end_page < $total_pages) {
3377 if ($end_page < $total_pages - 1) {
3378 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3379 }
3380 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3381 }
3382
3383 // Next button
3384 if ($page < $total_pages) {
3385 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3386 }
3387
3388 $pagination_html .= '</div>';
3389 }
3390
3391 wp_send_json_success(array(
3392 'html' => $html,
3393 'pagination_html' => $pagination_html,
3394 'total_count' => $total_records,
3395 'count_unit' => $count_unit,
3396 'total_pages' => $total_pages,
3397 'page' => $page,
3398 'per_page' => $per_page,
3399 'data_source' => 'pinecone'
3400 ));
3401 }
3402
3403 /**
3404 * Previous / Next markup for the Pinecone cursor path (plan dd6e10).
3405 *
3406 * Pinecone's /vectors/list has no random access, so numbered pages would be a
3407 * fiction; the page number here is only the cursor step, which the manager
3408 * maps to a stored paginationToken. Used by the page template on first render
3409 * and by the AJAX paginator, so the two can never drift.
3410 */
3411 /**
3412 * The Source column of the Knowledge list, one helper for its five render
3413 * sites (plan b339c0). A web source links; an uploaded file (upload://) is
3414 * named in plain text ("Uploaded file: manual.pdf, page 3"), and when it has
3415 * a Source URL that page gets the existing View Source link beside the name;
3416 * manual content keeps its muted label. esc_url() empties upload://, which
3417 * used to leave those rows with a View Source link whose address was empty.
3418 *
3419 * @param string $source_url Stored source identity.
3420 * @param string $bot_id Bot the entry belongs to (Source URLs are kept per bot).
3421 * @param bool $long "View Source" / "Manual Content" (grouped rows) or "View" / "Manual" (single rows).
3422 * @return string HTML.
3423 */
3424 public static function mxchat_source_cell_html($source_url, $bot_id = 'default', $long = true) {
3425 $view = $long ? __('View Source', 'mxchat') : __('View', 'mxchat');
3426 $manual = $long ? __('Manual Content', 'mxchat') : __('Manual', 'mxchat');
3427 $muted = '<span style="color: var(--mxch-text-muted);">%s</span>';
3428 $link = '<a href="%s" target="_blank" style="color: var(--mxch-primary); text-decoration: none;"><span class="dashicons dashicons-external" style="font-size: 14px;"></span> %s</a>';
3429
3430 if (empty($source_url) || !is_string($source_url) || strpos($source_url, 'mxchat://') === 0 || strpos($source_url, '_ungrouped_') === 0) {
3431 return sprintf($muted, esc_html($manual));
3432 }
3433
3434 if (MxChat_Utils::is_upload_source($source_url)) {
3435 $name = MxChat_Utils::upload_source_name($source_url);
3436 $page = MxChat_Utils::upload_source_page($source_url);
3437 $label = $page > 0
3438 /* translators: 1: file name, 2: page number */
3439 ? sprintf(__('Uploaded file: %1$s, page %2$d', 'mxchat'), $name, $page)
3440 /* translators: %s: file name */
3441 : sprintf(__('Uploaded file: %s', 'mxchat'), $name);
3442 $html = sprintf($muted, esc_html($label));
3443 $page_url = MxChat_Utils::get_upload_source_url($source_url, $bot_id);
3444 if ($page_url !== '') {
3445 $html .= ' ' . sprintf($link, esc_url($page_url), esc_html($view));
3446 }
3447 return $html;
3448 }
3449
3450 return sprintf($link, esc_url($source_url), esc_html($view));
3451 }
3452
3453 public static function mxchat_cursor_pagination_html($page, $has_next, $search_query = '', $content_type_filter = '') {
3454 $page = max(1, absint($page));
3455 if ($page <= 1 && !$has_next) {
3456 return '';
3457 }
3458 $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) . '">';
3459 if ($page > 1) {
3460 $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3461 }
3462 $html .= '<span class="mxchat-page-current">' . esc_html($page) . '</span> ';
3463 if ($has_next) {
3464 $html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3465 }
3466 $html .= '</div>';
3467 return $html;
3468 }
3469
3470 /**
3471 * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
3472 * Returns paginated entries without requiring a full page reload
3473 */
3474 public function ajax_mxchat_paginate_entries() {
3475 check_ajax_referer('mxchat_entries_nonce', 'nonce');
3476
3477 if (!current_user_can('manage_options')) {
3478 wp_send_json_error(array('message' => 'Unauthorized'));
3479 return;
3480 }
3481
3482 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
3483 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3484 $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3485 $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
3486 $per_page = 25;
3487
3488 // Check if Pinecone is enabled for this bot
3489 $pinecone_manager = $this->mxchat_get_pinecone_manager();
3490 $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
3491 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3492 $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
3493
3494 if ($use_pinecone && $has_pinecone_api) {
3495 // Delegate to Pinecone pagination handler (pass search params)
3496 $_POST['page'] = $page;
3497 $_POST['search'] = $search_query;
3498 $_POST['content_type'] = $content_type_filter;
3499 $this->ajax_mxchat_refresh_pinecone_entries();
3500 return;
3501 }
3502
3503 // WordPress DB pagination - MUST match initial page load logic exactly
3504 global $wpdb;
3505 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3506 $offset = ($page - 1) * $per_page;
3507
3508 // Build WHERE clause for search and content type filtering
3509 $where_clauses = array();
3510 $where_values = array();
3511
3512 if ($search_query) {
3513 $where_clauses[] = "article_content LIKE %s";
3514 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
3515 }
3516
3517 if ($content_type_filter) {
3518 switch ($content_type_filter) {
3519 case 'manual':
3520 $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
3521 break;
3522 case 'pdf':
3523 // A PDF's pages are stored as <pdf>#page=<n>, so the address
3524 // alone never ended in .pdf and this filter found no page of
3525 // any PDF (plan b339c0). The stored content_type is the honest
3526 // test; the two LIKEs keep rows written before it existed.
3527 $where_clauses[] = "(content_type = 'pdf' OR source_url LIKE '%.pdf' OR source_url LIKE '%.pdf#page=%')";
3528 break;
3529 case 'url':
3530 // Everything with a web address: not manual content, not a PDF
3531 // page, not an uploaded file (upload://, which has no address).
3532 $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE 'upload://%' AND NOT (content_type = 'pdf' OR source_url LIKE '%.pdf' OR source_url LIKE '%.pdf#page=%')";
3533 break;
3534 }
3535 }
3536
3537 $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
3538
3539 // Count grouped entries with filters applied
3540 if (!empty($where_values)) {
3541 $count_args = array_merge($where_values, $where_values);
3542 $count_query = $wpdb->prepare(
3543 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3544 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
3545 ...$count_args
3546 );
3547 $total_records = $wpdb->get_var($count_query);
3548 } else if (!empty($where_sql)) {
3549 // Content type filter only (no search), no prepared values needed
3550 $total_records = $wpdb->get_var(
3551 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3552 (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
3553 );
3554 } else {
3555 // No filters
3556 $total_records = $wpdb->get_var(
3557 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
3558 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
3559 );
3560 }
3561 $total_pages = ceil($total_records / $per_page);
3562
3563 // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
3564 if (!empty($where_values)) {
3565 $query_args = array_merge($where_values, array($per_page, $offset));
3566 $urls_query = $wpdb->prepare(
3567 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3568 {$where_sql}
3569 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3570 ...$query_args
3571 );
3572 } else if (!empty($where_sql)) {
3573 $urls_query = $wpdb->prepare(
3574 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3575 {$where_sql}
3576 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3577 $per_page, $offset
3578 );
3579 } else {
3580 $urls_query = $wpdb->prepare(
3581 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
3582 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
3583 $per_page, $offset
3584 );
3585 }
3586 $page_urls = $wpdb->get_results($urls_query);
3587
3588 // Step 2: Build list of source_urls to fetch
3589 $url_list = array();
3590 $url_order_map = array();
3591 $order_index = 0;
3592 foreach ($page_urls as $url_row) {
3593 $url = $url_row->source_url;
3594 $url_list[] = $url;
3595 $url_order_map[$url] = $order_index++;
3596 }
3597
3598 // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
3599 $prompts = array();
3600 if (!empty($url_list)) {
3601 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
3602 if ($search_query) {
3603 // Include search filter in the final fetch
3604 $prompts_query = $wpdb->prepare(
3605 "SELECT id, article_content, source_url, timestamp, role_restriction
3606 FROM {$table_name}
3607 WHERE source_url IN ($placeholders) AND article_content LIKE %s
3608 ORDER BY timestamp DESC",
3609 ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
3610 );
3611 } else {
3612 $prompts_query = $wpdb->prepare(
3613 "SELECT id, article_content, source_url, timestamp, role_restriction
3614 FROM {$table_name}
3615 WHERE source_url IN ($placeholders)
3616 ORDER BY timestamp DESC",
3617 $url_list
3618 );
3619 }
3620 $prompts = $wpdb->get_results($prompts_query);
3621 }
3622
3623 // Group prompts by source_url for chunk display
3624 $grouped_prompts = array();
3625 foreach ($prompts as $prompt) {
3626 $source_url = $prompt->source_url ?? '';
3627
3628 // Parse chunk metadata using the proper chunker method (same as initial page load)
3629 if (class_exists('MxChat_Chunker')) {
3630 $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
3631 $prompt->chunk_metadata = $chunk_meta['metadata'];
3632 $prompt->display_content = $chunk_meta['text'];
3633 } else {
3634 $prompt->chunk_metadata = array();
3635 $prompt->display_content = $prompt->article_content;
3636 }
3637
3638 if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
3639 if (!isset($grouped_prompts[$source_url])) {
3640 $grouped_prompts[$source_url] = array();
3641 }
3642 $grouped_prompts[$source_url][] = $prompt;
3643 } else {
3644 // Ungrouped entries
3645 $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
3646 }
3647 }
3648
3649 // Sort groups by the original URL order (newest first)
3650 uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
3651 $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
3652 $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
3653 return $order_a - $order_b;
3654 });
3655
3656 // Sort each group internally by chunk_index
3657 foreach ($grouped_prompts as $source_url => &$group) {
3658 usort($group, function($a, $b) {
3659 $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3660 $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3661 return $index_a - $index_b;
3662 });
3663 }
3664 unset($group);
3665
3666 // Build HTML for the table rows
3667 ob_start();
3668 $display_index = 0;
3669 $current_page = $page;
3670 $data_source = 'wordpress';
3671 $current_bot_id = $bot_id;
3672 $preview_length = 150;
3673
3674 if (empty($grouped_prompts)) {
3675 echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3676 esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
3677 echo '</td></tr>';
3678 } else {
3679 foreach ($grouped_prompts as $source_url => $group) {
3680 $chunk_count = count($group);
3681 $first_prompt = $group[0];
3682 $display_index++;
3683
3684 if ($chunk_count > 1) {
3685 // Multiple chunks - show grouped row with expand button
3686 $group_id = 'group-' . md5($source_url);
3687 ?>
3688 <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3689 class="mxchat-chunk-group-header"
3690 data-source="<?php echo esc_attr($data_source); ?>"
3691 data-group-id="<?php echo esc_attr($group_id); ?>"
3692 style="border-bottom: 1px solid var(--mxch-card-border);">
3693 <td style="padding: 12px 16px; text-align: center;">
3694 <input type="checkbox"
3695 class="mxchat-entry-checkbox"
3696 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3697 data-source="<?php echo esc_attr($data_source); ?>"
3698 data-source-url="<?php echo esc_attr($source_url); ?>"
3699 data-is-group="true"
3700 data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3701 </td>
3702 <td style="padding: 12px 16px; font-size: 13px;">
3703 <?php echo esc_html($first_prompt->id); ?>
3704 </td>
3705 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3706 <div class="mxchat-chunk-group-info">
3707 <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3708 <span class="dashicons dashicons-arrow-right-alt2"></span>
3709 </button>
3710 <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3711 <span class="mxchat-chunk-preview">
3712 <?php
3713 $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
3714 $content_preview = mb_substr($parent_content, 0, 100);
3715 echo esc_html($content_preview . '...');
3716 ?>
3717 </span>
3718 </div>
3719 </td>
3720 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3721 <?php echo self::mxchat_source_cell_html($source_url, $current_bot_id, true); ?>
3722 </td>
3723 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3724 <?php if ($data_source !== 'pinecone') : ?>
3725 <button type="button"
3726 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3727 data-source-url="<?php echo esc_attr($source_url); ?>"
3728 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3729 data-data-source="<?php echo esc_attr($data_source); ?>"
3730 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3731 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3732 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3733 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3734 </button>
3735 <?php endif; ?>
3736 <button type="button"
3737 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3738 data-source-url="<?php echo esc_attr($source_url); ?>"
3739 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3740 data-data-source="<?php echo esc_attr($data_source); ?>"
3741 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3742 data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3743 style="color: var(--mxch-error);"
3744 title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3745 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3746 </button>
3747 </td>
3748 </tr>
3749 <?php
3750 // Render hidden chunk rows
3751 foreach ($group as $chunk_index => $chunk) {
3752 $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3753 $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3754 $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
3755 $content_preview = mb_strlen($content) > $preview_length
3756 ? mb_substr($content, 0, $preview_length) . '...'
3757 : $content;
3758 ?>
3759 <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3760 class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3761 data-source="<?php echo esc_attr($data_source); ?>"
3762 style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3763 <td style="padding: 12px 16px; text-align: center;">
3764 <!-- Checkbox column placeholder for chunks (managed by group) -->
3765 </td>
3766 <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3767 <!-- Hidden ID column for chunks -->
3768 </td>
3769 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3770 <div class="mxchat-accordion-wrapper">
3771 <div class="mxchat-content-preview">
3772 <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3773 <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3774 </span>
3775 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3776 <?php if (mb_strlen($content) > $preview_length) : ?>
3777 <button class="mxchat-expand-toggle" type="button">
3778 <span class="dashicons dashicons-arrow-down-alt2"></span>
3779 </button>
3780 <?php endif; ?>
3781 </div>
3782 <div class="mxchat-content-full" style="display: none;">
3783 <div class="content-view">
3784 <?php
3785 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3786 echo '<div dir="rtl" lang="he" class="rtl-content">';
3787 echo wp_kses_post(wpautop($content));
3788 echo '</div>';
3789 } else {
3790 echo wp_kses_post(wpautop($content));
3791 }
3792 ?>
3793 </div>
3794 </div>
3795 </div>
3796 </td>
3797 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3798 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3799 </td>
3800 <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3801 <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3802 </td>
3803 </tr>
3804 <?php
3805 }
3806 } else {
3807 // Single entry - display normally with accordion
3808 $prompt = $first_prompt;
3809 $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
3810 $content_preview = mb_strlen($content) > $preview_length
3811 ? mb_substr($content, 0, $preview_length) . '...'
3812 : $content;
3813 ?>
3814 <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3815 data-source="<?php echo esc_attr($data_source); ?>"
3816 style="border-bottom: 1px solid var(--mxch-card-border);">
3817 <td style="padding: 12px 16px; text-align: center;">
3818 <input type="checkbox"
3819 class="mxchat-entry-checkbox"
3820 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3821 data-source="<?php echo esc_attr($data_source); ?>"
3822 data-source-url="<?php echo esc_attr($source_url); ?>"
3823 data-is-group="false">
3824 </td>
3825 <td style="padding: 12px 16px; font-size: 13px;">
3826 <?php echo esc_html($prompt->id); ?>
3827 </td>
3828 <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3829 <div class="mxchat-accordion-wrapper">
3830 <div class="mxchat-content-preview">
3831 <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3832 <?php if (mb_strlen($content) > $preview_length) : ?>
3833 <button class="mxchat-expand-toggle" type="button">
3834 <span class="dashicons dashicons-arrow-down-alt2"></span>
3835 </button>
3836 <?php endif; ?>
3837 </div>
3838 <div class="mxchat-content-full" style="display: none;">
3839 <div class="content-view">
3840 <?php
3841 if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3842 echo '<div dir="rtl" lang="he" class="rtl-content">';
3843 echo wp_kses_post(wpautop($content));
3844 echo '</div>';
3845 } else {
3846 echo wp_kses_post(wpautop($content));
3847 }
3848 ?>
3849 </div>
3850 </div>
3851 </div>
3852 </td>
3853 <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3854 <?php
3855 $actual_source = $source_url;
3856 if (strpos($source_url, '_ungrouped_') === 0) {
3857 $actual_source = $prompt->source_url ?? '';
3858 }
3859 echo self::mxchat_source_cell_html($actual_source, $current_bot_id, false);
3860 ?>
3861 </td>
3862 <td style="padding: 12px 16px; white-space: nowrap;">
3863 <button type="button"
3864 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3865 data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3866 data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3867 data-data-source="<?php echo esc_attr($data_source); ?>"
3868 data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3869 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3870 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3871 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3872 </button>
3873 <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);">
3874 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3875 </button>
3876 </td>
3877 </tr>
3878 <?php
3879 }
3880 }
3881 }
3882 $html = ob_get_clean();
3883
3884 // Generate pagination HTML (include search/filter data for subsequent pages)
3885 $pagination_html = '';
3886 if ($total_pages > 1) {
3887 $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) . '">';
3888
3889 // Previous button
3890 if ($page > 1) {
3891 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3892 }
3893
3894 // Page numbers
3895 $start_page = max(1, $page - 2);
3896 $end_page = min($total_pages, $page + 2);
3897
3898 if ($start_page > 1) {
3899 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3900 if ($start_page > 2) {
3901 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3902 }
3903 }
3904
3905 for ($i = $start_page; $i <= $end_page; $i++) {
3906 if ($i == $page) {
3907 $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3908 } else {
3909 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3910 }
3911 }
3912
3913 if ($end_page < $total_pages) {
3914 if ($end_page < $total_pages - 1) {
3915 $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3916 }
3917 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3918 }
3919
3920 // Next button
3921 if ($page < $total_pages) {
3922 $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3923 }
3924
3925 $pagination_html .= '</div>';
3926 }
3927
3928 wp_send_json_success(array(
3929 'html' => $html,
3930 'pagination_html' => $pagination_html,
3931 'total_count' => $total_records,
3932 'total_pages' => $total_pages,
3933 'page' => $page,
3934 'per_page' => $per_page,
3935 'data_source' => 'wordpress'
3936 ));
3937 }
3938
3939 /**
3940 * AJAX handler to detect available sitemaps on the site
3941 * Optimized for speed - only checks primary sitemap indexes first
3942 */
3943 public function ajax_mxchat_detect_sitemaps() {
3944 check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3945
3946 if (!current_user_can('manage_options')) {
3947 wp_send_json_error(array('message' => 'Unauthorized'));
3948 return;
3949 }
3950
3951 $site_url = get_site_url();
3952 $sitemaps = array();
3953 $found_index = false;
3954
3955 // Only check the main sitemap index files first (much faster)
3956 // These are the primary entry points that contain sub-sitemaps
3957 $primary_indexes = array(
3958 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3959 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3960 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3961 );
3962
3963 foreach ($primary_indexes as $path => $source) {
3964 $url = trailingslashit($site_url) . $path;
3965
3966 $response = wp_remote_head($url, array(
3967 'timeout' => 10,
3968 'sslverify' => false,
3969 'redirection' => 1,
3970 'user-agent' => mxchat_ingest_user_agent(),
3971 ));
3972
3973 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3974 // Found a sitemap index - parse it to get sub-sitemaps
3975 $sub_sitemaps = $this->parse_sitemap_index($url);
3976 if (!empty($sub_sitemaps)) {
3977 $sitemaps[] = array(
3978 'url' => $url,
3979 'type' => 'index',
3980 'source' => $source,
3981 'sub_sitemaps' => $sub_sitemaps
3982 );
3983 $found_index = true;
3984 // Found a valid index, no need to check others
3985 break;
3986 }
3987 }
3988 }
3989
3990 // If no sitemap index found, check for standalone sitemaps
3991 if (!$found_index) {
3992 $standalone_sitemaps = array(
3993 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3994 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3995 );
3996
3997 foreach ($standalone_sitemaps as $path => $info) {
3998 $url = trailingslashit($site_url) . $path;
3999
4000 $response = wp_remote_head($url, array(
4001 'timeout' => 2,
4002 'sslverify' => false
4003 ));
4004
4005 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4006 $sitemaps[] = array(
4007 'url' => $url,
4008 'type' => $info['type'],
4009 'source' => $info['source'],
4010 'url_count' => 0 // Skip URL count for speed
4011 );
4012 }
4013 }
4014 }
4015
4016 wp_send_json_success(array(
4017 'sitemaps' => $sitemaps,
4018 'site_url' => $site_url
4019 ));
4020 }
4021
4022 /**
4023 * Parse a sitemap index to get sub-sitemaps
4024 * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
4025 */
4026 private function parse_sitemap_index($url) {
4027 $sub_sitemaps = array();
4028
4029 $response = wp_remote_get($url, array(
4030 'timeout' => 30,
4031 'sslverify' => false,
4032 'user-agent' => mxchat_ingest_user_agent(),
4033 'headers' => array(
4034 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
4035 ),
4036 ));
4037
4038 if (is_wp_error($response)) {
4039 return $sub_sitemaps;
4040 }
4041
4042 $body = wp_remote_retrieve_body($response);
4043 if (empty($body)) {
4044 return $sub_sitemaps;
4045 }
4046
4047 // Suppress XML errors
4048 libxml_use_internal_errors(true);
4049 $xml = simplexml_load_string($body);
4050 libxml_clear_errors();
4051
4052 if ($xml === false) {
4053 return $sub_sitemaps;
4054 }
4055
4056 // Check if it's a sitemap index (contains <sitemap> elements)
4057 if (isset($xml->sitemap)) {
4058 foreach ($xml->sitemap as $sitemap) {
4059 $loc = (string) $sitemap->loc;
4060 if (!empty($loc)) {
4061 // Try to determine the type from the URL
4062 $type = 'content';
4063 if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
4064 $type = 'taxonomy';
4065 } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
4066 $type = 'author';
4067 }
4068
4069 // Skip URL count - too slow to fetch for each sitemap
4070 $sub_sitemaps[] = array(
4071 'url' => $loc,
4072 'type' => $type,
4073 'url_count' => 0, // Don't fetch - takes too long
4074 'name' => basename(parse_url($loc, PHP_URL_PATH))
4075 );
4076 }
4077 }
4078 }
4079
4080 return $sub_sitemaps;
4081 }
4082
4083 /**
4084 * Get URL count from a sitemap
4085 */
4086 private function get_sitemap_url_count($url) {
4087 $response = wp_remote_get($url, array(
4088 'timeout' => 30,
4089 'sslverify' => false,
4090 'user-agent' => mxchat_ingest_user_agent(),
4091 'headers' => array(
4092 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
4093 ),
4094 ));
4095
4096 if (is_wp_error($response)) {
4097 return 0;
4098 }
4099
4100 $body = wp_remote_retrieve_body($response);
4101 if (empty($body)) {
4102 return 0;
4103 }
4104
4105 // Count <url> or <loc> elements
4106 $count = preg_match_all('/<url>/i', $body, $matches);
4107 return $count ?: 0;
4108 }
4109
4110 /**
4111 * Get sitemaps declared in robots.txt
4112 */
4113 private function get_sitemaps_from_robots($site_url) {
4114 $sitemaps = array();
4115 $robots_url = trailingslashit($site_url) . 'robots.txt';
4116
4117 $response = wp_remote_get($robots_url, array(
4118 'timeout' => 15,
4119 'sslverify' => false,
4120 'user-agent' => mxchat_ingest_user_agent(),
4121 ));
4122
4123 if (is_wp_error($response)) {
4124 return $sitemaps;
4125 }
4126
4127 $body = wp_remote_retrieve_body($response);
4128 if (empty($body)) {
4129 return $sitemaps;
4130 }
4131
4132 // Find Sitemap: declarations
4133 if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
4134 foreach ($matches[1] as $sitemap_url) {
4135 $sitemap_url = trim($sitemap_url);
4136 if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
4137 $sitemaps[] = $sitemap_url;
4138 }
4139 }
4140 }
4141
4142 return $sitemaps;
4143 }
4144
4145 public function mxchat_stop_processing() {
4146 // Verify permissions
4147 if (!current_user_can('manage_options')) {
4148 wp_die(esc_html__('Unauthorized access', 'mxchat'));
4149 }
4150
4151 // Verify nonce
4152 check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
4153
4154 global $wpdb;
4155 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
4156
4157 // Get active queue IDs
4158 $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
4159 $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
4160
4161 // Delete all pending items from active queues
4162 if ($sitemap_queue_id) {
4163 $wpdb->delete(
4164 $table_name,
4165 array(
4166 'queue_id' => $sitemap_queue_id,
4167 'status' => 'pending'
4168 ),
4169 array('%s', '%s')
4170 );
4171
4172 delete_transient('mxchat_active_queue_sitemap');
4173 delete_transient('mxchat_last_sitemap_url');
4174 }
4175
4176 if ($pdf_queue_id) {
4177 // Get PDF path before deleting
4178 $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
4179
4180 $wpdb->delete(
4181 $table_name,
4182 array(
4183 'queue_id' => $pdf_queue_id,
4184 'status' => 'pending'
4185 ),
4186 array('%s', '%s')
4187 );
4188
4189 // Delete PDF file
4190 if ($pdf_path && file_exists($pdf_path)) {
4191 wp_delete_file($pdf_path);
4192 }
4193
4194 delete_transient('mxchat_active_queue_pdf');
4195 delete_transient('mxchat_last_pdf_url');
4196 }
4197
4198 // Redirect back with a success message
4199 set_transient('mxchat_admin_notice_success',
4200 esc_html__('Processing has been stopped successfully.', 'mxchat'),
4201 30
4202 );
4203 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
4204 exit;
4205 }
4206
4207 /**
4208 * Get content list for processing
4209 */
4210 public function ajax_mxchat_get_content_list() {
4211 // Verify the nonce
4212 check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
4213
4214 if (!current_user_can('manage_options')) {
4215 wp_send_json_error(__('Unauthorized access', 'mxchat'));
4216 }
4217
4218 $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
4219 $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
4220 $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
4221 $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
4222 $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
4223 $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
4224
4225 // Build query args
4226 $args = array(
4227 'posts_per_page' => $per_page,
4228 'paged' => $page,
4229 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
4230 'orderby' => 'date',
4231 'order' => 'DESC',
4232 );
4233
4234 // Supported media types, and the one-shot WHERE filter that scopes the MIME gate
4235 // to attachment rows when this query spans several post types (plan 01010a).
4236 $attachment_mimes = self::mxchat_supported_attachment_mime_types();
4237 $attachment_where = null;
4238
4239 // Handle post types - IMPROVED VERSION
4240 if ($post_type !== 'all') {
4241 $args['post_type'] = $post_type;
4242
4243 // Media is offered by the dropdown above (attachment is a public post type)
4244 // but could never return a row: attachments are stored with post_status
4245 // 'inherit' and this query asks for publish/draft/pending, so the list was
4246 // empty by construction. Force the status attachments actually use, and
4247 // offer only the file types we can extract text from (plan 01010a).
4248 if ($post_type === 'attachment') {
4249 $args['post_status'] = 'inherit';
4250 $args['post_mime_type'] = $attachment_mimes;
4251 }
4252 } else {
4253 // Get all available post types that might contain content
4254 $all_post_types = array();
4255
4256 // First get all public post types
4257 $public_types = get_post_types(array('public' => true), 'names');
4258 $all_post_types = array_merge($all_post_types, $public_types);
4259
4260 // Add common forum/community post types
4261 $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
4262 foreach ($forum_types as $forum_type) {
4263 if (post_type_exists($forum_type)) {
4264 $all_post_types[] = $forum_type;
4265 }
4266 }
4267
4268 // Add other commonly used post types
4269 $common_types = array('product', 'job_listing', 'event', 'portfolio');
4270 foreach ($common_types as $common_type) {
4271 if (post_type_exists($common_type)) {
4272 $all_post_types[] = $common_type;
4273 }
4274 }
4275
4276 // Remove duplicates and ensure we have at least some post types
4277 $all_post_types = array_unique($all_post_types);
4278
4279 if (empty($all_post_types)) {
4280 // Fallback to basic post types
4281 $all_post_types = array('post', 'page');
4282 }
4283
4284 // Attachments in the "All Content Types" view (plan 01010a). Two things have
4285 // to happen at once and neither can ride $args on its own:
4286 //
4287 // - 'inherit' has to join the status list, because WP_Query takes ONE status
4288 // set for the whole query. Only attachments and revisions use it, and
4289 // revisions are not in $all_post_types, so nothing else widens.
4290 // - the MIME gate CANNOT be $args['post_mime_type'] here — that clause is
4291 // applied to every row, and an ordinary post has an empty post_mime_type,
4292 // so it would empty the entire listing rather than filter the media half.
4293 // A scoped WHERE is the only shape that filters attachments alone.
4294 //
4295 // Media joins this view under the default Published filter and under All
4296 // Statuses; picking Drafts deliberately drops it, because a file has no draft
4297 // state and answering a draft filter with published media is a wrong answer.
4298 $wants_attachments = in_array('attachment', $all_post_types, true)
4299 && ($post_status === 'publish' || $post_status === 'all');
4300
4301 if ($wants_attachments) {
4302 $statuses = array_values(array_unique(array_merge((array) $args['post_status'], array('inherit'))));
4303 $args['post_status'] = $statuses;
4304
4305 global $wpdb;
4306 $mime_placeholders = implode(', ', array_fill(0, count($attachment_mimes), '%s'));
4307 $mime_clause = $wpdb->prepare(
4308 " AND ({$wpdb->posts}.post_type <> 'attachment' OR {$wpdb->posts}.post_mime_type IN ($mime_placeholders))",
4309 $attachment_mimes
4310 );
4311 $attachment_where = function ($where) use ($mime_clause) {
4312 return $where . $mime_clause;
4313 };
4314 add_filter('posts_where', $attachment_where);
4315 } else {
4316 $all_post_types = array_values(array_diff($all_post_types, array('attachment')));
4317 }
4318
4319 $args['post_type'] = $all_post_types;
4320
4321 // Debug logging to see what post types are being queried
4322 //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
4323 }
4324
4325 if (!empty($search)) {
4326 $args['s'] = $search;
4327 }
4328
4329 // Get processed data from storage
4330 $processed_data = array();
4331
4332 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4333 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4334
4335 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4336 // Get fresh data from Pinecone - no caching
4337 $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
4338 } else {
4339 // WordPress DB checking with better URL matching for all post types
4340 global $wpdb;
4341 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4342 $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
4343
4344 // Group items by source_url to count chunks
4345 $url_chunk_counts = array();
4346 $url_latest_timestamp = array();
4347 $url_first_id = array();
4348
4349 if (!empty($processed_items)) {
4350 foreach ($processed_items as $item) {
4351 $url = $item->source_url;
4352 if (empty($url)) continue;
4353
4354 // Count chunks per URL
4355 if (!isset($url_chunk_counts[$url])) {
4356 $url_chunk_counts[$url] = 0;
4357 $url_latest_timestamp[$url] = $item->timestamp;
4358 $url_first_id[$url] = $item->id;
4359 }
4360 $url_chunk_counts[$url]++;
4361
4362 // Track latest timestamp
4363 if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
4364 $url_latest_timestamp[$url] = $item->timestamp;
4365 }
4366 }
4367
4368 // Now build processed_data with chunk counts
4369 foreach ($url_chunk_counts as $url => $chunk_count) {
4370 $post_id = $this->mxchat_url_to_post_id_improved($url);
4371
4372 if ($post_id) {
4373 $processed_data[$post_id] = array(
4374 'db_id' => $url_first_id[$url],
4375 'timestamp' => $url_latest_timestamp[$url],
4376 'url' => $url,
4377 'source' => 'wordpress',
4378 'chunk_count' => $chunk_count
4379 );
4380 }
4381 }
4382 }
4383 }
4384
4385 // Get processed IDs as a simple array for in_array checks
4386 $processed_ids = array_keys($processed_data);
4387
4388 // Handle processed/unprocessed filter
4389 if ($processed_filter === 'processed' && !empty($processed_ids)) {
4390 $args['post__in'] = $processed_ids;
4391 } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
4392 $args['post__not_in'] = $processed_ids;
4393 }
4394
4395 // Run the query
4396 $query = new WP_Query($args);
4397
4398 // Scoped to this one query — leaving it attached would MIME-filter every later
4399 // WP_Query in the request.
4400 if ($attachment_where) {
4401 remove_filter('posts_where', $attachment_where);
4402 }
4403
4404 $content_items = array();
4405
4406 if ($query->have_posts()) {
4407 while ($query->have_posts()) {
4408 $query->the_post();
4409 $id = get_the_ID();
4410 $post_date = get_the_date();
4411 $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
4412 $word_count = str_word_count(strip_tags(get_the_content()));
4413
4414 $is_processed = in_array($id, $processed_ids);
4415 $processed_date = '';
4416 $db_record_id = 0;
4417 $data_source = 'none';
4418
4419 if ($is_processed && isset($processed_data[$id])) {
4420 $item_data = $processed_data[$id];
4421 $data_source = $item_data['source'];
4422
4423 if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
4424 // WordPress DB format
4425 $timestamp = strtotime($item_data['timestamp']);
4426 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4427 $db_record_id = $item_data['db_id'];
4428 } elseif ($data_source === 'pinecone') {
4429 // Pinecone format
4430 $processed_date = $item_data['processed_date'];
4431 $db_record_id = $item_data['db_id'];
4432 }
4433 }
4434
4435 // Get chunk count for this item
4436 $chunk_count = 0;
4437 if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
4438 $chunk_count = intval($processed_data[$id]['chunk_count']);
4439 }
4440
4441 // Media rows describe themselves differently (plan 01010a): the link has
4442 // to be the FILE, not the attachment page nobody wants; "attachment" and
4443 // "0 words" tell the user nothing, so the row carries the file type and
4444 // its size instead. Word count is deliberately NOT computed — that would
4445 // mean parsing every PDF in the library just to render a list.
4446 $permalink = get_permalink();
4447 $type_label = get_post_type();
4448 $size_label = '';
4449
4450 if (get_post_type() === 'attachment') {
4451 $file_url = wp_get_attachment_url($id);
4452 $permalink = $file_url ? $file_url : $permalink;
4453 $type_label = self::mxchat_attachment_type_label(get_post_mime_type($id));
4454
4455 $file_path = get_attached_file($id);
4456 $file_size = ($file_path && file_exists($file_path)) ? @filesize($file_path) : false;
4457 if ($file_size !== false) {
4458 $size_label = size_format($file_size, 0);
4459 }
4460
4461 if ($excerpt === '') {
4462 $excerpt = $file_url ? wp_basename($file_url) : '';
4463 }
4464 }
4465
4466 $content_items[] = array(
4467 'id' => $id,
4468 'title' => get_the_title(),
4469 'permalink' => $permalink,
4470 'date' => $post_date,
4471 'type' => get_post_type(),
4472 'type_label' => $type_label,
4473 'size_label' => $size_label,
4474 'status' => get_post_status(),
4475 'excerpt' => $excerpt,
4476 'word_count' => $word_count,
4477 'already_processed' => $is_processed,
4478 'processed_date' => $processed_date,
4479 'db_record_id' => $db_record_id,
4480 'data_source' => $data_source,
4481 'chunk_count' => $chunk_count
4482 );
4483 }
4484 wp_reset_postdata();
4485 }
4486
4487 $response = array(
4488 'items' => $content_items,
4489 'total' => $query->found_posts,
4490 'total_pages' => $query->max_num_pages,
4491 'current_page' => $page,
4492 'processed_count' => count($processed_ids)
4493 );
4494
4495 wp_send_json_success($response);
4496 exit;
4497 }
4498
4499
4500 /**
4501 * This function handles various WooCommerce URL formats and permalink structures
4502 */
4503 private function mxchat_url_to_post_id_improved($url) {
4504 // First try the standard WordPress function
4505 $post_id = url_to_postid($url);
4506
4507 if ($post_id > 0) {
4508 return $post_id;
4509 }
4510
4511 // Media entries are stored under the FILE url (…/wp-content/uploads/…), which is
4512 // not a permalink — url_to_postid() never resolves one, and every slug fallback
4513 // below excludes 'attachment' explicitly. Without this, an imported media file
4514 // renders as "Not In Knowledge Base" forever and re-imports as new every time
4515 // (plan 01010a). attachment_url_to_postid() matches on _wp_attached_file and
4516 // returns 0 for anything outside the uploads directory, so post permalinks are
4517 // unaffected.
4518 $attachment_id = (int) attachment_url_to_postid($url);
4519 if ($attachment_id > 0) {
4520 return $attachment_id;
4521 }
4522
4523 // If that fails, try more aggressive URL matching
4524 // Remove trailing slashes and query parameters for better matching
4525 $clean_url = rtrim($url, '/');
4526 $clean_url = strtok($clean_url, '?'); // Remove query parameters
4527
4528 // Try again with cleaned URL
4529 $post_id = url_to_postid($clean_url);
4530 if ($post_id > 0) {
4531 return $post_id;
4532 }
4533
4534 // For bbPress forum topics, try extracting slug from URL
4535 if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
4536 // Handle bbPress URLs: /forums/topic/topic-name/
4537 if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
4538 $topic_slug = $matches[1];
4539
4540 // Look up topic by slug
4541 $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
4542 if ($topic) {
4543 return $topic->ID;
4544 }
4545
4546 // Alternative method: query by post_name
4547 global $wpdb;
4548 $post_id = $wpdb->get_var($wpdb->prepare(
4549 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4550 $topic_slug
4551 ));
4552
4553 if ($post_id) {
4554 return intval($post_id);
4555 }
4556 }
4557
4558 // Handle simpler topic URLs: /topic/topic-name/
4559 if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
4560 $topic_slug = $matches[1];
4561
4562 global $wpdb;
4563 $post_id = $wpdb->get_var($wpdb->prepare(
4564 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
4565 $topic_slug
4566 ));
4567
4568 if ($post_id) {
4569 return intval($post_id);
4570 }
4571 }
4572 }
4573
4574 // For WooCommerce products
4575 if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
4576 // Extract product slug from various URL formats
4577 $product_slug = '';
4578
4579 // Handle pretty permalinks: /product/product-name/
4580 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
4581 $product_slug = $matches[1];
4582 }
4583 // Handle query parameters: ?product=product-name
4584 elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
4585 $product_slug = $matches[1];
4586 }
4587
4588 if (!empty($product_slug)) {
4589 // Look up product by slug
4590 $product = get_page_by_path($product_slug, OBJECT, 'product');
4591 if ($product) {
4592 return $product->ID;
4593 }
4594
4595 // Alternative method: query by post_name
4596 global $wpdb;
4597 $post_id = $wpdb->get_var($wpdb->prepare(
4598 "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
4599 $product_slug
4600 ));
4601
4602 if ($post_id) {
4603 return intval($post_id);
4604 }
4605 }
4606 }
4607
4608 // Generic approach: try to extract slug and match against all post types
4609 $parsed_url = wp_parse_url($clean_url);
4610 $path = $parsed_url['path'] ?? '';
4611
4612 if (!empty($path)) {
4613 // Get the last part of the path as potential slug
4614 $path_parts = array_filter(explode('/', trim($path, '/')));
4615 $potential_slug = end($path_parts);
4616
4617 if (!empty($potential_slug)) {
4618 global $wpdb;
4619
4620 // Try to find any post with this slug
4621 $post_id = $wpdb->get_var($wpdb->prepare(
4622 "SELECT ID FROM {$wpdb->posts}
4623 WHERE post_name = %s
4624 AND post_status IN ('publish', 'closed', 'private')
4625 AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
4626 ORDER BY CASE
4627 WHEN post_type = 'post' THEN 1
4628 WHEN post_type = 'page' THEN 2
4629 WHEN post_type = 'topic' THEN 3
4630 WHEN post_type = 'product' THEN 4
4631 ELSE 5
4632 END
4633 LIMIT 1",
4634 $potential_slug
4635 ));
4636
4637 if ($post_id) {
4638 return intval($post_id);
4639 }
4640 }
4641 }
4642
4643 // ADDITIONAL: Try direct database lookup by URL variations
4644 global $wpdb;
4645 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4646
4647 // Try variations of the URL (with/without trailing slash, http/https)
4648 $url_variations = array(
4649 $url,
4650 rtrim($url, '/'),
4651 $url . '/',
4652 str_replace('http://', 'https://', $url),
4653 str_replace('https://', 'http://', $url),
4654 str_replace('http://', 'https://', rtrim($url, '/')),
4655 str_replace('https://', 'http://', rtrim($url, '/'))
4656 );
4657
4658 // Remove duplicates
4659 $url_variations = array_unique($url_variations);
4660
4661 foreach ($url_variations as $variation) {
4662 $existing_record = $wpdb->get_row($wpdb->prepare(
4663 "SELECT id, source_url FROM $table_name WHERE source_url = %s",
4664 $variation
4665 ));
4666
4667 if ($existing_record) {
4668 // Try to get post ID from this stored URL
4669 $stored_post_id = url_to_postid($existing_record->source_url);
4670 if ($stored_post_id > 0) {
4671 return $stored_post_id;
4672 }
4673 }
4674 }
4675
4676 return 0; // No match found
4677 }
4678 /**
4679 * Process selected content via AJAX
4680 */
4681 public function ajax_mxchat_process_selected_content() {
4682 // Basic request validation
4683 if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
4684 wp_send_json_error('Invalid nonce');
4685 exit;
4686 }
4687
4688 if (!current_user_can('manage_options')) {
4689 wp_send_json_error('Unauthorized access');
4690 exit;
4691 }
4692
4693 // Get post IDs - safely parse the array
4694 $post_ids = array();
4695 if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
4696 foreach ($_POST['post_ids'] as $id) {
4697 $post_ids[] = absint($id);
4698 }
4699 }
4700
4701 if (empty($post_ids)) {
4702 wp_send_json_error('No content selected');
4703 exit;
4704 }
4705
4706 // Get bot_id from request
4707 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
4708
4709 // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
4710 // plan 11720c). The import modal shows a passive status line pointing
4711 // there; the old per-batch checkbox and its remembered default are gone.
4712 $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
4713
4714 // Process only ONE post at a time to avoid request size issues
4715 $post_id = reset($post_ids);
4716 $post = get_post($post_id);
4717
4718 if (!$post) {
4719 wp_send_json_error('Post not found');
4720 exit;
4721 }
4722
4723 // Media-library attachments take their own route (plan 01010a) — different source
4724 // URL (the file, not the attachment page), different body (an extractor, not
4725 // post_content), and a file that yields no text is refused with a reason instead
4726 // of stored empty. Sends its own response and exits.
4727 if ($post->post_type === 'attachment') {
4728 $this->mxchat_import_attachment($post, $bot_id);
4729 }
4730
4731 /**
4732 * Allow developers to modify post data before processing into the knowledge base.
4733 * Applied on BOTH content-preparation paths (this manual bulk import and the
4734 * auto-sync path in mxchat_handle_post_update) with the same signature, so a
4735 * callback registered once covers every indexing route. Purely additive —
4736 * zero behaviour change when unhooked.
4737 *
4738 * @param WP_Post $post The post about to be indexed.
4739 * @param string $bot_id Bot context for this import.
4740 */
4741 $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
4742 if (!($post instanceof WP_Post)) {
4743 $post = get_post($post_id); // defend against a bad callback return
4744 }
4745
4746 // Assemble the indexable text via the shared post-kind assembler (a3d60c).
4747 // Bulk import reads raw post fields, has always included product custom tabs,
4748 // and passes the install-level ACF→PDF option.
4749 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
4750 'read_display' => false,
4751 'extract_acf_pdfs' => $extract_acf_pdfs,
4752 'include_product_tabs' => true,
4753 ));
4754 $content = $prepared['content'];
4755 $pdf_extracted_count = $prepared['pdf_extracted_count'];
4756
4757 // Debug logging for WordPress Import content
4758 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4759 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4760 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
4761 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4762
4763 // Note: Removed 10,000 char limit - chunking now handles large content properly
4764
4765 // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
4766 $bot_options = $this->get_bot_options($bot_id);
4767 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4768
4769 $preflight = MxChat_Utils::embedding_preflight($options);
4770 if (!$preflight['ok']) {
4771 MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4772 wp_send_json_error($preflight['reason']);
4773 exit;
4774 }
4775 $api_key = $preflight['api_key'];
4776
4777 $source_url = get_permalink($post_id);
4778 $vector_id = md5($source_url); // Vector ID for Pinecone
4779
4780 // Check for existing content in bot-specific storage
4781 $is_update = false;
4782
4783 // Get bot-specific Pinecone configuration
4784 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4785 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
4786
4787 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
4788 // Check Pinecone for this bot
4789 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
4790 if (isset($pinecone_data[$post_id])) {
4791 $is_update = true;
4792 }
4793 } else {
4794 // Check WordPress DB (same as before since it's shared)
4795 global $wpdb;
4796 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4797 $existing_record = $wpdb->get_row($wpdb->prepare(
4798 "SELECT id FROM $table_name WHERE source_url = %s",
4799 $source_url
4800 ));
4801
4802 if ($existing_record) {
4803 $is_update = true;
4804 }
4805 }
4806
4807 // UPDATED 2.5.6: Determine content type based on post_type
4808 $post_type = $post->post_type;
4809 $content_type = 'content'; // Default fallback
4810
4811 // Map WordPress post types to content types
4812 switch ($post_type) {
4813 case 'post':
4814 $content_type = 'post';
4815 break;
4816 case 'page':
4817 $content_type = 'page';
4818 break;
4819 case 'product':
4820 $content_type = 'product';
4821 break;
4822 default:
4823 // For custom post types, use the post type name
4824 $content_type = sanitize_key($post_type);
4825 break;
4826 }
4827
4828 // Use the centralized utility function with bot_id and content_type
4829 $result = MxChat_Utils::submit_content_to_db(
4830 $content,
4831 $source_url,
4832 $api_key,
4833 $vector_id,
4834 $bot_id,
4835 $content_type
4836 );
4837
4838 if (is_wp_error($result)) {
4839 MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
4840 wp_send_json_error('Storage failed: ' . $result->get_error_message());
4841 exit;
4842 }
4843
4844 // Automatically apply role restriction based on tags
4845 $this->apply_role_restriction_to_post($post_id, $source_url);
4846
4847 $operation_type = $is_update ? 'update' : 'new';
4848
4849 // Count ACF fields for debugging
4850 $acf_field_count = $prepared['acf_fields_found'];
4851
4852 // Success response with minimal data
4853 wp_send_json_success(array(
4854 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4855 'post_id' => $post_id,
4856 'title' => $post->post_title,
4857 'operation_type' => $operation_type,
4858 'vector_id' => $vector_id,
4859 'acf_fields_found' => $acf_field_count,
4860 'pdf_extracted_count' => (int) $pdf_extracted_count,
4861 'content_preview' => substr($content, 0, 100) . '...',
4862 'bot_id' => $bot_id
4863 ));
4864 exit;
4865 }
4866
4867 private function apply_role_restriction_to_post($post_id, $source_url) {
4868 $highest_role = 'public';
4869
4870 // Tag-derived restriction. Tags only exist on posts, which is why pages
4871 // could never be restricted from the Role Restrictions screen (plan 6ab002).
4872 $mappings = get_option('mxchat_tag_role_mappings', array());
4873 if (!empty($mappings)) {
4874 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4875 if (!empty($post_tags)) {
4876 foreach ($post_tags as $tag_slug) {
4877 if (isset($mappings[$tag_slug])) {
4878 $highest_role = self::mxchat_stricter_role($highest_role, $mappings[$tag_slug]);
4879 }
4880 }
4881 }
4882 }
4883
4884 // Per-entry restriction set on the post itself, which works for pages and
4885 // every other public post type. The stricter of the two wins.
4886 $meta_role = get_post_meta($post_id, '_mxchat_kb_role_restriction', true);
4887 if (is_string($meta_role) && $meta_role !== '') {
4888 $highest_role = self::mxchat_stricter_role($highest_role, $meta_role);
4889 }
4890
4891 // Nothing restricts this entry — leave it public.
4892 if ($highest_role === 'public' || $highest_role === '') {
4893 return;
4894 }
4895
4896 // Update the role restriction in the database
4897 global $wpdb;
4898
4899 // Check if using Pinecone
4900 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4901 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4902
4903 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4904 // Update Pinecone role restriction
4905 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4906 $vector_id = md5($source_url);
4907
4908 $wpdb->replace(
4909 $roles_table,
4910 array(
4911 'vector_id' => $vector_id,
4912 'role_restriction' => $highest_role,
4913 'updated_at' => current_time('mysql')
4914 ),
4915 array('%s', '%s', '%s')
4916 );
4917 } else {
4918 // Update WordPress DB
4919 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4920
4921 $wpdb->update(
4922 $table_name,
4923 array('role_restriction' => $highest_role),
4924 array('source_url' => $source_url),
4925 array('%s'),
4926 array('%s')
4927 );
4928 }
4929
4930 // The entry's restriction just changed — keep the OpenAI Vector Store
4931 // mirror consistent: non-public pulls the file (file_search has no
4932 // per-role filtering), public re-mirrors it (plan 15b5c6).
4933 if (class_exists('MxChat_Vectorstore_Manager')) {
4934 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
4935 }
4936 }
4937
4938 public function mxchat_get_public_post_types() {
4939 // Get all public post types
4940 $post_types = get_post_types(array('public' => true), 'objects');
4941 $post_type_options = array();
4942
4943 foreach ($post_types as $post_type) {
4944 $post_type_options[$post_type->name] = $post_type->label;
4945 }
4946
4947 // Also include common forum/community post types that might not be marked as public
4948 $additional_types = array(
4949 'topic' => 'Forum Topics (bbPress)',
4950 'reply' => 'Forum Replies (bbPress)',
4951 'forum' => 'Forums (bbPress)',
4952 'wpforo_topic' => 'wpForo Topics',
4953 'wpforo_post' => 'wpForo Posts'
4954 );
4955
4956 foreach ($additional_types as $type_name => $type_label) {
4957 if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4958 $post_type_options[$type_name] = $type_label;
4959 }
4960 }
4961
4962 return $post_type_options;
4963 }
4964
4965 /**
4966 * Retrieves processed content from Pinecone API
4967 */
4968 public function mxchat_get_pinecone_processed_content($pinecone_options) {
4969 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4970 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4971
4972 if (empty($api_key) || empty($host)) {
4973 return array();
4974 }
4975
4976 $pinecone_data = array();
4977
4978 try {
4979 // Always get fresh data from Pinecone
4980 $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4981
4982 // Method 2: Final fallback - try stats endpoint (if available)
4983 if (empty($pinecone_data)) {
4984 $stats_url = "https://{$host}/describe_index_stats";
4985
4986 $response = wp_remote_post($stats_url, array(
4987 'headers' => array(
4988 'Api-Key' => $api_key,
4989 'Content-Type' => 'application/json'
4990 ),
4991 'body' => json_encode(array()),
4992 'timeout' => 30
4993 ));
4994
4995 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4996 $body = wp_remote_retrieve_body($response);
4997 $stats_data = json_decode($body, true);
4998 }
4999 }
5000
5001 } catch (Exception $e) {
5002 // Log error but return fresh data only
5003 }
5004
5005 return $pinecone_data;
5006 }
5007 public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
5008 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5009 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5010 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5011
5012 if (empty($api_key) || empty($host) || empty($vector_ids)) {
5013 return array();
5014 }
5015
5016 try {
5017 // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
5018 // repeated in the query string (ids=a&ids=b — http_build_query would
5019 // emit ids[0]=a); the old POST here was answered 200-with-an-empty-body,
5020 // which read as "nothing indexed". Chunked at 100 ids to stay well
5021 // under the measured HTTP 414 URL-length boundary.
5022 $vectors = array();
5023 // Document index (plan 362c31): documents/fetch instead of the GET loop (same id => vector shape).
5024 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
5025 $vectors = MxChat_Pinecone_Documents::fetch(array_values($vector_ids), MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
5026 } else
5027 foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
5028 $fetch_query = array();
5029 foreach ($chunk as $fetch_vid) {
5030 $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
5031 }
5032 if (!empty($namespace)) {
5033 $fetch_query[] = 'namespace=' . rawurlencode($namespace);
5034 }
5035
5036 $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
5037 'headers' => array(
5038 'Api-Key' => $api_key,
5039 'accept' => 'application/json'
5040 ),
5041 'timeout' => 30
5042 ));
5043
5044 if (is_wp_error($response)) {
5045 error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
5046 continue;
5047 }
5048
5049 if (wp_remote_retrieve_response_code($response) !== 200) {
5050 error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
5051 continue;
5052 }
5053
5054 $data = json_decode(wp_remote_retrieve_body($response), true);
5055 if (isset($data['vectors']) && is_array($data['vectors'])) {
5056 $vectors += $data['vectors'];
5057 }
5058 }
5059
5060 if (empty($vectors)) {
5061 return array();
5062 }
5063
5064 $processed_data = array();
5065
5066 foreach ($vectors as $vector_id => $vector_data) {
5067 $metadata = $vector_data['metadata'] ?? array();
5068 $source_url = $metadata['source_url'] ?? '';
5069
5070 if (!empty($source_url)) {
5071 $post_id = url_to_postid($source_url);
5072 // A media entry is stored under its FILE url, which url_to_postid() cannot
5073 // resolve — without this the Pinecone read reports every imported
5074 // attachment as unprocessed while the WordPress-DB read (which goes
5075 // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
5076 if (!$post_id) {
5077 $post_id = (int) attachment_url_to_postid($source_url);
5078 }
5079 if ($post_id) {
5080 $created_at = $metadata['created_at'] ?? '';
5081 $processed_date = 'Recently';
5082
5083 if (!empty($created_at)) {
5084 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
5085 if ($timestamp) {
5086 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
5087 }
5088 }
5089
5090 $processed_data[$post_id] = array(
5091 'db_id' => $vector_id,
5092 'processed_date' => $processed_date,
5093 'url' => $source_url,
5094 'source' => 'pinecone',
5095 'timestamp' => $timestamp ?? current_time('timestamp')
5096 );
5097 }
5098 }
5099 }
5100
5101 //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
5102 return $processed_data;
5103
5104 } catch (Exception $e) {
5105 //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
5106 return array();
5107 }
5108 }
5109
5110 /**
5111 * Get embedding dimensions based on the selected model.
5112 */
5113 private function mxchat_get_embedding_dimensions() {
5114 // See MxChat_Pinecone_Manager::mxchat_get_embedding_dimensions() — same answer (plan 418afd).
5115 $options = get_option('mxchat_options', array());
5116 $dimension = MxChat_Utils::expected_embedding_dimension($options);
5117 if (!is_wp_error($dimension) && (int) $dimension > 0) {
5118 return (int) $dimension;
5119 }
5120 return MxChat_Utils::standard_embedding_dimension($options);
5121 }
5122
5123 /**
5124 * Scan Pinecone for processed content
5125 */
5126 public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
5127 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5128 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5129 // plan 793b82: this scan was namespace-blind — on a namespaced setup it
5130 // surveyed the default namespace and reported the wrong content as indexed.
5131 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5132
5133 if (empty($api_key) || empty($host)) {
5134 return array();
5135 }
5136
5137 try {
5138 // Use multiple random vectors to get better coverage
5139 $all_matches = array();
5140 $seen_ids = array();
5141
5142 // Get correct dimensions for the configured embedding model
5143 $dimensions = $this->mxchat_get_embedding_dimensions();
5144
5145 // Try 3 different random vectors to get better coverage
5146 for ($i = 0; $i < 3; $i++) {
5147 $query_url = "https://{$host}/query";
5148
5149 // Generate a random unit vector instead of zeros
5150 $random_vector = array();
5151 for ($j = 0; $j < $dimensions; $j++) {
5152 $random_vector[] = (rand(-1000, 1000) / 1000.0);
5153 }
5154
5155 // Normalize the vector to unit length
5156 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
5157 if ($magnitude > 0) {
5158 $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
5159 }
5160
5161 $query_data = array(
5162 'includeMetadata' => true,
5163 'includeValues' => false,
5164 'topK' => 10000,
5165 'vector' => $random_vector
5166 );
5167
5168 if (!empty($namespace)) {
5169 $query_data['namespace'] = $namespace;
5170 }
5171
5172 // Document index (plan 362c31): same random-vector sweep through documents/search.
5173 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
5174 $doc_hits = MxChat_Pinecone_Documents::search_dense($random_vector, 10000, null, MxChat_Pinecone_Documents::cfg_from_options($pinecone_options), array('source_url', 'created_at'));
5175 foreach ((is_wp_error($doc_hits) ? array() : $doc_hits) as $match) {
5176 $match_id = $match['id'] ?? '';
5177 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5178 $all_matches[] = $match;
5179 $seen_ids[$match_id] = true;
5180 }
5181 }
5182 continue;
5183 }
5184
5185 $response = wp_remote_post($query_url, array(
5186 'headers' => array(
5187 'Api-Key' => $api_key,
5188 'Content-Type' => 'application/json'
5189 ),
5190 'body' => json_encode($query_data),
5191 'timeout' => 30
5192 ));
5193
5194 if (is_wp_error($response)) {
5195 continue;
5196 }
5197
5198 $response_code = wp_remote_retrieve_response_code($response);
5199
5200 if ($response_code !== 200) {
5201 continue;
5202 }
5203
5204 $body = wp_remote_retrieve_body($response);
5205 $data = json_decode($body, true);
5206
5207 if (isset($data['matches'])) {
5208 foreach ($data['matches'] as $match) {
5209 $match_id = $match['id'] ?? '';
5210 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
5211 $all_matches[] = $match;
5212 $seen_ids[$match_id] = true;
5213 }
5214 }
5215 }
5216 }
5217
5218 // Convert matches to processed data format, grouping by URL to count chunks
5219 $processed_data = array();
5220 $url_chunk_counts = array();
5221
5222 foreach ($all_matches as $match) {
5223 $metadata = $match['metadata'] ?? array();
5224 $source_url = $metadata['source_url'] ?? '';
5225 $match_id = $match['id'] ?? '';
5226
5227 if (!empty($source_url) && !empty($match_id)) {
5228 $post_id = url_to_postid($source_url);
5229 // A media entry is stored under its FILE url, which url_to_postid() cannot
5230 // resolve — without this the Pinecone read reports every imported
5231 // attachment as unprocessed while the WordPress-DB read (which goes
5232 // through mxchat_url_to_post_id_improved) reports it correctly (plan 01010a).
5233 if (!$post_id) {
5234 $post_id = (int) attachment_url_to_postid($source_url);
5235 }
5236 if ($post_id) {
5237 // Count chunks per post_id
5238 if (!isset($url_chunk_counts[$post_id])) {
5239 $url_chunk_counts[$post_id] = 0;
5240 }
5241 $url_chunk_counts[$post_id]++;
5242
5243 $created_at = $metadata['created_at'] ?? '';
5244 $processed_date = 'Recently';
5245
5246 if (!empty($created_at)) {
5247 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
5248 if ($timestamp) {
5249 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
5250 }
5251 }
5252
5253 // Only store if not already set, or update with newer timestamp
5254 if (!isset($processed_data[$post_id]) ||
5255 ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
5256 $processed_data[$post_id] = array(
5257 'db_id' => $match_id,
5258 'processed_date' => $processed_date,
5259 'url' => $source_url,
5260 'source' => 'pinecone',
5261 'timestamp' => $timestamp ?? current_time('timestamp')
5262 );
5263 }
5264 }
5265 }
5266 }
5267
5268 // Add chunk counts to processed data
5269 foreach ($url_chunk_counts as $post_id => $chunk_count) {
5270 if (isset($processed_data[$post_id])) {
5271 $processed_data[$post_id]['chunk_count'] = $chunk_count;
5272 }
5273 }
5274
5275 return $processed_data;
5276
5277 } catch (Exception $e) {
5278 return array();
5279 }
5280 }
5281 /**
5282 * Generate embeddings from input text for MXChat with bot support
5283 */
5284 private function mxchat_generate_embedding($text, $bot_id = 'default') {
5285 // Enable detailed logging for debugging
5286 //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
5287 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
5288
5289 // Get bot-specific options
5290 $bot_options = $this->get_bot_options($bot_id);
5291 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
5292
5293 // Opt-in: when the custom provider is selected for embeddings, index through
5294 // the same custom endpoint the query path uses so stored vectors and query
5295 // vectors share a model. Returns the vector array on success, or an error
5296 // string on failure (this function's existing failure contract).
5297 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5298 if (!class_exists('MxChat_Utils')) {
5299 require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
5300 }
5301 return MxChat_Utils::generate_embedding_custom($text, $options);
5302 }
5303
5304 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5305 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
5306
5307 // Determine provider and endpoint
5308 if (strpos($selected_model, 'voyage') === 0) {
5309 $api_key = $options['voyage_api_key'] ?? '';
5310 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5311 $provider_name = 'Voyage AI';
5312 //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
5313 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5314 $api_key = $options['gemini_api_key'] ?? '';
5315 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5316 $provider_name = 'Google Gemini';
5317 //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
5318 } else {
5319 $api_key = $options['api_key'] ?? '';
5320 $endpoint = 'https://api.openai.com/v1/embeddings';
5321 $provider_name = 'OpenAI';
5322 //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
5323 }
5324
5325 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
5326
5327 if (empty($api_key)) {
5328 $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
5329 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
5330 return $error_message;
5331 }
5332
5333 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
5334 $estimated_tokens = ceil(str_word_count($text) / 0.75);
5335 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
5336
5337 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
5338 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
5339 // Consider truncating text here
5340 }
5341
5342 // Prepare request body based on provider
5343 if (strpos($selected_model, 'gemini-embedding') === 0) {
5344 // Gemini API format
5345 $request_body = array(
5346 'model' => 'models/' . $selected_model,
5347 'content' => array(
5348 'parts' => array(
5349 array('text' => $text)
5350 )
5351 )
5352 );
5353
5354 // Set output dimensionality to 1536 for consistency with other models
5355 $request_body['outputDimensionality'] = 1536;
5356 } else {
5357 // OpenAI/Voyage API format
5358 $request_body = array(
5359 'model' => $selected_model,
5360 'input' => $text
5361 );
5362
5363 // Add output_dimension for voyage-3-large model
5364 if ($selected_model === 'voyage-3-large') {
5365 $request_body['output_dimension'] = 2048;
5366 }
5367 }
5368
5369 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
5370
5371 // Prepare headers based on provider
5372 if (strpos($selected_model, 'gemini-embedding') === 0) {
5373 // Gemini uses API key as query parameter
5374 $endpoint .= '?key=' . $api_key;
5375 $headers = array(
5376 'Content-Type' => 'application/json'
5377 );
5378 } else {
5379 // OpenAI/Voyage use Bearer token
5380 $headers = array(
5381 'Authorization' => 'Bearer ' . $api_key,
5382 'Content-Type' => 'application/json'
5383 );
5384 }
5385
5386 // Make API request
5387 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
5388 $response = wp_remote_post($endpoint, array(
5389 'body' => wp_json_encode($request_body),
5390 'headers' => $headers,
5391 'timeout' => 60 // Increased timeout for large inputs
5392 ));
5393
5394 // Handle wp_remote_post errors
5395 if (is_wp_error($response)) {
5396 $error_message = $response->get_error_message();
5397 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
5398 return 'Connection error: ' . $error_message;
5399 }
5400
5401 // Get and check HTTP response code
5402 $http_code = wp_remote_retrieve_response_code($response);
5403 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
5404
5405 if ($http_code !== 200) {
5406 $error_body = wp_remote_retrieve_body($response);
5407 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
5408
5409 // Try to parse error for more details
5410 $error_json = json_decode($error_body, true);
5411 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
5412 $error_type = $error_json['error']['type'] ?? 'unknown';
5413 $error_message = $error_json['error']['message'] ?? 'No message';
5414 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
5415 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
5416
5417 // Keep the provider's own diagnostic — a restricted-key 401 names the
5418 // exact missing scope, and replacing it with "check your API key" sent
5419 // a customer to regenerate two keys (plan 46b596). Same shape as
5420 // MxChat_Utils::embedding_failure_error() so both ingestion paths read
5421 // identically. Key never appears in provider messages, but scrub anyway.
5422 if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
5423 if (is_string($api_key) && $api_key !== '') {
5424 $error_message = str_replace($api_key, '[redacted]', $error_message);
5425 }
5426 $error_message = sprintf(
5427 'Embedding failed (%s, HTTP %d): %s',
5428 $selected_model,
5429 $http_code,
5430 substr($error_message, 0, 300)
5431 );
5432 }
5433
5434 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5435 return $error_message;
5436 }
5437
5438 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
5439 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5440 return $error_message;
5441 }
5442
5443 // Parse response body
5444 $response_body = wp_remote_retrieve_body($response);
5445 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
5446
5447 $response_data = json_decode($response_body, true);
5448
5449 if (json_last_error() !== JSON_ERROR_NONE) {
5450 $error = json_last_error_msg();
5451 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
5452 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
5453 return "Failed to parse API response: $error";
5454 }
5455
5456 // Handle different response formats based on provider
5457 if (strpos($selected_model, 'gemini-embedding') === 0) {
5458 // Gemini API response format
5459 if (isset($response_data['embedding']['values'])) {
5460 $embedding_dimensions = count($response_data['embedding']['values']);
5461 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
5462
5463 // Check if embedding dimensions are as expected (should be 1536)
5464 if ($embedding_dimensions !== 1536) {
5465 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
5466 }
5467
5468 return $response_data['embedding']['values'];
5469 } else {
5470 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
5471 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5472
5473 if (isset($response_data['error'])) {
5474 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
5475 //error_log('[MXCHAT-EMBED] ' . $error_message);
5476 return $error_message;
5477 }
5478
5479 $error_message = "Invalid Gemini API response format: No embedding found";
5480 //error_log('[MXCHAT-EMBED] ' . $error_message);
5481 return $error_message;
5482 }
5483 } else {
5484 // OpenAI/Voyage API response format
5485 if (isset($response_data['data'][0]['embedding'])) {
5486 $embedding_dimensions = count($response_data['data'][0]['embedding']);
5487 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
5488
5489 // Check if embedding dimensions are as expected
5490 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
5491 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
5492 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
5493 }
5494
5495 return $response_data['data'][0]['embedding'];
5496 } else {
5497 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
5498 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
5499
5500 if (isset($response_data['error'])) {
5501 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
5502 //error_log('[MXCHAT-EMBED] ' . $error_message);
5503 return $error_message;
5504 }
5505
5506 $error_message = "Invalid API response format: No embedding found";
5507 //error_log('[MXCHAT-EMBED] ' . $error_message);
5508 return $error_message;
5509 }
5510 }
5511 }
5512
5513 /**
5514 * Get bot-specific options for multi-bot functionality
5515 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
5516 */
5517 private function get_bot_options($bot_id = 'default') {
5518 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
5519
5520 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5521 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
5522 return array();
5523 }
5524
5525 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
5526
5527 if (!empty($bot_options)) {
5528 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
5529 if (isset($bot_options['similarity_threshold'])) {
5530 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
5531 }
5532 }
5533
5534 return is_array($bot_options) ? $bot_options : array();
5535 }
5536
5537 /**
5538 * Get bot-specific Pinecone configuration
5539 * Used in the knowledge retrieval functions
5540 */
5541 // Also add debugging to your get_bot_pinecone_config function
5542 private function get_bot_pinecone_config($bot_id = 'default') {
5543 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
5544
5545 // If default bot or multi-bot add-on not active, use default Pinecone config
5546 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
5547 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
5548 $addon_options = get_option('mxchat_pinecone_addon_options', array());
5549 $config = array(
5550 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
5551 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
5552 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
5553 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
5554 );
5555 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
5556 return $config;
5557 }
5558
5559 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
5560
5561 // Hook for multi-bot add-on to provide bot-specific Pinecone config
5562 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
5563
5564 if (!empty($bot_pinecone_config)) {
5565 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
5566 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
5567 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
5568 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
5569 } else {
5570 //error_log("MXCHAT DEBUG: Filter returned empty config!");
5571 }
5572
5573 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
5574 }
5575
5576
5577 public function mxchat_ajax_dismiss_completed_status() {
5578 try {
5579 // Verify the request
5580 check_ajax_referer('mxchat_status_nonce', 'nonce');
5581
5582 if (!current_user_can('manage_options')) {
5583 wp_send_json_error('Unauthorized access');
5584 exit;
5585 }
5586
5587 $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
5588
5589 if ($card_type === 'pdf') {
5590 // Clear PDF status
5591 $pdf_url = get_transient('mxchat_last_pdf_url');
5592 if ($pdf_url) {
5593 delete_transient('mxchat_pdf_status_' . md5($pdf_url));
5594 delete_transient('mxchat_last_pdf_url');
5595 }
5596 } elseif ($card_type === 'sitemap') {
5597 // Clear sitemap status
5598 $sitemap_url = get_transient('mxchat_last_sitemap_url');
5599 if ($sitemap_url) {
5600 delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
5601 delete_transient('mxchat_last_sitemap_url');
5602 }
5603 }
5604
5605 wp_send_json_success(array('message' => 'Status dismissed successfully'));
5606
5607 } catch (Exception $e) {
5608 wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
5609 }
5610 }
5611
5612 /**
5613 * Render completed status cards on page load
5614 * This ensures completed processing status persists through page refreshes
5615 */
5616 public function mxchat_render_completed_status_cards() {
5617 $output = '';
5618
5619 // Check for completed PDF status
5620 $pdf_url = get_transient('mxchat_last_pdf_url');
5621 if ($pdf_url) {
5622 $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
5623 if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
5624 $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
5625 }
5626 }
5627
5628 // Check for completed sitemap status
5629 $sitemap_url = get_transient('mxchat_last_sitemap_url');
5630 if ($sitemap_url) {
5631 $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
5632 if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
5633 $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
5634 }
5635 }
5636
5637 return $output;
5638 }
5639
5640 /**
5641 * Render PDF status card HTML
5642 */
5643 private function mxchat_render_pdf_status_card($status, $pdf_url) {
5644 $html = '<div class="mxchat-status-card" data-card-type="pdf">';
5645 $html .= '<div class="mxchat-status-header">';
5646 $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
5647
5648 // Add dismiss button for completed status
5649 if ($status['status'] === 'complete' || $status['status'] === 'error') {
5650 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5651 }
5652
5653 // Process Batch button for processing status
5654 if ($status['status'] === 'processing') {
5655 $html .= '<button type="button" class="mxchat-manual-batch-btn"
5656 data-process-type="pdf"
5657 data-url="' . esc_attr($pdf_url) . '">
5658 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5659 }
5660
5661 // Add status badges
5662 if ($status['status'] === 'error') {
5663 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5664 } elseif ($status['status'] === 'complete') {
5665 if ($status['failed_pages'] > 0) {
5666 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5667 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
5668 } else {
5669 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5670 }
5671 }
5672
5673 $html .= '</div>'; // End header
5674
5675 // Progress bar
5676 $html .= '<div class="mxchat-progress-bar">';
5677 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5678 $html .= '</div>';
5679
5680 // Status details
5681 $html .= '<div class="mxchat-status-details">';
5682 $html .= '<p>' . sprintf(
5683 esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
5684 $status['processed_pages'],
5685 $status['total_pages'],
5686 $status['percentage']
5687 ) . '</p>';
5688
5689 // Show failed pages count if any
5690 if ($status['failed_pages'] > 0) {
5691 $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
5692 }
5693
5694 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5695 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5696
5697 // Add completion summary if available AND it's an array
5698 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5699 $summary = $status['completion_summary'];
5700 $html .= '<div class="mxchat-completion-summary">';
5701 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5702 $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
5703 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
5704 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
5705 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5706 $html .= '</div>';
5707 }
5708
5709 // Add failed pages list if any AND it's an array
5710 if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
5711 $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
5712 }
5713
5714 // Add error message if any
5715 if (isset($status['error']) && !empty($status['error'])) {
5716 $html .= '<div class="mxchat-error-notice">';
5717 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5718 $html .= '</div>';
5719 }
5720
5721 $html .= '</div>'; // End details
5722 $html .= '</div>'; // End card
5723
5724 return $html;
5725 }
5726 /**
5727 * Render sitemap status card HTML
5728 */
5729 private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
5730 $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
5731 $html .= '<div class="mxchat-status-header">';
5732 $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
5733
5734 // Add dismiss button for completed status
5735 if ($status['status'] === 'complete' || $status['status'] === 'error') {
5736 $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5737 }
5738
5739 // Process Batch button for processing status
5740 if ($status['status'] === 'processing') {
5741 $html .= '<button type="button" class="mxchat-manual-batch-btn"
5742 data-process-type="sitemap"
5743 data-url="' . esc_attr($sitemap_url) . '">
5744 ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5745 }
5746
5747 // Add status badges
5748 if ($status['status'] === 'error') {
5749 $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5750 } elseif ($status['status'] === 'complete') {
5751 if ($status['failed_urls'] > 0) {
5752 $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5753 sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
5754 } else {
5755 $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5756 }
5757 }
5758
5759 $html .= '</div>'; // End header
5760
5761 // Progress bar
5762 $html .= '<div class="mxchat-progress-bar">';
5763 $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5764 $html .= '</div>';
5765
5766 // Status details
5767 $html .= '<div class="mxchat-status-details">';
5768 $html .= '<p>' . sprintf(
5769 esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
5770 $status['processed_urls'],
5771 $status['total_urls'],
5772 $status['percentage']
5773 ) . '</p>';
5774
5775 // Show failed URLs count if any
5776 if ($status['failed_urls'] > 0) {
5777 $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
5778 }
5779
5780 $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5781 $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5782
5783 // Add completion summary if available AND it's an array
5784 if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5785 $summary = $status['completion_summary'];
5786 $html .= '<div class="mxchat-completion-summary">';
5787 $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5788 $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
5789 $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
5790 $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
5791 $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5792 $html .= '</div>';
5793 }
5794
5795 // Add error messages if any (but not the failed URLs list)
5796 if (!empty($status['error']) || !empty($status['last_error'])) {
5797 $html .= '<div class="mxchat-error-notice">';
5798
5799 if (!empty($status['error'])) {
5800 $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5801 }
5802
5803 if (!empty($status['last_error'])) {
5804 $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
5805 }
5806
5807 $html .= '</div>';
5808 }
5809
5810 $html .= '</div>'; // End details
5811 $html .= '</div>'; // End card
5812
5813 return $html;
5814 }
5815
5816
5817 /**
5818 * Render failed pages list
5819 */
5820 private function mxchat_render_failed_pages_list($failed_pages_list) {
5821 // Validate that $failed_pages_list is an array and not empty
5822 if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
5823 return '';
5824 }
5825
5826 $html = '<div class="mxchat-error-notice">';
5827 $html .= '<div class="mxchat-failed-pages-container">';
5828 $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
5829 $html .= '<details>';
5830 $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
5831 $html .= '<div class="mxchat-failed-pages-list">';
5832
5833 // Create table for failed pages
5834 $html .= '<table class="widefat striped">';
5835 $html .= '<thead><tr>';
5836 $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
5837 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5838 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5839 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5840 $html .= '</tr></thead><tbody>';
5841
5842 // Sort failed pages by most recent
5843 $sorted_failed_pages = $failed_pages_list;
5844 usort($sorted_failed_pages, function($a, $b) {
5845 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5846 });
5847
5848 foreach ($sorted_failed_pages as $item) {
5849 // Ensure $item is an array before accessing its elements
5850 if (!is_array($item)) {
5851 continue;
5852 }
5853
5854 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5855 $html .= '<tr>';
5856 $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
5857 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5858 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5859 $html .= '<td>' . esc_html($time_ago) . '</td>';
5860 $html .= '</tr>';
5861 }
5862
5863 $html .= '</tbody></table>';
5864 $html .= '</div></details></div></div>';
5865
5866 return $html;
5867 }
5868
5869 /**
5870 * Render failed URLs list
5871 */
5872 private function mxchat_render_failed_urls_list($failed_urls_list) {
5873 // Validate that $failed_urls_list is an array and not empty
5874 if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5875 return '';
5876 }
5877
5878 $html = '<div class="mxchat-failed-urls-container">';
5879 $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5880 $html .= '<details>';
5881 $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5882 $html .= '<div class="mxchat-failed-urls-list">';
5883
5884 // Create table for failed URLs
5885 $html .= '<table class="widefat striped">';
5886 $html .= '<thead><tr>';
5887 $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5888 $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5889 $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5890 $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5891 $html .= '</tr></thead><tbody>';
5892
5893 // Sort failed URLs by most recent
5894 $sorted_failed_urls = $failed_urls_list;
5895 usort($sorted_failed_urls, function($a, $b) {
5896 return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5897 });
5898
5899 // Show up to 50 failed URLs
5900 $display_urls = array_slice($sorted_failed_urls, 0, 50);
5901
5902 foreach ($display_urls as $item) {
5903 // Ensure $item is an array before accessing its elements
5904 if (!is_array($item)) {
5905 continue;
5906 }
5907
5908 $url = $item['url'] ?? '';
5909 $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5910
5911 // Truncate URL for display
5912 $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5913
5914 $html .= '<tr>';
5915 $html .= '<td style="word-break: break-all;">';
5916 if (!empty($url)) {
5917 $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5918 } else {
5919 $html .= esc_html__('Unknown URL', 'mxchat');
5920 }
5921 $html .= '</td>';
5922 $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5923 $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5924 $html .= '<td>' . esc_html($time_ago) . '</td>';
5925 $html .= '</tr>';
5926 }
5927
5928 $html .= '</tbody></table>';
5929
5930 if (count($failed_urls_list) > 50) {
5931 $html .= '<div class="mxchat-failed-urls-more">+ ' .
5932 (count($failed_urls_list) - 50) .
5933 ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5934 }
5935
5936 $html .= '</div></details></div>';
5937
5938 return $html;
5939 }
5940
5941 /**
5942 * Get all ACF fields for a specific post, excluding any fields the user has disabled
5943 */
5944 public function mxchat_get_acf_fields_for_post($post_id) {
5945 if (!function_exists('get_field_objects')) {
5946 return array();
5947 }
5948
5949 // Field OBJECTS, not get_fields(): exclusion matches on the field KEY
5950 // (unique per field) rather than the name (shared across groups — plan
5951 // 30e81f). ACF's own get_fields() is implemented as get_field_objects()
5952 // reduced to name => value, so the un-excluded reduction below is the
5953 // identical shape and order the previous get_fields() call produced.
5954 $field_objects = get_field_objects($post_id);
5955 if (!$field_objects || !is_array($field_objects)) {
5956 return array();
5957 }
5958
5959 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5960 if (!is_array($excluded_fields)) {
5961 $excluded_fields = array();
5962 }
5963
5964 $fields = array();
5965 foreach ($field_objects as $field_name => $field_object) {
5966 if (!empty($excluded_fields)) {
5967 $field_key = isset($field_object['key']) ? $field_object['key'] : '';
5968 // Legacy name entries stay honored: a stored name whose group was
5969 // inactive at migration time still excludes every field wearing it.
5970 if (in_array($field_key, $excluded_fields, true) || in_array($field_name, $excluded_fields, true)) {
5971 continue;
5972 }
5973 }
5974 $fields[$field_name] = isset($field_object['value']) ? $field_object['value'] : null;
5975 }
5976
5977 return $fields;
5978 }
5979
5980 /**
5981 * Get all registered ACF field groups and their fields for the settings UI
5982 */
5983 public function mxchat_get_all_acf_fields() {
5984 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5985 return array();
5986 }
5987
5988 // Keyed by GROUP KEY, not title (titles are not unique), and each field
5989 // entry carries its ACF field key — the unique identifier every toggle,
5990 // save, and index-time exclusion now runs on (plans 30e81f / bf57e0).
5991 $all_fields = array();
5992 $field_groups = acf_get_field_groups();
5993
5994 if (!empty($field_groups)) {
5995 foreach ($field_groups as $group) {
5996 $group_fields = acf_get_fields($group['key']);
5997 if (!empty($group_fields)) {
5998 $entry = array(
5999 'title' => $group['title'],
6000 'fields' => array(),
6001 );
6002 foreach ($group_fields as $field) {
6003 $entry['fields'][] = array(
6004 'key' => $field['key'],
6005 'name' => $field['name'],
6006 'label' => $field['label'],
6007 'type' => $field['type']
6008 );
6009 }
6010 $all_fields[$group['key']] = $entry;
6011 }
6012 }
6013 }
6014
6015 return $all_fields;
6016 }
6017
6018 /**
6019 * Get whitelisted custom post meta for a given post
6020 * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
6021 */
6022 public function mxchat_get_whitelisted_post_meta($post_id) {
6023 $whitelist = get_option('mxchat_custom_meta_whitelist', '');
6024
6025 if (empty($whitelist)) {
6026 return array();
6027 }
6028
6029 // Parse the whitelist - one meta key per line
6030 $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
6031
6032 if (empty($meta_keys)) {
6033 return array();
6034 }
6035
6036 $result = array();
6037
6038 foreach ($meta_keys as $key) {
6039 // Skip empty keys
6040 if (empty($key)) {
6041 continue;
6042 }
6043
6044 $value = get_post_meta($post_id, $key, true);
6045
6046 // Only include non-empty string values
6047 if (!empty($value) && is_string($value)) {
6048 $result[$key] = $value;
6049 } elseif (!empty($value) && is_array($value)) {
6050 // Handle array values by joining them
6051 $flat_value = $this->mxchat_flatten_meta_array($value);
6052 if (!empty($flat_value)) {
6053 $result[$key] = $flat_value;
6054 }
6055 }
6056 }
6057
6058 return $result;
6059 }
6060
6061 /**
6062 * Flatten array meta values into a readable string
6063 */
6064 private function mxchat_flatten_meta_array($array, $depth = 0) {
6065 if ($depth > 3) {
6066 return ''; // Prevent infinite recursion
6067 }
6068
6069 $parts = array();
6070
6071 foreach ($array as $key => $value) {
6072 if (is_string($value) && !empty($value)) {
6073 $parts[] = $value;
6074 } elseif (is_array($value)) {
6075 $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
6076 if (!empty($nested)) {
6077 $parts[] = $nested;
6078 }
6079 }
6080 }
6081
6082 return implode(', ', $parts);
6083 }
6084
6085 /**
6086 * Format ACF field values for content extraction
6087 */
6088 public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
6089 if (empty($value)) {
6090 return '';
6091 }
6092
6093 // Handle WP_Post objects first (THIS IS THE KEY FIX)
6094 if ($value instanceof WP_Post) {
6095 return $value->post_title ?: '';
6096 }
6097
6098 // Handle other WP objects
6099 if (is_object($value)) {
6100 if (isset($value->post_title)) {
6101 return $value->post_title;
6102 } elseif (isset($value->display_name)) {
6103 return $value->display_name;
6104 } elseif (isset($value->name)) {
6105 return $value->name;
6106 } elseif (method_exists($value, '__toString')) {
6107 try {
6108 return (string) $value;
6109 } catch (Exception $e) {
6110 return '';
6111 }
6112 }
6113 // For any other objects, return empty string
6114 return '';
6115 }
6116
6117 // Handle different ACF field types
6118 if (is_array($value)) {
6119 // Check if it's an image/file field
6120 if (isset($value['url'])) {
6121 // Image field - return alt text, title, or caption
6122 if (!empty($value['alt'])) {
6123 return $value['alt'];
6124 } elseif (!empty($value['title'])) {
6125 return $value['title'];
6126 } elseif (!empty($value['caption'])) {
6127 return $value['caption'];
6128 } else {
6129 return ''; // Don't include just the URL
6130 }
6131 }
6132
6133 // Check if it's a post object or relationship field
6134 if (isset($value['post_title'])) {
6135 return $value['post_title'];
6136 }
6137
6138 // Check if it's a user field
6139 if (isset($value['display_name'])) {
6140 return $value['display_name'];
6141 }
6142
6143 // Check if it's a taxonomy term
6144 if (isset($value['name']) && isset($value['taxonomy'])) {
6145 return $value['name'];
6146 }
6147
6148 // Check if it's a select field with label
6149 if (isset($value['label'])) {
6150 return $value['label'];
6151 }
6152
6153 // Check for repeater field or flexible content
6154 if (is_numeric(key($value))) {
6155 $sub_values = array();
6156 foreach ($value as $sub_item) {
6157 if (is_array($sub_item)) {
6158 // For repeater/flexible content, extract text values
6159 $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
6160 if (!empty($sub_text)) {
6161 $sub_values[] = $sub_text;
6162 }
6163 } elseif ($sub_item instanceof WP_Post) {
6164 // Handle WP_Post objects in arrays
6165 $sub_values[] = $sub_item->post_title ?: '';
6166 } else {
6167 $sub_values[] = (string) $sub_item;
6168 }
6169 }
6170 return implode(', ', array_filter($sub_values));
6171 }
6172
6173 // For other arrays, try to extract meaningful text
6174 $text_values = array();
6175 foreach ($value as $key => $val) {
6176 if (is_string($val) && !empty(trim($val))) {
6177 $text_values[] = trim($val);
6178 } elseif ($val instanceof WP_Post) {
6179 // Handle WP_Post objects in associative arrays
6180 $text_values[] = $val->post_title ?: '';
6181 } elseif (is_array($val) && isset($val['post_title'])) {
6182 $text_values[] = $val['post_title'];
6183 } elseif (is_array($val) && isset($val['name'])) {
6184 $text_values[] = $val['name'];
6185 }
6186 }
6187
6188 return implode(', ', array_filter($text_values));
6189 }
6190
6191 // Handle boolean values
6192 if (is_bool($value)) {
6193 return $value ? 'Yes' : 'No';
6194 }
6195
6196 // Handle numeric values
6197 if (is_numeric($value)) {
6198 return (string) $value;
6199 }
6200
6201 // Handle string values
6202 if (is_string($value)) {
6203 return trim($value);
6204 }
6205
6206 // For anything else that we can't handle, return empty string
6207 // This prevents the "Object could not be converted to string" error
6208 return '';
6209 }
6210
6211 /**
6212 * Extract text from complex ACF array structures
6213 */
6214 private function mxchat_extract_text_from_acf_array($array) {
6215 if (!is_array($array)) {
6216 return '';
6217 }
6218
6219 $text_parts = array();
6220
6221 foreach ($array as $key => $value) {
6222 if (is_string($value) && !empty(trim($value))) {
6223 // Skip keys that are likely to be IDs or technical values
6224 if (!is_numeric($value) || strlen($value) > 10) {
6225 $text_parts[] = trim($value);
6226 }
6227 } elseif ($value instanceof WP_Post) {
6228 // Handle WP_Post objects
6229 $text_parts[] = $value->post_title ?: '';
6230 } elseif (is_array($value)) {
6231 if (isset($value['post_title'])) {
6232 $text_parts[] = $value['post_title'];
6233 } elseif (isset($value['name'])) {
6234 $text_parts[] = $value['name'];
6235 } elseif (isset($value['label'])) {
6236 $text_parts[] = $value['label'];
6237 }
6238 } elseif (is_object($value)) {
6239 // Handle other objects safely
6240 if (isset($value->post_title)) {
6241 $text_parts[] = $value->post_title;
6242 } elseif (isset($value->name)) {
6243 $text_parts[] = $value->name;
6244 } elseif (isset($value->display_name)) {
6245 $text_parts[] = $value->display_name;
6246 }
6247 }
6248 }
6249
6250 return implode(', ', array_filter($text_parts));
6251 }
6252
6253 /**
6254 * Walk an ACF field value tree and collect attachment IDs for any value that
6255 * resolves to a PDF in the WordPress media library. Handles the three shapes
6256 * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
6257 * plain URL string), and recurses through repeater/group/flexible content.
6258 *
6259 * @param mixed $value The ACF field value (any depth)
6260 * @param array $out Accumulator (passed by reference) for attachment IDs
6261 * @param int $depth Recursion guard
6262 */
6263 private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
6264 if ($depth > 6) {
6265 return; // prevent runaway recursion on circular/very-deep structures
6266 }
6267
6268 if (empty($value)) {
6269 return;
6270 }
6271
6272 // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
6273 if (is_array($value)) {
6274 // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
6275 $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
6276 if ($looks_like_attachment) {
6277 $att_id = 0;
6278 if (!empty($value['ID']) && is_numeric($value['ID'])) {
6279 $att_id = (int) $value['ID'];
6280 } elseif (!empty($value['id']) && is_numeric($value['id'])) {
6281 $att_id = (int) $value['id'];
6282 } elseif (!empty($value['url']) && is_string($value['url'])) {
6283 $att_id = (int) attachment_url_to_postid($value['url']);
6284 }
6285
6286 $is_pdf = false;
6287 if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
6288 $is_pdf = true;
6289 } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
6290 $is_pdf = true;
6291 } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
6292 $is_pdf = true;
6293 } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
6294 $is_pdf = true;
6295 }
6296
6297 if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
6298 $out[] = $att_id;
6299 }
6300 // An array node that represents one attachment doesn't contain other
6301 // attachments inside it — done with this branch.
6302 return;
6303 }
6304
6305 // Recurse: repeater rows, flexible-content layouts, groups, etc.
6306 foreach ($value as $sub) {
6307 $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
6308 }
6309 return;
6310 }
6311
6312 // Plain numeric attachment ID (ACF File field set to "Return: ID")
6313 if (is_numeric($value)) {
6314 $att_id = (int) $value;
6315 if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6316 $out[] = $att_id;
6317 }
6318 return;
6319 }
6320
6321 // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
6322 if (is_string($value)) {
6323 $trimmed = trim($value);
6324 if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
6325 $att_id = (int) attachment_url_to_postid($trimmed);
6326 if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
6327 $out[] = $att_id;
6328 }
6329 }
6330 return;
6331 }
6332 }
6333
6334 /**
6335 * Heuristic: does this URL/string look like a PDF reference?
6336 * Tolerates query strings and fragments (#page=2).
6337 */
6338 private function mxchat_url_looks_like_pdf($url) {
6339 if (!is_string($url) || $url === '') {
6340 return false;
6341 }
6342 // Strip query + fragment before checking extension
6343 $path = preg_replace('/[?#].*$/', '', $url);
6344 return (bool) preg_match('/\.pdf$/i', $path);
6345 }
6346
6347 /**
6348 * Extract text from a PDF attachment by ID using the bundled Smalot parser.
6349 * Reads the file directly from disk via get_attached_file (no HTTP fetch).
6350 * Result is cached on the attachment as post_meta keyed by file mtime so we
6351 * only parse the same PDF once unless the file changes on disk.
6352 *
6353 * @param int $attachment_id
6354 * @return string Extracted plain text, or '' on failure.
6355 */
6356 private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
6357 $attachment_id = (int) $attachment_id;
6358 if ($attachment_id <= 0) {
6359 return '';
6360 }
6361 if (get_post_mime_type($attachment_id) !== 'application/pdf') {
6362 return '';
6363 }
6364
6365 $pdf_path = get_attached_file($attachment_id);
6366 if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
6367 return '';
6368 }
6369
6370 // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
6371 // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
6372 $default_max_bytes = 25 * 1024 * 1024;
6373 $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
6374 if ($max_bytes > 0) {
6375 $file_size = @filesize($pdf_path);
6376 if ($file_size !== false && $file_size > $max_bytes) {
6377 error_log(sprintf(
6378 '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
6379 $attachment_id,
6380 basename($pdf_path),
6381 $file_size,
6382 $max_bytes
6383 ));
6384 return '';
6385 }
6386 }
6387
6388 $mtime = @filemtime($pdf_path);
6389 $cache_meta_key = '_mxchat_acf_pdf_text_v1';
6390 $cached = get_post_meta($attachment_id, $cache_meta_key, true);
6391 if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
6392 return (string) $cached['text'];
6393 }
6394
6395 $text = '';
6396 try {
6397 if (function_exists('mxchat_load_pdf_parser')) {
6398 mxchat_load_pdf_parser();
6399 }
6400 if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
6401 return '';
6402 }
6403 $parser = new \Smalot\PdfParser\Parser();
6404 $pdf = $parser->parseFile($pdf_path);
6405 $pages = $pdf->getPages();
6406 $page_texts = array();
6407 $acf_page_num = 0;
6408 foreach ($pages as $page) {
6409 $acf_page_num++;
6410 $page_text = '';
6411 try {
6412 $page_text = $page->getText();
6413 } catch (\Exception $e) {
6414 $page_text = '';
6415 }
6416 if (!empty($page_text)) {
6417 $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num);
6418 $page_texts[] = $page_text;
6419 }
6420 }
6421 $text = trim(implode("\n\n", $page_texts));
6422 } catch (\Exception $e) {
6423 error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
6424 return '';
6425 } catch (\Throwable $e) {
6426 error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
6427 return '';
6428 }
6429
6430 // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
6431 // The chunker downstream will still split this into multiple vectors.
6432 $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
6433 if ($max_len > 0 && strlen($text) > $max_len) {
6434 $text = substr($text, 0, $max_len);
6435 }
6436
6437 update_post_meta($attachment_id, $cache_meta_key, array(
6438 'mtime' => (int) $mtime,
6439 'text' => $text,
6440 ));
6441
6442 return $text;
6443 }
6444
6445 /**
6446 * The media-library MIME types the knowledge base can actually read (plan 01010a).
6447 *
6448 * The importer's post-type dropdown is built from get_post_types(['public' => true]),
6449 * and 'attachment' is public — so Media has always been OFFERED as a source. What it
6450 * could never do is return anything, and the fix is deliberately narrower than the
6451 * offer: only files one of the THREE extractors already shipping in this plugin can
6452 * turn into text belong on that list. Images, audio and video are not listed at all
6453 * rather than listed and greyed out, because "nothing to import here" is a clearer
6454 * answer than a row you cannot select.
6455 *
6456 * text/markdown has no entry in WordPress's default wp_get_mime_types(), so a .md
6457 * file is only ever in the library on a site that added the mapping itself; it stays
6458 * on the list for those sites and costs nothing on the rest.
6459 *
6460 * @return array MIME type strings.
6461 */
6462 public static function mxchat_supported_attachment_mime_types() {
6463 /**
6464 * Filter the attachment MIME types offered by the WordPress Content importer.
6465 *
6466 * Adding a type here makes it listable and importable — mxchat_extract_attachment_text()
6467 * reads anything text/* as plain text, so a text-shaped MIME works out of the box.
6468 * A binary format needs its own extractor.
6469 *
6470 * @param array $mime_types Supported MIME types.
6471 */
6472 return (array) apply_filters('mxchat_kb_attachment_mime_types', array(
6473 'application/pdf',
6474 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
6475 'text/plain',
6476 'text/markdown',
6477 ));
6478 }
6479
6480 /**
6481 * Short human label for a supported attachment MIME type ("PDF", "Word", ...).
6482 * Used in the importer list, where "attachment" as a type and "0 words" as a size
6483 * would tell the user nothing about what they are about to import.
6484 */
6485 public static function mxchat_attachment_type_label($mime_type) {
6486 $labels = array(
6487 'application/pdf' => __('PDF', 'mxchat'),
6488 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => __('Word', 'mxchat'),
6489 'text/plain' => __('Text', 'mxchat'),
6490 'text/markdown' => __('Markdown', 'mxchat'),
6491 );
6492
6493 return isset($labels[$mime_type]) ? $labels[$mime_type] : __('Document', 'mxchat');
6494 }
6495
6496 /**
6497 * Extract indexable text from a media-library attachment (plan 01010a).
6498 *
6499 * Wiring, not parsing: every extractor here already ships and is already used
6500 * elsewhere — the Smalot path via mxchat_extract_pdf_text_by_attachment_id() (ACF
6501 * PDFs), MXChat_Word_Handler::extract_docx_text() and the plain-text read (both from
6502 * Document Upload, which is also where the character-estimated page cap comes from).
6503 * No new parser was added for this.
6504 *
6505 * Returns a reason instead of an empty string on failure so the caller can say WHY a
6506 * file was skipped; an attachment that yields no text must never be stored as an
6507 * empty entry.
6508 *
6509 * @param int $attachment_id
6510 * @return array{text: string, reason: string} reason is '' on success.
6511 */
6512 private function mxchat_extract_attachment_text($attachment_id) {
6513 $attachment_id = (int) $attachment_id;
6514 $mime_type = get_post_mime_type($attachment_id);
6515
6516 if (!in_array($mime_type, self::mxchat_supported_attachment_mime_types(), true)) {
6517 return array(
6518 'text' => '',
6519 'reason' => __('Unsupported file type — only PDF, Word, text and Markdown files can be imported.', 'mxchat'),
6520 );
6521 }
6522
6523 // PDF: the shared extractor owns its own size cap (25 MB raw, filterable), its
6524 // 50,000-character output cap, the RTL normaliser and the mtime-keyed cache, so
6525 // re-importing the same PDF does not re-parse it.
6526 if ($mime_type === 'application/pdf') {
6527 $text = $this->mxchat_extract_pdf_text_by_attachment_id($attachment_id);
6528 $text = trim((string) $text);
6529
6530 if ($text === '') {
6531 return array(
6532 'text' => '',
6533 'reason' => __('No extractable text — the PDF is empty, scanned images only, or larger than the size limit.', 'mxchat'),
6534 );
6535 }
6536
6537 return array('text' => $text, 'reason' => '');
6538 }
6539
6540 $file_path = get_attached_file($attachment_id);
6541 if (empty($file_path) || !file_exists($file_path) || !is_readable($file_path)) {
6542 return array(
6543 'text' => '',
6544 'reason' => __('The file could not be read from disk.', 'mxchat'),
6545 );
6546 }
6547
6548 if ($mime_type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
6549 $text = MXChat_Word_Handler::extract_docx_text($file_path);
6550 if ($text === false) {
6551 return array(
6552 'text' => '',
6553 'reason' => __('The Word file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
6554 );
6555 }
6556 $text = trim((string) $text);
6557 } else {
6558 // .txt / .md read as-is, the same way Document Upload reads them —
6559 // Markdown deliberately keeps its syntax, because headings are useful
6560 // retrieval signal.
6561 $text = (string) file_get_contents($file_path);
6562 $text = wp_check_invalid_utf8($text);
6563 $text = trim($text);
6564 }
6565
6566 if ($text === '') {
6567 return array(
6568 'text' => '',
6569 'reason' => __('The file contains no readable text.', 'mxchat'),
6570 );
6571 }
6572
6573 // Same cap Document Upload applies to the same three formats: the pdf_max_pages
6574 // setting, estimated by CHARACTERS (~2500/page) because the .docx cleaner
6575 // collapses newlines and a paragraph count reads 1 for any Word file. Importing
6576 // is synchronous, so an unbounded document risks a timeout.
6577 $options = get_option('mxchat_options', array());
6578 $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
6579 $estimated_pages = (int) ceil(strlen($text) / 2500);
6580 if ($max_pages > 0 && $estimated_pages > $max_pages) {
6581 return array(
6582 'text' => '',
6583 'reason' => sprintf(
6584 /* translators: 1: estimated page count, 2: configured page limit */
6585 __('Too large (about %1$d pages; the limit is %2$d). Split the file, or raise the PDF max pages setting.', 'mxchat'),
6586 $estimated_pages,
6587 $max_pages
6588 ),
6589 );
6590 }
6591
6592 return array('text' => $text, 'reason' => '');
6593 }
6594
6595 /**
6596 * Assemble the stored text for an attachment: title first, then the extracted body.
6597 * Mirrors the post assembler's shape (title on its own line, blank line, content) so
6598 * a media entry retrieves on its filename/title the same way a post does.
6599 */
6600 private function mxchat_build_attachment_content($post, $extracted_text) {
6601 $title = $this->mxchat_decode_entities_for_indexing($post->post_title);
6602 $parts = array($title);
6603
6604 // The media modal's Caption and Description fields are real, editable metadata
6605 // and are frequently the only human-written context a file has.
6606 $caption = trim(wp_strip_all_tags((string) $post->post_excerpt));
6607 if ($caption !== '') {
6608 $parts[] = $this->mxchat_decode_entities_for_indexing($caption);
6609 }
6610
6611 $description = trim(wp_strip_all_tags((string) $post->post_content));
6612 if ($description !== '') {
6613 $parts[] = $this->mxchat_decode_entities_for_indexing($description);
6614 }
6615
6616 $parts[] = $extracted_text;
6617
6618 return implode("\n\n", array_filter($parts, function ($part) {
6619 return $part !== '';
6620 }));
6621 }
6622
6623 /**
6624 * Import one media-library attachment into the knowledge base — the attachment
6625 * branch of ajax_mxchat_process_selected_content (plan 01010a).
6626 *
6627 * Separate from the post path rather than folded into it because almost every step
6628 * differs: the source URL is the FILE url (get_permalink() on an attachment returns
6629 * its attachment page, which is not what anyone means by "where this document
6630 * lives"), the body comes from an extractor instead of post_content, and a file that
6631 * yields no text has to be refused with a reason rather than stored empty.
6632 *
6633 * Sends its own JSON response and exits, exactly like its caller.
6634 */
6635 private function mxchat_import_attachment($post, $bot_id) {
6636 $post_id = (int) $post->ID;
6637
6638 $extracted = $this->mxchat_extract_attachment_text($post_id);
6639 if ($extracted['text'] === '') {
6640 wp_send_json_error(sprintf(
6641 /* translators: 1: attachment title, 2: reason the file was skipped */
6642 __('Skipped "%1$s": %2$s', 'mxchat'),
6643 $post->post_title,
6644 $extracted['reason']
6645 ));
6646 exit;
6647 }
6648
6649 $bot_options = $this->get_bot_options($bot_id);
6650 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6651
6652 $preflight = MxChat_Utils::embedding_preflight($options);
6653 if (!$preflight['ok']) {
6654 MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (attachment import)');
6655 wp_send_json_error($preflight['reason']);
6656 exit;
6657 }
6658
6659 $source_url = wp_get_attachment_url($post_id);
6660 if (empty($source_url)) {
6661 wp_send_json_error(__('The file has no URL — it may be missing from the uploads folder.', 'mxchat'));
6662 exit;
6663 }
6664
6665 $vector_id = md5($source_url);
6666 $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6667
6668 // "Already imported" read, same two-backend split the post path uses.
6669 $is_update = false;
6670 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6671 $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
6672
6673 if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
6674 $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
6675 if (isset($pinecone_data[$post_id])) {
6676 $is_update = true;
6677 }
6678 } else {
6679 global $wpdb;
6680 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6681 $existing_record = $wpdb->get_row($wpdb->prepare(
6682 "SELECT id FROM $table_name WHERE source_url = %s",
6683 $source_url
6684 ));
6685 if ($existing_record) {
6686 $is_update = true;
6687 }
6688 }
6689
6690 // content_type 'attachment' — NOT 'document'. The Knowledge screen's type filter
6691 // is built from public post types, so 'attachment' is exactly what its "Media"
6692 // option filters on; 'document' (Document Upload's label) would file these under
6693 // an option that dropdown does not offer.
6694 $result = MxChat_Utils::submit_content_to_db(
6695 $content,
6696 $source_url,
6697 $preflight['api_key'],
6698 $vector_id,
6699 $bot_id,
6700 'attachment'
6701 );
6702
6703 if (is_wp_error($result)) {
6704 MxChat_Admin::mxchat_log_debug('storage_error', 'Attachment storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
6705 wp_send_json_error('Storage failed: ' . $result->get_error_message());
6706 exit;
6707 }
6708
6709 // Attachments carry no tags, so the tag half of this is always a no-op — the
6710 // per-entry dropdown and the MxChat meta box on the attachment's own edit screen
6711 // are what restrict a media entry (plan 6ab002).
6712 $this->apply_role_restriction_to_post($post_id, $source_url);
6713
6714 $this->mxchat_remember_attachment_signature($post_id, $source_url, $content);
6715
6716 wp_send_json_success(array(
6717 'message' => $is_update ? 'Content updated successfully' : 'Content processed successfully',
6718 'post_id' => $post_id,
6719 'title' => $post->post_title,
6720 'operation_type' => $is_update ? 'update' : 'new',
6721 'vector_id' => $vector_id,
6722 'acf_fields_found' => 0,
6723 'pdf_extracted_count' => (get_post_mime_type($post_id) === 'application/pdf') ? 1 : 0,
6724 'content_preview' => substr($content, 0, 100) . '...',
6725 'bot_id' => $bot_id
6726 ));
6727 exit;
6728 }
6729
6730 /**
6731 * Is media auto-sync switched on? The toggle is the generic per-post-type one the
6732 * Advanced Custom Post Sync Settings list already renders for Media (attachment is a
6733 * public post type), so there is no new setting and no new UI for it.
6734 */
6735 private function mxchat_attachment_auto_sync_enabled() {
6736 return get_option('mxchat_auto_sync_attachment') === '1';
6737 }
6738
6739 /**
6740 * Record what we last indexed for an attachment: the URL it was stored under and a
6741 * hash of the stored text.
6742 *
6743 * This exists because 'edit_attachment' is noisy — it fires on every metadata touch,
6744 * including ones that change nothing we index (alt text, regenerating thumbnails).
6745 * Without a signature, each of those would pay for a fresh embedding call. The stored
6746 * URL is also what lets a file REPLACEMENT clean up after itself: the old URL's
6747 * vectors are deleted before the new ones are written, instead of being orphaned.
6748 */
6749 private function mxchat_remember_attachment_signature($attachment_id, $source_url, $content) {
6750 update_post_meta((int) $attachment_id, '_mxchat_kb_attachment_sig', array(
6751 'url' => (string) $source_url,
6752 'hash' => md5((string) $content),
6753 ));
6754 }
6755
6756 /**
6757 * Auto-sync an attachment on upload or edit (add_attachment / edit_attachment).
6758 *
6759 * Silent by design, like the post auto-sync path: a media upload is not a knowledge
6760 * base action from the user's point of view, so a file we cannot read is skipped
6761 * without an admin notice. The manual importer is where a skip gets a visible reason.
6762 */
6763 public function mxchat_handle_attachment_change($attachment_id) {
6764 $attachment_id = (int) $attachment_id;
6765
6766 if (!$this->mxchat_attachment_auto_sync_enabled()) {
6767 return;
6768 }
6769
6770 $post = get_post($attachment_id);
6771 if (!$post || $post->post_type !== 'attachment') {
6772 return;
6773 }
6774
6775 if (!in_array(get_post_mime_type($attachment_id), self::mxchat_supported_attachment_mime_types(), true)) {
6776 return;
6777 }
6778
6779 $source_url = wp_get_attachment_url($attachment_id);
6780 if (empty($source_url)) {
6781 return;
6782 }
6783
6784 $extracted = $this->mxchat_extract_attachment_text($attachment_id);
6785 if ($extracted['text'] === '') {
6786 return;
6787 }
6788
6789 $content = $this->mxchat_build_attachment_content($post, $extracted['text']);
6790 $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6791
6792 if (is_array($signature) && isset($signature['url'], $signature['hash'])) {
6793 // Nothing we index changed — don't pay for another embedding.
6794 if ($signature['url'] === $source_url && $signature['hash'] === md5($content)) {
6795 return;
6796 }
6797 // The file was replaced under a new URL. md5(old_url) vectors would survive
6798 // as an entry citing a file that no longer exists there, so clear them first.
6799 if ($signature['url'] !== $source_url && $signature['url'] !== '') {
6800 MxChat_Utils::delete_chunks_for_url($signature['url'], 'default');
6801 }
6802 }
6803
6804 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6805 if (!$preflight['ok']) {
6806 return;
6807 }
6808
6809 $result = MxChat_Utils::submit_content_to_db(
6810 $content,
6811 $source_url,
6812 $preflight['api_key'],
6813 md5($source_url),
6814 'default',
6815 'attachment'
6816 );
6817
6818 if (is_wp_error($result)) {
6819 return;
6820 }
6821
6822 $this->apply_role_restriction_to_post($attachment_id, $source_url);
6823 $this->mxchat_remember_attachment_signature($attachment_id, $source_url, $content);
6824 }
6825
6826 /**
6827 * Remove an attachment's knowledge entry when the file is deleted.
6828 *
6829 * Fires on 'delete_attachment', which is the ONLY delete hook an attachment gets —
6830 * wp_delete_post() hands attachments to wp_delete_attachment() before firing
6831 * before_delete_post, so mxchat_handle_post_delete never runs for one.
6832 *
6833 * Gated on the same auto-sync toggle as the post delete path, for the same reason it
6834 * is there: with auto-sync off, MxChat does not act on content changes by itself.
6835 */
6836 public function mxchat_handle_attachment_delete($attachment_id) {
6837 $attachment_id = (int) $attachment_id;
6838
6839 if (!$this->mxchat_attachment_auto_sync_enabled()) {
6840 return;
6841 }
6842
6843 // Still resolvable — this hook fires before the row and the file are removed.
6844 $source_url = wp_get_attachment_url($attachment_id);
6845 $signature = get_post_meta($attachment_id, '_mxchat_kb_attachment_sig', true);
6846
6847 $urls = array();
6848 if (!empty($source_url)) {
6849 $urls[] = $source_url;
6850 }
6851 // A replaced-then-deleted file can still have vectors under the URL it was
6852 // last indexed at, if that differs from where it lives now.
6853 if (is_array($signature) && !empty($signature['url'])) {
6854 $urls[] = $signature['url'];
6855 }
6856
6857 foreach (array_unique($urls) as $url) {
6858 MxChat_Utils::delete_chunks_for_url($url, 'default');
6859 }
6860 }
6861
6862 /**
6863 * Handle ACF save - fires after ACF fields are saved
6864 * This ensures ACF field data is available when syncing to knowledge base
6865 */
6866 public function mxchat_handle_acf_save($post_id) {
6867 // Skip if not a valid post
6868 if (!$post_id || $post_id === 'options') {
6869 return;
6870 }
6871
6872 // Skip autosaves and revisions
6873 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6874 return;
6875 }
6876
6877 $post = get_post($post_id);
6878 if (!$post) {
6879 return;
6880 }
6881
6882 $post_type = $post->post_type;
6883
6884 // Check if sync is enabled for this post type
6885 $should_sync = false;
6886
6887 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6888 $should_sync = true;
6889 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6890 $should_sync = true;
6891 } else if ($post_type === 'product' && class_exists('WooCommerce')) {
6892 // WooCommerce products - check if WooCommerce integration is enabled
6893 $options = get_option('mxchat_options', array());
6894 if (isset($options['enable_woocommerce_integration']) &&
6895 ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
6896 $should_sync = true;
6897 }
6898 } else {
6899 // Check custom post types
6900 $option_name = 'mxchat_auto_sync_' . $post_type;
6901 if (get_option($option_name) === '1') {
6902 $should_sync = true;
6903 }
6904 }
6905
6906 if (!$should_sync) {
6907 return;
6908 }
6909
6910 // Only process published posts
6911 if ($post->post_status !== 'publish') {
6912 return;
6913 }
6914
6915 // Check if this post has any ACF fields - if not, no need to re-sync
6916 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6917 if (empty($acf_fields)) {
6918 return;
6919 }
6920
6921 // Use a transient to prevent duplicate processing (post_updated may have already run)
6922 $transient_key = 'mxchat_acf_synced_' . $post_id;
6923 if (get_transient($transient_key)) {
6924 return;
6925 }
6926 set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
6927
6928 // Re-run the sync with ACF data now available
6929 // We pass $update=true since this is effectively an update with ACF data
6930 $this->mxchat_handle_post_update($post_id, $post, true);
6931 }
6932
6933 public function mxchat_handle_post_update($post_id, $post, $update) {
6934 // The in-flight-update marker has done its job the moment post_updated runs; drop it
6935 // before any early return so it can never outlive its own save (a failed $wpdb->update
6936 // inside wp_insert_post returns after pre_post_update but before the transition).
6937 unset($this->pending_post_update[$post_id]);
6938
6939 // Basic validation checks
6940 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6941 return;
6942 }
6943
6944 $post_type = $post->post_type;
6945
6946 // Check if sync is enabled for this post type
6947 $should_sync = false;
6948
6949 // Check built-in post types first
6950 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6951 $should_sync = true;
6952 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6953 $should_sync = true;
6954 } else {
6955 // Check custom post types
6956 $option_name = 'mxchat_auto_sync_' . $post_type;
6957 if (get_option($option_name) === '1') {
6958 $should_sync = true;
6959 }
6960 }
6961
6962 if (!$should_sync) {
6963 return;
6964 }
6965
6966 // Check if we have stored the previous status and URL in our transients
6967 $previous_status_key = 'mxchat_prev_status_' . $post_id;
6968 $previous_status = get_transient($previous_status_key);
6969
6970 $previous_url_key = 'mxchat_prev_url_' . $post_id;
6971 $previous_url = get_transient($previous_url_key);
6972
6973 // If the post was previously published but is now not published, remove from knowledge base
6974 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
6975 // Use the stored URL from when it was published, or fall back to current permalink
6976 $source_url = $previous_url ?: get_permalink($post_id);
6977
6978 // mxchat_handle_status_transition already deleted for this post earlier in this
6979 // request (it fires first inside wp_insert_post); skip the redundant round-trip.
6980 if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
6981 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6982 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6983 }
6984
6985 // Clean up the transients and exit early
6986 delete_transient($previous_status_key);
6987 delete_transient($previous_url_key);
6988 return;
6989 }
6990
6991 // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
6992 // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
6993 if ($post->post_status === 'publish' && !empty($previous_url)) {
6994 $current_url = get_permalink($post_id);
6995 if ($current_url && $current_url !== $previous_url) {
6996 MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
6997 }
6998 }
6999
7000 // Store the current status for next time (if this is an update)
7001 if ($update) {
7002 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
7003
7004 // If the post is currently published, also store its URL
7005 if ($post->post_status === 'publish') {
7006 $current_url = get_permalink($post_id);
7007 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
7008 }
7009 }
7010
7011 // Only process currently published content for adding/updating.
7012 // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
7013 // already indexed this post earlier in this request (editor publishes fire
7014 // transition_post_status first, then post_updated) — skip the duplicate embed.
7015 // Consume-once: the flag is cleared when honoured, so a LATER save of the same
7016 // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
7017 if ($post->post_status === 'publish') {
7018 if (!empty($this->transition_indexed_posts[$post_id])) {
7019 unset($this->transition_indexed_posts[$post_id]);
7020 } else {
7021 $this->mxchat_index_published_post($post_id, $post);
7022 }
7023 }
7024
7025 // Clean up the stored previous status if not used above
7026 if ($previous_status !== 'publish' || $post->post_status === 'publish') {
7027 delete_transient($previous_status_key);
7028 delete_transient($previous_url_key);
7029 }
7030 }
7031
7032 /**
7033 * Index a published post into the knowledge base: preprocessing filter, content
7034 * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
7035 * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
7036 * upsert, then tag-based role restriction.
7037 *
7038 * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
7039 * transition_post_status arrival edge (mxchat_handle_status_transition), so
7040 * scheduled publishes (wp_publish_post) and direct status=publish inserts index
7041 * identically to editor saves (plan 3055e1). Pure extraction of the former
7042 * publish branch — body indentation retained to keep the diff reviewable.
7043 */
7044 private function mxchat_index_published_post($post_id, $post) {
7045 $post_type = $post->post_type;
7046
7047 // WooCommerce products are owned by the WC-object assembler (plan a3d60c):
7048 // whenever WooCommerce is active AND the integration is enabled, every
7049 // product save also fires save_post_product, which queues
7050 // mxchat_store_product_embedding on shutdown — and that writer runs LAST,
7051 // overwriting the same md5(permalink) row this path would write. Assembling
7052 // and embedding the product here was pure duplicate spend (measured: two
7053 // embedding calls per product save, second one wins). Skip ONLY under the
7054 // exact conditions the shutdown writer runs — same option read as its own
7055 // gate — because with the integration off (or WooCommerce inactive) this
7056 // path is the sole product indexer and must keep working.
7057 if ($post_type === 'product'
7058 && class_exists('WooCommerce')
7059 && isset($this->options['enable_woocommerce_integration'])
7060 && in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
7061 return;
7062 }
7063
7064 // Get the source URL
7065 $source_url = get_permalink($post_id);
7066
7067 // A draft published programmatically (wp_publish_post) can reach this
7068 // point with an EMPTY post_name — wp_insert_post skips slug generation
7069 // for draft/pending — and get_permalink() then resolves to the bare
7070 // site root. A knowledge row keyed to the homepage cites the wrong URL
7071 // and answers homepage questions with this post's body, so refuse to
7072 // write it; the post indexes correctly on its next save, once the slug
7073 // exists. The empty-post_name test is what keeps a legitimate static
7074 // front page (which has a slug but a root permalink) indexable.
7075 // (Plan d138c4.)
7076 if ('' === $post->post_name
7077 && untrailingslashit($source_url) === untrailingslashit(home_url())) {
7078 return;
7079 }
7080
7081 /**
7082 * Allow developers to modify post data before processing into the knowledge base.
7083 * Same filter and signature as the manual bulk-import path
7084 * (ajax_mxchat_process_selected_content), so a callback registered once covers
7085 * every indexing route. Purely additive — zero behaviour change when unhooked.
7086 * Auto-sync runs under the 'default' bot context, matching the rest of this
7087 * function.
7088 *
7089 * @param WP_Post $post The post about to be indexed.
7090 * @param string $bot_id Bot context ('default' on auto-sync).
7091 */
7092 $post = apply_filters('mxchat_before_process_post', $post, 'default');
7093 if (!($post instanceof WP_Post)) {
7094 $post = get_post($post_id); // defend against a bad callback return
7095 }
7096
7097 // Assemble the indexable text via the shared post-kind assembler (a3d60c),
7098 // reading from the FILTERED post object — not re-fetched by ID, which would
7099 // discard it. Auto-sync reads content/excerpt in its historical
7100 // get_post_field() display context, appends a product's custom tabs
7101 // (plan f9573e — the bulk import wrote them and this save path dropped them
7102 // again; its product branch is reachable only with the WooCommerce
7103 // integration off, the state every store set up from today's screens is in),
7104 // and gates ACF→PDF extraction behind its own opt-in option — default OFF,
7105 // because re-parsing every ACF PDF on every editor save is expensive and
7106 // most sites don't want it (the 25 MB size cap lives in the shared
7107 // extractor either way).
7108 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
7109 'read_display' => true,
7110 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
7111 'include_product_tabs' => true,
7112 ));
7113 $final_content = $prepared['content'];
7114
7115 // Embedding decision — custom-provider-aware. Gating on a cloud API key
7116 // here silently killed auto-sync on keyless custom-embeddings sites,
7117 // because generate_embedding() routes custom FIRST and never needs the
7118 // key (plan cbd5fd). Silent-return shape preserved.
7119 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
7120 if (!$preflight['ok']) {
7121 return;
7122 }
7123 $api_key = $preflight['api_key'];
7124
7125 // Use the centralized utility function for storage
7126 $result = MxChat_Utils::submit_content_to_db(
7127 $final_content,
7128 $source_url,
7129 $api_key,
7130 md5($source_url) // Vector ID for Pinecone
7131 );
7132
7133 // After successful storage, apply role restriction based on tags
7134 if (!is_wp_error($result)) {
7135 $this->apply_role_restriction_to_post($post_id, $source_url);
7136 }
7137 }
7138
7139 /**
7140 * Shared post-fields content assembler (plan a3d60c) — the ONE body behind both
7141 * post-kind ingestion paths: manual bulk import (ajax_mxchat_process_selected_content)
7142 * and auto-sync (mxchat_index_published_post). Behavior-preserving extraction; the
7143 * measured per-caller differences ride $args instead of living as drifting copies:
7144 *
7145 * 'read_display' bool Auto-sync historically reads content/excerpt via
7146 * get_post_field() in its default 'display' context
7147 * (the post_content / post_excerpt display filters
7148 * fire); bulk import reads the raw properties. Inert
7149 * on a stock install — preserved per-path, not converged.
7150 * 'extract_acf_pdfs' bool Each caller passes its OWN option (bulk:
7151 * mxchat_acf_pdf_extraction; auto-sync:
7152 * mxchat_auto_sync_acf_pdfs) — the two-option design
7153 * is deliberate (plan 11720c). Gates BOTH the PDF-id
7154 * collection walk and the extraction loop; the ids are
7155 * only ever read inside the extraction branch, so
7156 * gating collection is output-identical on every install.
7157 * 'include_product_tabs' bool Append the yikes_woo custom tabs (own + applied
7158 * reusable) to product content. The bulk path has always
7159 * done so; the auto-sync product branch (reachable only
7160 * with the WooCommerce integration off) did not until
7161 * plan f9573e, so every editor save of a product dropped
7162 * the tabs its import had written. Both callers pass true
7163 * now; kb-reimport's 'sync' route mirrors the auto-sync
7164 * value so "what a save writes today" stays the truth it
7165 * compares against. Empty text for a product with no tabs.
7166 *
7167 * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
7168 * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
7169 */
7170 private function mxchat_prepare_post_content_for_indexing($post_id, $post, $args) {
7171 $read_display = !empty($args['read_display']);
7172 $extract_acf_pdfs = !empty($args['extract_acf_pdfs']);
7173 $include_product_tabs = !empty($args['include_product_tabs']);
7174
7175 // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
7176 // convert_chars and prepends the "Protected:" / "Private:" display chrome.
7177 // The knowledge base stores facts, not display strings. Entity decode at
7178 // output time (single-pass, shared helper) — a stored `&amp;` embeds worse
7179 // than `&` and gets quoted back to visitors (d2c92e).
7180 $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n";
7181
7182 $raw_excerpt = $read_display ? get_post_field('post_excerpt', $post) : $post->post_excerpt;
7183 $raw_content = $read_display ? get_post_field('post_content', $post) : $post->post_content;
7184
7185 // Add short description if it exists (WooCommerce products use post_excerpt for short description)
7186 // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
7187 // empty, and testing the raw value emitted a bare "Short Description: " label
7188 // with no value after it. trim() only in the TEST — the emitted value is
7189 // untouched, so a populated excerpt is byte-identical to before. A
7190 // whitespace-only excerpt is an empty excerpt and must not produce a labelled
7191 // line with nothing after it.
7192 $clean_excerpt = $this->strip_shortcode_tags_preserve_content($raw_excerpt);
7193 if (trim($clean_excerpt) !== '') {
7194 $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
7195 }
7196
7197 // Main content — resolve text the page pulls in by reference (synced patterns,
7198 // WoodMart HTML Blocks; plan 735943) by lookup, then remove shortcode tags but
7199 // preserve content inside them, then strip tags (don't use 'the_content'
7200 // filter as it may re-add shortcodes and would execute every shortcode).
7201 $clean_content = $this->strip_shortcode_tags_preserve_content($this->mxchat_inline_referenced_content($raw_content));
7202 $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
7203
7204 // WooCommerce product enrichment (post-fields kind). The WC-object assembler
7205 // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
7206 // integration is on; this branch serves the bulk import (all configurations)
7207 // and auto-sync with the integration off.
7208 if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
7209 $product = wc_get_product($post_id);
7210
7211 if ($product) {
7212 $content .= "\n";
7213 $content .= $this->mxchat_woo_product_summary_lines($product);
7214 }
7215
7216 if ($include_product_tabs) {
7217 $content .= $this->mxchat_woo_custom_tabs_text($post_id);
7218 }
7219 }
7220
7221 // For custom post types like job_listing, include additional fields
7222 if (get_post_type($post_id) === 'job_listing') {
7223 // Add job-specific meta if available
7224 $job_location = get_post_meta($post_id, '_job_location', true);
7225 if (!empty($job_location)) {
7226 $content .= "\n\nLocation: " . $job_location;
7227 }
7228
7229 // Get job type terms
7230 $job_types = get_the_terms($post_id, 'job_listing_type');
7231 if (!empty($job_types) && !is_wp_error($job_types)) {
7232 $types = array();
7233 foreach ($job_types as $type) {
7234 $types[] = $type->name;
7235 }
7236 $content .= "\n\nJob Type: " . implode(', ', $types);
7237 }
7238
7239 // Get company name if available
7240 $company_name = get_post_meta($post_id, '_company_name', true);
7241 if (!empty($company_name)) {
7242 $content .= "\n\nCompany: " . $company_name;
7243 }
7244 }
7245
7246 // ADD ACF FIELDS SUPPORT
7247 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
7248 $pdf_extracted_count = 0;
7249 if (!empty($acf_fields)) {
7250 $acf_content_parts = array();
7251 $pdf_attachment_ids = array();
7252
7253 foreach ($acf_fields as $field_name => $field_value) {
7254 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
7255
7256 if (!empty($formatted_value)) {
7257 // Both separators: a hyphenated ACF name should read as words.
7258 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
7259 $acf_content_parts[] = $field_label . ": " . $formatted_value;
7260 }
7261
7262 // Walk this field's value tree for any PDF attachment references and
7263 // queue them for extraction — only when this caller's PDF option is on.
7264 if ($extract_acf_pdfs) {
7265 $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
7266 }
7267 }
7268
7269 if (!empty($acf_content_parts)) {
7270 $content .= "\n\n" . implode("\n", $acf_content_parts);
7271 }
7272
7273 // Extract text from each unique PDF found in ACF fields and append as a labeled section
7274 if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
7275 $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
7276 $pdf_sections = array();
7277 foreach ($pdf_attachment_ids as $att_id) {
7278 $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
7279 if (!empty($pdf_text)) {
7280 $pdf_title = get_the_title($att_id);
7281 $pdf_url = wp_get_attachment_url($att_id);
7282 $header = 'PDF Attachment';
7283 if (!empty($pdf_title)) {
7284 $header .= ': ' . $pdf_title;
7285 }
7286 if (!empty($pdf_url)) {
7287 $header .= ' (' . $pdf_url . ')';
7288 }
7289 $pdf_sections[] = $header . "\n" . $pdf_text;
7290 $pdf_extracted_count++;
7291 }
7292 }
7293 if (!empty($pdf_sections)) {
7294 $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
7295 }
7296 }
7297 }
7298
7299 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
7300 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
7301 if (!empty($custom_meta)) {
7302 $meta_content_parts = array();
7303
7304 foreach ($custom_meta as $meta_key => $meta_value) {
7305 // Convert meta key to readable label
7306 $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
7307 $meta_content_parts[] = $meta_label . ": " . $meta_value;
7308 }
7309
7310 if (!empty($meta_content_parts)) {
7311 $content .= "\n\n" . implode("\n", $meta_content_parts);
7312 }
7313 }
7314
7315 return array(
7316 'content' => $content,
7317 'acf_fields_found' => count($acf_fields),
7318 'pdf_extracted_count' => $pdf_extracted_count,
7319 );
7320 }
7321
7322 /**
7323 * Shared WC-object product assembler (plan a3d60c) — the ONE body behind the two
7324 * WooCommerce-object ingestion paths: the auto-sync product writer
7325 * (mxchat_store_product_embedding) and the URL/sitemap product import
7326 * (mxchat_extract_woocommerce_product_content). Assembles from the WC_Product,
7327 * the authoritative source for product rows (scope decision on the plan).
7328 */
7329 private function mxchat_prepare_product_content_for_indexing($product) {
7330 $title = $product->get_name();
7331 $description = $product->get_description();
7332 $short_description = $product->get_short_description();
7333
7334 // Format content consistently
7335 $content = $title . "\n\n";
7336
7337 if (!empty($short_description)) {
7338 $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7339 }
7340
7341 if (!empty($description)) {
7342 $content .= wp_strip_all_tags($description) . "\n\n";
7343 }
7344
7345 $content .= $this->mxchat_woo_product_summary_lines($product);
7346 $content .= $this->mxchat_woo_custom_tabs_text($product->get_id());
7347
7348 return $content;
7349 }
7350
7351 /**
7352 * Pricing + SKU + categories + tags lines for a product — shared by both assembler kinds
7353 * (the post-fields product enrichment and the WC-object assembler).
7354 */
7355 private function mxchat_woo_product_summary_lines($product) {
7356 // Add pricing information (base-currency pinned — see mxchat_product_price_lines)
7357 $lines = $this->mxchat_product_price_lines($product);
7358
7359 $sku = $product->get_sku();
7360 if (!empty($sku)) {
7361 $lines .= "SKU: " . $sku . "\n";
7362 }
7363
7364 // Get product categories
7365 $categories = wp_get_post_terms($product->get_id(), 'product_cat', array('fields' => 'names'));
7366 if (!empty($categories) && !is_wp_error($categories)) {
7367 $lines .= "Categories: " . implode(', ', $categories) . "\n";
7368 }
7369
7370 // Product tags (plan 799cc4). A store's tags often carry the words a
7371 // visitor asks with ("vegan", "gift") and none of them are in the
7372 // description. No line at all when the product has no tags.
7373 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
7374 if (!empty($tags) && !is_wp_error($tags)) {
7375 $lines .= "Tags: " . $this->mxchat_decode_entities_for_indexing(implode(', ', $tags)) . "\n";
7376 }
7377
7378 return $lines;
7379 }
7380
7381 /**
7382 * Custom Product Tabs text (supports "Custom Product Tabs for WooCommerce" by
7383 * Code Parrots) — direct tabs plus applied reusable/saved tabs. The ONE copy of
7384 * the yikes_woo logic; three sites carried byte-identical clones before a3d60c.
7385 */
7386 private function mxchat_woo_custom_tabs_text($product_id) {
7387 $text = '';
7388
7389 $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7390 if (!empty($custom_tabs) && is_array($custom_tabs)) {
7391 foreach ($custom_tabs as $tab) {
7392 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7393 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7394
7395 if (!empty($tab_title) && !empty($tab_content)) {
7396 $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7397 }
7398 }
7399 }
7400
7401 // Also check for reusable/saved tabs applied to this product
7402 $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7403 if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7404 $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7405 if (!empty($saved_tabs) && is_array($saved_tabs)) {
7406 foreach ($applied_saved_tabs as $saved_tab_id) {
7407 if (isset($saved_tabs[$saved_tab_id])) {
7408 $tab = $saved_tabs[$saved_tab_id];
7409 $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7410 $tab_content = isset($tab['content']) ? $tab['content'] : '';
7411
7412 if (!empty($tab_title) && !empty($tab_content)) {
7413 $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7414 }
7415 }
7416 }
7417 }
7418 }
7419
7420 return $text;
7421 }
7422
7423 /**
7424 * Store the post status and URL before update to detect status transitions
7425 * This runs before the post is actually updated in the database
7426 */
7427 public function mxchat_store_pre_update_status($post_id, $data) {
7428 // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
7429 // this request and can consume the arrival-edge guard (plan a664f3).
7430 $this->pending_post_update[$post_id] = true;
7431
7432 // Get the current post from database (before update)
7433 $current_post = get_post($post_id);
7434
7435 if ($current_post) {
7436 // Store the current status temporarily
7437 $status_key = 'mxchat_prev_status_' . $post_id;
7438 set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
7439
7440 // If the post is currently published, also store its URL
7441 if ($current_post->post_status === 'publish') {
7442 $url_key = 'mxchat_prev_url_' . $post_id;
7443 $current_url = get_permalink($post_id);
7444 set_transient($url_key, $current_url, HOUR_IN_SECONDS);
7445 }
7446 }
7447 }
7448
7449 /**
7450 * Whether auto-sync is enabled for a post type (mirrors the checks used by the
7451 * update/delete handlers; kept as one helper so new call sites cannot drift).
7452 */
7453 private function mxchat_is_auto_sync_enabled($post_type) {
7454 if ($post_type === 'post') {
7455 return get_option('mxchat_auto_sync_posts') === '1';
7456 }
7457 if ($post_type === 'page') {
7458 return get_option('mxchat_auto_sync_pages') === '1';
7459 }
7460 return get_option('mxchat_auto_sync_' . $post_type) === '1';
7461 }
7462
7463 /**
7464 * Remove a post's vectors the moment it leaves 'publish', using the authoritative
7465 * old status core passes to transition_post_status — no transient involved (plan 816fb1).
7466 *
7467 * Covers status changes that never route through wp_update_post (scheduled-expiry
7468 * plugins and others that flip post_status directly and call wp_transition_post_status),
7469 * where neither pre_post_update nor post_updated fires and the old detection missed.
7470 */
7471 public function mxchat_handle_status_transition($new_status, $old_status, $post) {
7472 if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
7473 return;
7474 }
7475
7476 // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
7477 // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
7478 // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
7479 // auto-sync ADD path alone misses them. Editor publishes also pass through here;
7480 // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
7481 // a second time in the same request.
7482 if ($new_status === 'publish' && $old_status !== 'publish') {
7483 if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
7484 $this->mxchat_index_published_post($post->ID, $post);
7485
7486 // Arm the double-fire guard ONLY when a post_updated is actually coming to
7487 // consume it (plan a664f3). Two publish paths never fire post_updated at all:
7488 // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
7489 // call check_and_publish_future_post() makes for scheduled posts. Arming the
7490 // guard unconditionally left it set with nothing to consume it, so the NEXT
7491 // update of that post was swallowed entirely: zero embed calls, no knowledge
7492 // -base row, silently. Consume-once on this side too, so a guard can never
7493 // outlive the single save it was armed for.
7494 if (!empty($this->pending_post_update[$post->ID])) {
7495 unset($this->pending_post_update[$post->ID]);
7496 $this->transition_indexed_posts[$post->ID] = true;
7497 }
7498 }
7499 return;
7500 }
7501
7502 // Only the publish -> not-publish edge matters here.
7503 if ($old_status !== 'publish' || $new_status === 'publish') {
7504 return;
7505 }
7506 // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
7507 // resolution; skip to avoid a second network round-trip per trash.
7508 if ($new_status === 'trash') {
7509 return;
7510 }
7511 if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
7512 return;
7513 }
7514
7515 $urls = array();
7516
7517 // The DB may already hold the new status when this fires, so get_permalink() on the
7518 // live post could build a draft-style URL whose md5 misses the stored vector IDs.
7519 // Reconstruct the published permalink from a clone instead.
7520 $published_clone = clone $post;
7521 $published_clone->post_status = 'publish';
7522 $published_url = get_permalink($published_clone);
7523 if ($published_url) {
7524 $urls[] = $published_url;
7525 }
7526
7527 // Honour the pre-update capture when present (covers a slug change in the same save).
7528 $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
7529 if (!empty($previous_url)) {
7530 $urls[] = $previous_url;
7531 }
7532
7533 foreach (array_unique($urls) as $url) {
7534 MxChat_Utils::delete_chunks_for_url($url, 'default');
7535 }
7536
7537 if (!empty($urls)) {
7538 $this->transition_deleted_posts[$post->ID] = true;
7539 }
7540 }
7541
7542 /**
7543 * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
7544 * trashed, or made private before the transition_post_status handler existed.
7545 *
7546 * Walks every auto-synced post type's non-published posts, reconstructs each one's
7547 * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
7548 * Deletion is idempotent, so never-indexed posts are a cheap no-op.
7549 *
7550 * ## OPTIONS
7551 *
7552 * [--dry-run]
7553 * : Report what would be removed without deleting anything.
7554 *
7555 * ## EXAMPLES
7556 *
7557 * wp mxchat prune-unpublished --dry-run
7558 * wp mxchat prune-unpublished
7559 */
7560 public function cli_prune_unpublished($args, $assoc_args) {
7561 global $wpdb;
7562 $dry_run = !empty($assoc_args['dry-run']);
7563 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7564
7565 $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
7566 $synced_types = array();
7567 foreach ($candidate_types as $type) {
7568 if ($this->mxchat_is_auto_sync_enabled($type)) {
7569 $synced_types[] = $type;
7570 }
7571 }
7572 if (empty($synced_types)) {
7573 WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
7574 return;
7575 }
7576
7577 $scanned = 0;
7578 $pruned = 0;
7579 $paged = 1;
7580 do {
7581 $query = new WP_Query(array(
7582 'post_type' => $synced_types,
7583 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
7584 'posts_per_page' => 100,
7585 'paged' => $paged,
7586 'fields' => 'ids',
7587 ));
7588 foreach ($query->posts as $post_id) {
7589 $post = get_post($post_id);
7590 if (!$post) {
7591 continue;
7592 }
7593 $scanned++;
7594
7595 // Rebuild the permalink the post had while published: publish-status clone,
7596 // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
7597 $clone = clone $post;
7598 $clone->post_status = 'publish';
7599 if (substr($clone->post_name, -9) === '__trashed') {
7600 $clone->post_name = substr($clone->post_name, 0, -9);
7601 }
7602 $url = get_permalink($clone);
7603 if (!$url) {
7604 continue;
7605 }
7606
7607 // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
7608 // reads 0 but the delete below still routes to Pinecone and is idempotent.
7609 $local_rows = (int) $wpdb->get_var($wpdb->prepare(
7610 "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
7611 ));
7612
7613 if ($dry_run) {
7614 if ($local_rows > 0) {
7615 WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7616 $pruned += $local_rows;
7617 }
7618 continue;
7619 }
7620
7621 MxChat_Utils::delete_chunks_for_url($url, 'default');
7622 if ($local_rows > 0) {
7623 WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
7624 $pruned += $local_rows;
7625 }
7626 }
7627 $more = $paged < $query->max_num_pages;
7628 $paged++;
7629 } while ($more);
7630
7631 WP_CLI::success(sprintf(
7632 '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
7633 $dry_run ? 'Would remove' : 'Removed',
7634 $pruned,
7635 $scanned,
7636 ' (Pinecone-mode deletions are not counted locally.)'
7637 ));
7638 }
7639
7640 /**
7641 * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual
7642 * (reversed) order before the RTL normalizer existed. 32bf9e fixed new
7643 * imports only; this fixes rows already in the table without the customer
7644 * having to re-source and re-upload the original PDFs (plan d1e6f7).
7645 *
7646 * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a
7647 * row is a candidate exactly when the normalizer would change it, so the
7648 * import-time heuristic and the repair heuristic can never disagree.
7649 * Repaired rows are RE-EMBEDDED — the stored vector was computed over
7650 * reversed text and is as broken as the text — so a wet run calls the
7651 * embedding provider once per repaired row on the site's API key. Runs
7652 * beyond 25 rows therefore require --yes.
7653 *
7654 * Scope notes:
7655 * - Scans the WordPress knowledge table. Pinecone-mode entries live in
7656 * Pinecone, not this table, and are not scanned; if a scanned row's bot
7657 * ALSO has Pinecone enabled (hybrid drift), the repaired entry is
7658 * re-submitted through the normal import path so the md5-keyed Pinecone
7659 * vector is replaced too.
7660 * - Knowledge rows do not carry a bot id; --bot only selects whose
7661 * embedding configuration (model + key) is used for re-embedding.
7662 * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled
7663 * normalization gets detections of zero, not surprise rewrites.
7664 * - The metadata header the PDF importer stores before the text separator
7665 * is preserved byte-identical; only the text segment is repaired.
7666 *
7667 * ## OPTIONS
7668 *
7669 * [--dry-run]
7670 * : List the rows that would be repaired without changing anything.
7671 *
7672 * [--bot=<id>]
7673 * : Embedding configuration to use for re-embedding. Default: default.
7674 *
7675 * [--all-content]
7676 * : Scan every row containing right-to-left text, not just rows with PDF
7677 * provenance (a page anchor in the source URL, or pdf content type).
7678 *
7679 * [--yes]
7680 * : Proceed even when more than 25 rows need re-embedding (API cost gate).
7681 *
7682 * ## EXAMPLES
7683 *
7684 * wp mxchat rtl-repair --dry-run
7685 * wp mxchat rtl-repair
7686 * wp mxchat rtl-repair --all-content --yes
7687 */
7688 public function cli_rtl_repair($args, $assoc_args) {
7689 global $wpdb;
7690 $dry_run = !empty($assoc_args['dry-run']);
7691 $all = !empty($assoc_args['all-content']);
7692 $yes = !empty($assoc_args['yes']);
7693 $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default';
7694 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7695
7696 // Detection pass — no API calls. Walk the table in id batches so a large
7697 // knowledge base never loads at once.
7698 $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';
7699 $candidates = array();
7700 $scanned = 0;
7701 $last_id = 0;
7702 do {
7703 if ($all) {
7704 $rows = $wpdb->get_results($wpdb->prepare(
7705 "SELECT id, article_content, source_url, content_type FROM {$table}
7706 WHERE id > %d ORDER BY id ASC LIMIT 200",
7707 $last_id
7708 ));
7709 } else {
7710 $rows = $wpdb->get_results($wpdb->prepare(
7711 "SELECT id, article_content, source_url, content_type FROM {$table}
7712 WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf')
7713 ORDER BY id ASC LIMIT 200",
7714 $last_id,
7715 '%' . $wpdb->esc_like('#page=') . '%'
7716 ));
7717 }
7718 foreach ($rows as $row) {
7719 $last_id = (int) $row->id;
7720 $scanned++;
7721 $content = (string) $row->article_content;
7722 if (!preg_match($rtl_re, $content)) {
7723 continue;
7724 }
7725 list($header, $text) = $this->mxchat_rtl_repair_split($content);
7726 $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id);
7727 if (is_string($normalized) && $normalized !== $text) {
7728 $candidates[] = array(
7729 'id' => (int) $row->id,
7730 'source_url' => (string) $row->source_url,
7731 'content_type' => (string) $row->content_type,
7732 'new_content' => $header . $normalized,
7733 );
7734 }
7735 }
7736 } while (count($rows) === 200);
7737
7738 WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates)));
7739 if (empty($candidates)) {
7740 WP_CLI::success('No reversed RTL rows found — nothing to repair.');
7741 return;
7742 }
7743
7744 foreach ($candidates as $c) {
7745 WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url']));
7746 }
7747 if ($dry_run) {
7748 WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates)));
7749 return;
7750 }
7751
7752 // Cost gate: re-embedding spends the customer's API budget.
7753 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)));
7754 if (count($candidates) > 25 && !$yes) {
7755 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)));
7756 }
7757
7758 $bot_options = $this->get_bot_options($bot_id);
7759 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7760 $preflight = MxChat_Utils::embedding_preflight($options);
7761 if (!$preflight['ok']) {
7762 WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']);
7763 }
7764 $api_key = $preflight['api_key'];
7765
7766 $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id);
7767 $repaired = 0;
7768 $failed = 0;
7769 foreach ($candidates as $c) {
7770 $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id);
7771 if (!is_array($vector)) {
7772 $failed++;
7773 $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed';
7774 // Text and vector must stay consistent: never write repaired text
7775 // beside the stale reversed-text vector.
7776 WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason));
7777 continue;
7778 }
7779 $wpdb->update(
7780 $table,
7781 array(
7782 'article_content' => $c['new_content'],
7783 'embedding_vector' => maybe_serialize($vector),
7784 ),
7785 array('id' => $c['id']),
7786 array('%s', '%s'),
7787 array('%d')
7788 );
7789 $repaired++;
7790 if (class_exists('MxChat_Admin')) {
7791 MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array(
7792 'row_id' => $c['id'],
7793 'source_url' => $c['source_url'],
7794 'bot' => $bot_id,
7795 ));
7796 }
7797 // Hybrid drift: the bot indexes into Pinecone but this row sat in the
7798 // WP table — push the repaired entry through the normal import path so
7799 // the md5(source_url)-keyed Pinecone vector is replaced as well.
7800 if ($pinecone_hybrid) {
7801 MxChat_Utils::submit_content_to_db(
7802 $c['new_content'],
7803 $c['source_url'],
7804 $api_key,
7805 null,
7806 $bot_id,
7807 $c['content_type'] !== '' ? $c['content_type'] : 'pdf'
7808 );
7809 }
7810 }
7811
7812 WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned));
7813 }
7814
7815 /**
7816 * Split a stored KB row into (metadata header incl. separator, text segment).
7817 * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text —
7818 * repair must touch only the text and keep the header byte-identical.
7819 */
7820 private function mxchat_rtl_repair_split($content) {
7821 $sep = "\n---\n";
7822 $pos = strpos($content, $sep);
7823 if ($pos !== false && $pos > 0 && $content[0] === '{') {
7824 $maybe_json = substr($content, 0, $pos);
7825 if (json_decode($maybe_json) !== null) {
7826 return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep)));
7827 }
7828 }
7829 return array('', $content);
7830 }
7831
7832 /**
7833 * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for
7834 * the repair CLI's hybrid-drift check.
7835 */
7836 private function mxchat_rtl_repair_pinecone_enabled($bot_id) {
7837 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
7838 $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
7839 if (!empty($cfg)) {
7840 return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']);
7841 }
7842 }
7843 $po = get_option('mxchat_pinecone_addon_options');
7844 return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0'
7845 && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']);
7846 }
7847
7848 /* =========================================================================
7849 * Re-import affected entries (plan 89739b)
7850 *
7851 * Several releases corrected how knowledge text is imported, each only for
7852 * entries added afterwards: HTML entities (d2c92e), currency signs (7403ec),
7853 * combining marks and symbols (a19914), joiner characters (209e57) and
7854 * right-to-left PDF order (32bf9e). This walks the knowledge base, rebuilds
7855 * each entry's text from its source with the CURRENT import chain, and
7856 * rewrites an entry only when (a) the rebuilt text differs from the stored
7857 * text in one of those ways and (b) the two are otherwise the same document.
7858 * An entry whose source simply changed since it was imported is reported and
7859 * left alone: that is a different question from "was it damaged at import".
7860 *
7861 * One engine, two callers: wp mxchat kb-reimport and the Knowledge screen's
7862 * resumable action. A scan makes no embedding call and writes no entry.
7863 * ====================================================================== */
7864
7865 /** Release date of 3.2.20, the last release that changed how text is imported. */
7866 const REIMPORT_DEFAULT_BEFORE = '2026-08-28';
7867 const REIMPORT_STATE_OPTION = 'mxchat_kb_reimport_state';
7868
7869 /** Which store holds the entries for this bot: wordpress, pinecone or documents. */
7870 private function mxchat_reimport_store($bot_id) {
7871 if (!$this->mxchat_rtl_repair_pinecone_enabled($bot_id)) {
7872 return 'wordpress';
7873 }
7874 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
7875 return 'documents';
7876 }
7877 return 'pinecone';
7878 }
7879
7880 private function mxchat_reimport_pinecone_cfg($bot_id) {
7881 $po = class_exists('MxChat_Pinecone_Manager')
7882 ? MxChat_Pinecone_Manager::get_instance()->mxchat_get_bot_pinecone_options($bot_id)
7883 : get_option('mxchat_pinecone_addon_options', array());
7884 return array(
7885 'api_key' => (string) ($po['mxchat_pinecone_api_key'] ?? ''),
7886 'host' => (string) ($po['mxchat_pinecone_host'] ?? ''),
7887 'namespace' => (string) ($po['mxchat_pinecone_namespace'] ?? ''),
7888 );
7889 }
7890
7891 private function mxchat_reimport_new_state($args) {
7892 $bot_id = isset($args['bot']) ? sanitize_key($args['bot']) : 'default';
7893 $before = isset($args['before']) ? (string) $args['before'] : self::REIMPORT_DEFAULT_BEFORE;
7894 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $before)) {
7895 $before = self::REIMPORT_DEFAULT_BEFORE;
7896 }
7897 global $wpdb;
7898 $store = $this->mxchat_reimport_store($bot_id);
7899 return array(
7900 'phase' => 'scan',
7901 'bot' => $bot_id,
7902 'store' => $store,
7903 'source' => isset($args['source']) && $args['source'] !== '' ? (string) $args['source'] : 'all',
7904 'before' => $before,
7905 'cap' => isset($args['cap']) ? (int) $args['cap'] : 100,
7906 // Plan a00ef8: also rewrite entries whose page was edited after import;
7907 // with the default date they are offered whatever their own date says.
7908 'with_edited' => !empty($args['edited']),
7909 'before_set' => !empty($args['before_set']),
7910 'cursor' => $store === 'wordpress' ? 0 : '',
7911 'max_id' => $store === 'wordpress' ? (int) $wpdb->get_var("SELECT MAX(id) FROM {$wpdb->prefix}mxchat_system_prompt_content") : 0,
7912 'scan_done' => false,
7913 'scanned' => 0,
7914 'unchanged' => 0,
7915 'changed' => 0,
7916 'edited' => 0,
7917 'skipped' => 0,
7918 'candidates' => array(),
7919 'notes' => array(),
7920 'pos' => 0,
7921 'repaired' => 0,
7922 'failed' => 0,
7923 'error' => '',
7924 'updated' => time(),
7925 );
7926 }
7927
7928 /** True when the entry passes --source and --before ($ignore_date: --source only). */
7929 private function mxchat_reimport_selected(array $entry, array $state, $ignore_date = false) {
7930 $url = (string) $entry['source_url'];
7931 $source = (string) $state['source'];
7932 if ($source === 'upload') {
7933 if (!MxChat_Utils::is_upload_source($url)) {
7934 return false;
7935 }
7936 } elseif ($source === 'manual') {
7937 if ($url !== '' && strpos($url, 'mxchat://') !== 0) {
7938 return false;
7939 }
7940 } elseif ($source !== 'all' && $source !== '') {
7941 if (strpos($url, $source) !== 0) {
7942 return false;
7943 }
7944 }
7945 if ($ignore_date) {
7946 return true;
7947 }
7948 $limit = strtotime($state['before'] . ' 00:00:00');
7949 $when = $this->mxchat_reimport_entry_time($entry);
7950 // An entry with no date on record predates the date field: treat it as old.
7951 return ($when === 0) || ($limit === false) || ($when < $limit);
7952 }
7953
7954 /** When the entry was last written, as a Unix time; 0 when nothing is on record. */
7955 private function mxchat_reimport_entry_time(array $entry) {
7956 $when = $entry['updated'];
7957 if (is_string($when) && $when !== '' && !ctype_digit($when)) {
7958 $when = strtotime($when);
7959 }
7960 return (int) $when;
7961 }
7962
7963 /**
7964 * True when the post, page, product or Media Library file behind an entry was
7965 * modified after the entry was written (plan a00ef8). With no date on the
7966 * entry there is nothing to compare, so false.
7967 */
7968 private function mxchat_reimport_edited_after(array $entry, $post_id) {
7969 $when = $this->mxchat_reimport_entry_time($entry);
7970 if ($when <= 0 || !$post_id) {
7971 return false;
7972 }
7973 return ((int) get_post_modified_time('U', true, (int) $post_id)) > $when;
7974 }
7975
7976 /**
7977 * One page of entries (not rows: a chunked entry is one entry).
7978 * @return array{entries:array,done:bool}|WP_Error
7979 */
7980 private function mxchat_reimport_list_page(array &$state, $limit) {
7981 if ($state['store'] === 'wordpress') {
7982 global $wpdb;
7983 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
7984 $rows = $wpdb->get_results($wpdb->prepare(
7985 "SELECT MIN(id) AS first_id, MAX(source_url) AS source_url, MAX(content_type) AS content_type,
7986 MAX(`timestamp`) AS updated, COUNT(*) AS n
7987 FROM {$table}
7988 GROUP BY CASE WHEN source_url IS NULL OR source_url = '' THEN CONCAT('#', id) ELSE source_url END
7989 HAVING first_id > %d AND first_id <= %d
7990 ORDER BY first_id ASC LIMIT %d",
7991 (int) $state['cursor'], (int) $state['max_id'], (int) $limit
7992 ));
7993 $entries = array();
7994 foreach ((array) $rows as $row) {
7995 $state['cursor'] = (int) $row->first_id;
7996 $entries[] = array(
7997 'store' => 'wordpress',
7998 'id' => (int) $row->first_id,
7999 'source_url' => (string) $row->source_url,
8000 'content_type' => (string) $row->content_type,
8001 'updated' => (string) $row->updated,
8002 'chunked' => ((int) $row->n) > 1,
8003 );
8004 }
8005 return array('entries' => $entries, 'done' => count((array) $rows) < $limit);
8006 }
8007
8008 $cfg = $this->mxchat_reimport_pinecone_cfg($state['bot']);
8009 if ($cfg['api_key'] === '' || $cfg['host'] === '') {
8010 return new WP_Error('pinecone_config', __('Pinecone is switched on but has no API key or host.', 'mxchat'));
8011 }
8012 $is_docs = ($state['store'] === 'documents');
8013 $page = $is_docs
8014 ? MxChat_Pinecone_Documents::list_page('', $limit, (string) $state['cursor'], $cfg)
8015 : MxChat_Pinecone_Documents::classic_list_page($cfg['host'], $cfg['api_key'], $cfg['namespace'], $limit, (string) $state['cursor']);
8016 if (is_wp_error($page)) {
8017 return $page;
8018 }
8019 // A chunked entry is taken once, at its first chunk; the other chunk ids are passed over.
8020 $anchors = array();
8021 foreach ($page['ids'] as $id) {
8022 if (preg_match('/_chunk_(\d+)$/', $id, $m) && (int) $m[1] !== 0) {
8023 continue;
8024 }
8025 $anchors[] = $id;
8026 }
8027 $records = array();
8028 if (!empty($anchors)) {
8029 $records = $is_docs
8030 ? MxChat_Pinecone_Documents::fetch($anchors, $cfg)
8031 : MxChat_Pinecone_Documents::classic_fetch($cfg['host'], $cfg['api_key'], $cfg['namespace'], $anchors);
8032 if (is_wp_error($records)) {
8033 return $records;
8034 }
8035 }
8036 $entries = array();
8037 foreach ($anchors as $id) {
8038 if (!isset($records[$id])) {
8039 continue;
8040 }
8041 $meta = isset($records[$id]['metadata']) && is_array($records[$id]['metadata']) ? $records[$id]['metadata'] : array();
8042 $entries[] = array(
8043 'store' => $state['store'],
8044 'id' => (string) $id,
8045 'source_url' => (string) ($meta['source_url'] ?? ''),
8046 'content_type' => (string) ($meta['type'] ?? 'content'),
8047 'updated' => (int) ($meta['last_updated'] ?? 0),
8048 'chunked' => (bool) preg_match('/_chunk_0$/', $id),
8049 'text' => (string) ($meta['text'] ?? ''),
8050 );
8051 }
8052 $state['cursor'] = (string) $page['next'];
8053 return array('entries' => $entries, 'done' => ((string) $page['next'] === ''));
8054 }
8055
8056 /** The entry's stored text, chunks joined. @return string|WP_Error */
8057 private function mxchat_reimport_stored_text(array $entry, $bot_id) {
8058 if ($entry['store'] === 'wordpress') {
8059 $got = $this->get_wordpress_entry_content($entry['source_url'], (int) $entry['id']);
8060 } elseif (empty($entry['chunked']) && isset($entry['text'])) {
8061 return (string) $entry['text'];
8062 } else {
8063 // A chunked entry, or one read back from a saved scan (which keeps no text).
8064 $got = $this->get_pinecone_entry_content($entry['source_url'], (string) $entry['id'], $bot_id);
8065 }
8066 return is_wp_error($got) ? $got : (string) $got['content'];
8067 }
8068
8069 private function mxchat_reimport_is_pdf(array $entry) {
8070 $url = (string) $entry['source_url'];
8071 return $entry['content_type'] === 'pdf'
8072 || strpos($url, '#page=') !== false
8073 || (MxChat_Utils::is_upload_source($url) && (bool) preg_match('/\.pdf$/i', (string) MxChat_Utils::upload_source_base($url)));
8074 }
8075
8076 /** Text that came out of the PDF reader, page entry or whole file: reading order can apply. */
8077 private function mxchat_reimport_from_pdf(array $entry) {
8078 return $this->mxchat_reimport_is_pdf($entry) || (bool) preg_match('/\.pdf(?:[?#]|$)/i', (string) $entry['source_url']);
8079 }
8080
8081 private function mxchat_reimport_squash($text) {
8082 $out = preg_replace('/[\s\p{Z}]+/u', '', (string) $text);
8083 return is_string($out) ? $out : (string) $text;
8084 }
8085
8086 /**
8087 * Comparison form only, never stored. Both texts are reduced to what the
8088 * oldest import chain kept (letters, numbers, punctuation, math symbols:
8089 * the allowlist that shipped until 3.2.18), with entities decoded and
8090 * right-to-left PDF text put in reading order. Two texts with the same form
8091 * are the same document, differing only in what the fixes restored.
8092 */
8093 private function mxchat_reimport_norm($text, $is_pdf) {
8094 $text = (string) $text;
8095 for ($i = 0; $i < 3; $i++) {
8096 $decoded = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
8097 if ($decoded === $text) {
8098 break;
8099 }
8100 $text = $decoded;
8101 }
8102 if ($is_pdf) {
8103 $logical = MxChat_Utils::normalize_pdf_rtl($text, 'kb-reimport compare');
8104 if (is_string($logical)) {
8105 $text = $logical;
8106 }
8107 }
8108 $kept = preg_replace('/[^\p{L}\p{N}\p{P}\p{Sm}]+/u', '', $text);
8109 return is_string($kept) ? $kept : $this->mxchat_reimport_squash($text);
8110 }
8111
8112 private function mxchat_reimport_count($pattern, $text) {
8113 $n = preg_match_all($pattern, (string) $text);
8114 return $n ? (int) $n : 0;
8115 }
8116
8117 private function mxchat_reimport_entity_count($text) {
8118 if (!preg_match_all('/&(?:#[0-9]{1,7}|#x[0-9a-f]{1,6}|[a-z][a-z0-9]{1,31});/i', (string) $text, $m)) {
8119 return 0;
8120 }
8121 $n = 0;
8122 foreach ($m[0] as $entity) {
8123 if (html_entity_decode($entity, ENT_QUOTES | ENT_HTML5, 'UTF-8') !== $entity) {
8124 $n++;
8125 }
8126 }
8127 return $n;
8128 }
8129
8130 /** Why the rebuilt text differs from the stored text, in the fixes' own terms. */
8131 private function mxchat_reimport_reasons($stored, $fresh, $is_pdf) {
8132 $reasons = array();
8133 if ($this->mxchat_reimport_entity_count($stored) > $this->mxchat_reimport_entity_count($fresh)) {
8134 $reasons[] = 'entities';
8135 }
8136 if ($this->mxchat_reimport_count('/\p{Sc}/u', $fresh) > $this->mxchat_reimport_count('/\p{Sc}/u', $stored)) {
8137 $reasons[] = 'currency';
8138 }
8139 if ($this->mxchat_reimport_count('/\p{M}/u', $fresh) > $this->mxchat_reimport_count('/\p{M}/u', $stored)) {
8140 $reasons[] = 'combining marks';
8141 }
8142 if ($this->mxchat_reimport_count('/\p{So}/u', $fresh) > $this->mxchat_reimport_count('/\p{So}/u', $stored)) {
8143 $reasons[] = 'symbols';
8144 }
8145 if ($this->mxchat_reimport_count('/[\x{200C}\x{200D}]/u', $fresh) > $this->mxchat_reimport_count('/[\x{200C}\x{200D}]/u', $stored)) {
8146 $reasons[] = 'joiners';
8147 }
8148 if ($is_pdf) {
8149 $logical = MxChat_Utils::normalize_pdf_rtl($stored, 'kb-reimport detect');
8150 if (is_string($logical) && $logical !== $stored) {
8151 $reasons[] = 'RTL';
8152 }
8153 }
8154 return $reasons;
8155 }
8156
8157 /** A product price line written before 7403ec has no currency code; one written since has. */
8158 private function mxchat_reimport_price_code_missing($stored, $fresh) {
8159 $coded = '/^(?:Price|Sale Price|Price Range): [A-Z]{3} /m';
8160 return (bool) preg_match('/^(?:Price|Sale Price|Price Range): /m', $stored)
8161 && !preg_match($coded, $stored)
8162 && (bool) preg_match($coded, $fresh);
8163 }
8164
8165 /** What submit_content_to_db() does to text before it stores it. */
8166 private function mxchat_reimport_storable($text) {
8167 $text = wp_check_invalid_utf8((string) $text);
8168 $out = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
8169 return is_string($out) ? $out : $text;
8170 }
8171
8172 /**
8173 * Rebuild an entry's text from its source with the current import chain.
8174 *
8175 * @return array{mode:string,texts?:array,title?:string,post_id?:int,note?:string}
8176 * mode 'inplace' : no source to go back to (manual text, an uploaded file, a
8177 * PDF page) - only reading order can be repaired, in place.
8178 * mode 'refetch' : 'texts' holds one rebuilt text per import route that could
8179 * have written this entry (label => text).
8180 * mode 'skip' : 'note' says why.
8181 */
8182 private function mxchat_reimport_fresh(array $entry, $bot_id) {
8183 $url = (string) $entry['source_url'];
8184 $type = (string) $entry['content_type'];
8185
8186 if (!preg_match('#^https?://#i', $url) || $this->mxchat_reimport_is_pdf($entry)) {
8187 return array('mode' => 'inplace');
8188 }
8189 // The WordPress store files any mxchat.ai address as manual text and would
8190 // add a second row instead of updating this one, so treat it as manual here too.
8191 if ($entry['store'] === 'wordpress' && strpos($url, 'mxchat.ai') !== false) {
8192 return array('mode' => 'inplace');
8193 }
8194 if ($type === 'youtube') {
8195 return array('mode' => 'skip', 'note' => __('a video entry, not an imported page', 'mxchat'), 'quiet' => true);
8196 }
8197
8198 // Imported by address (URL or sitemap import): fetch the page the way the importer does.
8199 if ($type === 'url' || $type === 'product') {
8200 $texts = array();
8201 if (class_exists('WooCommerce') && (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false)) {
8202 $product_text = $this->mxchat_extract_woocommerce_product_content($url);
8203 if (!empty($product_text)) {
8204 $texts['product'] = $product_text;
8205 }
8206 }
8207 if ($type === 'url' || empty($texts)) {
8208 $response = wp_remote_get($url, array(
8209 'timeout' => 30,
8210 'redirection' => 5,
8211 'user-agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat',
8212 ));
8213 if (is_wp_error($response)) {
8214 if (empty($texts)) {
8215 return array('mode' => 'skip', 'note' => sprintf(__('the page could not be fetched (%s)', 'mxchat'), $response->get_error_message()));
8216 }
8217 } else {
8218 $code = (int) wp_remote_retrieve_response_code($response);
8219 if ($code !== 200) {
8220 if (empty($texts)) {
8221 return array('mode' => 'skip', 'note' => sprintf(__('the page is gone (HTTP %d)', 'mxchat'), $code));
8222 }
8223 } elseif ($this->mxchat_is_pdf_url($url, $response)) {
8224 return array('mode' => 'inplace');
8225 } else {
8226 $html = wp_remote_retrieve_body($response);
8227 $page = $this->mxchat_sanitize_content_for_api($this->mxchat_extract_main_content($html));
8228 if ($page !== '') {
8229 $texts['page'] = $page;
8230 $title = MxChat_Utils::extract_page_title($html);
8231 }
8232 }
8233 }
8234 }
8235 if ($type === 'product' && !isset($texts['bulk'])) {
8236 $bulk = $this->mxchat_reimport_post_text($url, $bot_id, 'bulk');
8237 if (is_array($bulk)) {
8238 $texts['bulk'] = $bulk['text'];
8239 }
8240 }
8241 if (empty($texts)) {
8242 return array('mode' => 'skip', 'note' => __('the page has no readable text now', 'mxchat'));
8243 }
8244 return array('mode' => 'refetch', 'texts' => $texts, 'title' => isset($title) ? (string) $title : '');
8245 }
8246
8247 // Everything else was built from a post, a product or a media file on this site.
8248 $post_id = (int) $this->mxchat_url_to_post_id_improved($url);
8249 $post = $post_id ? get_post($post_id) : null;
8250 if (!$post) {
8251 if ($type === 'content' || $type === '') {
8252 // Text an owner typed and gave an address to: there is no source to rebuild it from.
8253 return array('mode' => 'inplace');
8254 }
8255 return array('mode' => 'skip', 'note' => __('its source is gone (no post or file on this site has this address)', 'mxchat'));
8256 }
8257 if ($post->post_type === 'attachment') {
8258 $extracted = $this->mxchat_extract_attachment_text($post_id);
8259 if ($extracted['text'] === '') {
8260 return array('mode' => 'skip', 'note' => sprintf(__('the file could not be read (%s)', 'mxchat'), $extracted['reason']));
8261 }
8262 return array(
8263 'mode' => 'refetch',
8264 'texts' => array('file' => $this->mxchat_build_attachment_content($post, $extracted['text'])),
8265 'post_id' => $post_id,
8266 'is_file' => true,
8267 );
8268 }
8269 if ($post->post_status !== 'publish') {
8270 return array('mode' => 'skip', 'note' => __('its source is no longer published', 'mxchat'));
8271 }
8272
8273 $texts = array();
8274 $routes = ($type === 'content' || $type === '') ? array('sync', 'bulk') : array('bulk', 'sync');
8275 foreach ($routes as $route) {
8276 $built = $this->mxchat_reimport_post_text($url, $bot_id, $route, $post);
8277 if (is_array($built)) {
8278 $texts[$route] = $built['text'];
8279 }
8280 }
8281 if ($post->post_type === 'product' && class_exists('WooCommerce') && function_exists('wc_get_product')) {
8282 $product = wc_get_product($post_id);
8283 if ($product) {
8284 $from_product = array('product' => $this->mxchat_prepare_product_content_for_indexing($product));
8285 // With the WooCommerce integration on, the product writer owns auto-synced product entries.
8286 $owns = isset($this->options['enable_woocommerce_integration']) && in_array($this->options['enable_woocommerce_integration'], array('1', 'on'), true);
8287 $texts = $owns ? $from_product + $texts : $texts + $from_product;
8288 }
8289 }
8290 if (empty($texts)) {
8291 return array('mode' => 'skip', 'note' => __('its source has no text now', 'mxchat'));
8292 }
8293 return array('mode' => 'refetch', 'texts' => $texts, 'post_id' => $post_id);
8294 }
8295
8296 /** A post's text as the bulk import ('bulk') or auto-sync ('sync') builds it. @return array|null */
8297 private function mxchat_reimport_post_text($url, $bot_id, $route, $post = null) {
8298 if (!$post) {
8299 $post_id = (int) $this->mxchat_url_to_post_id_improved($url);
8300 $post = $post_id ? get_post($post_id) : null;
8301 }
8302 if (!$post || $post->post_status !== 'publish' || $post->post_type === 'attachment') {
8303 return null;
8304 }
8305 $post_id = (int) $post->ID;
8306 $filtered = apply_filters('mxchat_before_process_post', $post, $route === 'sync' ? 'default' : $bot_id);
8307 if (!($filtered instanceof WP_Post)) {
8308 $filtered = $post;
8309 }
8310 // Same argument sets as the two writers (auto-sync appends product tabs since plan f9573e).
8311 $args = ($route === 'sync')
8312 ? array('read_display' => true, 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1', 'include_product_tabs' => true)
8313 : array('read_display' => false, 'extract_acf_pdfs' => get_option('mxchat_acf_pdf_extraction', '0') === '1', 'include_product_tabs' => true);
8314 $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $filtered, $args);
8315 return array('text' => (string) $prepared['content']);
8316 }
8317
8318 /**
8319 * Decide what to do with one entry. Makes no embedding call and writes nothing.
8320 *
8321 * @return array{outcome:string,reasons:array,note:string,mode:string,fresh:string,title:string,post_id:int,is_file:bool}
8322 * outcome: repair | unchanged | changed | skipped
8323 */
8324 private function mxchat_reimport_evaluate(array $entry, $bot_id) {
8325 $result = array('outcome' => 'unchanged', 'reasons' => array(), 'note' => '', 'mode' => 'refetch', 'fresh' => '', 'title' => '', 'post_id' => 0, 'is_file' => false, 'quiet' => false);
8326 $is_pdf = $this->mxchat_reimport_from_pdf($entry);
8327
8328 $fresh = $this->mxchat_reimport_fresh($entry, $bot_id);
8329 $result['mode'] = $fresh['mode'];
8330 if ($fresh['mode'] === 'skip') {
8331 $result['outcome'] = 'skipped';
8332 $result['note'] = $fresh['note'];
8333 $result['quiet'] = !empty($fresh['quiet']);
8334 return $result;
8335 }
8336
8337 if ($fresh['mode'] === 'inplace') {
8338 // Reading order is the one defect that can be repaired without the source.
8339 if (!$this->mxchat_reimport_is_pdf($entry) && !$this->mxchat_reimport_has_pdf_header($entry)) {
8340 return $result;
8341 }
8342 $rows = $this->mxchat_reimport_inplace_rows($entry, $bot_id);
8343 if (is_wp_error($rows)) {
8344 $result['outcome'] = 'skipped';
8345 $result['note'] = $rows->get_error_message();
8346 return $result;
8347 }
8348 foreach ($rows as $row) {
8349 if ($row['new'] !== $row['old']) {
8350 $result['outcome'] = 'repair';
8351 $result['reasons'] = array('RTL');
8352 return $result;
8353 }
8354 }
8355 return $result;
8356 }
8357
8358 $stored = $this->mxchat_reimport_stored_text($entry, $bot_id);
8359 if (is_wp_error($stored)) {
8360 $result['outcome'] = 'skipped';
8361 $result['note'] = $stored->get_error_message();
8362 return $result;
8363 }
8364 $stored_squash = $this->mxchat_reimport_squash($stored);
8365 $stored_norm = $this->mxchat_reimport_norm($stored, $is_pdf);
8366 $stored_title = $this->mxchat_reimport_norm(strtok($stored, "\n"), false);
8367
8368 // More than one import route can have written an entry (a product is built
8369 // one way by auto-sync and another by the bulk import). Take the route whose
8370 // text shares the most lines with what is stored.
8371 $stored_lines = array_flip(array_filter(array_map('trim', explode("
8372 ", $stored)), 'strlen'));
8373 $repair = null;
8374 $best = -1;
8375 foreach ($fresh['texts'] as $route => $text) {
8376 $text = $this->mxchat_reimport_storable($text);
8377 if ($this->mxchat_reimport_squash($text) === $stored_squash) {
8378 return $result; // identical to what one import route builds today
8379 }
8380 $reasons = $this->mxchat_reimport_reasons($stored, $text, $is_pdf);
8381 $same_doc = ($this->mxchat_reimport_norm($text, $is_pdf) === $stored_norm);
8382 if (!$same_doc && $route !== 'page' && $this->mxchat_reimport_price_code_missing($stored, $text)
8383 && $this->mxchat_reimport_norm(strtok($text, "\n"), false) === $stored_title) {
8384 // A product indexed before prices carried their currency code.
8385 $same_doc = true;
8386 if (!in_array('currency', $reasons, true)) {
8387 $reasons[] = 'currency';
8388 }
8389 }
8390 if ($same_doc && !empty($reasons)) {
8391 $shared = count(array_intersect_key($stored_lines, array_flip(array_filter(array_map('trim', explode("
8392 ", $text)), 'strlen'))));
8393 if ($shared > $best) {
8394 $best = $shared;
8395 $repair = array('reasons' => $reasons, 'fresh' => $text);
8396 }
8397 }
8398 }
8399
8400 if ($repair === null) {
8401 $result['outcome'] = 'changed';
8402 $result['note'] = __('its source reads differently now, and not because of these fixes', 'mxchat');
8403 $post_id = isset($fresh['post_id']) ? (int) $fresh['post_id'] : 0;
8404 $type = (string) $entry['content_type'];
8405 if ($type === 'url') {
8406 // Imported by address: nothing on this site says when the page changed.
8407 $result['note'] .= ' ' . __('(imported by address: no edit date to compare)', 'mxchat');
8408 } elseif ($post_id && $type !== 'content' && $type !== '' && $this->mxchat_reimport_edited_after($entry, $post_id)) {
8409 // A post, page, product or Media Library file edited after this entry
8410 // was written (plan a00ef8): offered as "edited", rewritten only when
8411 // asked. Text an owner typed under a page's address (type content) is
8412 // never read as edited: there is no import to compare it with.
8413 $best = -1;
8414 foreach ($fresh['texts'] as $text) {
8415 $text = $this->mxchat_reimport_storable($text);
8416 $shared = count(array_intersect_key($stored_lines, array_flip(array_filter(array_map('trim', explode("\n", $text)), 'strlen'))));
8417 if ($shared > $best) {
8418 $best = $shared;
8419 $result['fresh'] = $text;
8420 }
8421 }
8422 $result['outcome'] = 'edited';
8423 $result['reasons'] = array('edited');
8424 $result['note'] = __('edited since import', 'mxchat');
8425 $result['title'] = isset($fresh['title']) ? (string) $fresh['title'] : '';
8426 $result['post_id'] = $post_id;
8427 $result['is_file'] = !empty($fresh['is_file']);
8428 }
8429 return $result;
8430 }
8431 $result['outcome'] = 'repair';
8432 $result['reasons'] = $repair['reasons'];
8433 $result['fresh'] = $repair['fresh'];
8434 $result['title'] = isset($fresh['title']) ? (string) $fresh['title'] : '';
8435 $result['post_id'] = isset($fresh['post_id']) ? (int) $fresh['post_id'] : 0;
8436 $result['is_file'] = !empty($fresh['is_file']);
8437 return $result;
8438 }
8439
8440 /** An uploaded PDF page stored before 3.2.24 has a manual identity; its header is what marks it as a PDF. */
8441 private function mxchat_reimport_has_pdf_header(array $entry) {
8442 if ($entry['store'] !== 'wordpress') {
8443 return false;
8444 }
8445 global $wpdb;
8446 $head = (string) $wpdb->get_var($wpdb->prepare(
8447 "SELECT LEFT(article_content, 400) FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
8448 (int) $entry['id']
8449 ));
8450 return $head !== '' && $head[0] === '{' && stripos($head, 'pdf') !== false && strpos($head, "\n---\n") !== false;
8451 }
8452
8453 /**
8454 * The stored pieces of an entry with reading order restored, piece by piece.
8455 * WordPress rows keep their metadata header byte for byte (as rtl-repair does).
8456 * @return array[]|WP_Error each: id, old, new
8457 */
8458 private function mxchat_reimport_inplace_rows(array $entry, $bot_id) {
8459 $out = array();
8460 if ($entry['store'] === 'wordpress') {
8461 global $wpdb;
8462 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
8463 $rows = ($entry['source_url'] !== '')
8464 ? $wpdb->get_results($wpdb->prepare("SELECT id, article_content FROM {$table} WHERE source_url = %s ORDER BY id ASC", $entry['source_url']))
8465 : $wpdb->get_results($wpdb->prepare("SELECT id, article_content FROM {$table} WHERE id = %d", (int) $entry['id']));
8466 foreach ((array) $rows as $row) {
8467 list($header, $text) = $this->mxchat_rtl_repair_split((string) $row->article_content);
8468 $logical = MxChat_Utils::normalize_pdf_rtl($text, 'kb-reimport row ' . $row->id);
8469 $out[] = array(
8470 'id' => (int) $row->id,
8471 'old' => (string) $row->article_content,
8472 'new' => $header . (is_string($logical) ? $logical : $text),
8473 );
8474 }
8475 return $out;
8476 }
8477 $stored = $this->mxchat_reimport_stored_text($entry, $bot_id);
8478 if (is_wp_error($stored)) {
8479 return $stored;
8480 }
8481 $logical = MxChat_Utils::normalize_pdf_rtl($stored, 'kb-reimport ' . $entry['id']);
8482 $out[] = array('id' => (string) $entry['id'], 'old' => $stored, 'new' => is_string($logical) ? $logical : $stored);
8483 return $out;
8484 }
8485
8486 /** The role an entry is restricted to, read before a rewrite so it can be put back. */
8487 private function mxchat_reimport_role(array $entry) {
8488 global $wpdb;
8489 if ($entry['store'] === 'wordpress') {
8490 $role = ($entry['source_url'] !== '')
8491 ? $wpdb->get_var($wpdb->prepare("SELECT role_restriction FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE source_url = %s AND role_restriction IS NOT NULL AND role_restriction <> '' AND role_restriction <> 'public' LIMIT 1", $entry['source_url']))
8492 : $wpdb->get_var($wpdb->prepare("SELECT role_restriction FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d", (int) $entry['id']));
8493 return ($role === null || $role === '') ? 'public' : (string) $role;
8494 }
8495 $base = preg_replace('/_chunk_\d+$/', '', (string) $entry['id']);
8496 $table = $wpdb->prefix . 'mxchat_pinecone_roles';
8497 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
8498 return 'public';
8499 }
8500 $role = $wpdb->get_var($wpdb->prepare(
8501 "SELECT role_restriction FROM {$table} WHERE (vector_id = %s OR vector_id LIKE %s) AND role_restriction <> 'public' LIMIT 1",
8502 $base, $wpdb->esc_like($base . '_chunk_') . '%'
8503 ));
8504 return ($role === null || $role === '') ? 'public' : (string) $role;
8505 }
8506
8507 /** Put a restriction back on every piece of a rewritten entry. */
8508 private function mxchat_reimport_restore_role(array $entry, $role, $bot_id) {
8509 if ($role === 'public' || $entry['source_url'] === '') {
8510 return;
8511 }
8512 global $wpdb;
8513 if ($entry['store'] === 'wordpress') {
8514 $wpdb->update($wpdb->prefix . 'mxchat_system_prompt_content', array('role_restriction' => $role), array('source_url' => $entry['source_url']), array('%s'), array('%s'));
8515 if (class_exists('MxChat_Vectorstore_Manager')) {
8516 MxChat_Vectorstore_Manager::handle_role_change($entry['source_url'], $bot_id, $role);
8517 }
8518 return;
8519 }
8520 $base = md5($entry['source_url']);
8521 $ids = array($base);
8522 $n = MxChat_Utils::count_chunks_for_url($entry['source_url'], $bot_id);
8523 for ($i = 0; $i < (int) $n; $i++) {
8524 $ids[] = MxChat_Chunker::generate_chunk_vector_id($entry['source_url'], $i);
8525 }
8526 foreach (array_unique($ids) as $vector_id) {
8527 $wpdb->replace($wpdb->prefix . 'mxchat_pinecone_roles', array(
8528 'vector_id' => $vector_id,
8529 'role_restriction' => $role,
8530 'updated_at' => current_time('mysql'),
8531 ), array('%s', '%s', '%s'));
8532 }
8533 }
8534
8535 /**
8536 * Rewrite one entry. Re-evaluates first, so a second run (or a source that
8537 * changed between the scan and the confirm) cannot write something the scan
8538 * did not show.
8539 * @return array{outcome:string,reasons:array,note:string}
8540 */
8541 private function mxchat_reimport_apply_entry(array $entry, $bot_id, $api_key, $rewrite_edited = false) {
8542 $eval = $this->mxchat_reimport_evaluate($entry, $bot_id);
8543 if ($eval['outcome'] !== 'repair' && !($rewrite_edited && $eval['outcome'] === 'edited')) {
8544 return array('outcome' => $eval['outcome'], 'reasons' => array(), 'note' => $eval['note']);
8545 }
8546 $role = $this->mxchat_reimport_role($entry);
8547
8548 if ($eval['mode'] === 'inplace') {
8549 $rows = $this->mxchat_reimport_inplace_rows($entry, $bot_id);
8550 if (is_wp_error($rows)) {
8551 return array('outcome' => 'failed', 'reasons' => $eval['reasons'], 'note' => $rows->get_error_message());
8552 }
8553 if ($entry['store'] === 'wordpress') {
8554 global $wpdb;
8555 foreach ($rows as $row) {
8556 if ($row['new'] === $row['old']) {
8557 continue;
8558 }
8559 $vector = $this->mxchat_reimport_with_backoff(function () use ($row, $api_key, $bot_id) {
8560 return MxChat_Utils::regenerate_embedding($row['new'], $api_key, $bot_id);
8561 });
8562 if (!is_array($vector)) {
8563 // Text and vector stay consistent: no repaired text beside a stale vector.
8564 return array('outcome' => 'failed', 'reasons' => $eval['reasons'], 'note' => is_wp_error($vector) ? $vector->get_error_message() : __('the embedding request failed', 'mxchat'));
8565 }
8566 $wpdb->update(
8567 $wpdb->prefix . 'mxchat_system_prompt_content',
8568 array('article_content' => $row['new'], 'embedding_vector' => maybe_serialize($vector)),
8569 array('id' => $row['id']),
8570 array('%s', '%s'),
8571 array('%d')
8572 );
8573 }
8574 return array('outcome' => 'repaired', 'reasons' => $eval['reasons'], 'note' => '');
8575 }
8576 if ($entry['source_url'] === '') {
8577 // A Pinecone record with no source has no stable id to write back to.
8578 return array('outcome' => 'skipped', 'reasons' => array(), 'note' => __('a manual Pinecone entry: open it on the Knowledge screen and save it to repair it', 'mxchat'));
8579 }
8580 $eval['fresh'] = $rows[0]['new'];
8581 }
8582
8583 $source_url = $entry['source_url'];
8584 $type = $entry['content_type'] !== '' ? $entry['content_type'] : 'content';
8585 $fresh = $eval['fresh'];
8586 $title = $eval['title'];
8587 $result = $this->mxchat_reimport_with_backoff(function () use ($fresh, $source_url, $api_key, $bot_id, $type, $title) {
8588 return MxChat_Utils::submit_content_to_db($fresh, $source_url, $api_key, md5($source_url), $bot_id, $type, $title);
8589 });
8590 if ($result !== true) {
8591 return array('outcome' => 'failed', 'reasons' => $eval['reasons'], 'note' => is_wp_error($result) ? $result->get_error_message() : __('the entry could not be stored', 'mxchat'));
8592 }
8593 $this->mxchat_reimport_restore_role($entry, $role, $bot_id);
8594 if ($eval['is_file'] && $eval['post_id']) {
8595 $this->mxchat_remember_attachment_signature($eval['post_id'], $source_url, $fresh);
8596 }
8597 return array('outcome' => 'repaired', 'reasons' => $eval['reasons'], 'note' => '');
8598 }
8599
8600 /** Run a write; on a rate-limit answer wait and try again, twice at most. */
8601 private function mxchat_reimport_with_backoff(callable $call) {
8602 $result = null;
8603 for ($attempt = 0; $attempt < 3; $attempt++) {
8604 $result = $call();
8605 if (!is_wp_error($result) || !preg_match('/\b429\b|rate.?limit|too many requests/i', $result->get_error_message())) {
8606 return $result;
8607 }
8608 if ($attempt < 2) {
8609 sleep($attempt === 0 ? 2 : 5);
8610 }
8611 }
8612 return $result;
8613 }
8614
8615 private function mxchat_reimport_label(array $entry) {
8616 if ($entry['source_url'] !== '' && strpos($entry['source_url'], 'mxchat://') !== 0) {
8617 return $entry['source_url'];
8618 }
8619 return sprintf(__('Manual entry %s', 'mxchat'), $entry['id']);
8620 }
8621
8622 private function mxchat_reimport_note(array &$state, $kind, array $entry, $note) {
8623 if ($state['cap'] > 0 && count($state['notes']) >= $state['cap']) {
8624 return;
8625 }
8626 $state['notes'][] = array('kind' => $kind, 'label' => $this->mxchat_reimport_label($entry), 'note' => $note);
8627 }
8628
8629 /** Scan until the deadline: sort entries into repair / unchanged / changed / skipped. */
8630 private function mxchat_reimport_scan_step(array &$state, $deadline) {
8631 while (!$state['scan_done'] && microtime(true) < $deadline) {
8632 $page = $this->mxchat_reimport_list_page($state, 10);
8633 if (is_wp_error($page)) {
8634 $state['error'] = $page->get_error_message();
8635 $state['scan_done'] = true;
8636 break;
8637 }
8638 foreach ($page['entries'] as $entry) {
8639 $in_window = $this->mxchat_reimport_selected($entry, $state);
8640 // "Also rewrite edited entries" with the default date (plan a00ef8): an
8641 // entry whose page was edited after import is stale whatever its own
8642 // date says, so it is looked at for that one outcome only.
8643 $for_edit_only = !$in_window && !empty($state['with_edited']) && empty($state['before_set'])
8644 && $this->mxchat_reimport_selected($entry, $state, true);
8645 if (!$in_window && !$for_edit_only) {
8646 continue;
8647 }
8648 $eval = $this->mxchat_reimport_evaluate($entry, $state['bot']);
8649 if ($for_edit_only && $eval['outcome'] !== 'edited') {
8650 continue;
8651 }
8652 $state['scanned']++;
8653 if ($eval['outcome'] === 'repair' || ($eval['outcome'] === 'edited' && !empty($state['with_edited']))) {
8654 unset($entry['text']);
8655 $entry['reasons'] = $eval['reasons'];
8656 $entry['label'] = $this->mxchat_reimport_label($entry);
8657 $state['candidates'][] = $entry;
8658 } elseif ($eval['outcome'] === 'edited') {
8659 $state['edited']++;
8660 $this->mxchat_reimport_note($state, 'edited', $entry, $eval['note']);
8661 } elseif ($eval['outcome'] === 'unchanged') {
8662 $state['unchanged']++;
8663 } elseif ($eval['outcome'] === 'changed') {
8664 $state['changed']++;
8665 $this->mxchat_reimport_note($state, 'changed', $entry, $eval['note']);
8666 } elseif (!empty($eval['quiet'])) {
8667 $state['unchanged']++;
8668 } else {
8669 $state['skipped']++;
8670 $this->mxchat_reimport_note($state, 'skipped', $entry, $eval['note']);
8671 }
8672 }
8673 if ($page['done']) {
8674 $state['scan_done'] = true;
8675 }
8676 }
8677 if ($state['scan_done'] && $state['phase'] === 'scan') {
8678 $state['phase'] = 'scanned';
8679 }
8680 $state['updated'] = time();
8681 }
8682
8683 /** Rewrite scanned candidates until the deadline (or $limit repairs). */
8684 private function mxchat_reimport_apply_step(array &$state, $deadline, $api_key, $sleep_ms = 0, $limit = 0) {
8685 $total = count($state['candidates']);
8686 while ($state['pos'] < $total && microtime(true) < $deadline) {
8687 if ($limit > 0 && $state['repaired'] >= $limit) {
8688 break;
8689 }
8690 $i = $state['pos'];
8691 $entry = $state['candidates'][$i];
8692 $done = $this->mxchat_reimport_apply_entry($entry, $state['bot'], $api_key, !empty($state['with_edited']));
8693 $state['candidates'][$i]['result'] = $done['outcome'];
8694 $state['candidates'][$i]['note'] = $done['note'];
8695 if ($done['outcome'] === 'repaired') {
8696 $state['repaired']++;
8697 if ($sleep_ms > 0) {
8698 usleep($sleep_ms * 1000);
8699 }
8700 } elseif ($done['outcome'] === 'failed') {
8701 $state['failed']++;
8702 }
8703 $state['pos']++;
8704 }
8705 if ($state['pos'] >= $total || ($limit > 0 && $state['repaired'] >= $limit)) {
8706 $state['phase'] = 'done';
8707 }
8708 $state['updated'] = time();
8709 }
8710
8711 /** Embedding key for a bot, or WP_Error with the reason in plain words. */
8712 private function mxchat_reimport_api_key($bot_id) {
8713 $bot_options = $this->get_bot_options($bot_id);
8714 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
8715 $preflight = MxChat_Utils::embedding_preflight($options);
8716 if (!$preflight['ok']) {
8717 return new WP_Error('embedding_config', $preflight['reason']);
8718 }
8719 return $preflight['api_key'];
8720 }
8721
8722 /**
8723 * WP-CLI: re-import knowledge entries that were damaged by an import defect
8724 * fixed in a later release (HTML entities left in the text, currency signs,
8725 * accents and other marks removed, joiner characters turned into spaces,
8726 * right-to-left PDF text stored backwards).
8727 *
8728 * Each entry's text is rebuilt from its source with the current importer and
8729 * compared with what is stored. An entry is rewritten and re-embedded only
8730 * when the difference is one of those defects. An entry whose source has
8731 * simply changed since it was imported is listed and left alone, and so is
8732 * an entry whose source is gone. Nothing is ever deleted.
8733 *
8734 * ## OPTIONS
8735 *
8736 * [--dry-run]
8737 * : List what would be rewritten, with the reason, without changing anything.
8738 * No embedding request is made.
8739 *
8740 * [--source=<source>]
8741 * : Which entries to check: all (default), upload, manual, or the start of an
8742 * address (for example https://example.com/docs/).
8743 *
8744 * [--before=<date>]
8745 * : Only entries last written before this date (YYYY-MM-DD). Default
8746 * 2026-08-28, the release date of the last of these fixes.
8747 *
8748 * [--bot=<id>]
8749 * : The bot whose knowledge base and embedding settings to use. Default: default.
8750 *
8751 * [--limit=<n>]
8752 * : Rewrite at most this many entries, then stop.
8753 *
8754 * [--sleep=<ms>]
8755 * : Pause this many milliseconds after each rewritten entry.
8756 *
8757 * [--edited]
8758 * : Also rewrite entries whose post, page, product or Media Library file was
8759 * edited after the entry was written (for sites that keep auto-sync off).
8760 * Without it such entries are listed as "Edited since import, not rewritten".
8761 * With it the default --before date does not apply to them; a --before you
8762 * give still does. Entries imported by address have no edit date and are left alone.
8763 *
8764 * [--yes]
8765 * : Proceed when more than 25 entries need re-embedding (API cost gate).
8766 *
8767 * ## EXAMPLES
8768 *
8769 * wp mxchat kb-reimport --dry-run
8770 * wp mxchat kb-reimport --source=https://example.com/docs/ --limit=50
8771 * wp mxchat kb-reimport --before=2026-10-01 --yes
8772 * wp mxchat kb-reimport --edited --dry-run
8773 */
8774 public function cli_kb_reimport($args, $assoc_args) {
8775 $dry_run = !empty($assoc_args['dry-run']);
8776 $yes = !empty($assoc_args['yes']);
8777 $limit = isset($assoc_args['limit']) ? max(0, (int) $assoc_args['limit']) : 0;
8778 $sleep = isset($assoc_args['sleep']) ? max(0, (int) $assoc_args['sleep']) : 0;
8779 if (isset($assoc_args['before']) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $assoc_args['before'])) {
8780 WP_CLI::error('--before takes a date as YYYY-MM-DD.');
8781 }
8782
8783 $state = $this->mxchat_reimport_new_state(array(
8784 'bot' => isset($assoc_args['bot']) ? $assoc_args['bot'] : 'default',
8785 'source' => isset($assoc_args['source']) ? $assoc_args['source'] : 'all',
8786 'before' => isset($assoc_args['before']) ? $assoc_args['before'] : self::REIMPORT_DEFAULT_BEFORE,
8787 'cap' => 0,
8788 'edited' => !empty($assoc_args['edited']),
8789 'before_set' => isset($assoc_args['before']),
8790 ));
8791 $stores = array('wordpress' => 'the WordPress database', 'pinecone' => 'Pinecone', 'documents' => 'a Pinecone document index');
8792 WP_CLI::log(sprintf('Checking entries in %s last written before %s (source: %s).', $stores[$state['store']], $state['before'], $state['source']));
8793 if ($state['with_edited']) {
8794 WP_CLI::log($state['before_set']
8795 ? 'Entries whose page was edited after import are rewritten too (within the --before date).'
8796 : 'Entries whose page was edited after import are rewritten too, whatever their own date.');
8797 }
8798
8799 while (!$state['scan_done']) {
8800 $this->mxchat_reimport_scan_step($state, microtime(true) + 20);
8801 }
8802 if ($state['error'] !== '') {
8803 WP_CLI::error('The scan stopped: ' . $state['error'] . ' Nothing was changed.');
8804 }
8805
8806 $kinds = array('changed' => 'Left alone', 'edited' => 'Edited since import, not rewritten', 'skipped' => 'Skipped');
8807 foreach ($state['notes'] as $note) {
8808 WP_CLI::log(sprintf('%s %s (%s)', isset($kinds[$note['kind']]) ? $kinds[$note['kind']] : 'Skipped', $note['label'], $note['note']));
8809 }
8810 foreach ($state['candidates'] as $c) {
8811 WP_CLI::log(sprintf('%s %s (%s)', $dry_run ? 'Would rewrite' : 'Will rewrite', $c['label'], implode(', ', $c['reasons'])));
8812 }
8813 $count = count($state['candidates']);
8814 $summary = sprintf('%d checked: %d to rewrite, %d already correct, %d left alone (source changed), %d edited since import, %d skipped.', $state['scanned'], $count, $state['unchanged'], $state['changed'], $state['edited'], $state['skipped']);
8815 if ($dry_run) {
8816 WP_CLI::success('Dry run. ' . $summary . ' Nothing was changed.');
8817 return;
8818 }
8819 if ($count === 0) {
8820 WP_CLI::success($summary . ' Nothing to rewrite.');
8821 return;
8822 }
8823 $planned = ($limit > 0) ? min($limit, $count) : $count;
8824 if ($planned > 25 && !$yes) {
8825 WP_CLI::error(sprintf('%d entries need re-embedding (more than 25). Re-run with --yes to confirm the API cost, or with --limit. Nothing was changed.', $planned));
8826 }
8827 $api_key = $this->mxchat_reimport_api_key($state['bot']);
8828 if (is_wp_error($api_key)) {
8829 WP_CLI::error('Embedding configuration problem: ' . $api_key->get_error_message());
8830 }
8831
8832 $state['phase'] = 'apply';
8833 $shown = 0;
8834 while ($state['phase'] === 'apply') {
8835 $this->mxchat_reimport_apply_step($state, microtime(true) + 20, $api_key, $sleep, $limit);
8836 for (; $shown < $state['pos']; $shown++) {
8837 $c = $state['candidates'][$shown];
8838 if ($c['result'] === 'repaired') {
8839 WP_CLI::log(sprintf('Rewritten %s (%s)', $c['label'], implode(', ', $c['reasons'])));
8840 } elseif ($c['result'] === 'failed') {
8841 WP_CLI::warning(sprintf('Not rewritten %s: %s. Entry left unchanged.', $c['label'], $c['note']));
8842 } else {
8843 WP_CLI::log(sprintf('No longer needed %s', $c['label']));
8844 }
8845 }
8846 }
8847 $remaining = $count - $state['pos'];
8848 WP_CLI::success(sprintf('Rewrote and re-embedded %d entr%s; %d failed%s. %s', $state['repaired'], $state['repaired'] === 1 ? 'y' : 'ies', $state['failed'], $remaining > 0 ? sprintf('; %d left for the next run', $remaining) : '', $summary));
8849 }
8850
8851 /** What the Knowledge screen is shown of a state: counts, a capped list, no entry text. */
8852 private function mxchat_reimport_public_state(array $state) {
8853 $list = array();
8854 foreach (array_slice($state['candidates'], 0, 100) as $c) {
8855 $list[] = array(
8856 'label' => $c['label'],
8857 'reasons' => $c['reasons'],
8858 'result' => isset($c['result']) ? $c['result'] : '',
8859 'note' => isset($c['note']) ? $c['note'] : '',
8860 );
8861 }
8862 return array(
8863 'phase' => $state['phase'],
8864 'before' => $state['before'],
8865 'store' => $state['store'],
8866 'scanned' => (int) $state['scanned'],
8867 'to_repair' => count($state['candidates']),
8868 'unchanged' => (int) $state['unchanged'],
8869 'changed' => (int) $state['changed'],
8870 'edited' => isset($state['edited']) ? (int) $state['edited'] : 0,
8871 'with_edited' => !empty($state['with_edited']),
8872 'skipped' => (int) $state['skipped'],
8873 'pos' => (int) $state['pos'],
8874 'repaired' => (int) $state['repaired'],
8875 'failed' => (int) $state['failed'],
8876 'error' => (string) $state['error'],
8877 'list' => $list,
8878 'notes' => array_slice($state['notes'], 0, 100),
8879 );
8880 }
8881
8882 /**
8883 * AJAX: the Knowledge screen's "Re-import affected entries" action, in
8884 * resumable steps. op = scan (restart=1 begins a new scan), apply, cancel,
8885 * status. A scan step and an apply step each run for a few seconds and save
8886 * where they stopped, so a closed tab or a timeout loses nothing.
8887 */
8888 public function ajax_mxchat_kb_reimport() {
8889 check_ajax_referer('mxchat_kb_reimport', 'nonce');
8890 if (!current_user_can('manage_options')) {
8891 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
8892 }
8893 $op = isset($_POST['op']) ? sanitize_key($_POST['op']) : 'status';
8894 $state = get_option(self::REIMPORT_STATE_OPTION, array());
8895 if (!is_array($state) || empty($state['phase'])) {
8896 $state = array();
8897 }
8898
8899 if ($op === 'cancel') {
8900 delete_option(self::REIMPORT_STATE_OPTION);
8901 wp_send_json_success(array('state' => array('phase' => 'idle')));
8902 }
8903 if ($op === 'status') {
8904 wp_send_json_success(array('state' => empty($state) ? array('phase' => 'idle') : $this->mxchat_reimport_public_state($state)));
8905 }
8906
8907 if ($op === 'scan') {
8908 if (!empty($_POST['restart']) || empty($state) || $state['phase'] !== 'scan') {
8909 $before = isset($_POST['before']) ? sanitize_text_field(wp_unslash($_POST['before'])) : self::REIMPORT_DEFAULT_BEFORE;
8910 $state = $this->mxchat_reimport_new_state(array(
8911 'bot' => isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default',
8912 'before' => $before,
8913 'edited' => !empty($_POST['edited']) && $_POST['edited'] !== '0',
8914 // The card always posts a date; one left at the default is the default.
8915 'before_set' => $before !== self::REIMPORT_DEFAULT_BEFORE,
8916 ));
8917 }
8918 $this->mxchat_reimport_scan_step($state, microtime(true) + 8);
8919 update_option(self::REIMPORT_STATE_OPTION, $state, false);
8920 wp_send_json_success(array('state' => $this->mxchat_reimport_public_state($state)));
8921 }
8922
8923 if ($op === 'apply') {
8924 if (empty($state) || !in_array($state['phase'], array('scanned', 'apply'), true)) {
8925 wp_send_json_error(array('message' => __('Check the knowledge base first.', 'mxchat')));
8926 }
8927 $api_key = $this->mxchat_reimport_api_key($state['bot']);
8928 if (is_wp_error($api_key)) {
8929 wp_send_json_error(array('message' => $api_key->get_error_message()));
8930 }
8931 $state['phase'] = 'apply';
8932 $this->mxchat_reimport_apply_step($state, microtime(true) + 8, $api_key);
8933 update_option(self::REIMPORT_STATE_OPTION, $state, false);
8934 wp_send_json_success(array('state' => $this->mxchat_reimport_public_state($state)));
8935 }
8936
8937 wp_send_json_error(array('message' => __('Unknown request.', 'mxchat')));
8938 }
8939
8940 public function mxchat_handle_post_delete($post_id) {
8941 // Get post data before it's deleted
8942 $post = get_post($post_id);
8943
8944 // Basic validation
8945 if (!$post || wp_is_post_revision($post_id)) {
8946 return;
8947 }
8948
8949 $post_type = $post->post_type;
8950
8951 // Check if sync is enabled for this post type
8952 $should_sync = false;
8953
8954 // Check built-in post types first
8955 if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
8956 $should_sync = true;
8957 } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
8958 $should_sync = true;
8959 } else {
8960 // Check custom post types
8961 $option_name = 'mxchat_auto_sync_' . $post_type;
8962 if (get_option($option_name) === '1') {
8963 $should_sync = true;
8964 }
8965 }
8966
8967 if (!$should_sync) {
8968 return;
8969 }
8970
8971 // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
8972 // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
8973 // real vector IDs stored under the original URL.
8974 $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
8975 if (!$source_url) {
8976 //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
8977 return;
8978 }
8979
8980 // Use chunk-aware deletion (handles both chunked and non-chunked content)
8981 $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
8982
8983 if (is_wp_error($delete_result)) {
8984 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
8985 }
8986
8987 delete_transient('mxchat_prev_url_' . $post_id);
8988 delete_transient('mxchat_prev_status_' . $post_id);
8989 }
8990
8991 /**
8992 * Resolve the source URL for a post being trashed/deleted.
8993 *
8994 * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
8995 * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
8996 * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
8997 * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
8998 */
8999 private function mxchat_resolve_pre_trash_url($post_id) {
9000 $previous_url = get_transient('mxchat_prev_url_' . $post_id);
9001 if (!empty($previous_url)) {
9002 return $previous_url;
9003 }
9004
9005 $current = get_permalink($post_id);
9006 if (!$current) {
9007 return '';
9008 }
9009 return preg_replace('#__trashed(/?)$#', '$1', $current);
9010 }
9011
9012
9013
9014 public function mxchat_handle_product_change($post_id, $post, $update) {
9015 if ($post->post_type !== 'product') {
9016 return;
9017 }
9018
9019 if ($post->post_status === 'publish') {
9020 add_action('shutdown', function() use ($post_id) {
9021 $product = wc_get_product($post_id);
9022 if ($product) {
9023 $this->mxchat_store_product_embedding($product);
9024 }
9025 });
9026 }
9027 }
9028
9029 /**
9030 * Store WooCommerce product embeddings
9031 */
9032 private function mxchat_store_product_embedding($product) {
9033 if (!isset($this->options['enable_woocommerce_integration']) ||
9034 !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
9035 return;
9036 }
9037
9038 $source_url = get_permalink($product->get_id());
9039 $product_id = $product->get_id();
9040
9041 // Build product content via the shared WC-object assembler (a3d60c) — this
9042 // writer owns product rows whenever the integration is on.
9043 $content = $this->mxchat_prepare_product_content_for_indexing($product);
9044
9045 // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
9046 // shape preserved.
9047 $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
9048 if (!$preflight['ok']) {
9049 //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
9050 return;
9051 }
9052 $api_key = $preflight['api_key'];
9053
9054 // Use the centralized utility function for storage
9055 $result = MxChat_Utils::submit_content_to_db(
9056 $content,
9057 $source_url,
9058 $api_key,
9059 md5($source_url) // Vector ID for Pinecone
9060 );
9061
9062 // After successful storage, apply role restriction based on tags
9063 if (!is_wp_error($result)) {
9064 $this->apply_role_restriction_to_post($product_id, $source_url);
9065 }
9066
9067 if (is_wp_error($result)) {
9068 //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
9069 }
9070 }
9071
9072 public function mxchat_handle_product_delete($post_id) {
9073 if (get_post_type($post_id) !== 'product') {
9074 return;
9075 }
9076
9077 $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
9078 if (!$source_url) {
9079 return;
9080 }
9081
9082 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
9083 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
9084
9085 delete_transient('mxchat_prev_url_' . $post_id);
9086 delete_transient('mxchat_prev_status_' . $post_id);
9087 }
9088
9089 /**
9090 * Handle individual Pinecone content deletion
9091 */
9092 public function mxchat_handle_pinecone_prompt_delete() {
9093 // Check permissions
9094 if (!current_user_can('manage_options')) {
9095 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
9096 }
9097
9098 // Verify nonce
9099 if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
9100 wp_die(esc_html__('Security check failed.', 'mxchat'));
9101 }
9102
9103 $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
9104
9105 if (empty($vector_id)) {
9106 set_transient('mxchat_admin_notice_error',
9107 esc_html__('Invalid vector ID.', 'mxchat'),
9108 30
9109 );
9110 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
9111 exit;
9112 }
9113
9114 // Get Pinecone settings
9115 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
9116 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9117
9118 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
9119 set_transient('mxchat_admin_notice_error',
9120 esc_html__('Pinecone is not properly configured.', 'mxchat'),
9121 30
9122 );
9123 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
9124 exit;
9125 }
9126
9127 // Delete from Pinecone
9128 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
9129 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
9130 $vector_id,
9131 $pinecone_options['mxchat_pinecone_api_key'],
9132 $pinecone_options['mxchat_pinecone_host'],
9133 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
9134 );
9135
9136 if ($result['success']) {
9137 // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6);
9138 // a chunk vector id reduces to its base entry there.
9139 if (class_exists('MxChat_Vectorstore_Manager')) {
9140 MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, 'default');
9141 }
9142 MxChat_Utils::forget_attachment_signature_by_key($vector_id, 'default');
9143 // No cache clearing needed since we removed caching
9144 set_transient('mxchat_admin_notice_success',
9145 esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
9146 30
9147 );
9148 } else {
9149 set_transient('mxchat_admin_notice_error',
9150 esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
9151 30
9152 );
9153 }
9154
9155 wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
9156 exit;
9157 }
9158 /**
9159 * Handle individual Pinecone content deletion via AJAX
9160 */
9161 public function ajax_mxchat_delete_pinecone_prompt() {
9162 // Verify nonce and permissions
9163 if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
9164 wp_send_json_error('Invalid nonce');
9165 exit;
9166 }
9167
9168 if (!current_user_can('manage_options')) {
9169 wp_send_json_error('Unauthorized access');
9170 exit;
9171 }
9172
9173 $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
9174 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
9175
9176 if (empty($vector_id)) {
9177 wp_send_json_error('Missing vector ID');
9178 exit;
9179 }
9180
9181 // Get bot-specific Pinecone settings
9182 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
9183 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
9184
9185 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9186
9187 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
9188 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
9189 exit;
9190 }
9191
9192 // Delete from the correct Pinecone index and namespace
9193 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
9194 $vector_id,
9195 $pinecone_options['mxchat_pinecone_api_key'],
9196 $pinecone_options['mxchat_pinecone_host'],
9197 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
9198 );
9199
9200 if ($result['success']) {
9201 // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6)
9202 if (class_exists('MxChat_Vectorstore_Manager')) {
9203 MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, $bot_id);
9204 }
9205 // A media entry removed here must lose its auto-sync signature too,
9206 // or the file is skipped as "unchanged" on every later save (b0ae54).
9207 MxChat_Utils::forget_attachment_signature_by_key($vector_id, $bot_id);
9208 // No cache clearing needed since we removed caching
9209 wp_send_json_success(array(
9210 'message' => 'Entry deleted successfully from Pinecone',
9211 'vector_id' => $vector_id,
9212 'bot_id' => $bot_id
9213 ));
9214 } else {
9215 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
9216 wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
9217 }
9218
9219 exit;
9220 }
9221
9222 /**
9223 * Handle deletion of all chunks for a given source URL via AJAX
9224 * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
9225 */
9226 public function ajax_mxchat_delete_chunks_by_url() {
9227 // Verify nonce and permissions
9228 if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
9229 wp_send_json_error('Invalid nonce');
9230 exit;
9231 }
9232
9233 if (!current_user_can('manage_options')) {
9234 wp_send_json_error('Unauthorized access');
9235 exit;
9236 }
9237
9238 // Identity-preserving (plan dcc19d): esc_url_raw() empties upload:// and
9239 // mxchat:// identities, so a chunked uploaded file or manual entry could
9240 // not be deleted from its group row ("Missing source URL").
9241 $source_url = $this->sanitize_entry_source_url(isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '');
9242 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
9243 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
9244
9245 if (empty($source_url)) {
9246 wp_send_json_error('Missing source URL');
9247 exit;
9248 }
9249
9250 // Generate the base vector ID from the source URL (same as how chunks are created)
9251 $base_vector_id = md5($source_url);
9252
9253 if ($data_source === 'pinecone') {
9254 // Get bot-specific Pinecone settings (same as working delete function)
9255 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
9256 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
9257
9258 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9259
9260 if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
9261 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
9262 exit;
9263 }
9264
9265 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
9266 $host = $pinecone_options['mxchat_pinecone_host'];
9267 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
9268
9269 // Document index (plan 362c31): base id + chunk ids through the documents API.
9270 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options)) {
9271 $docs_cfg = MxChat_Pinecone_Documents::cfg_from_options($pinecone_options);
9272 $doc_ids = array_merge(array($base_vector_id), MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $docs_cfg));
9273 $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_ids, $docs_cfg);
9274 if (is_wp_error($doc_result)) {
9275 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete documents from Pinecone: ' . $doc_result->get_error_message(), array('source_url' => $source_url));
9276 wp_send_json_error('Failed to delete from Pinecone: ' . $doc_result->get_error_message());
9277 exit;
9278 }
9279 if (class_exists('MxChat_Vectorstore_Manager')) {
9280 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9281 }
9282 MxChat_Utils::forget_attachment_signature($source_url, $bot_id);
9283 wp_send_json_success(array(
9284 'message' => 'All chunks deleted successfully from Pinecone',
9285 'source_url' => $source_url,
9286 'deleted_count' => count($doc_ids)
9287 ));
9288 exit;
9289 }
9290
9291 // Collect all vector IDs to delete
9292 $vectors_to_delete = array();
9293
9294 // Add the original single-vector ID (for non-chunked content)
9295 $vectors_to_delete[] = $base_vector_id;
9296
9297 // Use Pinecone list API to find all chunk vectors with this prefix
9298 // NOTE: Pinecone List API is a GET request with query parameters, not POST
9299 $prefix = $base_vector_id . '_chunk_';
9300
9301 $query_params = array(
9302 'prefix' => $prefix,
9303 'limit' => 100
9304 );
9305
9306 if (!empty($namespace)) {
9307 $query_params['namespace'] = $namespace;
9308 }
9309
9310 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
9311
9312 $list_response = wp_remote_get($list_url, array(
9313 'headers' => array(
9314 'Api-Key' => $api_key,
9315 'accept' => 'application/json'
9316 ),
9317 'timeout' => 30
9318 ));
9319
9320 if (!is_wp_error($list_response)) {
9321 $list_body_response = wp_remote_retrieve_body($list_response);
9322 $list_data = json_decode($list_body_response, true);
9323 if (!empty($list_data['vectors'])) {
9324 foreach ($list_data['vectors'] as $vector) {
9325 if (isset($vector['id'])) {
9326 $vectors_to_delete[] = $vector['id'];
9327 }
9328 }
9329 }
9330 }
9331
9332 if (empty($vectors_to_delete)) {
9333 // Entry already gone from Pinecone — still clear any mirrored
9334 // Vector Store file so it can't outlive the entry (plan 15b5c6).
9335 if (class_exists('MxChat_Vectorstore_Manager')) {
9336 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9337 }
9338 MxChat_Utils::forget_attachment_signature($source_url, $bot_id);
9339 wp_send_json_success(array(
9340 'message' => 'No vectors found to delete',
9341 'source_url' => $source_url
9342 ));
9343 exit;
9344 }
9345
9346 // Delete all vectors using the same endpoint as the working function
9347 $delete_url = "https://{$host}/vectors/delete";
9348
9349 $delete_body = array(
9350 'ids' => $vectors_to_delete
9351 );
9352
9353 if (!empty($namespace)) {
9354 $delete_body['namespace'] = $namespace;
9355 }
9356
9357 $delete_response = wp_remote_post($delete_url, array(
9358 'headers' => array(
9359 'Api-Key' => $api_key,
9360 'accept' => 'application/json',
9361 'content-type' => 'application/json'
9362 ),
9363 'body' => wp_json_encode($delete_body),
9364 'timeout' => 30
9365 ));
9366
9367 if (is_wp_error($delete_response)) {
9368 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
9369 wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
9370 exit;
9371 }
9372
9373 $response_code = wp_remote_retrieve_response_code($delete_response);
9374
9375 if ($response_code !== 200) {
9376 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
9377 wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
9378 exit;
9379 }
9380
9381 // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
9382 if (class_exists('MxChat_Vectorstore_Manager')) {
9383 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9384 }
9385 MxChat_Utils::forget_attachment_signature($source_url, $bot_id);
9386
9387 wp_send_json_success(array(
9388 'message' => 'All chunks deleted successfully from Pinecone',
9389 'source_url' => $source_url,
9390 'deleted_count' => count($vectors_to_delete)
9391 ));
9392
9393 } else {
9394 // WordPress database deletion
9395 global $wpdb;
9396 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9397
9398 $result = $wpdb->delete(
9399 $table_name,
9400 array('source_url' => $source_url),
9401 array('%s')
9402 );
9403
9404 if ($result === false) {
9405 MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
9406 wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
9407 exit;
9408 }
9409
9410 // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
9411 if (class_exists('MxChat_Vectorstore_Manager')) {
9412 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
9413 }
9414 // The delete above removed every bot's rows for this URL.
9415 MxChat_Utils::forget_attachment_signature($source_url, 'default');
9416
9417 wp_send_json_success(array(
9418 'message' => 'All chunks deleted successfully from database',
9419 'source_url' => $source_url,
9420 'deleted_count' => $result
9421 ));
9422 }
9423
9424 exit;
9425 }
9426
9427 /**
9428 * Handle individual WordPress database content deletion via AJAX
9429 * Mirrors the Pinecone delete handler but for WordPress database entries
9430 */
9431 public function ajax_mxchat_delete_wordpress_prompt() {
9432 // Verify nonce and permissions
9433 if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
9434 wp_send_json_error('Invalid nonce');
9435 exit;
9436 }
9437
9438 if (!current_user_can('manage_options')) {
9439 wp_send_json_error('Unauthorized access');
9440 exit;
9441 }
9442
9443 $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
9444
9445 if (empty($entry_id)) {
9446 wp_send_json_error('Missing entry ID');
9447 exit;
9448 }
9449
9450 global $wpdb;
9451 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9452
9453 // Capture the identity BEFORE the row disappears — needed to mirror the
9454 // change into the Vector Store (plan 15b5c6).
9455 $source_url = $wpdb->get_var($wpdb->prepare(
9456 "SELECT source_url FROM {$table_name} WHERE id = %d",
9457 $entry_id
9458 ));
9459
9460 // Clear cache for this entry
9461 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
9462
9463 // Delete from database
9464 $result = $wpdb->delete(
9465 $table_name,
9466 array('id' => $entry_id),
9467 array('%d')
9468 );
9469
9470 if ($result !== false) {
9471 // Mirror to the Vector Store: if sibling rows remain (this was one
9472 // chunk of a larger entry) the entry's file is REFRESHED from what's
9473 // left; if none remain, the file is removed.
9474 if (!empty($source_url)) {
9475 $remaining = (int) $wpdb->get_var($wpdb->prepare(
9476 "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
9477 $source_url
9478 ));
9479 if (class_exists('MxChat_Vectorstore_Manager')) {
9480 if ($remaining > 0) {
9481 MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, '', 'default');
9482 } else {
9483 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, 'default');
9484 }
9485 }
9486 // Last row of a media entry gone: drop its auto-sync signature (b0ae54).
9487 if ($remaining === 0) {
9488 MxChat_Utils::forget_attachment_signature($source_url, 'default');
9489 }
9490 }
9491
9492 wp_send_json_success(array(
9493 'message' => 'Entry deleted successfully',
9494 'entry_id' => $entry_id
9495 ));
9496 } else {
9497 wp_send_json_error('Failed to delete entry from database');
9498 }
9499
9500 exit;
9501 }
9502
9503 /**
9504 * Handle bulk deletion of knowledge entries via AJAX
9505 * Supports both Pinecone and WordPress database entries
9506 */
9507 public function ajax_mxchat_bulk_delete_knowledge() {
9508 // Verify nonce and permissions
9509 if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
9510 wp_send_json_error('Invalid nonce');
9511 exit;
9512 }
9513
9514 if (!current_user_can('manage_options')) {
9515 wp_send_json_error('Unauthorized access');
9516 exit;
9517 }
9518
9519 $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
9520 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
9521
9522 if (empty($entries) || !is_array($entries)) {
9523 wp_send_json_error('No entries provided');
9524 exit;
9525 }
9526
9527 // Extend execution time — bulk Pinecone operations can take a while
9528 if (function_exists('set_time_limit')) {
9529 set_time_limit(120);
9530 }
9531
9532 $success_ids = array();
9533 $failed_ids = array();
9534 $errors = array();
9535
9536 global $wpdb;
9537 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9538
9539 // Get Pinecone manager for Pinecone deletions
9540 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
9541 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
9542 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
9543
9544 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
9545 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
9546 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
9547 // Document index (plan 362c31): list + delete go through the documents API below.
9548 $bulk_docs = class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_options($pinecone_options);
9549 $bulk_docs_cfg = $bulk_docs ? MxChat_Pinecone_Documents::cfg_from_options($pinecone_options) : array();
9550
9551 // =============================================
9552 // PHASE 1: Collect all Pinecone vector IDs
9553 // and separate WordPress entries
9554 // =============================================
9555 $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
9556 $wordpress_entries = array(); // entries for WordPress DB deletion
9557 $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
9558 $vs_mirror_urls = array(); // Vector Store mirror: URLs to delete (plan 15b5c6)
9559 $vs_mirror_keys = array(); // Vector Store mirror: bare vector ids to delete
9560
9561 foreach ($entries as $entry) {
9562 $entry_id = sanitize_text_field($entry['id'] ?? '');
9563 $source = sanitize_text_field($entry['source'] ?? 'wordpress');
9564 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
9565 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
9566
9567 if (empty($entry_id)) {
9568 continue;
9569 }
9570
9571 if ($source === 'pinecone') {
9572 if (!$use_pinecone || empty($api_key)) {
9573 $failed_ids[] = $entry_id;
9574 $errors[] = "Pinecone not configured for entry: $entry_id";
9575 continue;
9576 }
9577
9578 $pinecone_entry_ids[] = $entry_id;
9579
9580 if ($is_group && !empty($source_url)) {
9581 // Grouped/chunked entry: collect base ID + chunk IDs via List API
9582 $base_vector_id = md5($source_url);
9583 $all_vector_ids[] = $base_vector_id;
9584
9585 if ($bulk_docs) {
9586 $all_vector_ids = array_merge($all_vector_ids, MxChat_Pinecone_Documents::list_ids($base_vector_id . '_chunk_', $bulk_docs_cfg));
9587 } else {
9588 $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
9589 if (!empty($namespace)) {
9590 $list_url .= '&namespace=' . rawurlencode($namespace);
9591 }
9592 $list_response = wp_remote_get($list_url, array(
9593 'headers' => array(
9594 'Api-Key' => $api_key,
9595 'accept' => 'application/json'
9596 ),
9597 'timeout' => 30
9598 ));
9599
9600 if (!is_wp_error($list_response)) {
9601 $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
9602 if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
9603 foreach ($list_body['vectors'] as $vector) {
9604 if (isset($vector['id'])) {
9605 $all_vector_ids[] = $vector['id'];
9606 }
9607 }
9608 }
9609 }
9610 } // end classic (vector index) list
9611 } else {
9612 // Single entry: the entry_id IS the vector ID
9613 $all_vector_ids[] = $entry_id;
9614 }
9615
9616 if (!empty($source_url)) {
9617 $vs_mirror_urls[] = $source_url;
9618 } else {
9619 $vs_mirror_keys[] = $entry_id;
9620 }
9621 } else {
9622 $wordpress_entries[] = $entry;
9623 }
9624 }
9625
9626 // =============================================
9627 // PHASE 2: Single batch delete to Pinecone
9628 // =============================================
9629 if (!empty($all_vector_ids)) {
9630 $all_vector_ids = array_values(array_unique($all_vector_ids));
9631 $pinecone_success = true;
9632 $batches = array_chunk($all_vector_ids, 100);
9633
9634 // Document index (plan 362c31): one documents/delete call for every id.
9635 if ($bulk_docs) {
9636 $doc_result = MxChat_Pinecone_Documents::delete_ids($all_vector_ids, $bulk_docs_cfg);
9637 if (is_wp_error($doc_result)) {
9638 $pinecone_success = false;
9639 $errors[] = 'Pinecone batch deletion failed: ' . $doc_result->get_error_message();
9640 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete (document index) failed: ' . $doc_result->get_error_message());
9641 }
9642 } else
9643 foreach ($batches as $batch) {
9644 $delete_body = array('ids' => $batch);
9645 if (!empty($namespace)) {
9646 $delete_body['namespace'] = $namespace;
9647 }
9648 $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
9649 'headers' => array(
9650 'Api-Key' => $api_key,
9651 'accept' => 'application/json',
9652 'content-type' => 'application/json'
9653 ),
9654 'body' => wp_json_encode($delete_body),
9655 'timeout' => 60
9656 ));
9657
9658 if (is_wp_error($delete_response)) {
9659 $pinecone_success = false;
9660 $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
9661 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
9662 } else {
9663 $response_code = wp_remote_retrieve_response_code($delete_response);
9664 if ($response_code !== 200) {
9665 $pinecone_success = false;
9666 $response_body = wp_remote_retrieve_body($delete_response);
9667 $errors[] = "Pinecone API error (HTTP $response_code)";
9668 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
9669 }
9670 }
9671 }
9672
9673 // Mark all pinecone entries based on batch result
9674 foreach ($pinecone_entry_ids as $eid) {
9675 if ($pinecone_success) {
9676 $success_ids[] = $eid;
9677 } else {
9678 $failed_ids[] = $eid;
9679 }
9680 }
9681
9682 // Mirror the removals to the OpenAI Vector Store (plan 15b5c6)
9683 if ($pinecone_success && class_exists('MxChat_Vectorstore_Manager')) {
9684 foreach (array_unique($vs_mirror_urls) as $vs_url) {
9685 MxChat_Vectorstore_Manager::sync_delete_entry($vs_url, $bot_id);
9686 }
9687 foreach (array_unique($vs_mirror_keys) as $vs_key) {
9688 MxChat_Vectorstore_Manager::sync_delete_by_key($vs_key, $bot_id);
9689 }
9690 }
9691 // Media entries removed here lose their auto-sync signature (b0ae54).
9692 if ($pinecone_success) {
9693 foreach (array_unique($vs_mirror_urls) as $vs_url) {
9694 MxChat_Utils::forget_attachment_signature($vs_url, $bot_id);
9695 }
9696 foreach (array_unique($vs_mirror_keys) as $vs_key) {
9697 MxChat_Utils::forget_attachment_signature_by_key($vs_key, $bot_id);
9698 }
9699 }
9700 }
9701
9702 // =============================================
9703 // PHASE 3: WordPress database deletions
9704 // =============================================
9705 foreach ($wordpress_entries as $entry) {
9706 $entry_id = sanitize_text_field($entry['id'] ?? '');
9707 $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
9708 $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
9709
9710 if (empty($entry_id)) {
9711 continue;
9712 }
9713
9714 try {
9715 if ($is_group && !empty($source_url)) {
9716 $result = $wpdb->delete(
9717 $table_name,
9718 array('source_url' => $source_url),
9719 array('%s')
9720 );
9721 $row_url = $source_url;
9722 } else {
9723 // Identity captured pre-delete for the Vector Store mirror
9724 $row_url = $wpdb->get_var($wpdb->prepare(
9725 "SELECT source_url FROM {$table_name} WHERE id = %d",
9726 intval($entry_id)
9727 ));
9728 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
9729 $result = $wpdb->delete(
9730 $table_name,
9731 array('id' => intval($entry_id)),
9732 array('%d')
9733 );
9734 }
9735
9736 if ($result !== false) {
9737 $success_ids[] = $entry_id;
9738 // Mirror to the Vector Store: refresh the entry's file when
9739 // sibling chunk rows survive, remove it when none do.
9740 if (!empty($row_url)) {
9741 $remaining = (int) $wpdb->get_var($wpdb->prepare(
9742 "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
9743 $row_url
9744 ));
9745 if (class_exists('MxChat_Vectorstore_Manager')) {
9746 if ($remaining > 0) {
9747 MxChat_Vectorstore_Manager::sync_upsert_entry($row_url, '', $bot_id);
9748 } else {
9749 MxChat_Vectorstore_Manager::sync_delete_entry($row_url, $bot_id);
9750 }
9751 }
9752 if ($remaining === 0) {
9753 MxChat_Utils::forget_attachment_signature($row_url, 'default');
9754 }
9755 }
9756 } else {
9757 $failed_ids[] = $entry_id;
9758 $errors[] = "Database error for entry: $entry_id";
9759 }
9760 } catch (Exception $e) {
9761 $failed_ids[] = $entry_id;
9762 $errors[] = $e->getMessage();
9763 }
9764 }
9765
9766 wp_send_json_success(array(
9767 'success_ids' => $success_ids,
9768 'failed_ids' => $failed_ids,
9769 'errors' => $errors,
9770 'total_processed' => count($success_ids) + count($failed_ids)
9771 ));
9772
9773 exit;
9774 }
9775
9776 /**
9777 * Get the roles offered for a knowledge-entry restriction.
9778 *
9779 * Built from the site's ACTUAL roles (wp_roles()->get_names()) rather than a
9780 * hardcoded five, so roles added by User Role Editor / Members / a membership
9781 * plugin can be restricted to. The two virtual levels stay first, and the
9782 * built-in five keep their existing keys AND their existing labels, so
9783 * restrictions already stored on entries continue to mean what they meant.
9784 */
9785 public function mxchat_get_role_options() {
9786 return self::mxchat_role_options();
9787 }
9788
9789 /**
9790 * The same list, callable without an instance.
9791 *
9792 * The constructor registers hooks, so anything outside this class that needs
9793 * the role list (the post meta box) must not instantiate one just to read it.
9794 */
9795 public static function mxchat_role_options() {
9796 // Existing labels, in the existing order. A built-in that a site has
9797 // removed is simply not offered.
9798 $builtin_labels = array(
9799 'subscriber' => __('Subscribers & Above', 'mxchat'),
9800 'contributor' => __('Contributors & Above', 'mxchat'),
9801 'author' => __('Authors & Above', 'mxchat'),
9802 'editor' => __('Editors & Above', 'mxchat'),
9803 'administrator' => __('Administrators Only', 'mxchat'),
9804 );
9805
9806 $options = array(
9807 'public' => __('Public (Everyone)', 'mxchat'),
9808 'logged_in' => __('Logged In Users', 'mxchat'),
9809 );
9810
9811 $site_roles = function_exists('wp_roles') ? wp_roles()->get_names() : array();
9812 if (empty($site_roles) || !is_array($site_roles)) {
9813 // No roles readable (very early boot): fall back to the built-in set so
9814 // the dropdown is never empty.
9815 return array_merge($options, $builtin_labels);
9816 }
9817
9818 foreach ($builtin_labels as $slug => $label) {
9819 if (isset($site_roles[$slug])) {
9820 $options[$slug] = $label;
9821 }
9822 }
9823
9824 // Everything else the site defines, alphabetical by display name.
9825 $custom = array();
9826 foreach ($site_roles as $slug => $name) {
9827 if (!isset($builtin_labels[$slug])) {
9828 $custom[$slug] = translate_user_role($name);
9829 }
9830 }
9831 asort($custom);
9832
9833 return array_merge($options, $custom);
9834 }
9835
9836 /**
9837 * Check if the current user may see content carrying a role restriction.
9838 *
9839 * Access is granted when the user holds the required role outright, or holds
9840 * a role whose capabilities are a superset of the required role's. That keeps
9841 * the built-in "& Above" ladder exactly as it was — administrator's caps
9842 * cover editor's, editor's cover author's, and so on down to subscriber —
9843 * while working for any custom role, which a hardcoded five-name hierarchy
9844 * could not.
9845 *
9846 * The old hierarchy scored an unknown role 0, so restricting an entry TO a
9847 * custom role silently granted it to every logged-in user. Fails closed now.
9848 */
9849 public function mxchat_user_has_content_access($role_restriction) {
9850 // Public content is always accessible
9851 if ($role_restriction === 'public' || empty($role_restriction)) {
9852 return true;
9853 }
9854
9855 // Check if user is logged in for logged_in restriction
9856 if ($role_restriction === 'logged_in') {
9857 return is_user_logged_in();
9858 }
9859
9860 // If not logged in, no access to role-restricted content
9861 if (!is_user_logged_in()) {
9862 return false;
9863 }
9864
9865 $user = wp_get_current_user();
9866 $user_roles = (array) $user->roles;
9867
9868 if (empty($user_roles)) {
9869 return false;
9870 }
9871
9872 // Holding the required role is always enough.
9873 if (in_array($role_restriction, $user_roles, true)) {
9874 return true;
9875 }
9876
9877 // Site administrators keep the blanket access the old level-5 ladder gave
9878 // them. This also stops a restriction naming a since-deleted role from
9879 // locking the owner out of their own content.
9880 if (current_user_can('manage_options')) {
9881 return true;
9882 }
9883
9884 foreach ($user_roles as $user_role) {
9885 if (self::mxchat_role_covers($user_role, $role_restriction)) {
9886 return true;
9887 }
9888 }
9889
9890 return false;
9891 }
9892
9893 /**
9894 * Does $candidate_role grant everything $required_role grants?
9895 *
9896 * Compared on capabilities, not names. Memoised per request because this runs
9897 * once per retrieved knowledge row.
9898 *
9899 * A required role that does not exist, or that grants nothing at all, is NOT
9900 * treated as covered by anything — an empty capability set is a superset of
9901 * itself for every role on the site, which would quietly make the restriction
9902 * meaningless. Those cases fall back to the exact-role check in the caller.
9903 *
9904 * @param string $candidate_role Role slug the user holds.
9905 * @param string $required_role Role slug the entry is restricted to.
9906 * @return bool
9907 */
9908 private static function mxchat_role_covers($candidate_role, $required_role) {
9909 static $cache = array();
9910
9911 $key = $candidate_role . '|' . $required_role;
9912 if (isset($cache[$key])) {
9913 return $cache[$key];
9914 }
9915
9916 $cache[$key] = false;
9917
9918 if (!function_exists('wp_roles')) {
9919 return false;
9920 }
9921
9922 $roles = wp_roles();
9923 $candidate = $roles->get_role($candidate_role);
9924 $required = $roles->get_role($required_role);
9925
9926 if (!$candidate || !$required) {
9927 return false;
9928 }
9929
9930 $required_caps = self::mxchat_real_caps($required);
9931 $candidate_caps = self::mxchat_real_caps($candidate);
9932
9933 if (empty($required_caps)) {
9934 return false;
9935 }
9936
9937 foreach (array_keys($required_caps) as $cap) {
9938 if (empty($candidate_caps[$cap])) {
9939 return false;
9940 }
9941 }
9942
9943 $cache[$key] = true;
9944 return true;
9945 }
9946
9947 /**
9948 * A role's granted capabilities, minus the vestigial user-level ones.
9949 *
9950 * WordPress still stamps level_0 ... level_10 onto its built-in roles for
9951 * back-compat with user levels, deprecated since 3.0. They carry no meaning
9952 * and a hand-built custom role usually has none of them — so comparing them
9953 * makes a role that genuinely does more than a subscriber fail to cover
9954 * subscriber, purely because it lacks level_0. Measured on this box: a
9955 * Teacher role with read/edit_posts/delete_posts/upload_files was denied
9956 * subscriber-restricted content for exactly that reason.
9957 *
9958 * @param WP_Role $role
9959 * @return array Capability name => true, for granted non-level capabilities.
9960 */
9961 /**
9962 * Of two role restrictions, the one that admits FEWER people.
9963 *
9964 * 'public' loses to everything; 'logged_in' loses to any real role. Between
9965 * two real roles the comparison is the same capability-superset test the
9966 * access check uses: if A's capabilities cover B's, then everyone who
9967 * satisfies A also satisfies B, so A is the stricter requirement.
9968 *
9969 * Two unrelated custom roles are genuinely incomparable — neither covers the
9970 * other. In that case $b wins, because callers pass the per-entry choice as
9971 * $b: an owner who set the dropdown on the page itself meant it.
9972 *
9973 * @param string $a Current winner (may be 'public').
9974 * @param string $b Challenger.
9975 * @return string
9976 */
9977 private static function mxchat_stricter_role($a, $b) {
9978 $rank = array('public' => 0, 'logged_in' => 1);
9979
9980 if ($b === '' || $b === null) { return $a; }
9981 if ($a === '' || $a === null) { return $b; }
9982 if ($a === $b) { return $a; }
9983
9984 $a_virtual = isset($rank[$a]);
9985 $b_virtual = isset($rank[$b]);
9986
9987 if ($a_virtual && $b_virtual) {
9988 return $rank[$b] > $rank[$a] ? $b : $a;
9989 }
9990 // A real role always outranks 'public' and 'logged_in'.
9991 if ($a_virtual) { return $b; }
9992 if ($b_virtual) { return $a; }
9993
9994 // Both are real roles: whichever covers the other is the stricter one.
9995 if (self::mxchat_role_covers($a, $b)) { return $a; }
9996 if (self::mxchat_role_covers($b, $a)) { return $b; }
9997
9998 // Incomparable — the explicit per-entry choice wins.
9999 return $b;
10000 }
10001
10002 private static function mxchat_real_caps($role) {
10003 $caps = array();
10004 foreach ((array) $role->capabilities as $cap => $granted) {
10005 if (!$granted) {
10006 continue;
10007 }
10008 if (preg_match('/^level_\d+$/', $cap)) {
10009 continue;
10010 }
10011 $caps[$cap] = true;
10012 }
10013 return $caps;
10014 }
10015
10016 /**
10017 * Handle role restriction updates via AJAX
10018 * Removed cache clearing call since we removed caching
10019 */
10020 public function ajax_mxchat_update_role_restriction() {
10021 // Verify nonce and permissions
10022 if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
10023 wp_send_json_error('Invalid nonce');
10024 exit;
10025 }
10026
10027 if (!current_user_can('manage_options')) {
10028 wp_send_json_error('Unauthorized access');
10029 exit;
10030 }
10031
10032 $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
10033 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
10034 $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
10035
10036 if (empty($entry_id)) {
10037 wp_send_json_error('Invalid entry ID');
10038 exit;
10039 }
10040
10041 // Get knowledge manager instance to validate role restriction
10042 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
10043 $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
10044 if (!in_array($role_restriction, $valid_roles)) {
10045 wp_send_json_error('Invalid role restriction');
10046 exit;
10047 }
10048
10049 global $wpdb;
10050
10051 if ($data_source === 'pinecone') {
10052 // Handle Pinecone role restriction (stored separately in WordPress table)
10053 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10054
10055 // Use REPLACE to insert or update the role restriction
10056 $result = $wpdb->replace(
10057 $roles_table,
10058 array(
10059 'vector_id' => $entry_id,
10060 'role_restriction' => $role_restriction,
10061 'updated_at' => current_time('mysql')
10062 ),
10063 array('%s', '%s', '%s')
10064 );
10065
10066 // No cache clearing needed since we removed caching
10067
10068 } else {
10069 // Handle WordPress database role restriction (existing functionality)
10070 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10071
10072 $result = $wpdb->update(
10073 $table_name,
10074 array('role_restriction' => $role_restriction),
10075 array('id' => absint($entry_id)),
10076 array('%s'),
10077 array('%d')
10078 );
10079 }
10080
10081 if ($result === false) {
10082 wp_send_json_error('Database update failed: ' . $wpdb->last_error);
10083 exit;
10084 }
10085
10086 // Keep the OpenAI Vector Store mirror consistent with the new restriction
10087 // (plan 15b5c6): non-public pulls the mirrored file, public re-mirrors.
10088 if (class_exists('MxChat_Vectorstore_Manager')) {
10089 if ($data_source === 'pinecone') {
10090 if ($role_restriction !== 'public') {
10091 MxChat_Vectorstore_Manager::sync_delete_by_key($entry_id, 'default');
10092 }
10093 // Public again: Pinecone-mode content isn't held locally, so the
10094 // entry re-mirrors on its next save/import rather than here.
10095 } else {
10096 $row_url = $wpdb->get_var($wpdb->prepare(
10097 "SELECT source_url FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
10098 absint($entry_id)
10099 ));
10100 if (!empty($row_url)) {
10101 MxChat_Vectorstore_Manager::handle_role_change($row_url, 'default', $role_restriction);
10102 }
10103 }
10104 }
10105
10106 wp_send_json_success(array(
10107 'message' => 'Role restriction updated successfully',
10108 'role_restriction' => $role_restriction,
10109 'data_source' => $data_source,
10110 'entry_id' => $entry_id
10111 ));
10112 exit;
10113 }
10114
10115 // ========================================
10116 // ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
10117 // Add these to your MxChat_Knowledge_Manager class
10118 // ========================================
10119
10120 /**
10121 * Initialize role-based content hooks
10122 * Add this call to your __construct() or mxchat_init_hooks() method
10123 */
10124 private function mxchat_init_role_hooks() {
10125 // AJAX handlers for tag-role mappings
10126 add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
10127 add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
10128 add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
10129 add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
10130
10131 // Hook to automatically update role restrictions when tags are added/removed
10132 add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
10133
10134 // Hook to apply role restrictions on auto-sync
10135 add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
10136 }
10137
10138 /**
10139 * Add tag-role mapping via AJAX
10140 */
10141 public function ajax_add_tag_role_mapping() {
10142 // Verify nonce and permissions
10143 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
10144
10145 if (!current_user_can('manage_options')) {
10146 wp_send_json_error('Unauthorized access');
10147 exit;
10148 }
10149
10150 $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
10151 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
10152
10153 if (empty($tag_input)) {
10154 wp_send_json_error('Please enter a tag name or slug');
10155 exit;
10156 }
10157
10158 // Validate role restriction
10159 $valid_roles = array_keys($this->mxchat_get_role_options());
10160 if (!in_array($role_restriction, $valid_roles)) {
10161 wp_send_json_error('Invalid role restriction');
10162 exit;
10163 }
10164
10165 // Resolve the tag by slug first, then fall back to its display name, so users can
10166 // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
10167 // labeled by name but previously validated by slug only, producing the confusing
10168 // "Tag does not exist in WordPress" error when a real tag's name was typed.)
10169 $term = get_term_by('slug', $tag_input, 'post_tag');
10170 if (!$term) {
10171 $term = get_term_by('name', $tag_input, 'post_tag');
10172 }
10173 if (!$term) {
10174 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.');
10175 exit;
10176 }
10177
10178 // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
10179 // compares against each post's tag slugs, so the stored key must be a slug,
10180 // never the raw (possibly display-name) input.
10181 $tag_slug = $term->slug;
10182
10183 // Get existing mappings
10184 $mappings = get_option('mxchat_tag_role_mappings', array());
10185
10186 // Check if mapping already exists
10187 if (isset($mappings[$tag_slug])) {
10188 wp_send_json_error('Mapping for this tag already exists');
10189 exit;
10190 }
10191
10192 // Add new mapping
10193 $mappings[$tag_slug] = $role_restriction;
10194 update_option('mxchat_tag_role_mappings', $mappings);
10195
10196 wp_send_json_success(array(
10197 'message' => 'Tag-role mapping added successfully',
10198 'tag_slug' => $tag_slug,
10199 'role_restriction' => $role_restriction
10200 ));
10201 exit;
10202 }
10203
10204 /**
10205 * Delete tag-role mapping via AJAX
10206 */
10207 public function ajax_delete_tag_role_mapping() {
10208 // Verify nonce and permissions
10209 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
10210
10211 if (!current_user_can('manage_options')) {
10212 wp_send_json_error('Unauthorized access');
10213 exit;
10214 }
10215
10216 $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
10217
10218 if (empty($tag_slug)) {
10219 wp_send_json_error('Tag slug is required');
10220 exit;
10221 }
10222
10223 // Get existing mappings
10224 $mappings = get_option('mxchat_tag_role_mappings', array());
10225
10226 // Check if mapping exists
10227 if (!isset($mappings[$tag_slug])) {
10228 wp_send_json_error('Mapping does not exist');
10229 exit;
10230 }
10231
10232 // Remove mapping
10233 unset($mappings[$tag_slug]);
10234 update_option('mxchat_tag_role_mappings', $mappings);
10235
10236 wp_send_json_success(array(
10237 'message' => 'Tag-role mapping deleted successfully',
10238 'tag_slug' => $tag_slug
10239 ));
10240 exit;
10241 }
10242
10243 /**
10244 * Get all tag-role mappings via AJAX
10245 */
10246 public function ajax_get_tag_role_mappings() {
10247 // Verify nonce and permissions
10248 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
10249
10250 if (!current_user_can('manage_options')) {
10251 wp_send_json_error('Unauthorized access');
10252 exit;
10253 }
10254
10255 // Get mappings
10256 $mappings = get_option('mxchat_tag_role_mappings', array());
10257 $role_options = $this->mxchat_get_role_options();
10258
10259 $formatted_mappings = array();
10260
10261 foreach ($mappings as $tag_slug => $role_restriction) {
10262 // Get tag object
10263 $term = get_term_by('slug', $tag_slug, 'post_tag');
10264
10265 // Count posts with this tag
10266 $post_count = 0;
10267 if ($term) {
10268 $post_count = $term->count;
10269 }
10270
10271 $formatted_mappings[] = array(
10272 'tag_slug' => $tag_slug,
10273 'role_restriction' => $role_restriction,
10274 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
10275 'post_count' => $post_count
10276 );
10277 }
10278
10279 wp_send_json_success(array(
10280 'mappings' => $formatted_mappings
10281 ));
10282 exit;
10283 }
10284
10285 /**
10286 * Bulk update role restrictions for all existing content with mapped tags
10287 */
10288 public function ajax_bulk_update_tag_roles() {
10289 // Verify nonce and permissions
10290 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
10291
10292 if (!current_user_can('manage_options')) {
10293 wp_send_json_error('Unauthorized access');
10294 exit;
10295 }
10296
10297 // Get mappings
10298 $mappings = get_option('mxchat_tag_role_mappings', array());
10299
10300 if (empty($mappings)) {
10301 wp_send_json_error('No tag-role mappings found');
10302 exit;
10303 }
10304
10305 global $wpdb;
10306
10307 // Check if using Pinecone
10308 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10309 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10310
10311 $updated_count = 0;
10312 $details = array();
10313
10314 foreach ($mappings as $tag_slug => $role_restriction) {
10315 // Get all posts with this tag
10316 $posts = get_posts(array(
10317 'tag' => $tag_slug,
10318 'post_type' => 'any',
10319 'posts_per_page' => -1,
10320 'fields' => 'ids',
10321 'post_status' => 'publish'
10322 ));
10323
10324 if (empty($posts)) {
10325 continue;
10326 }
10327
10328 $tag_updated = 0;
10329
10330 foreach ($posts as $post_id) {
10331 $source_url = get_permalink($post_id);
10332 if (!$source_url) {
10333 continue;
10334 }
10335
10336 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10337 // Update Pinecone role restriction
10338 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10339 $vector_id = md5($source_url);
10340
10341 $result = $wpdb->replace(
10342 $roles_table,
10343 array(
10344 'vector_id' => $vector_id,
10345 'role_restriction' => $role_restriction,
10346 'updated_at' => current_time('mysql')
10347 ),
10348 array('%s', '%s', '%s')
10349 );
10350 } else {
10351 // Update WordPress DB
10352 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10353
10354 $result = $wpdb->update(
10355 $table_name,
10356 array('role_restriction' => $role_restriction),
10357 array('source_url' => $source_url),
10358 array('%s'),
10359 array('%s')
10360 );
10361 }
10362
10363 if ($result !== false) {
10364 $tag_updated++;
10365 $updated_count++;
10366 }
10367 }
10368
10369 if ($tag_updated > 0) {
10370 $details[] = sprintf(
10371 'Tag "%s" (%s): %d posts updated',
10372 $tag_slug,
10373 $role_restriction,
10374 $tag_updated
10375 );
10376 }
10377 }
10378
10379 wp_send_json_success(array(
10380 'message' => 'Bulk update completed',
10381 'updated_count' => $updated_count,
10382 'tags_processed' => count($mappings),
10383 'details' => $details
10384 ));
10385 exit;
10386 }
10387
10388 /**
10389 * Handle tag changes on posts (when tags are added or removed)
10390 */
10391 public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
10392 // Only process post tags
10393 if ($taxonomy !== 'post_tag') {
10394 return;
10395 }
10396
10397 // Get tag-role mappings
10398 $mappings = get_option('mxchat_tag_role_mappings', array());
10399
10400 if (empty($mappings)) {
10401 return;
10402 }
10403
10404 // Get the post's URL
10405 $source_url = get_permalink($object_id);
10406 if (!$source_url) {
10407 return;
10408 }
10409
10410 // Determine the highest role restriction based on tags
10411 $highest_role = 'public';
10412 $role_hierarchy = array(
10413 'public' => 0,
10414 'logged_in' => 1,
10415 'subscriber' => 2,
10416 'contributor' => 3,
10417 'author' => 4,
10418 'editor' => 5,
10419 'administrator' => 6
10420 );
10421
10422 // Get all current tags for the post
10423 $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
10424
10425 // Find the highest role restriction among the tags
10426 foreach ($current_tags as $tag_slug) {
10427 if (isset($mappings[$tag_slug])) {
10428 $role = $mappings[$tag_slug];
10429 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
10430 $highest_role = $role;
10431 }
10432 }
10433 }
10434
10435 // Update the role restriction in the database
10436 global $wpdb;
10437
10438 // Check if using Pinecone
10439 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10440 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10441
10442 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10443 // Update Pinecone role restriction
10444 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10445 $vector_id = md5($source_url);
10446
10447 $wpdb->replace(
10448 $roles_table,
10449 array(
10450 'vector_id' => $vector_id,
10451 'role_restriction' => $highest_role,
10452 'updated_at' => current_time('mysql')
10453 ),
10454 array('%s', '%s', '%s')
10455 );
10456 } else {
10457 // Update WordPress DB
10458 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10459
10460 $wpdb->update(
10461 $table_name,
10462 array('role_restriction' => $highest_role),
10463 array('source_url' => $source_url),
10464 array('%s'),
10465 array('%s')
10466 );
10467 }
10468
10469 // The entry's restriction just changed — keep the OpenAI Vector Store
10470 // mirror consistent: non-public pulls the file (file_search has no
10471 // per-role filtering), public re-mirrors it (plan 15b5c6).
10472 if (class_exists('MxChat_Vectorstore_Manager')) {
10473 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
10474 }
10475 }
10476
10477 /**
10478 * Apply role restriction after content is stored (for auto-sync)
10479 */
10480 public function apply_role_restriction_after_storage($post_id, $source_url) {
10481 // Get tag-role mappings
10482 $mappings = get_option('mxchat_tag_role_mappings', array());
10483
10484 if (empty($mappings)) {
10485 return;
10486 }
10487
10488 // Get all tags for the post
10489 $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
10490
10491 if (empty($post_tags)) {
10492 return;
10493 }
10494
10495 // Determine the highest role restriction based on tags
10496 $highest_role = 'public';
10497 $role_hierarchy = array(
10498 'public' => 0,
10499 'logged_in' => 1,
10500 'subscriber' => 2,
10501 'contributor' => 3,
10502 'author' => 4,
10503 'editor' => 5,
10504 'administrator' => 6
10505 );
10506
10507 foreach ($post_tags as $tag_slug) {
10508 if (isset($mappings[$tag_slug])) {
10509 $role = $mappings[$tag_slug];
10510 if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
10511 $highest_role = $role;
10512 }
10513 }
10514 }
10515
10516 // If no restricted tags found, return (leave as public)
10517 if ($highest_role === 'public') {
10518 return;
10519 }
10520
10521 // Update the role restriction
10522 global $wpdb;
10523
10524 // Check if using Pinecone
10525 $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
10526 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
10527
10528 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
10529 // Update Pinecone role restriction
10530 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
10531 $vector_id = md5($source_url);
10532
10533 $wpdb->replace(
10534 $roles_table,
10535 array(
10536 'vector_id' => $vector_id,
10537 'role_restriction' => $highest_role,
10538 'updated_at' => current_time('mysql')
10539 ),
10540 array('%s', '%s', '%s')
10541 );
10542 } else {
10543 // Update WordPress DB
10544 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10545
10546 $wpdb->update(
10547 $table_name,
10548 array('role_restriction' => $highest_role),
10549 array('source_url' => $source_url),
10550 array('%s'),
10551 array('%s')
10552 );
10553 }
10554
10555 // The entry's restriction just changed — keep the OpenAI Vector Store
10556 // mirror consistent: non-public pulls the file (file_search has no
10557 // per-role filtering), public re-mirrors it (plan 15b5c6).
10558 if (class_exists('MxChat_Vectorstore_Manager')) {
10559 MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
10560 }
10561 }
10562
10563
10564 // ========================================
10565 // HELPER METHODS
10566 // ========================================
10567
10568 /**
10569 * Check if user has required permissions for content processing
10570 */
10571 private function mxchat_check_user_permissions() {
10572 if (!current_user_can('manage_options')) {
10573 wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
10574 }
10575 }
10576
10577 /**
10578 * Validate nonce for security
10579 */
10580 private function mxchat_validate_nonce($nonce_name, $nonce_action) {
10581 if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
10582 wp_die(esc_html__('Security check failed.', 'mxchat'));
10583 }
10584 }
10585
10586 /**
10587 * Get embedding API credentials
10588 */
10589 private function mxchat_get_embedding_credentials() {
10590 $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
10591
10592 if (strpos($embedding_model, 'text-embedding-') !== false) {
10593 return array(
10594 'type' => 'openai',
10595 'api_key' => $this->options['api_key'] ?? ''
10596 );
10597 } elseif (strpos($embedding_model, 'voyage-') !== false) {
10598 return array(
10599 'type' => 'voyage',
10600 'api_key' => $this->options['voyage_api_key'] ?? ''
10601 );
10602 } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
10603 return array(
10604 'type' => 'gemini',
10605 'api_key' => $this->options['gemini_api_key'] ?? ''
10606 );
10607 }
10608
10609 return array('type' => 'unknown', 'api_key' => '');
10610 }
10611
10612 /**
10613 * Log processing errors
10614 */
10615 private function mxchat_log_processing_error($operation, $error_message) {
10616 //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
10617 }
10618
10619 /**
10620 * Set admin notice transient
10621 */
10622 private function mxchat_set_admin_notice($type, $message) {
10623 set_transient("mxchat_admin_notice_{$type}", $message, 30);
10624 }
10625
10626 /**
10627 * Get Pinecone manager instance for vector operations
10628 */
10629 private function mxchat_get_pinecone_manager() {
10630 return MxChat_Pinecone_Manager::get_instance();
10631 }
10632
10633
10634 // ========================================
10635 // DATABASE QUEUE TABLE MANAGEMENT
10636 // ========================================
10637
10638 /**
10639 * Create queue table on plugin activation
10640 * Call this from your plugin activation hook
10641 */
10642 public function mxchat_create_queue_table() {
10643 global $wpdb;
10644
10645 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10646 $charset_collate = $wpdb->get_charset_collate();
10647
10648 $sql = "CREATE TABLE IF NOT EXISTS $table_name (
10649 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
10650 queue_id varchar(64) NOT NULL,
10651 item_type varchar(20) NOT NULL,
10652 item_data longtext NOT NULL,
10653 status varchar(20) NOT NULL DEFAULT 'pending',
10654 bot_id varchar(50) NOT NULL DEFAULT 'default',
10655 priority int(11) NOT NULL DEFAULT 0,
10656 attempts int(11) NOT NULL DEFAULT 0,
10657 max_attempts int(11) NOT NULL DEFAULT 3,
10658 error_message text DEFAULT NULL,
10659 created_at datetime NOT NULL,
10660 started_at datetime DEFAULT NULL,
10661 completed_at datetime DEFAULT NULL,
10662 PRIMARY KEY (id),
10663 KEY queue_id (queue_id),
10664 KEY status (status),
10665 KEY item_type (item_type),
10666 KEY priority (priority)
10667 ) $charset_collate;";
10668
10669 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
10670 dbDelta($sql);
10671
10672 // Also create a meta table for queue metadata
10673 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10674
10675 $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
10676 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
10677 queue_id varchar(64) NOT NULL,
10678 meta_key varchar(255) NOT NULL,
10679 meta_value longtext,
10680 PRIMARY KEY (id),
10681 KEY queue_id (queue_id),
10682 KEY meta_key (meta_key)
10683 ) $charset_collate;";
10684
10685 dbDelta($meta_sql);
10686 }
10687
10688 /**
10689 * Add items to the processing queue
10690 *
10691 * @param string $queue_id Unique identifier for this queue batch
10692 * @param string $item_type Type of item (url, pdf_page)
10693 * @param array $items Array of items to queue
10694 * @param string $bot_id Bot ID for processing
10695 * @return int Number of items queued
10696 */
10697 private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
10698 global $wpdb;
10699 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10700
10701 $queued_count = 0;
10702 $priority = 0;
10703
10704 foreach ($items as $item) {
10705 $result = $wpdb->insert(
10706 $table_name,
10707 array(
10708 'queue_id' => $queue_id,
10709 'item_type' => $item_type,
10710 'item_data' => wp_json_encode($item),
10711 'status' => 'pending',
10712 'bot_id' => $bot_id,
10713 'priority' => $priority,
10714 'attempts' => 0,
10715 'max_attempts' => 3,
10716 'created_at' => current_time('mysql')
10717 ),
10718 array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
10719 );
10720
10721 if ($result) {
10722 $queued_count++;
10723 }
10724
10725 $priority++; // Process in order
10726 }
10727
10728 return $queued_count;
10729 }
10730
10731 /**
10732 * Store queue metadata (total counts, source URL, etc.)
10733 */
10734 private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
10735 global $wpdb;
10736 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10737
10738 // Check if meta exists
10739 $existing = $wpdb->get_var($wpdb->prepare(
10740 "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
10741 $queue_id,
10742 $meta_key
10743 ));
10744
10745 if ($existing) {
10746 // Update
10747 $wpdb->update(
10748 $meta_table,
10749 array('meta_value' => maybe_serialize($meta_value)),
10750 array('queue_id' => $queue_id, 'meta_key' => $meta_key),
10751 array('%s'),
10752 array('%s', '%s')
10753 );
10754 } else {
10755 // Insert
10756 $wpdb->insert(
10757 $meta_table,
10758 array(
10759 'queue_id' => $queue_id,
10760 'meta_key' => $meta_key,
10761 'meta_value' => maybe_serialize($meta_value)
10762 ),
10763 array('%s', '%s', '%s')
10764 );
10765 }
10766 }
10767
10768 /**
10769 * Get queue metadata
10770 */
10771 private function mxchat_get_queue_meta($queue_id, $meta_key) {
10772 global $wpdb;
10773 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
10774
10775 $value = $wpdb->get_var($wpdb->prepare(
10776 "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
10777 $queue_id,
10778 $meta_key
10779 ));
10780
10781 return maybe_unserialize($value);
10782 }
10783
10784 // ========================================
10785 // AJAX QUEUE PROCESSING HANDLERS
10786 // ========================================
10787
10788 /**
10789 * AJAX: Get next item from queue to process
10790 */
10791 public function ajax_mxchat_get_next_queue_item() {
10792 // Verify nonce and permissions
10793 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10794
10795 if (!current_user_can('manage_options')) {
10796 wp_send_json_error('Unauthorized access');
10797 }
10798
10799 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
10800
10801 if (empty($queue_id)) {
10802 wp_send_json_error('Missing queue ID');
10803 }
10804
10805 global $wpdb;
10806 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10807
10808 // Get next pending item with retry logic for failed items
10809 $next_item = $wpdb->get_row($wpdb->prepare(
10810 "SELECT * FROM $table_name
10811 WHERE queue_id = %s
10812 AND status IN ('pending', 'failed')
10813 AND attempts < max_attempts
10814 ORDER BY priority ASC, id ASC
10815 LIMIT 1",
10816 $queue_id
10817 ));
10818
10819 if (!$next_item) {
10820 // No more items - queue complete
10821 wp_send_json_success(array(
10822 'complete' => true,
10823 'message' => 'Queue processing complete'
10824 ));
10825 }
10826
10827 // Mark item as processing
10828 $wpdb->update(
10829 $table_name,
10830 array(
10831 'status' => 'processing',
10832 'started_at' => current_time('mysql'),
10833 'attempts' => $next_item->attempts + 1
10834 ),
10835 array('id' => $next_item->id),
10836 array('%s', '%s', '%d'),
10837 array('%d')
10838 );
10839
10840 wp_send_json_success(array(
10841 'complete' => false,
10842 'item' => array(
10843 'id' => $next_item->id,
10844 'type' => $next_item->item_type,
10845 'data' => json_decode($next_item->item_data, true),
10846 'bot_id' => $next_item->bot_id,
10847 'attempt' => $next_item->attempts + 1
10848 )
10849 ));
10850 }
10851
10852 /**
10853 * AJAX: Process a single queue item
10854 */
10855 public function ajax_mxchat_process_queue_item() {
10856 // Verify nonce and permissions
10857 check_ajax_referer('mxchat_queue_nonce', 'nonce');
10858
10859 if (!current_user_can('manage_options')) {
10860 wp_send_json_error('Unauthorized access');
10861 }
10862
10863 $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
10864 $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
10865 $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
10866 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
10867
10868 if (empty($item_id) || empty($item_type)) {
10869 wp_send_json_error('Missing item data');
10870 }
10871
10872 global $wpdb;
10873 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
10874
10875 // Process based on item type
10876 try {
10877 set_time_limit(60); // Give processing 60 seconds
10878
10879 $result = false;
10880 $error_message = '';
10881
10882 // Read item directly from DB to get queue_id and preserve special chars in item_data
10883 // (POST round-trip through JS mangles characters like apostrophes in URLs)
10884 $db_item = $wpdb->get_row($wpdb->prepare(
10885 "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
10886 $item_id
10887 ));
10888 $item_queue_id = $db_item ? $db_item->queue_id : '';
10889 if ($db_item && !empty($db_item->item_data)) {
10890 $db_data = json_decode($db_item->item_data, true);
10891 if (is_array($db_data)) {
10892 $item_data = $db_data;
10893 }
10894 }
10895
10896 switch ($item_type) {
10897 case 'url':
10898 $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
10899 break;
10900
10901 case 'pdf_page':
10902 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
10903 break;
10904
10905 default:
10906 throw new Exception('Unknown item type: ' . $item_type);
10907 }
10908
10909 if (is_wp_error($result)) {
10910 $error_code = $result->get_error_code();
10911 // Content errors (empty page, sanitization) are permanent — retrying won't help
10912 $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
10913 if (in_array($error_code, $permanent_codes)) {
10914 // Mark as permanently failed — set attempts = max_attempts so it won't be retried
10915 $current_item = $wpdb->get_row($wpdb->prepare(
10916 "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
10917 ));
10918 $wpdb->update(
10919 $table_name,
10920 array(
10921 'status' => 'failed',
10922 'error_message' => $result->get_error_message(),
10923 'attempts' => $current_item ? $current_item->max_attempts : 3
10924 ),
10925 array('id' => $item_id),
10926 array('%s', '%s', '%d'),
10927 array('%d')
10928 );
10929 wp_send_json_error(array(
10930 'message' => $result->get_error_message(),
10931 'permanent_failure' => true,
10932 'item_id' => $item_id
10933 ));
10934 return;
10935 }
10936 throw new Exception($result->get_error_message());
10937 }
10938
10939 if ($result === false) {
10940 throw new Exception('Processing returned false - item may be empty or invalid');
10941 }
10942
10943 // Mark as completed
10944 $wpdb->update(
10945 $table_name,
10946 array(
10947 'status' => 'completed',
10948 'completed_at' => current_time('mysql'),
10949 'error_message' => null
10950 ),
10951 array('id' => $item_id),
10952 array('%s', '%s', '%s'),
10953 array('%d')
10954 );
10955
10956 wp_send_json_success(array(
10957 'processed' => true,
10958 'item_id' => $item_id,
10959 'message' => 'Item processed successfully'
10960 ));
10961
10962 } catch (Exception $e) {
10963 $error_message = $e->getMessage();
10964
10965 // Get current attempt count
10966 $item = $wpdb->get_row($wpdb->prepare(
10967 "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
10968 $item_id
10969 ));
10970
10971 // Check if we've exhausted retries
10972 if ($item && $item->attempts >= $item->max_attempts) {
10973 // Permanently failed
10974 $wpdb->update(
10975 $table_name,
10976 array(
10977 'status' => 'failed',
10978 'error_message' => $error_message
10979 ),
10980 array('id' => $item_id),
10981 array('%s', '%s'),
10982 array('%d')
10983 );
10984
10985 wp_send_json_error(array(
10986 'message' => 'Item failed after maximum attempts: ' . $error_message,
10987 'permanent_failure' => true,
10988 'item_id' => $item_id
10989 ));
10990 } else {
10991 // Mark for retry
10992 $wpdb->update(
10993 $table_name,
10994 array(
10995 'status' => 'failed',
10996 'error_message' => $error_message
10997 ),
10998 array('id' => $item_id),
10999 array('%s', '%s'),
11000 array('%d')
11001 );
11002
11003 wp_send_json_error(array(
11004 'message' => 'Item processing failed, will retry: ' . $error_message,
11005 'can_retry' => true,
11006 'item_id' => $item_id,
11007 'attempts' => $item ? $item->attempts : 0
11008 ));
11009 }
11010 }
11011 }
11012
11013 /**
11014 * Process a URL from the queue
11015 */
11016 private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
11017 $url = isset($item_data['url']) ? $item_data['url'] : '';
11018
11019 if (empty($url)) {
11020 return new WP_Error('invalid_url', 'URL is empty');
11021 }
11022
11023 // Get bot-specific embedding decision early (needed for both paths) —
11024 // custom-provider-aware (plan cbd5fd). Error code preserved.
11025 $bot_options = $this->get_bot_options($bot_id);
11026 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
11027
11028 $preflight = MxChat_Utils::embedding_preflight($options);
11029 if (!$preflight['ok']) {
11030 return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
11031 }
11032 $api_key = $preflight['api_key'];
11033
11034 // Check if this is a WooCommerce product URL and WooCommerce is active
11035 $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
11036 $content_type = $is_product_url ? 'product' : 'url';
11037
11038 // Try to get WooCommerce product data if it's a product URL
11039 if ($is_product_url && class_exists('WooCommerce')) {
11040 $product_content = $this->mxchat_extract_woocommerce_product_content($url);
11041
11042 if (!empty($product_content)) {
11043 // Successfully extracted WooCommerce product data with pricing
11044 $result = MxChat_Utils::submit_content_to_db(
11045 $product_content,
11046 $url,
11047 $api_key,
11048 null,
11049 $bot_id,
11050 'product'
11051 );
11052 return $result;
11053 }
11054 // If WooCommerce extraction failed, fall through to HTML extraction
11055 }
11056
11057 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
11058 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
11059 $response = wp_remote_get($url, array(
11060 'timeout' => $is_likely_pdf ? 120 : 30,
11061 'redirection' => 5,
11062 'user-agent' => mxchat_ingest_user_agent(),
11063 ));
11064
11065 if (is_wp_error($response)) {
11066 return $response;
11067 }
11068
11069 $response_code = wp_remote_retrieve_response_code($response);
11070 if ($response_code !== 200) {
11071 return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
11072 }
11073
11074 // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
11075 if ($this->mxchat_is_pdf_url($url, $response)) {
11076 return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
11077 }
11078
11079 $html = wp_remote_retrieve_body($response);
11080
11081 if (empty($html)) {
11082 return new WP_Error('empty_response', 'Empty response body');
11083 }
11084
11085 // Extract and sanitize content
11086 $content = $this->mxchat_extract_main_content($html);
11087 $sanitized = $this->mxchat_sanitize_content_for_api($content);
11088
11089 if (empty($sanitized)) {
11090 // Not an error - just no content found (maybe a redirect or empty page)
11091 return false;
11092 }
11093
11094 // Submit to database with content_type (+ the page's own title, plan d9ee66)
11095 $result = MxChat_Utils::submit_content_to_db(
11096 $sanitized,
11097 $url,
11098 $api_key,
11099 null,
11100 $bot_id,
11101 $content_type,
11102 MxChat_Utils::extract_page_title($html)
11103 );
11104
11105 return $result;
11106 }
11107
11108 /**
11109 * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
11110 * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
11111 * and adds pdf_page items to the same queue so they process with full progress tracking.
11112 */
11113 private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
11114 set_time_limit(120); // PDFs need extra time for download + parsing
11115
11116 $upload_dir = wp_upload_dir();
11117 $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
11118 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
11119
11120 $response_body = wp_remote_retrieve_body($response);
11121 if (empty($response_body)) {
11122 return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
11123 }
11124
11125 if (!wp_mkdir_p(dirname($pdf_path))) {
11126 return new WP_Error('dir_error', 'Failed to create upload directory');
11127 }
11128
11129 file_put_contents($pdf_path, $response_body);
11130
11131 if (!file_exists($pdf_path)) {
11132 return new WP_Error('save_error', 'Failed to save PDF file');
11133 }
11134
11135 try {
11136 $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
11137
11138 if ($total_pages === false || $total_pages < 1) {
11139 wp_delete_file($pdf_path);
11140 return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
11141 }
11142
11143 // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
11144 $pages = array();
11145 for ($i = 1; $i <= $total_pages; $i++) {
11146 $pages[] = array(
11147 'pdf_path' => $pdf_path,
11148 'pdf_url' => $pdf_url,
11149 'page_number' => $i,
11150 'total_pages' => $total_pages
11151 );
11152 }
11153
11154 // Add pdf_page items to the SAME queue so the JS picks them up automatically
11155 if (!empty($queue_id)) {
11156 $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
11157 } else {
11158 // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
11159 $new_queue_id = 'pdf_' . md5($pdf_url . time());
11160 $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
11161 $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
11162 $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
11163 $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
11164 $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
11165 $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
11166 $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
11167 }
11168
11169 if ($queued_count === 0) {
11170 wp_delete_file($pdf_path);
11171 return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
11172 }
11173
11174 // Return true so the original URL item is marked complete
11175 // The new pdf_page items will be processed in subsequent batches
11176 return true;
11177
11178 } catch (Exception $e) {
11179 if (file_exists($pdf_path)) {
11180 wp_delete_file($pdf_path);
11181 }
11182 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
11183 }
11184 }
11185
11186 /**
11187 * Legacy: Process a PDF URL inline during sitemap queue processing.
11188 * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
11189 */
11190 private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
11191 set_time_limit(120); // PDFs need more time — downloading + parsing all pages
11192
11193 $upload_dir = wp_upload_dir();
11194 $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
11195 $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
11196
11197 $response_body = wp_remote_retrieve_body($response);
11198 if (empty($response_body)) {
11199 return new WP_Error('empty_pdf', 'Empty PDF response');
11200 }
11201
11202 if (!wp_mkdir_p(dirname($pdf_path))) {
11203 return new WP_Error('dir_error', 'Failed to create upload directory');
11204 }
11205
11206 file_put_contents($pdf_path, $response_body);
11207
11208 if (!file_exists($pdf_path)) {
11209 return new WP_Error('save_error', 'Failed to save PDF file');
11210 }
11211
11212 try {
11213 mxchat_load_pdf_parser();
11214 $parser = new \Smalot\PdfParser\Parser();
11215 $pdf = $parser->parseFile($pdf_path);
11216 $pages = $pdf->getPages();
11217 $total_pages = count($pages);
11218
11219 if ($total_pages < 1) {
11220 wp_delete_file($pdf_path);
11221 return new WP_Error('no_pages', 'PDF has no pages');
11222 }
11223
11224 $processed = 0;
11225 $skipped_pages = array();
11226
11227 for ($i = 0; $i < $total_pages; $i++) {
11228 $page_num = $i + 1;
11229 $text = $pages[$i]->getText();
11230 $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num);
11231 if (empty($text)) {
11232 $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
11233 continue;
11234 }
11235
11236 $sanitized = $this->mxchat_sanitize_content_for_api($text);
11237 if (empty($sanitized)) {
11238 $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
11239 continue;
11240 }
11241
11242 $metadata = array(
11243 'document_type' => 'pdf',
11244 'total_pages' => $total_pages,
11245 'current_page' => $page_num,
11246 'source_url' => $pdf_url,
11247 );
11248
11249 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
11250 $page_url = MxChat_Utils::pdf_page_identity($pdf_url, $page_num);
11251
11252 MxChat_Utils::submit_content_to_db(
11253 $content_with_metadata,
11254 $page_url,
11255 $api_key,
11256 null,
11257 $bot_id,
11258 'pdf'
11259 );
11260
11261 $processed++;
11262 }
11263
11264 // Clean up the temp PDF file
11265 wp_delete_file($pdf_path);
11266
11267 if (!empty($skipped_pages)) {
11268 error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
11269 }
11270
11271 return $processed > 0 ? true : false;
11272
11273 } catch (Exception $e) {
11274 if (file_exists($pdf_path)) {
11275 wp_delete_file($pdf_path);
11276 }
11277 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
11278 }
11279 }
11280
11281 /**
11282 * Extract WooCommerce product content including pricing
11283 *
11284 * @param string $url The product URL
11285 * @return string|false Product content with pricing, or false if not found
11286 */
11287 private function mxchat_extract_woocommerce_product_content($url) {
11288 // Try to get product ID from URL
11289 $product_id = url_to_postid($url);
11290
11291 // If url_to_postid fails, try to extract from URL pattern
11292 if (!$product_id) {
11293 $product_slug = '';
11294
11295 // Handle pretty permalinks: /product/product-name/
11296 if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
11297 $product_slug = $matches[1];
11298 }
11299
11300 if (!empty($product_slug)) {
11301 $product_post = get_page_by_path($product_slug, OBJECT, 'product');
11302 if ($product_post) {
11303 $product_id = $product_post->ID;
11304 }
11305 }
11306 }
11307
11308 if (!$product_id) {
11309 return false;
11310 }
11311
11312 // Get WooCommerce product object
11313 $product = wc_get_product($product_id);
11314
11315 if (!$product) {
11316 return false;
11317 }
11318
11319 // Build product content via the shared WC-object assembler (a3d60c) — same
11320 // body as the auto-sync product writer, so the two paths can never drift.
11321 $content = $this->mxchat_prepare_product_content_for_indexing($product);
11322
11323 return $this->mxchat_sanitize_content_for_api($content);
11324 }
11325
11326 /**
11327 * Process a PDF page from the queue
11328 */
11329 private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
11330 $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
11331 $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
11332 $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
11333 $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
11334
11335 if (empty($pdf_path) || !file_exists($pdf_path)) {
11336 return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
11337 }
11338
11339 if ($page_number < 1) {
11340 return new WP_Error('invalid_page', 'Invalid page number');
11341 }
11342
11343 try {
11344 mxchat_load_pdf_parser();
11345 $parser = new \Smalot\PdfParser\Parser();
11346 $pdf = $parser->parseFile($pdf_path);
11347 $pages = $pdf->getPages();
11348
11349 if (!isset($pages[$page_number - 1])) {
11350 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
11351 }
11352
11353 $text = $pages[$page_number - 1]->getText();
11354 $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number);
11355
11356 if (empty($text)) {
11357 return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
11358 }
11359
11360 $sanitized = $this->mxchat_sanitize_content_for_api($text);
11361
11362 if (empty($sanitized)) {
11363 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');
11364 }
11365
11366 // Create metadata
11367 $metadata = array(
11368 'document_type' => 'pdf',
11369 'total_pages' => $total_pages,
11370 'current_page' => $page_number,
11371 'source_url' => $pdf_url
11372 );
11373
11374 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
11375 $page_url = MxChat_Utils::pdf_page_identity($pdf_url, $page_number);
11376
11377 // Get bot-specific embedding decision — custom-provider-aware
11378 // (plan cbd5fd). Error code preserved.
11379 $bot_options = $this->get_bot_options($bot_id);
11380 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
11381
11382 $preflight = MxChat_Utils::embedding_preflight($options);
11383 if (!$preflight['ok']) {
11384 return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
11385 }
11386 $api_key = $preflight['api_key'];
11387
11388 // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
11389 $result = MxChat_Utils::submit_content_to_db(
11390 $content_with_metadata,
11391 $page_url,
11392 $api_key,
11393 null,
11394 $bot_id,
11395 'pdf'
11396 );
11397
11398 return $result;
11399
11400 } catch (Exception $e) {
11401 return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
11402 }
11403 }
11404
11405 /**
11406 * AJAX: Get queue processing status
11407 */
11408 public function ajax_mxchat_get_queue_status() {
11409 // Verify nonce and permissions
11410 check_ajax_referer('mxchat_queue_nonce', 'nonce');
11411
11412 if (!current_user_can('manage_options')) {
11413 wp_send_json_error('Unauthorized access');
11414 }
11415
11416 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11417
11418 if (empty($queue_id)) {
11419 wp_send_json_error('Missing queue ID');
11420 }
11421
11422 global $wpdb;
11423 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
11424
11425 // Get counts by status
11426 $counts = $wpdb->get_results($wpdb->prepare(
11427 "SELECT status, COUNT(*) as count
11428 FROM $table_name
11429 WHERE queue_id = %s
11430 GROUP BY status",
11431 $queue_id
11432 ), OBJECT_K);
11433
11434 $total = 0;
11435 $completed = 0;
11436 $failed = 0;
11437 $processing = 0;
11438 $pending = 0;
11439
11440 foreach ($counts as $status => $data) {
11441 $count = absint($data->count);
11442 $total += $count;
11443
11444 switch ($status) {
11445 case 'completed':
11446 $completed = $count;
11447 break;
11448 case 'failed':
11449 $failed = $count;
11450 break;
11451 case 'processing':
11452 $processing = $count;
11453 break;
11454 case 'pending':
11455 $pending = $count;
11456 break;
11457 }
11458 }
11459
11460 // Calculate percentage
11461 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
11462
11463 // Get failed items details (include all failed items, not just those that exhausted retries)
11464 $failed_items = array();
11465 if ($failed > 0) {
11466 $failed_items = $wpdb->get_results($wpdb->prepare(
11467 "SELECT item_type, item_data, error_message, attempts
11468 FROM $table_name
11469 WHERE queue_id = %s
11470 AND status = 'failed'
11471 ORDER BY id DESC
11472 LIMIT 50",
11473 $queue_id
11474 ));
11475 }
11476
11477 // Get queue metadata
11478 $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
11479 $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
11480
11481 // Determine if queue is complete
11482 $is_complete = ($pending === 0 && $processing === 0);
11483
11484 wp_send_json_success(array(
11485 'queue_id' => $queue_id,
11486 'queue_type' => $queue_type,
11487 'source_url' => $source_url,
11488 'total' => $total,
11489 'completed' => $completed,
11490 'failed' => $failed,
11491 'processing' => $processing,
11492 'pending' => $pending,
11493 'percentage' => $percentage,
11494 'is_complete' => $is_complete,
11495 'failed_items' => $failed_items,
11496 'status' => $is_complete ? 'complete' : 'processing'
11497 ));
11498 }
11499
11500 /**
11501 * AJAX: Clear completed queue
11502 */
11503 public function ajax_mxchat_clear_queue() {
11504 // Verify nonce and permissions
11505 check_ajax_referer('mxchat_queue_nonce', 'nonce');
11506
11507 if (!current_user_can('manage_options')) {
11508 wp_send_json_error('Unauthorized access');
11509 }
11510
11511 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11512
11513 if (empty($queue_id)) {
11514 wp_send_json_error('Missing queue ID');
11515 }
11516
11517 global $wpdb;
11518 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
11519 $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
11520
11521 // Delete queue items
11522 $wpdb->delete(
11523 $table_name,
11524 array('queue_id' => $queue_id),
11525 array('%s')
11526 );
11527
11528 // Delete queue metadata
11529 $wpdb->delete(
11530 $meta_table,
11531 array('queue_id' => $queue_id),
11532 array('%s')
11533 );
11534
11535 wp_send_json_success(array(
11536 'message' => 'Queue cleared successfully'
11537 ));
11538 }
11539
11540 /**
11541 * AJAX: Retry failed items in queue
11542 */
11543 public function ajax_mxchat_retry_failed() {
11544 // Verify nonce and permissions
11545 check_ajax_referer('mxchat_queue_nonce', 'nonce');
11546
11547 if (!current_user_can('manage_options')) {
11548 wp_send_json_error('Unauthorized access');
11549 }
11550
11551 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11552
11553 if (empty($queue_id)) {
11554 wp_send_json_error('Missing queue ID');
11555 }
11556
11557 global $wpdb;
11558 $table_name = $wpdb->prefix . 'mxchat_processing_queue';
11559
11560 // Reset failed items to pending and reset attempt count
11561 $updated = $wpdb->update(
11562 $table_name,
11563 array(
11564 'status' => 'pending',
11565 'attempts' => 0,
11566 'error_message' => null
11567 ),
11568 array(
11569 'queue_id' => $queue_id,
11570 'status' => 'failed'
11571 ),
11572 array('%s', '%d', '%s'),
11573 array('%s', '%s')
11574 );
11575
11576 wp_send_json_success(array(
11577 'message' => 'Reset ' . $updated . ' failed items for retry',
11578 'reset_count' => $updated
11579 ));
11580 }
11581
11582
11583 public function ajax_mxchat_mark_queue_complete() {
11584 check_ajax_referer('mxchat_queue_nonce', 'nonce');
11585
11586 if (!current_user_can('manage_options')) {
11587 wp_send_json_error('Unauthorized access');
11588 }
11589
11590 $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
11591
11592 if (empty($queue_id)) {
11593 wp_send_json_error('Missing queue ID');
11594 }
11595
11596 // Clear active queue transients
11597 if (strpos($queue_id, 'sitemap_') === 0) {
11598 delete_transient('mxchat_active_queue_sitemap');
11599 } else if (strpos($queue_id, 'pdf_') === 0) {
11600 delete_transient('mxchat_active_queue_pdf');
11601 }
11602
11603 wp_send_json_success(array('message' => 'Queue marked as complete'));
11604 }
11605
11606
11607 // ========================================
11608 // STATIC ACCESS METHODS
11609 // ========================================
11610
11611 /**
11612 * Get singleton instance
11613 */
11614 public static function get_instance() {
11615 static $instance = null;
11616 if ($instance === null) {
11617 $instance = new self();
11618 }
11619 return $instance;
11620 }
11621 }
11622
11623 // Initialize the Knowledge manager
11624 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();