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

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