PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.6
MxChat – AI Chatbot & Content Generation for WordPress v3.0.6
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | admin/class-knowledge-manager.php +7108 -8927 3.2.183.0.6 View file →
@@ -1,8928 +1,7109 @@
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_youtube', array($this, 'mxchat_handle_youtube_submission'));
52 - add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
53 -
54 - // AJAX handlers for real-time processing and status updates
55 - add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
56 - add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
57 - add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
58 - add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
59 - add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
60 - add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
61 - add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
62 - add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
63 - add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
64 - add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
65 - add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
66 -
67 - // Queue-based processing AJAX handlers
68 - add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
69 - add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
70 - add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
71 - add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
72 - add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
73 - add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
74 - add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
75 - add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
76 - add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
77 - add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
78 - add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
79 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
80 -
81 - // WordPress post management hooks
82 - add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
83 - add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
84 - add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
85 - add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
86 - // Authoritative unpublish detection: core hands this hook the REAL previous status, so
87 - // removal no longer depends on the mxchat_prev_status_* transients (evictable by persistent
88 - // object caches, never written by paths that bypass wp_update_post, e.g. plugins flipping
89 - // post_status directly and calling wp_transition_post_status themselves).
90 - add_action('transition_post_status', array($this, 'mxchat_handle_status_transition'), 10, 3);
91 -
92 - // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
93 - // Priority 20 to run after ACF's own save (which runs at priority 10)
94 - add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
95 -
96 - // One-time cleanup for vectors orphaned by unpublishes that predate the
97 - // transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished
98 - if (defined('WP_CLI') && WP_CLI) {
99 - WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished'));
100 - }
101 -
102 - add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
103 -
104 - // WooCommerce product hooks (if WooCommerce is active)
105 - if (class_exists('WooCommerce')) {
106 - add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
107 - add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
108 - add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
109 - add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
110 - }
111 -}
112 -
113 - /**
114 - * Get current options (refreshed)
115 - */
116 - private function mxchat_get_options() {
117 - if (empty($this->options)) {
118 - $this->options = get_option('mxchat_options', array());
119 - }
120 - return $this->options;
121 - }
122 -
123 -
124 - // ========================================
125 - // MAIN CONTENT SUBMISSION HANDLERS
126 - // ========================================
127 -
128 -public function mxchat_handle_content_submission() {
129 - // Check if the form was submitted and the user has permission.
130 - if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
131 - return;
132 - }
133 -
134 - // Verify the nonce.
135 - $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
136 - if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
137 - wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
138 - }
139 -
140 - // Sanitize the inputs.
141 - // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
142 - $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
143 - $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
144 -
145 - // Get bot_id from form submission
146 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
147 -
148 - // Get bot-specific options and API key
149 - $bot_options = $this->get_bot_options($bot_id);
150 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
151 -
152 - // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
153 - $preflight = MxChat_Utils::embedding_preflight($options);
154 - if (!$preflight['ok']) {
155 - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
156 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
157 - exit;
158 - }
159 - $api_key = $preflight['api_key'];
160 -
161 - // Use centralized utility function with bot_id
162 - $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
163 -
164 - if (is_wp_error($result)) {
165 - set_transient('mxchat_admin_notice_error',
166 - esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
167 - 30
168 - );
169 - } else {
170 - set_transient('mxchat_admin_notice_success',
171 - esc_html__('Content successfully submitted!', 'mxchat'),
172 - 30
173 - );
174 - }
175 -
176 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
177 - exit;
178 -}
179 -
180 -/**
181 - * Handle the "YouTube" KB import source (admin-post form submission).
182 - *
183 - * Per-video description mode:
184 - * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
185 - * If no usable transcript, index the metadata anyway, tell the admin,
186 - * and bounce back with the manual box pre-filled (never fail silently).
187 - * - manual: the admin's own description is what gets indexed; metadata rides along.
188 - *
189 - * The row is stored with content_type 'youtube' and source_url = the canonical
190 - * watch URL, so re-importing the same video UPDATES the entry (source_url
191 - * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
192 - * "augment a metadata-only entry" path.
193 - */
194 -public function mxchat_handle_youtube_submission() {
195 - if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
196 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
197 - }
198 -
199 - check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
200 -
201 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
202 -
203 - $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
204 - $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
205 -
206 - if (empty($video_id)) {
207 - set_transient('mxchat_admin_notice_error',
208 - esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
209 - 30
210 - );
211 - wp_safe_redirect(esc_url($redirect_url));
212 - exit;
213 - }
214 -
215 - $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
216 -
217 - $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
218 - $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
219 -
220 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
221 -
222 - // Resolve the embedding decision exactly like the sibling handlers —
223 - // custom-provider-aware (plan cbd5fd).
224 - $bot_options = $this->get_bot_options($bot_id);
225 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
226 -
227 - $preflight = MxChat_Utils::embedding_preflight($options);
228 - if (!$preflight['ok']) {
229 - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
230 - wp_safe_redirect(esc_url($redirect_url));
231 - exit;
232 - }
233 - $api_key = $preflight['api_key'];
234 -
235 - // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
236 - // manual mode it enriches the indexed text with the real title/channel.
237 - $meta = $this->mxchat_fetch_youtube_oembed($video_id);
238 - $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
239 - $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
240 -
241 - $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
242 - if ($video_channel !== '') {
243 - $header_lines .= 'Channel: ' . $video_channel . "\n";
244 - }
245 - $header_lines .= 'URL: ' . $canonical_url . "\n\n";
246 -
247 - $transcript_missing = false;
248 -
249 - if ($description_mode === 'manual') {
250 - if ($manual_description === '') {
251 - set_transient('mxchat_admin_notice_error',
252 - esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
253 - 30
254 - );
255 - wp_safe_redirect(esc_url($redirect_url));
256 - exit;
257 - }
258 - $indexed_text = $header_lines . $manual_description;
259 - } else {
260 - $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
261 -
262 - if (strlen($transcript) >= 200) {
263 - $indexed_text = $header_lines . $transcript;
264 - } else {
265 - // Graceful fallback: captions disabled / blocked / no speech. Auto
266 - // reliably gets metadata; it does NOT guarantee a transcript.
267 - $transcript_missing = true;
268 -
269 - if ($video_title === '' && $video_channel === '') {
270 - // Both halves failed — nothing meaningful to index.
271 - set_transient('mxchat_admin_notice_error',
272 - 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'),
273 - 30
274 - );
275 - wp_safe_redirect(esc_url($redirect_url));
276 - exit;
277 - }
278 -
279 - $indexed_text = $header_lines . sprintf(
280 - /* translators: 1: video title, 2: channel name */
281 - __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
282 - $video_title !== '' ? $video_title : $canonical_url,
283 - $video_channel !== '' ? $video_channel : 'YouTube'
284 - );
285 - }
286 - }
287 -
288 - $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
289 -
290 - if (is_wp_error($result)) {
291 - set_transient('mxchat_admin_notice_error',
292 - esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
293 - 30
294 - );
295 - wp_safe_redirect(esc_url($redirect_url));
296 - exit;
297 - }
298 -
299 - if ($transcript_missing) {
300 - set_transient('mxchat_admin_notice_success',
301 - 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'),
302 - 30
303 - );
304 - // Bounce back with prefill args so the page reopens the YouTube form in
305 - // manual mode with the URL + fetched title ready to augment.
306 - $redirect_url = add_query_arg(array(
307 - 'mxchat_yt_prefill' => '1',
308 - 'yt_url' => rawurlencode($canonical_url),
309 - 'yt_title' => rawurlencode($video_title),
310 - ), $redirect_url);
311 - } else {
312 - set_transient('mxchat_admin_notice_success',
313 - esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
314 - 30
315 - );
316 - }
317 -
318 - wp_safe_redirect(esc_url_raw($redirect_url));
319 - exit;
320 -}
321 -
322 -/**
323 - * Fetch YouTube oEmbed metadata for a video (no API key required).
324 - * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
325 - */
326 -private function mxchat_fetch_youtube_oembed($video_id) {
327 - $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
328 - $response = wp_remote_get($oembed_url, array('timeout' => 15));
329 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
330 - return array();
331 - }
332 - $data = json_decode(wp_remote_retrieve_body($response), true);
333 - return is_array($data) ? $data : array();
334 -}
335 -
336 -/**
337 - * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
338 - * YouTube's unofficial timedtext route (the caption track list embedded in the
339 - * watch page), which YouTube has broken before and will break again. Every
340 - * failure mode returns '' so a break degrades to the metadata-only import path
341 - * instead of erroring the whole submission. Do not let anything in here throw.
342 - */
343 -private function mxchat_fetch_youtube_transcript($video_id) {
344 - $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
345 -
346 - // First try the honest ingest UA; some responses omit the player config for
347 - // bot UAs, so retry once with a browser UA before giving up.
348 - $user_agents = array(
349 - mxchat_ingest_user_agent(),
350 - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
351 - );
352 -
353 - $tracks = array();
354 - foreach ($user_agents as $ua) {
355 - $response = wp_remote_get($watch_url, array(
356 - 'timeout' => 20,
357 - 'user-agent' => $ua,
358 - ));
359 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
360 - continue;
361 - }
362 - $body = wp_remote_retrieve_body($response);
363 - if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
364 - continue;
365 - }
366 - $decoded = json_decode($m[1], true);
367 - if (is_array($decoded) && !empty($decoded)) {
368 - $tracks = $decoded;
369 - break;
370 - }
371 - }
372 -
373 - if (empty($tracks)) {
374 - return '';
375 - }
376 -
377 - // Prefer an English track, else take the first offered.
378 - $chosen = null;
379 - foreach ($tracks as $track) {
380 - if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
381 - $chosen = $track;
382 - break;
383 - }
384 - }
385 - if ($chosen === null) {
386 - $chosen = $tracks[0];
387 - }
388 - if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
389 - return '';
390 - }
391 -
392 - $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
393 - if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
394 - return '';
395 - }
396 - $xml = wp_remote_retrieve_body($timedtext);
397 - if (!is_string($xml) || strpos($xml, '<text') === false) {
398 - return '';
399 - }
400 -
401 - // <text start=".." dur="..">caption</text> — strip tags, decode the
402 - // double-encoded entities timedtext ships, collapse whitespace.
403 - $text = preg_replace('/<[^>]+>/', ' ', $xml);
404 - $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
405 - $text = trim(preg_replace('/\s+/u', ' ', $text));
406 -
407 - return $text;
408 -}
409 -
410 -public function mxchat_is_pdf_url($url, $response) {
411 - $content_type = wp_remote_retrieve_header($response, 'content-type');
412 - $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
413 -
414 - // Check Content-Disposition header for .pdf filename (Google Drive sends this)
415 - $disposition = wp_remote_retrieve_header($response, 'content-disposition');
416 - $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
417 -
418 - return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
419 -}
420 -
421 -
422 -public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
423 - if (!current_user_can('manage_options')) {
424 - return false;
425 - }
426 -
427 - $pdf_url = esc_url_raw($pdf_url);
428 - $upload_dir = wp_upload_dir();
429 -
430 - if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
431 - return false;
432 - }
433 -
434 - $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
435 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
436 -
437 - $response_body = wp_remote_retrieve_body($response);
438 - if (empty($response_body)) {
439 - return false;
440 - }
441 -
442 - if (!wp_mkdir_p(dirname($pdf_path))) {
443 - return false;
444 - }
445 -
446 - try {
447 - file_put_contents($pdf_path, $response_body);
448 -
449 - if (!file_exists($pdf_path)) {
450 - throw new Exception(__('Failed to save PDF file', 'mxchat'));
451 - }
452 -
453 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
454 -
455 - if ($total_pages === false || $total_pages < 1) {
456 - throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
457 - }
458 -
459 - // Create unique queue ID
460 - $queue_id = 'pdf_' . md5($pdf_url . time());
461 -
462 - // Create array of pages to process
463 - $pages = array();
464 - for ($i = 1; $i <= $total_pages; $i++) {
465 - $pages[] = array(
466 - 'pdf_path' => $pdf_path,
467 - 'pdf_url' => $pdf_url,
468 - 'page_number' => $i,
469 - 'total_pages' => $total_pages
470 - );
471 - }
472 -
473 - // Add pages to queue
474 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
475 -
476 - if ($queued_count === 0) {
477 - wp_delete_file($pdf_path);
478 - throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
479 - }
480 -
481 - // Store queue metadata
482 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
483 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
484 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
485 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
486 - $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
487 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
488 -
489 - // Store queue ID in transient for status tracking
490 - set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
491 - set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
492 -
493 - return 'queued';
494 -
495 - } catch (Exception $e) {
496 - if (file_exists($pdf_path)) {
497 - wp_delete_file($pdf_path);
498 - }
499 - return $e->getMessage();
500 - }
501 -}
502 -
503 -/**
504 - * Handle direct PDF file upload from the knowledge base page
505 - */
506 -public function mxchat_handle_pdf_file_submission() {
507 - if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
508 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
509 - }
510 -
511 - check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
512 -
513 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
514 -
515 - // Validate file upload
516 - if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
517 - $error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
518 - $error_messages = array(
519 - UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
520 - UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
521 - UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
522 - UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
523 - UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
524 - UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
525 - );
526 - $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
527 - set_transient('mxchat_admin_notice_error', $error_msg, 30);
528 - wp_safe_redirect(esc_url($redirect_url));
529 - exit;
530 - }
531 -
532 - $file = $_FILES['pdf_file'];
533 -
534 - // Validate MIME type
535 - $finfo = finfo_open(FILEINFO_MIME_TYPE);
536 - $mime_type = finfo_file($finfo, $file['tmp_name']);
537 - finfo_close($finfo);
538 -
539 - if ($mime_type !== 'application/pdf') {
540 - set_transient('mxchat_admin_notice_error',
541 - esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
542 - 30
543 - );
544 - wp_safe_redirect(esc_url($redirect_url));
545 - exit;
546 - }
547 -
548 - // Validate extension
549 - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
550 - if ($ext !== 'pdf') {
551 - set_transient('mxchat_admin_notice_error',
552 - esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
553 - 30
554 - );
555 - wp_safe_redirect(esc_url($redirect_url));
556 - exit;
557 - }
558 -
559 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
560 - $original_filename = sanitize_file_name($file['name']);
561 -
562 - $upload_dir = wp_upload_dir();
563 - if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
564 - set_transient('mxchat_admin_notice_error',
565 - esc_html__('WordPress upload directory is not writable.', 'mxchat'),
566 - 30
567 - );
568 - wp_safe_redirect(esc_url($redirect_url));
569 - exit;
570 - }
571 -
572 - $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
573 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
574 -
575 - if (!wp_mkdir_p(dirname($pdf_path))) {
576 - set_transient('mxchat_admin_notice_error',
577 - esc_html__('Failed to create upload directory.', 'mxchat'),
578 - 30
579 - );
580 - wp_safe_redirect(esc_url($redirect_url));
581 - exit;
582 - }
583 -
584 - // Move uploaded file
585 - if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
586 - set_transient('mxchat_admin_notice_error',
587 - esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
588 - 30
589 - );
590 - wp_safe_redirect(esc_url($redirect_url));
591 - exit;
592 - }
593 -
594 - try {
595 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
596 -
597 - if ($total_pages === false || $total_pages < 1) {
598 - throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
599 - }
600 -
601 - // Use original filename as the source identifier
602 - $source_label = 'upload://' . $original_filename;
603 -
604 - $queue_id = 'pdf_' . md5($source_label . time());
605 -
606 - $pages = array();
607 - for ($i = 1; $i <= $total_pages; $i++) {
608 - $pages[] = array(
609 - 'pdf_path' => $pdf_path,
610 - 'pdf_url' => $source_label,
611 - 'page_number' => $i,
612 - 'total_pages' => $total_pages,
613 - );
614 - }
615 -
616 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
617 -
618 - if ($queued_count === 0) {
619 - wp_delete_file($pdf_path);
620 - throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
621 - }
622 -
623 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
624 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
625 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
626 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
627 - $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
628 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
629 -
630 - set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
631 - set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
632 -
633 - set_transient('mxchat_admin_notice_success',
634 - sprintf(
635 - esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
636 - esc_html($original_filename),
637 - $total_pages
638 - ),
639 - 30
640 - );
641 -
642 - } catch (Exception $e) {
643 - if (file_exists($pdf_path)) {
644 - wp_delete_file($pdf_path);
645 - }
646 - set_transient('mxchat_admin_notice_error',
647 - esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
648 - 30
649 - );
650 - }
651 -
652 - wp_safe_redirect(esc_url($redirect_url));
653 - exit;
654 -}
655 -
656 -/**
657 - * Validate PDF and count pages with multiple parser attempts
658 - */
659 -private function mxchat_validate_and_count_pdf_pages($pdf_path) {
660 - // Method 1: Try with Smalot PDF Parser (your current method)
661 - try {
662 - mxchat_load_pdf_parser();
663 - $parser = new \Smalot\PdfParser\Parser();
664 - $pdf = $parser->parseFile($pdf_path);
665 - $pages = $pdf->getPages();
666 - $page_count = count($pages);
667 -
668 - if ($page_count > 0) {
669 - //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
670 - return $page_count;
671 - }
672 - } catch (Exception $e) {
673 - //error_log('Smalot PDF parser failed: ' . $e->getMessage());
674 - }
675 -
676 - // Method 2: Try with pdfinfo command (if available)
677 - if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
678 - try {
679 - $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
680 - $output = shell_exec($command);
681 -
682 - if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
683 - $page_count = intval($matches[1]);
684 - if ($page_count > 0) {
685 - //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
686 - return $page_count;
687 - }
688 - }
689 - } catch (Exception $e) {
690 - //error_log('pdfinfo command failed: ' . $e->getMessage());
691 - }
692 - }
693 -
694 - // Method 3: Try to repair PDF and parse again
695 - try {
696 - $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
697 - if ($repaired_path && $repaired_path !== $pdf_path) {
698 - mxchat_load_pdf_parser();
699 - $parser = new \Smalot\PdfParser\Parser();
700 - $pdf = $parser->parseFile($repaired_path);
701 - $pages = $pdf->getPages();
702 - $page_count = count($pages);
703 -
704 - if ($page_count > 0) {
705 - // Replace original with repaired version
706 - copy($repaired_path, $pdf_path);
707 - unlink($repaired_path);
708 - //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
709 - return $page_count;
710 - }
711 -
712 - // Clean up repaired file if it didn't work
713 - unlink($repaired_path);
714 - }
715 - } catch (Exception $e) {
716 - //error_log('PDF repair attempt failed: ' . $e->getMessage());
717 - }
718 -
719 - // Method 4: Manual PDF structure analysis (basic page count)
720 - try {
721 - $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
722 - if ($page_count > 0) {
723 - //error_log('PDF page count determined manually: ' . $page_count . ' pages');
724 - return $page_count;
725 - }
726 - } catch (Exception $e) {
727 - //error_log('Manual PDF analysis failed: ' . $e->getMessage());
728 - }
729 -
730 - //error_log('All PDF parsing methods failed for: ' . $pdf_path);
731 - return false;
732 -}
733 -
734 -/**
735 - * Check if shell_exec is disabled
736 - */
737 -private function mxchat_is_shell_disabled() {
738 - $disabled = explode(',', ini_get('disable_functions'));
739 - return in_array('shell_exec', $disabled);
740 -}
741 -
742 -/**
743 - * Attempt to repair PDF using basic methods
744 - */
745 -private function mxchat_attempt_pdf_repair($pdf_path) {
746 - try {
747 - $content = file_get_contents($pdf_path);
748 - if (!$content) {
749 - return false;
750 - }
751 -
752 - // Check if PDF starts with proper header
753 - if (substr($content, 0, 4) !== '%PDF') {
754 - // Try to find PDF header in the content
755 - $header_pos = strpos($content, '%PDF');
756 - if ($header_pos !== false && $header_pos < 1024) {
757 - // Remove junk before PDF header
758 - $content = substr($content, $header_pos);
759 - $repaired_path = $pdf_path . '.repaired';
760 - file_put_contents($repaired_path, $content);
761 - return $repaired_path;
762 - }
763 - }
764 -
765 - // Check for EOF marker
766 - $content = rtrim($content);
767 - if (!preg_match('/%%EOF\s*$/', $content)) {
768 - // Add EOF marker if missing
769 - $content .= "\n%%EOF";
770 - $repaired_path = $pdf_path . '.repaired';
771 - file_put_contents($repaired_path, $content);
772 - return $repaired_path;
773 - }
774 -
775 - } catch (Exception $e) {
776 - //error_log('PDF repair error: ' . $e->getMessage());
777 - }
778 -
779 - return false;
780 -}
781 -
782 -/**
783 - * Manual PDF page counting by analyzing PDF structure
784 - */
785 -private function mxchat_manual_pdf_page_count($pdf_path) {
786 - try {
787 - $content = file_get_contents($pdf_path);
788 - if (!$content) {
789 - return 0;
790 - }
791 -
792 - // Method 1: Count /Type /Page objects
793 - $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
794 - if ($page_count > 0) {
795 - return $page_count;
796 - }
797 -
798 - // Method 2: Look for /Count in pages object
799 - if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
800 - return intval($matches[1]);
801 - }
802 -
803 - // Method 3: Count page references
804 - $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
805 - if ($page_count > 0) {
806 - return $page_count;
807 - }
808 -
809 - } catch (Exception $e) {
810 - //error_log('Manual PDF analysis error: ' . $e->getMessage());
811 - }
812 -
813 - return 0;
814 -}
815 -
816 -
817 -public function mxchat_save_inline_prompt() {
818 - // DEBUG: Log what we're receiving
819 - //error_log('=== MXCHAT DEBUG ===');
820 - //error_log('POST data: ' . print_r($_POST, true));
821 - //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
822 -
823 - // Check for nonce security
824 - check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
825 -
826 - // If we get here, nonce passed
827 - //error_log('Nonce verification PASSED');
828 -
829 - // Verify permissions
830 - if (!current_user_can('manage_options')) {
831 - wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
832 - return;
833 - }
834 -
835 - global $wpdb;
836 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
837 -
838 - // Validate and sanitize input data
839 - $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
840 - $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
841 - $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
842 -
843 - if ($prompt_id > 0 && !empty($article_content)) {
844 - // Re-generate the embedding vector for the updated content
845 - $embedding_vector = $this->mxchat_generate_embedding($article_content);
846 - if (is_array($embedding_vector)) {
847 - // Serialize the embedding vector before storing it
848 - $embedding_vector_serialized = serialize($embedding_vector);
849 - // Update the prompt in the database
850 - $updated = $wpdb->update(
851 - $table_name,
852 - array(
853 - 'article_content' => $article_content,
854 - 'embedding_vector' => $embedding_vector_serialized,
855 - 'source_url' => $article_url,
856 - ),
857 - array('id' => $prompt_id),
858 - array('%s', '%s', '%s'),
859 - array('%d')
860 - );
861 - if ($updated !== false) {
862 - wp_send_json_success();
863 - } else {
864 - MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
865 - wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
866 - }
867 - } else {
868 - MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
869 - wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
870 - }
871 - } else {
872 - wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
873 - }
874 -}
875 -
876 -
877 -/**
878 - * AJAX: Get full content for editing — reassembles chunks if needed.
879 - * Works for both WordPress DB and Pinecone entries.
880 - */
881 -public function ajax_mxchat_get_entry_content() {
882 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
883 -
884 - if ( ! current_user_can('manage_options') ) {
885 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
886 - }
887 -
888 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
889 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
890 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
891 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
892 -
893 - if ( $data_source === 'pinecone' ) {
894 - // Pinecone: fetch vectors by source_url, reassemble chunks
895 - $content = $this->get_pinecone_entry_content( $source_url, $entry_id, $bot_id );
896 - } else {
897 - // WordPress DB
898 - $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
899 - }
900 -
901 - if ( is_wp_error( $content ) ) {
902 - wp_send_json_error( array( 'message' => $content->get_error_message() ) );
903 - }
904 -
905 - wp_send_json_success( $content );
906 -}
907 -
908 -/**
909 - * Get content from WordPress DB — reassembles chunks by source_url.
910 - */
911 -private function get_wordpress_entry_content( $source_url, $entry_id ) {
912 - global $wpdb;
913 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
914 -
915 - // If we have a source_url, check for chunks
916 - if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
917 - $rows = $wpdb->get_results( $wpdb->prepare(
918 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
919 - $source_url
920 - ) );
921 -
922 - if ( $rows && count( $rows ) > 1 ) {
923 - // Multiple rows = chunked. Reassemble.
924 - $chunks = array();
925 - foreach ( $rows as $row ) {
926 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
927 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
928 - $chunks[ $index ] = $parsed['text'];
929 - }
930 - ksort( $chunks );
931 - return array(
932 - 'content' => implode( "\n\n", $chunks ),
933 - 'source_url' => $source_url,
934 - 'is_chunked' => true,
935 - 'chunk_count' => count( $chunks ),
936 - 'content_type' => $rows[0]->content_type,
937 - );
938 - } elseif ( $rows && count( $rows ) === 1 ) {
939 - $parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
940 - return array(
941 - 'content' => $parsed['text'],
942 - 'source_url' => $source_url,
943 - 'entry_id' => $rows[0]->id,
944 - 'is_chunked' => false,
945 - 'content_type' => $rows[0]->content_type,
946 - );
947 - }
948 - }
949 -
950 - // Fallback: fetch by ID
951 - if ( $entry_id > 0 ) {
952 - $row = $wpdb->get_row( $wpdb->prepare(
953 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
954 - $entry_id
955 - ) );
956 - if ( $row ) {
957 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
958 - return array(
959 - 'content' => $parsed['text'],
960 - 'source_url' => $row->source_url,
961 - 'entry_id' => $row->id,
962 - 'is_chunked' => false,
963 - 'content_type' => $row->content_type,
964 - );
965 - }
966 - }
967 -
968 - return new WP_Error( 'not_found', 'Entry not found.' );
969 -}
970 -
971 -/**
972 - * Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
973 - */
974 -private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
975 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
976 - return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
977 - }
978 -
979 - // Get Pinecone config
980 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
981 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
982 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
983 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
984 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
985 - } else {
986 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
987 - $api_key = $bot_config['api_key'] ?? '';
988 - $host = $bot_config['host'] ?? '';
989 - $namespace = $bot_config['namespace'] ?? '';
990 - }
991 -
992 - if ( empty($host) || empty($api_key) ) {
993 - return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
994 - }
995 -
996 - // List vectors with the source_url prefix
997 - $base_id = md5( $source_url );
998 - $vector_ids = array( $base_id );
999 -
1000 - // Find chunk vectors
1001 - $list_url = "https://{$host}/vectors/list";
1002 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1003 - if ( ! empty($namespace) ) {
1004 - $list_body['namespace'] = $namespace;
1005 - }
1006 -
1007 - $list_resp = wp_remote_post( $list_url, array(
1008 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1009 - 'body' => wp_json_encode( $list_body ),
1010 - 'timeout' => 15,
1011 - ) );
1012 -
1013 - if ( ! is_wp_error($list_resp) ) {
1014 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1015 - if ( ! empty($list_data['vectors']) ) {
1016 - foreach ( $list_data['vectors'] as $v ) {
1017 - $vector_ids[] = $v['id'];
1018 - }
1019 - }
1020 - }
1021 -
1022 - // Fetch vectors with metadata
1023 - $fetch_url = "https://{$host}/vectors/fetch";
1024 - $fetch_body = array( 'ids' => $vector_ids );
1025 - if ( ! empty($namespace) ) {
1026 - $fetch_body['namespace'] = $namespace;
1027 - }
1028 -
1029 - $fetch_resp = wp_remote_post( $fetch_url, array(
1030 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1031 - 'body' => wp_json_encode( $fetch_body ),
1032 - 'timeout' => 15,
1033 - ) );
1034 -
1035 - if ( is_wp_error($fetch_resp) ) {
1036 - return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
1037 - }
1038 -
1039 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1040 - $vectors = $fetch_data['vectors'] ?? array();
1041 -
1042 - if ( empty($vectors) ) {
1043 - return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
1044 - }
1045 -
1046 - // Reassemble chunks
1047 - $chunks = array();
1048 - $content_type = 'content';
1049 - foreach ( $vectors as $vid => $vector ) {
1050 - $meta = $vector['metadata'] ?? array();
1051 - $text = $meta['text'] ?? '';
1052 - $index = $meta['chunk_index'] ?? 0;
1053 - $content_type = $meta['type'] ?? 'content';
1054 - $chunks[ intval($index) ] = $text;
1055 - }
1056 - ksort( $chunks );
1057 -
1058 - return array(
1059 - 'content' => implode( "\n\n", $chunks ),
1060 - 'source_url' => $source_url,
1061 - 'is_chunked' => count($chunks) > 1,
1062 - 'chunk_count' => count($chunks),
1063 - 'content_type' => $content_type,
1064 - );
1065 -}
1066 -
1067 -/**
1068 - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1069 - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1070 - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1071 - */
1072 -public function ajax_mxchat_inspect_entry() {
1073 - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1074 -
1075 - if ( ! current_user_can('manage_options') ) {
1076 - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1077 - }
1078 -
1079 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1080 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1081 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1082 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1083 -
1084 - if ( $data_source === 'pinecone' ) {
1085 - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
1086 - } else {
1087 - $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1088 - }
1089 -
1090 - if ( is_wp_error( $result ) ) {
1091 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1092 - }
1093 -
1094 - wp_send_json_success( $result );
1095 -}
1096 -
1097 -/**
1098 - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1099 - * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1100 - * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1101 - */
1102 -private function inspect_wordpress_entry( $source_url, $entry_id ) {
1103 - global $wpdb;
1104 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1105 -
1106 - $rows = array();
1107 -
1108 - // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1109 - // Direct Content entries (the spec's manual-entry case), which share one
1110 - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1111 - // display key (invented by the table view for rows with no source_url) is
1112 - // excluded; those fall through to the entry_id lookup below.
1113 - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1114 - $rows = $wpdb->get_results( $wpdb->prepare(
1115 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1116 - $source_url
1117 - ) );
1118 - }
1119 -
1120 - // Fallback / manual "Direct Content" entries: fetch the single row by id.
1121 - if ( empty( $rows ) && $entry_id > 0 ) {
1122 - $row = $wpdb->get_row( $wpdb->prepare(
1123 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1124 - $entry_id
1125 - ) );
1126 - if ( $row ) {
1127 - $rows = array( $row );
1128 - }
1129 - }
1130 -
1131 - if ( empty( $rows ) ) {
1132 - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1133 - }
1134 -
1135 - $chunks = array();
1136 - $content_type = '';
1137 - foreach ( $rows as $row ) {
1138 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1139 - $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1140 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1141 - $content_type = $row->content_type;
1142 - $chunks[] = array(
1143 - 'index' => $index,
1144 - 'text' => $text,
1145 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1146 - 'row_id' => intval( $row->id ),
1147 - );
1148 - }
1149 -
1150 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1151 -
1152 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1153 -
1154 - return array(
1155 - 'store' => 'wordpress',
1156 - 'source_url' => $source_url,
1157 - 'content_type' => $content_type,
1158 - 'is_chunked' => count( $chunks ) > 1,
1159 - 'chunk_count' => count( $chunks ),
1160 - 'assembled' => $assembled,
1161 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1162 - 'chunks' => array_values( $chunks ),
1163 - // WP-DB storage carries no separate vector metadata; surface that fact
1164 - // rather than letting the owner guess (the spec's taxonomy question).
1165 - 'metadata' => array(),
1166 - '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'),
1167 - );
1168 -}
1169 -
1170 -/**
1171 - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1172 - * but keeps each vector's text + metadata instead of imploding, so the owner can
1173 - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1174 - * are present per chunk. READ-ONLY.
1175 - */
1176 -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1177 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1178 - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1179 - }
1180 -
1181 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1182 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1183 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1184 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1185 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1186 - } else {
1187 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1188 - $api_key = $bot_config['api_key'] ?? '';
1189 - $host = $bot_config['host'] ?? '';
1190 - $namespace = $bot_config['namespace'] ?? '';
1191 - }
1192 -
1193 - if ( empty($host) || empty($api_key) ) {
1194 - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1195 - }
1196 -
1197 - $base_id = md5( $source_url );
1198 - $vector_ids = array( $base_id );
1199 -
1200 - $list_url = "https://{$host}/vectors/list";
1201 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1202 - if ( ! empty($namespace) ) {
1203 - $list_body['namespace'] = $namespace;
1204 - }
1205 -
1206 - $list_resp = wp_remote_post( $list_url, array(
1207 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1208 - 'body' => wp_json_encode( $list_body ),
1209 - 'timeout' => 15,
1210 - ) );
1211 -
1212 - if ( ! is_wp_error($list_resp) ) {
1213 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1214 - if ( ! empty($list_data['vectors']) ) {
1215 - foreach ( $list_data['vectors'] as $v ) {
1216 - $vector_ids[] = $v['id'];
1217 - }
1218 - }
1219 - }
1220 -
1221 - $fetch_url = "https://{$host}/vectors/fetch";
1222 - $fetch_body = array( 'ids' => $vector_ids );
1223 - if ( ! empty($namespace) ) {
1224 - $fetch_body['namespace'] = $namespace;
1225 - }
1226 -
1227 - $fetch_resp = wp_remote_post( $fetch_url, array(
1228 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1229 - 'body' => wp_json_encode( $fetch_body ),
1230 - 'timeout' => 15,
1231 - ) );
1232 -
1233 - if ( is_wp_error($fetch_resp) ) {
1234 - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1235 - }
1236 -
1237 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1238 - $vectors = $fetch_data['vectors'] ?? array();
1239 -
1240 - if ( empty($vectors) ) {
1241 - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1242 - }
1243 -
1244 - // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1245 - // what is (and is NOT) stored per vector.
1246 - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1247 - $chunks = array();
1248 - $content_type = '';
1249 - foreach ( $vectors as $vid => $vector ) {
1250 - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1251 - $text = $meta['text'] ?? '';
1252 - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1253 - $content_type = $meta['type'] ?? $content_type;
1254 -
1255 - $clean_meta = array();
1256 - foreach ( $meta_fields as $field ) {
1257 - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1258 - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1259 - }
1260 - }
1261 -
1262 - $chunks[] = array(
1263 - 'index' => $index,
1264 - 'text' => $text,
1265 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1266 - 'vector_id' => (string) $vid,
1267 - 'metadata' => $clean_meta,
1268 - );
1269 - }
1270 -
1271 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1272 -
1273 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1274 -
1275 - return array(
1276 - 'store' => 'pinecone',
1277 - 'source_url' => $source_url,
1278 - 'content_type' => $content_type,
1279 - 'is_chunked' => count( $chunks ) > 1,
1280 - 'chunk_count' => count( $chunks ),
1281 - 'assembled' => $assembled,
1282 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1283 - 'chunks' => array_values( $chunks ),
1284 - 'metadata' => array(),
1285 - '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'),
1286 - );
1287 -}
1288 -
1289 -/**
1290 - * AJAX: Save edited content — re-chunks and re-embeds as needed.
1291 - * Works for both WordPress DB and Pinecone entries.
1292 - */
1293 -public function ajax_mxchat_save_entry_content() {
1294 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1295 -
1296 - if ( ! current_user_can('manage_options') ) {
1297 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1298 - }
1299 -
1300 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1301 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1302 - $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1303 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1304 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1305 - $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
1306 -
1307 - if ( empty($content) ) {
1308 - wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
1309 - }
1310 -
1311 - // Get the embedding API key
1312 - $options = get_option('mxchat_options', array());
1313 - $api_key = '';
1314 -
1315 - if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
1316 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1317 - $api_key = $bot_options['api_key'] ?? '';
1318 - }
1319 - if ( empty($api_key) ) {
1320 - $api_key = $options['api_key'] ?? '';
1321 - }
1322 -
1323 - global $wpdb;
1324 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1325 -
1326 - // If source_url is empty but we have an entry_id, look it up
1327 - if ( empty($source_url) && $entry_id > 0 && $data_source === 'wordpress' ) {
1328 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1329 - if ( $row && ! empty($row->source_url) ) {
1330 - $source_url = $row->source_url;
1331 - }
1332 - }
1333 -
1334 - // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1335 - // so submit_content_to_db creates a replacement instead of a duplicate
1336 - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1337 - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1338 - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1339 - $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1340 - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1341 - // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1342 - if ( $is_legacy_manual ) {
1343 - $source_url = '';
1344 - }
1345 - }
1346 -
1347 - // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1348 - $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1349 -
1350 - // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1351 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1352 -
1353 - if ( is_wp_error($result) ) {
1354 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1355 - }
1356 -
1357 - wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
1358 -}
1359 -
1360 -public function mxchat_get_pdf_processing_status($pdf_url) {
1361 - $pdf_url = esc_url_raw($pdf_url);
1362 - $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
1363 -
1364 - if (!$status || !is_array($status)) {
1365 - return false;
1366 - }
1367 -
1368 - // Check for stalled processing (no updates for 5 minutes)
1369 - if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
1370 - $status['status'] = 'error';
1371 - $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
1372 -
1373 - // Save the updated status
1374 - set_transient(
1375 - sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
1376 - array_map('sanitize_text_field', $status),
1377 - DAY_IN_SECONDS
1378 - );
1379 - }
1380 -
1381 - $result = array(
1382 - 'total_pages' => absint($status['total_pages']),
1383 - 'processed_pages' => absint($status['processed_pages']),
1384 - 'failed_pages' => absint($status['failed_pages'] ?? 0),
1385 - 'percentage' => ($status['total_pages'] > 0)
1386 - ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
1387 - : 0,
1388 - 'status' => sanitize_text_field($status['status']),
1389 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1390 - 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
1391 - 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
1392 - );
1393 -
1394 - // Add error message if present
1395 - if (isset($status['error']) && !empty($status['error'])) {
1396 - $result['error'] = sanitize_text_field($status['error']);
1397 - }
1398 -
1399 - return $result;
1400 -}
1401 -
1402 -
1403 -public function mxchat_handle_sitemap_submission() {
1404 - // Check if the form was submitted and verify permissions
1405 - if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
1406 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
1407 - }
1408 -
1409 - // Verify nonce
1410 - check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
1411 -
1412 - // Validate URL
1413 - if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
1414 - set_transient('mxchat_admin_notice_error',
1415 - esc_html__('Please provide a valid URL.', 'mxchat'),
1416 - 30
1417 - );
1418 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1419 - exit;
1420 - }
1421 -
1422 - $submitted_url = esc_url_raw($_POST['sitemap_url']);
1423 -
1424 - // Convert Google Drive sharing URLs to direct download URLs
1425 - if ( strpos($submitted_url, 'drive.google.com') !== false ) {
1426 - $file_id = '';
1427 - if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
1428 - $file_id = $m[1];
1429 - } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
1430 - $file_id = $m[1];
1431 - }
1432 - if ( ! empty($file_id) ) {
1433 - $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
1434 - }
1435 - }
1436 -
1437 - // Get bot_id from form submission
1438 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1439 -
1440 - // Get bot-specific options and validate the embedding decision —
1441 - // custom-provider-aware (plan cbd5fd).
1442 - $bot_options = $this->get_bot_options($bot_id);
1443 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1444 -
1445 - $preflight = MxChat_Utils::embedding_preflight($options);
1446 - if (!$preflight['ok']) {
1447 - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
1448 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1449 - exit;
1450 - }
1451 - $api_key = $preflight['api_key'];
1452 -
1453 - // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1454 - // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1455 - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1456 - // from the site's own media library, which route through this same call).
1457 - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1458 - // the browser-only Accept-Language fingerprint is dropped so it stays
1459 - // coherent with a bot identity.
1460 - $response = wp_remote_get($submitted_url, array(
1461 - 'timeout' => 30,
1462 - 'sslverify' => false,
1463 - 'user-agent' => mxchat_ingest_user_agent(),
1464 - 'headers' => array(
1465 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1466 - ),
1467 - ));
1468 -
1469 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1470 - $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1471 - set_transient('mxchat_admin_notice_error',
1472 - sprintf(
1473 - esc_html__('Failed to fetch the URL: %s', 'mxchat'),
1474 - esc_html($error_message)
1475 - ),
1476 - 30
1477 - );
1478 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1479 - exit;
1480 - }
1481 -
1482 - $content_type = wp_remote_retrieve_header($response, 'content-type');
1483 - $body_content = wp_remote_retrieve_body($response);
1484 -
1485 - if (empty($body_content)) {
1486 - set_transient('mxchat_admin_notice_error',
1487 - esc_html__('Empty response received from URL.', 'mxchat'),
1488 - 30
1489 - );
1490 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1491 - exit;
1492 - }
1493 -
1494 - // Handle PDF URL
1495 - if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
1496 - $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
1497 -
1498 - if ($result === 'queued') {
1499 - set_transient('mxchat_admin_notice_success',
1500 - esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
1501 - 30
1502 - );
1503 - } else {
1504 - set_transient('mxchat_admin_notice_error',
1505 - esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
1506 - 30
1507 - );
1508 - }
1509 -
1510 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1511 - exit;
1512 - }
1513 -
1514 - // Handle Sitemap XML
1515 - if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
1516 - libxml_use_internal_errors(true);
1517 - $xml = simplexml_load_string($body_content);
1518 - $xml_errors = libxml_get_errors();
1519 - libxml_clear_errors();
1520 -
1521 - if ($xml === false || !empty($xml_errors)) {
1522 - set_transient('mxchat_admin_notice_error',
1523 - esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
1524 - 30
1525 - );
1526 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1527 - exit;
1528 - }
1529 -
1530 - $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
1531 -
1532 - if ($result === 'queued') {
1533 - set_transient('mxchat_admin_notice_success',
1534 - esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1535 - 30
1536 - );
1537 - } else {
1538 - // Surface the reason the handler already computed (embedding pre-flight,
1539 - // empty sitemap, queue failure). The old message pointed at the status
1540 - // area, which is empty on this path — nothing was ever queued.
1541 - if (is_string($result) && $result !== '') {
1542 - set_transient('mxchat_admin_notice_error',
1543 - esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
1544 - 30
1545 - );
1546 - } else {
1547 - set_transient('mxchat_admin_notice_error',
1548 - esc_html__('Failed to queue sitemap processing.', 'mxchat'),
1549 - 30
1550 - );
1551 - }
1552 - }
1553 -
1554 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1555 - exit;
1556 - }
1557 -
1558 - // Handle Regular URL (single page)
1559 - $page_content = $this->mxchat_extract_main_content($body_content);
1560 - $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
1561 -
1562 - //error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
1563 - //error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
1564 - //error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
1565 - //error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
1566 -
1567 - if (empty($sanitized_content)) {
1568 - set_transient('mxchat_admin_notice_error',
1569 - esc_html__('No valid content found on the provided URL.', 'mxchat'),
1570 - 30
1571 - );
1572 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1573 - exit;
1574 - }
1575 -
1576 - // For single URLs, process immediately using submit_content_to_db
1577 - // This handles chunking automatically for large content
1578 - $db_result = MxChat_Utils::submit_content_to_db(
1579 - $sanitized_content,
1580 - $submitted_url,
1581 - $api_key,
1582 - null,
1583 - $bot_id,
1584 - 'url' // content_type
1585 - );
1586 -
1587 - if (is_wp_error($db_result)) {
1588 - $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
1589 - set_transient('mxchat_admin_notice_error', $error_message, 30);
1590 - } else {
1591 - $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
1592 - set_transient('mxchat_admin_notice_success', $success_message, 30);
1593 - }
1594 -
1595 - wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1596 - exit;
1597 -}
1598 -
1599 -
1600 -public function mxchat_get_single_url_status() {
1601 - $status = get_transient('mxchat_single_url_status');
1602 - if (!$status) {
1603 - return null;
1604 - }
1605 -
1606 - // Add human-readable time
1607 - if (isset($status['timestamp'])) {
1608 - $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
1609 - }
1610 -
1611 - return $status;
1612 -}
1613 -
1614 -public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
1615 - if (!current_user_can('manage_options')) {
1616 - return false;
1617 - }
1618 -
1619 - try {
1620 - $sitemap_url = esc_url_raw($sitemap_url);
1621 -
1622 - if (!$xml || !is_object($xml)) {
1623 - throw new Exception(__('Invalid XML object provided', 'mxchat'));
1624 - }
1625 -
1626 - // Get bot-specific embedding API for validation
1627 - $bot_options = $this->get_bot_options($bot_id);
1628 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1629 -
1630 - // Test the embedding API before processing
1631 - $test_phrase = "Test embedding generation for MxChat";
1632 - $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
1633 -
1634 - if (is_string($test_result)) {
1635 - throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
1636 - }
1637 -
1638 - if (!is_array($test_result)) {
1639 - throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
1640 - }
1641 -
1642 - // Extract URLs from sitemap
1643 - $urls = array();
1644 - foreach ($xml->url as $url_element) {
1645 - $url = esc_url_raw((string)$url_element->loc);
1646 - if ($url) {
1647 - $urls[] = array('url' => $url);
1648 - }
1649 - }
1650 -
1651 - $total_urls = count($urls);
1652 -
1653 - if ($total_urls < 1) {
1654 - throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
1655 - }
1656 -
1657 - // Create unique queue ID
1658 - $queue_id = 'sitemap_' . md5($sitemap_url . time());
1659 -
1660 - // Add URLs to queue
1661 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
1662 -
1663 - if ($queued_count === 0) {
1664 - throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
1665 - }
1666 -
1667 - // Store queue metadata
1668 - $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
1669 - $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
1670 - $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
1671 - $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
1672 - $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
1673 -
1674 - // Store queue ID in transient for status tracking
1675 - set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
1676 - set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
1677 -
1678 - return 'queued';
1679 -
1680 - } catch (Exception $e) {
1681 - $error_message = $e->getMessage();
1682 - //error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
1683 -
1684 - return $error_message;
1685 - }
1686 -
1687 -}
1688 -
1689 -/**
1690 - * Remove shortcode tags but preserve the content inside them
1691 - * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
1692 - *
1693 - * @param string $content The content containing shortcodes
1694 - * @return string Content with shortcode tags removed but inner content preserved
1695 - */
1696 -private function strip_shortcode_tags_preserve_content($content) {
1697 - // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
1698 - // Content between tags is inherently preserved since only brackets are targeted
1699 - $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
1700 - return ($result !== null) ? $result : $content;
1701 -}
1702 -
1703 -public function mxchat_sanitize_content_for_api($content) {
1704 - //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
1705 -
1706 - // Remove shortcode tags but PRESERVE content inside them
1707 - $content = $this->strip_shortcode_tags_preserve_content($content);
1708 -
1709 - // Remove script, style tags, and HTML comments
1710 - $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
1711 - $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
1712 - $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
1713 -
1714 - // Remove all HTML tags and decode HTML entities
1715 - $content = wp_strip_all_tags($content);
1716 - $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
1717 -
1718 - // Normalize whitespace but preserve paragraph breaks
1719 - // First, normalize line endings to \n
1720 - $content = str_replace(["\r\n", "\r"], "\n", $content);
1721 - // Replace multiple spaces/tabs with single space, but preserve newlines
1722 - $content = preg_replace('/[ \t]+/', ' ', $content);
1723 - // Replace 3+ newlines with 2 newlines (max 2 blank lines)
1724 - $content = preg_replace('/\n{3,}/', "\n\n", $content);
1725 - // Trim each line
1726 - $lines = explode("\n", $content);
1727 - $lines = array_map('trim', $lines);
1728 - $content = implode("\n", $lines);
1729 - // Final trim
1730 - $content = trim($content);
1731 -
1732 - // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
1733 - $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
1734 -
1735 - // Remove NULL bytes which can cause database errors
1736 - $content = str_replace("\0", "", $content);
1737 -
1738 - // Ensure valid UTF-8 encoding
1739 - $content = wp_check_invalid_utf8($content);
1740 -
1741 - // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
1742 - // Counts CHARACTERS (/u), and never strips a run containing characters from a
1743 - // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
1744 - // where a normal paragraph is legitimately one unbroken run.
1745 - $content = preg_replace_callback('/\S{300,}/u', function ($m) {
1746 - return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
1747 - }, $content);
1748 -
1749 - // Remove emoji/symbol blocks only — not the whole supplementary plane, which
1750 - // also holds CJK Extension B ideographs used in real Chinese/Japanese names
1751 - $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}]/u', '', $content);
1752 -
1753 - // Replace any remaining potentially problematic characters with spaces
1754 - // BUT preserve newlines by temporarily replacing them
1755 - $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
1756 - $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
1757 - $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
1758 -
1759 - // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
1760 - // but cut on a character boundary so a multibyte char is never split mid-sequence)
1761 - $max_length = 65000; // Just under MySQL TEXT field limit
1762 - if (strlen($content) > $max_length) {
1763 - $content = mb_strcut($content, 0, $max_length, 'UTF-8');
1764 - }
1765 -
1766 - //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
1767 - return $content;
1768 -}
1769 -public function mxchat_extract_main_content($html) {
1770 - if (empty($html)) {
1771 - return '';
1772 - }
1773 - try {
1774 - $dom = new DOMDocument;
1775 - libxml_use_internal_errors(true); // Suppress HTML parsing errors
1776 - @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
1777 - $xpath = new DOMXPath($dom);
1778 -
1779 - // For debugging purposes
1780 - $debugEnabled = true; // Set to true to enable debugging output
1781 - $debug = function($message) use ($debugEnabled) {
1782 - if ($debugEnabled) {
1783 - //error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
1784 - }
1785 - };
1786 -
1787 - // Direct targeting for Gerow theme posts
1788 - $post_text = $xpath->query('//div[contains(@class, "post-text")]');
1789 - if ($post_text && $post_text->length > 0) {
1790 - $debug("Found post-text directly");
1791 - $content = '';
1792 - foreach ($post_text as $node) {
1793 - $content .= $dom->saveHTML($node);
1794 - }
1795 - if (!empty($content)) {
1796 - $debug("Returning post-text content");
1797 - return $content;
1798 - }
1799 - }
1800 -
1801 - // Try to get the blog details content which contains the post-text
1802 - $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
1803 - if ($blog_details && $blog_details->length > 0) {
1804 - $debug("Found blog-details-content");
1805 - $content = '';
1806 - foreach ($blog_details as $node) {
1807 - $content .= $dom->saveHTML($node);
1808 - }
1809 - if (!empty($content)) {
1810 - $debug("Returning blog-details-content");
1811 - return $content;
1812 - }
1813 - }
1814 -
1815 - // Try to get the article which contains the blog details
1816 - $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
1817 - if ($article && $article->length > 0) {
1818 - $debug("Found article with blog-details-wrap");
1819 - $content = '';
1820 - foreach ($article as $node) {
1821 - $content .= $dom->saveHTML($node);
1822 - }
1823 - if (!empty($content)) {
1824 - $debug("Returning article content");
1825 - return $content;
1826 - }
1827 - }
1828 -
1829 - // Try even broader with the blog-item-wrap
1830 - $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
1831 - if ($blog_item && $blog_item->length > 0) {
1832 - $debug("Found blog-item-wrap");
1833 - $content = '';
1834 - foreach ($blog_item as $node) {
1835 - $content .= $dom->saveHTML($node);
1836 - }
1837 - if (!empty($content)) {
1838 - $debug("Returning blog-item-wrap content");
1839 - return $content;
1840 - }
1841 - }
1842 -
1843 - // Specific Gerow theme path
1844 - $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
1845 - if ($gerow_path && $gerow_path->length > 0) {
1846 - $debug("Found Gerow theme path to post-text");
1847 - $content = '';
1848 - foreach ($gerow_path as $node) {
1849 - $content .= $dom->saveHTML($node);
1850 - }
1851 - if (!empty($content)) {
1852 - $debug("Returning Gerow post-text content");
1853 - return $content;
1854 - }
1855 - }
1856 -
1857 - // Generic blog post selectors
1858 - $selectors = [
1859 - // Blog post specific selectors
1860 - '//div[contains(@class, "post-text")]',
1861 - '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
1862 - '//div[contains(@class, "blog-details-content")]',
1863 - '//article[contains(@class, "blog-details-wrap")]',
1864 - '//div[contains(@class, "entry-content")]',
1865 - '//div[contains(@class, "blog-content")]',
1866 - '//div[contains(@class, "blog-item-wrap")]',
1867 -
1868 - // More general content selectors
1869 - '//div[contains(@class, "page__content")]',
1870 - '//div[contains(@class, "elementor-widget-container")]',
1871 - '//div[contains(@class, "elementor-text-editor")]',
1872 - '//div[contains(@class, "elementor-widget-text-editor")]',
1873 - '//*[contains(@class, "entry-content")]',
1874 - '//*[contains(@class, "post-content")]',
1875 - '//*[contains(@class, "article-content")]',
1876 - '//*[@id="content"]',
1877 - '//*[@id="main-content"]',
1878 - '//section[contains(@class, "blog-area")]',
1879 - '//article',
1880 - '//main',
1881 - '//div[contains(@class, "content")]'
1882 - ];
1883 -
1884 - // First handle Elementor content - get only leaf widget containers to avoid duplicates
1885 - $debug("Checking for Elementor content");
1886 - // Get widget containers that are direct children of widgets (not nested inside other widget containers)
1887 - $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
1888 - if ($elementor_widgets && $elementor_widgets->length > 0) {
1889 - $debug("Found Elementor widgets");
1890 - $seen_content = array(); // Track seen content to avoid duplicates
1891 - $combined_content = '';
1892 - foreach ($elementor_widgets as $widget) {
1893 - $widget_content = $dom->saveHTML($widget);
1894 - if (!empty($widget_content)) {
1895 - // Create a hash of the content to detect duplicates
1896 - $content_hash = md5($widget_content);
1897 - if (!isset($seen_content[$content_hash])) {
1898 - $seen_content[$content_hash] = true;
1899 - $combined_content .= $widget_content;
1900 - }
1901 - }
1902 - }
1903 - if (!empty($combined_content)) {
1904 - $debug("Returning Elementor content");
1905 - return $combined_content;
1906 - }
1907 - }
1908 -
1909 - // Try standard selectors one by one
1910 - foreach ($selectors as $selector) {
1911 - $debug("Trying selector: " . $selector);
1912 - $nodes = $xpath->query($selector);
1913 - if ($nodes && $nodes->length > 0) {
1914 - $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1915 - // Only take the FIRST matching node to avoid duplicate content
1916 - // (pages often have nested or multiple containers with same class)
1917 - $content = $dom->saveHTML($nodes->item(0));
1918 - if (!empty($content)) {
1919 - $debug("Returning content from selector: " . $selector . " (first match only)");
1920 - return $content;
1921 - }
1922 - }
1923 - }
1924 -
1925 - // Manual regex fallback for post-text if DOM methods fail
1926 - $debug("Trying regex fallback");
1927 - if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1928 - $debug("Found post-text via regex");
1929 - return '<div class="post-text">' . $matches[1] . '</div>';
1930 - }
1931 -
1932 - // Try to extract the blog section as a whole
1933 - $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1934 - if ($blog_section && $blog_section->length > 0) {
1935 - $debug("Found blog-area section");
1936 - $content = '';
1937 - foreach ($blog_section as $node) {
1938 - $content .= $dom->saveHTML($node);
1939 - }
1940 - if (!empty($content)) {
1941 - $debug("Returning blog-area section content");
1942 - return $content;
1943 - }
1944 - }
1945 -
1946 - // Generic container selectors for non-CMS sites (like .asp pages)
1947 - $debug("Trying generic container selectors");
1948 - $generic_selectors = [
1949 - '//div[@id="main"]',
1950 - '//div[@id="wrapper"]',
1951 - '//div[@id="page"]',
1952 - '//div[@id="site-content"]',
1953 - '//div[contains(@class, "main-content")]',
1954 - '//div[contains(@class, "page-content")]',
1955 - '//div[contains(@class, "site-content")]',
1956 - ];
1957 -
1958 - foreach ($generic_selectors as $selector) {
1959 - $debug("Trying generic selector: " . $selector);
1960 - $nodes = $xpath->query($selector);
1961 - if ($nodes && $nodes->length > 0) {
1962 - $content = $dom->saveHTML($nodes->item(0));
1963 - if (!empty($content)) {
1964 - $debug("Returning content from generic selector: " . $selector);
1965 - return $content;
1966 - }
1967 - }
1968 - }
1969 -
1970 - // Paragraph-based content detection - find regions with substantial text
1971 - $debug("Trying paragraph-based content detection");
1972 - $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1973 - if ($paragraphs && $paragraphs->length >= 3) {
1974 - $debug("Found " . $paragraphs->length . " substantial paragraphs");
1975 - // Collect all substantial paragraphs and their content
1976 - $paragraph_content = '';
1977 - foreach ($paragraphs as $p) {
1978 - $paragraph_content .= $dom->saveHTML($p) . "\n";
1979 - }
1980 - if (!empty($paragraph_content)) {
1981 - $debug("Returning paragraph-based content");
1982 - return $paragraph_content;
1983 - }
1984 - }
1985 -
1986 - // Improved body fallback - strip nav/header/footer elements first
1987 - $debug("Using improved body fallback");
1988 - $body = $dom->getElementsByTagName('body');
1989 - if ($body->length > 0) {
1990 - // Clone the body to avoid modifying the original DOM
1991 - $body_clone = $body->item(0)->cloneNode(true);
1992 -
1993 - // Remove common non-content elements by tag name
1994 - $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1995 - foreach ($remove_tags as $tag) {
1996 - $elements = $body_clone->getElementsByTagName($tag);
1997 - // Iterate backwards to safely remove elements
1998 - for ($i = $elements->length - 1; $i >= 0; $i--) {
1999 - $el = $elements->item($i);
2000 - if ($el && $el->parentNode) {
2001 - $el->parentNode->removeChild($el);
2002 - }
2003 - }
2004 - }
2005 -
2006 - // Remove elements with common non-content class names using XPath on the cloned body
2007 - $temp_dom = new DOMDocument();
2008 - @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
2009 - $temp_xpath = new DOMXPath($temp_dom);
2010 -
2011 - $remove_class_patterns = [
2012 - '//*[contains(@class, "nav")]',
2013 - '//*[contains(@class, "menu")]',
2014 - '//*[contains(@class, "sidebar")]',
2015 - '//*[contains(@class, "footer")]',
2016 - '//*[contains(@class, "header")]',
2017 - '//*[contains(@id, "nav")]',
2018 - '//*[contains(@id, "menu")]',
2019 - '//*[contains(@id, "sidebar")]',
2020 - '//*[contains(@id, "footer")]',
2021 - '//*[contains(@id, "header")]',
2022 - ];
2023 -
2024 - foreach ($remove_class_patterns as $pattern) {
2025 - $elements = $temp_xpath->query($pattern);
2026 - if ($elements) {
2027 - for ($i = $elements->length - 1; $i >= 0; $i--) {
2028 - $el = $elements->item($i);
2029 - if ($el && $el->parentNode) {
2030 - $el->parentNode->removeChild($el);
2031 - }
2032 - }
2033 - }
2034 - }
2035 -
2036 - $cleaned_content = $temp_dom->saveHTML();
2037 - if (!empty($cleaned_content)) {
2038 - $debug("Returning cleaned body content");
2039 - return $cleaned_content;
2040 - }
2041 - }
2042 -
2043 - // Last resort: return the original HTML
2044 - $debug("Returning original HTML");
2045 - return $html;
2046 - } catch (Exception $e) {
2047 - //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
2048 - return $html; // Return original HTML if parsing fails
2049 - } finally {
2050 - libxml_clear_errors();
2051 - }
2052 -}
2053 -public function mxchat_get_sitemap_processing_status($sitemap_url) {
2054 - $sitemap_url = esc_url_raw($sitemap_url);
2055 - $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
2056 - $status = get_transient($status_key);
2057 -
2058 - if (!$status || !is_array($status)) {
2059 - return false;
2060 - }
2061 -
2062 - // Auto-complete check: if all URLs are processed but status isn't complete
2063 - if (isset($status['processed_urls']) && isset($status['total_urls']) &&
2064 - $status['processed_urls'] >= $status['total_urls'] &&
2065 - isset($status['status']) && $status['status'] !== 'complete' &&
2066 - $status['status'] !== 'error') {
2067 -
2068 - // Mark as complete
2069 - $status['status'] = 'complete';
2070 - $status['processed_urls'] = $status['total_urls']; // Ensure exact match
2071 -
2072 - // Update the transient with the corrected status
2073 - set_transient($status_key, $status, DAY_IN_SECONDS);
2074 - }
2075 -
2076 - return array(
2077 - 'total_urls' => absint($status['total_urls']),
2078 - 'processed_urls' => absint($status['processed_urls']),
2079 - 'failed_urls' => absint($status['failed_urls'] ?? 0),
2080 - 'percentage' => ($status['total_urls'] > 0)
2081 - ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
2082 - : 0,
2083 - 'status' => sanitize_text_field($status['status']),
2084 - 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
2085 - 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
2086 - 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
2087 - 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
2088 - );
2089 -}
2090 -
2091 -public function mxchat_ajax_get_status_updates() {
2092 - try {
2093 - // Verify the request
2094 - check_ajax_referer('mxchat_status_nonce', 'nonce');
2095 -
2096 - // Get active queue IDs
2097 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2098 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2099 -
2100 - $sitemap_status = false;
2101 - $pdf_status = false;
2102 -
2103 - // Get sitemap queue status
2104 - if ($sitemap_queue_id) {
2105 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2106 - }
2107 -
2108 - // Get PDF queue status
2109 - if ($pdf_queue_id) {
2110 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2111 - }
2112 -
2113 - $is_active_processing =
2114 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2115 - ($pdf_status && $pdf_status['status'] === 'processing');
2116 -
2117 - // Return JSON response with the status data
2118 - wp_send_json(array(
2119 - 'pdf_status' => $pdf_status,
2120 - 'sitemap_status' => $sitemap_status,
2121 - 'is_processing' => $is_active_processing,
2122 - 'sitemap_queue_id' => $sitemap_queue_id,
2123 - 'pdf_queue_id' => $pdf_queue_id
2124 - ));
2125 -
2126 - } catch (Exception $e) {
2127 - //error_log('MxChat Status Update Error: ' . $e->getMessage());
2128 -
2129 - wp_send_json_error(array(
2130 - 'message' => 'Error getting status updates: ' . $e->getMessage(),
2131 - 'status' => 'error'
2132 - ));
2133 - }
2134 -}
2135 -
2136 -/**
2137 - * Helper function to get queue status data
2138 - */
2139 -private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
2140 - global $wpdb;
2141 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2142 -
2143 - // Get counts by status
2144 - $counts = $wpdb->get_results($wpdb->prepare(
2145 - "SELECT status, COUNT(*) as count
2146 - FROM $table_name
2147 - WHERE queue_id = %s
2148 - GROUP BY status",
2149 - $queue_id
2150 - ), OBJECT_K);
2151 -
2152 - $total = 0;
2153 - $completed = 0;
2154 - $failed = 0;
2155 - $processing = 0;
2156 - $pending = 0;
2157 -
2158 - foreach ($counts as $status => $data) {
2159 - $count = absint($data->count);
2160 - $total += $count;
2161 -
2162 - switch ($status) {
2163 - case 'completed':
2164 - $completed = $count;
2165 - break;
2166 - case 'failed':
2167 - $failed = $count;
2168 - break;
2169 - case 'processing':
2170 - $processing = $count;
2171 - break;
2172 - case 'pending':
2173 - $pending = $count;
2174 - break;
2175 - }
2176 - }
2177 -
2178 - if ($total === 0) {
2179 - return false;
2180 - }
2181 -
2182 - // Calculate percentage
2183 - $percentage = round((($completed + $failed) / $total) * 100);
2184 -
2185 - // Get failed items details (limit to 50)
2186 - $failed_items = array();
2187 - if ($failed > 0) {
2188 - $failed_results = $wpdb->get_results($wpdb->prepare(
2189 - "SELECT item_type, item_data, error_message, attempts, completed_at
2190 - FROM $table_name
2191 - WHERE queue_id = %s
2192 - AND status = 'failed'
2193 - AND attempts >= max_attempts
2194 - ORDER BY id DESC
2195 - LIMIT 50",
2196 - $queue_id
2197 - ));
2198 -
2199 - foreach ($failed_results as $item) {
2200 - $data = json_decode($item->item_data, true);
2201 - $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
2202 -
2203 - $failed_items[] = array(
2204 - 'url' => $url,
2205 - 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
2206 - 'error' => $item->error_message,
2207 - 'retries' => $item->attempts,
2208 - 'time' => strtotime($item->completed_at)
2209 - );
2210 - }
2211 - }
2212 -
2213 - // Get queue metadata
2214 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
2215 -
2216 - // Determine if queue is complete
2217 - $is_complete = ($pending === 0 && $processing === 0);
2218 -
2219 - // Get last update time
2220 - $last_update = $wpdb->get_var($wpdb->prepare(
2221 - "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
2222 - FROM $table_name
2223 - WHERE queue_id = %s",
2224 - $queue_id
2225 - ));
2226 -
2227 - $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
2228 -
2229 - // Format based on type
2230 - if ($type === 'pdf') {
2231 - return array(
2232 - 'total_pages' => $total,
2233 - 'processed_pages' => $completed + $failed,
2234 - 'failed_pages' => $failed,
2235 - 'percentage' => $percentage,
2236 - 'status' => $is_complete ? 'complete' : 'processing',
2237 - 'last_update' => $last_update_text,
2238 - 'failed_pages_list' => $failed_items,
2239 - 'pdf_url' => $source_url,
2240 - 'queue_id' => $queue_id
2241 - );
2242 - } else {
2243 - return array(
2244 - 'total_urls' => $total,
2245 - 'processed_urls' => $completed + $failed,
2246 - 'failed_urls' => $failed,
2247 - 'percentage' => $percentage,
2248 - 'status' => $is_complete ? 'complete' : 'processing',
2249 - 'last_update' => $last_update_text,
2250 - 'failed_urls_list' => $failed_items,
2251 - 'sitemap_url' => $source_url,
2252 - 'queue_id' => $queue_id
2253 - );
2254 - }
2255 -}
2256 -
2257 -/**
2258 - * Public method to get processing status for both sitemap and PDF queues
2259 - * Used by admin pages to display processing status
2260 - *
2261 - * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
2262 - */
2263 -public function mxchat_get_processing_statuses() {
2264 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2265 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2266 -
2267 - $sitemap_status = false;
2268 - $pdf_status = false;
2269 -
2270 - if ($sitemap_queue_id) {
2271 - $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
2272 - }
2273 -
2274 - if ($pdf_queue_id) {
2275 - $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
2276 - }
2277 -
2278 - $is_processing =
2279 - ($sitemap_status && $sitemap_status['status'] === 'processing') ||
2280 - ($pdf_status && $pdf_status['status'] === 'processing');
2281 -
2282 - return array(
2283 - 'sitemap_status' => $sitemap_status,
2284 - 'pdf_status' => $pdf_status,
2285 - 'is_processing' => $is_processing
2286 - );
2287 -}
2288 -
2289 -/**
2290 - * AJAX handler to get recent knowledge entries for real-time table updates
2291 - * UPDATED: Now supports both WordPress DB and Pinecone data sources
2292 - */
2293 -public function ajax_mxchat_get_recent_entries() {
2294 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2295 -
2296 - if (!current_user_can('manage_options')) {
2297 - wp_send_json_error(array('message' => 'Unauthorized'));
2298 - return;
2299 - }
2300 -
2301 - global $wpdb;
2302 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2303 -
2304 - // Get parameters
2305 - $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
2306 - $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
2307 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2308 -
2309 - // Check if Pinecone is enabled for this bot
2310 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2311 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2312 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2313 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2314 -
2315 - if ($use_pinecone && $has_pinecone_api) {
2316 - // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
2317 - // Use mxchat_fetch_pinecone_records which returns total_unique_entries
2318 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
2319 - $total_count = $records['total'] ?? 0;
2320 -
2321 - // For Pinecone, we don't return individual entries during polling
2322 - // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
2323 - // We just return the updated count
2324 - wp_send_json_success(array(
2325 - 'entries' => array(),
2326 - 'total_count' => absint($total_count),
2327 - 'max_id' => $last_id,
2328 - 'data_source' => 'pinecone'
2329 - ));
2330 - return;
2331 - }
2332 -
2333 - // WORDPRESS DB DATA SOURCE
2334 - // Build query to get entries newer than last_id
2335 - $where_clauses = array('1=1');
2336 - $where_values = array();
2337 -
2338 - if ($last_id > 0) {
2339 - $where_clauses[] = 'id > %d';
2340 - $where_values[] = $last_id;
2341 - }
2342 -
2343 - // Note: WordPress DB table doesn't have bot_id column
2344 - // Multi-bot filtering is handled via Pinecone namespaces
2345 -
2346 - $where_sql = implode(' AND ', $where_clauses);
2347 -
2348 - // Get recent entries
2349 - $query = "SELECT id, article_content, source_url, timestamp
2350 - FROM $table_name
2351 - WHERE $where_sql
2352 - ORDER BY id DESC
2353 - LIMIT %d";
2354 -
2355 - $where_values[] = $limit;
2356 -
2357 - $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
2358 -
2359 - // Get total count of GROUPED entries (by source_url) - matches pagination display
2360 - // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
2361 - $total_count = $wpdb->get_var(
2362 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2363 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2364 - );
2365 -
2366 - // Format entries for response
2367 - $formatted_entries = array();
2368 - $preview_length = 150;
2369 - foreach ($entries as $entry) {
2370 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2371 - if (class_exists('MxChat_Chunker')) {
2372 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
2373 - $display_content = $chunk_meta['text'];
2374 - $chunk_metadata = $chunk_meta['metadata'];
2375 - } else {
2376 - $display_content = $entry->article_content;
2377 - $chunk_metadata = array();
2378 - }
2379 -
2380 - $content_preview = mb_strlen($display_content) > $preview_length
2381 - ? mb_substr($display_content, 0, $preview_length) . '...'
2382 - : $display_content;
2383 -
2384 - $formatted_entries[] = array(
2385 - 'id' => $entry->id,
2386 - 'preview' => esc_html($content_preview),
2387 - 'full_content' => wp_kses_post(wpautop($display_content)),
2388 - 'content_length' => mb_strlen($display_content),
2389 - 'preview_length' => $preview_length,
2390 - 'source_url' => $entry->source_url,
2391 - 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2392 - 'chunk_metadata' => $chunk_metadata,
2393 - 'bot_id' => $entry->bot_id ?? 'default',
2394 - 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2395 - 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2396 - );
2397 - }
2398 -
2399 - wp_send_json_success(array(
2400 - 'entries' => $formatted_entries,
2401 - 'total_count' => absint($total_count),
2402 - 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
2403 - 'data_source' => 'wordpress'
2404 - ));
2405 -}
2406 -
2407 -/**
2408 - * Get Pinecone total count from stats API
2409 - * Helper function for ajax_mxchat_get_recent_entries
2410 - */
2411 -private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
2412 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2413 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
2414 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2415 -
2416 - if (empty($api_key) || empty($host)) {
2417 - return 0;
2418 - }
2419 -
2420 - try {
2421 - $stats_url = "https://{$host}/describe_index_stats";
2422 -
2423 - $response = wp_remote_post($stats_url, array(
2424 - 'headers' => array(
2425 - 'Api-Key' => $api_key,
2426 - 'Content-Type' => 'application/json'
2427 - ),
2428 - 'body' => '{}',
2429 - 'timeout' => 10
2430 - ));
2431 -
2432 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2433 - $body = wp_remote_retrieve_body($response);
2434 - $stats_data = json_decode($body, true);
2435 -
2436 - // If namespace is specified, get count from that specific namespace
2437 - if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
2438 - return intval($stats_data['namespaces'][$namespace]['vectorCount']);
2439 - }
2440 -
2441 - // If no namespace specified or namespace not found in response, use total
2442 - return intval($stats_data['totalVectorCount'] ?? 0);
2443 - }
2444 -
2445 - return 0;
2446 -
2447 - } catch (Exception $e) {
2448 - return 0;
2449 - }
2450 -}
2451 -
2452 -/**
2453 - * AJAX handler to refresh Pinecone entries table via AJAX
2454 - * Returns the table HTML for updating the UI without a full page reload
2455 - */
2456 -public function ajax_mxchat_refresh_pinecone_entries() {
2457 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2458 -
2459 - if (!current_user_can('manage_options')) {
2460 - wp_send_json_error(array('message' => 'Unauthorized'));
2461 - return;
2462 - }
2463 -
2464 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2465 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2466 - $per_page = 25;
2467 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2468 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2469 -
2470 - // Get Pinecone manager and options
2471 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2472 - if (!$pinecone_manager) {
2473 - wp_send_json_error(array('message' => 'Pinecone manager not available'));
2474 - return;
2475 - }
2476 -
2477 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
2478 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2479 - $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
2480 -
2481 - if (!$use_pinecone || empty($pinecone_api_key)) {
2482 - wp_send_json_error(array('message' => 'Pinecone not configured'));
2483 - return;
2484 - }
2485 -
2486 - // Fetch records from Pinecone
2487 - $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type_filter);
2488 - $prompts = $records['data'] ?? array();
2489 - $total_records = $records['total'] ?? 0;
2490 -
2491 - // Preprocess Pinecone records — set chunk_metadata and display_content
2492 - // (matches admin-knowledge-page.php preprocessing)
2493 - foreach ($prompts as $prompt) {
2494 - if (isset($prompt->chunk_index) && $prompt->chunk_index !== null) {
2495 - $prompt->chunk_metadata = array(
2496 - 'chunk_index' => intval($prompt->chunk_index),
2497 - 'total_chunks' => isset($prompt->total_chunks) ? intval($prompt->total_chunks) : null,
2498 - 'is_chunked' => isset($prompt->is_chunked) ? (bool) $prompt->is_chunked : true,
2499 - 'source_url' => $prompt->source_url ?? ''
2500 - );
2501 - $prompt->display_content = $prompt->article_content;
2502 - } else {
2503 - $prompt->chunk_metadata = array();
2504 - $prompt->display_content = $prompt->article_content ?? '';
2505 - }
2506 - }
2507 -
2508 - // Group prompts by source_url
2509 - $grouped_prompts = array();
2510 - foreach ($prompts as $prompt) {
2511 - $source_url = '';
2512 - if (!empty($prompt->chunk_metadata['source_url'])) {
2513 - $source_url = $prompt->chunk_metadata['source_url'];
2514 - } elseif (!empty($prompt->source_url)) {
2515 - $source_url = $prompt->source_url;
2516 - }
2517 -
2518 - if (!empty($source_url)) {
2519 - if (!isset($grouped_prompts[$source_url])) {
2520 - $grouped_prompts[$source_url] = array();
2521 - }
2522 - $grouped_prompts[$source_url][] = $prompt;
2523 - } else {
2524 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2525 - }
2526 - }
2527 -
2528 - // Sort each group by chunk_index
2529 - foreach ($grouped_prompts as $source_url => &$group) {
2530 - usort($group, function($a, $b) {
2531 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2532 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2533 - return $index_a - $index_b;
2534 - });
2535 - }
2536 - unset($group);
2537 -
2538 - // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
2539 - ob_start();
2540 - $display_index = 0;
2541 - $current_page = $page;
2542 - $data_source = 'pinecone';
2543 - $current_bot_id = $bot_id;
2544 - $preview_length = 150;
2545 -
2546 - if (empty($grouped_prompts)) {
2547 - echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2548 - esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
2549 - echo '</td></tr>';
2550 - } else {
2551 - foreach ($grouped_prompts as $source_url => $group) {
2552 - $chunk_count = count($group);
2553 - $first_prompt = $group[0];
2554 - $display_index++;
2555 -
2556 - if ($chunk_count > 1) {
2557 - // Multiple chunks - show grouped row with expand button
2558 - $group_id = 'group-' . md5($source_url);
2559 - ?>
2560 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2561 - class="mxchat-chunk-group-header"
2562 - data-source="<?php echo esc_attr($data_source); ?>"
2563 - data-group-id="<?php echo esc_attr($group_id); ?>"
2564 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2565 - <td style="padding: 12px 16px; text-align: center;">
2566 - <input type="checkbox"
2567 - class="mxchat-entry-checkbox"
2568 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2569 - data-source="<?php echo esc_attr($data_source); ?>"
2570 - data-source-url="<?php echo esc_attr($source_url); ?>"
2571 - data-is-group="true"
2572 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2573 - </td>
2574 - <td style="padding: 12px 16px; font-size: 13px;">
2575 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2576 - </td>
2577 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2578 - <div class="mxchat-chunk-group-info">
2579 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2580 - <span class="dashicons dashicons-arrow-right-alt2"></span>
2581 - </button>
2582 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2583 - <span class="mxchat-chunk-preview">
2584 - <?php
2585 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
2586 - $content_preview = mb_substr($parent_content, 0, 100);
2587 - echo esc_html($content_preview . '...');
2588 - ?>
2589 - </span>
2590 - </div>
2591 - </td>
2592 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2593 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2594 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2595 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2596 - <?php esc_html_e('View Source', 'mxchat'); ?>
2597 - </a>
2598 - <?php else : ?>
2599 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2600 - <?php endif; ?>
2601 - </td>
2602 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2603 - <?php if ($data_source !== 'pinecone') : ?>
2604 - <button type="button"
2605 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2606 - data-source-url="<?php echo esc_attr($source_url); ?>"
2607 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2608 - data-data-source="<?php echo esc_attr($data_source); ?>"
2609 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2610 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2611 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2612 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2613 - </button>
2614 - <?php endif; ?>
2615 - <button type="button"
2616 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2617 - data-source-url="<?php echo esc_attr($source_url); ?>"
2618 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2619 - data-data-source="<?php echo esc_attr($data_source); ?>"
2620 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2621 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2622 - style="color: var(--mxch-error);"
2623 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2624 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2625 - </button>
2626 - </td>
2627 - </tr>
2628 - <?php
2629 - // Render hidden chunk rows
2630 - foreach ($group as $chunk_index => $chunk) {
2631 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2632 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2633 - $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
2634 - $content_preview = mb_strlen($content) > $preview_length
2635 - ? mb_substr($content, 0, $preview_length) . '...'
2636 - : $content;
2637 - ?>
2638 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2639 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2640 - data-source="<?php echo esc_attr($data_source); ?>"
2641 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2642 - <td style="padding: 12px 16px; text-align: center;">
2643 - <!-- Checkbox column placeholder for chunks (managed by group) -->
2644 - </td>
2645 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2646 - <!-- Hidden ID column for chunks -->
2647 - </td>
2648 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2649 - <div class="mxchat-accordion-wrapper">
2650 - <div class="mxchat-content-preview">
2651 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2652 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2653 - </span>
2654 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2655 - <?php if (mb_strlen($content) > $preview_length) : ?>
2656 - <button class="mxchat-expand-toggle" type="button">
2657 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2658 - </button>
2659 - <?php endif; ?>
2660 - </div>
2661 - <div class="mxchat-content-full" style="display: none;">
2662 - <div class="content-view">
2663 - <?php
2664 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2665 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2666 - echo wp_kses_post(wpautop($content));
2667 - echo '</div>';
2668 - } else {
2669 - echo wp_kses_post(wpautop($content));
2670 - }
2671 - ?>
2672 - </div>
2673 - </div>
2674 - </div>
2675 - </td>
2676 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2677 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2678 - </td>
2679 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2680 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2681 - </td>
2682 - </tr>
2683 - <?php
2684 - }
2685 - } else {
2686 - // Single entry - display normally with accordion
2687 - $prompt = $first_prompt;
2688 - $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
2689 - $content_preview = mb_strlen($content) > $preview_length
2690 - ? mb_substr($content, 0, $preview_length) . '...'
2691 - : $content;
2692 - ?>
2693 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2694 - data-source="<?php echo esc_attr($data_source); ?>"
2695 - style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
2696 - <td style="padding: 12px 16px; text-align: center;">
2697 - <input type="checkbox"
2698 - class="mxchat-entry-checkbox"
2699 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2700 - data-source="<?php echo esc_attr($data_source); ?>"
2701 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2702 - data-is-group="false"
2703 - data-chunk-count="1">
2704 - </td>
2705 - <td style="padding: 12px 16px; font-size: 13px;">
2706 - <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
2707 - </td>
2708 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2709 - <div class="mxchat-accordion-wrapper">
2710 - <div class="mxchat-content-preview">
2711 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2712 - <?php if (mb_strlen($content) > $preview_length) : ?>
2713 - <button class="mxchat-expand-toggle" type="button">
2714 - <span class="dashicons dashicons-arrow-down-alt2"></span>
2715 - </button>
2716 - <?php endif; ?>
2717 - </div>
2718 - <div class="mxchat-content-full" style="display: none;">
2719 - <div class="content-view">
2720 - <?php
2721 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2722 - echo '<div dir="rtl" lang="he" class="rtl-content">';
2723 - echo wp_kses_post(wpautop($content));
2724 - echo '</div>';
2725 - } else {
2726 - echo wp_kses_post(wpautop($content));
2727 - }
2728 - ?>
2729 - </div>
2730 - </div>
2731 - </div>
2732 - </td>
2733 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2734 - <?php
2735 - $actual_source = $source_url;
2736 - if (strpos($source_url, '_ungrouped_') === 0) {
2737 - $actual_source = $prompt->source_url ?? '';
2738 - }
2739 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2740 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2741 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2742 - <?php esc_html_e('View', 'mxchat'); ?>
2743 - </a>
2744 - <?php else : ?>
2745 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2746 - <?php endif; ?>
2747 - </td>
2748 - <td style="padding: 12px 16px;">
2749 - <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);">
2750 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2751 - </button>
2752 - </td>
2753 - </tr>
2754 - <?php
2755 - }
2756 - }
2757 - }
2758 - $html = ob_get_clean();
2759 -
2760 - // Generate pagination HTML for Pinecone
2761 - $total_pages = ceil($total_records / $per_page);
2762 - $pagination_html = '';
2763 - if ($total_pages > 1) {
2764 - $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) . '">';
2765 -
2766 - // Previous button
2767 - if ($page > 1) {
2768 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2769 - }
2770 -
2771 - // Page numbers
2772 - $start_page = max(1, $page - 2);
2773 - $end_page = min($total_pages, $page + 2);
2774 -
2775 - if ($start_page > 1) {
2776 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2777 - if ($start_page > 2) {
2778 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2779 - }
2780 - }
2781 -
2782 - for ($i = $start_page; $i <= $end_page; $i++) {
2783 - if ($i == $page) {
2784 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2785 - } else {
2786 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2787 - }
2788 - }
2789 -
2790 - if ($end_page < $total_pages) {
2791 - if ($end_page < $total_pages - 1) {
2792 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2793 - }
2794 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2795 - }
2796 -
2797 - // Next button
2798 - if ($page < $total_pages) {
2799 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2800 - }
2801 -
2802 - $pagination_html .= '</div>';
2803 - }
2804 -
2805 - wp_send_json_success(array(
2806 - 'html' => $html,
2807 - 'pagination_html' => $pagination_html,
2808 - 'total_count' => $total_records,
2809 - 'total_pages' => $total_pages,
2810 - 'page' => $page,
2811 - 'per_page' => $per_page,
2812 - 'data_source' => 'pinecone'
2813 - ));
2814 -}
2815 -
2816 -/**
2817 - * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
2818 - * Returns paginated entries without requiring a full page reload
2819 - */
2820 -public function ajax_mxchat_paginate_entries() {
2821 - check_ajax_referer('mxchat_entries_nonce', 'nonce');
2822 -
2823 - if (!current_user_can('manage_options')) {
2824 - wp_send_json_error(array('message' => 'Unauthorized'));
2825 - return;
2826 - }
2827 -
2828 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
2829 - $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
2830 - $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
2831 - $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
2832 - $per_page = 25;
2833 -
2834 - // Check if Pinecone is enabled for this bot
2835 - $pinecone_manager = $this->mxchat_get_pinecone_manager();
2836 - $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
2837 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2838 - $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
2839 -
2840 - if ($use_pinecone && $has_pinecone_api) {
2841 - // Delegate to Pinecone pagination handler (pass search params)
2842 - $_POST['page'] = $page;
2843 - $_POST['search'] = $search_query;
2844 - $_POST['content_type'] = $content_type_filter;
2845 - $this->ajax_mxchat_refresh_pinecone_entries();
2846 - return;
2847 - }
2848 -
2849 - // WordPress DB pagination - MUST match initial page load logic exactly
2850 - global $wpdb;
2851 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2852 - $offset = ($page - 1) * $per_page;
2853 -
2854 - // Build WHERE clause for search and content type filtering
2855 - $where_clauses = array();
2856 - $where_values = array();
2857 -
2858 - if ($search_query) {
2859 - $where_clauses[] = "article_content LIKE %s";
2860 - $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
2861 - }
2862 -
2863 - if ($content_type_filter) {
2864 - switch ($content_type_filter) {
2865 - case 'manual':
2866 - $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
2867 - break;
2868 - case 'pdf':
2869 - $where_clauses[] = "source_url LIKE '%.pdf'";
2870 - break;
2871 - case 'url':
2872 - $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
2873 - break;
2874 - }
2875 - }
2876 -
2877 - $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
2878 -
2879 - // Count grouped entries with filters applied
2880 - if (!empty($where_values)) {
2881 - $count_args = array_merge($where_values, $where_values);
2882 - $count_query = $wpdb->prepare(
2883 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2884 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
2885 - ...$count_args
2886 - );
2887 - $total_records = $wpdb->get_var($count_query);
2888 - } else if (!empty($where_sql)) {
2889 - // Content type filter only (no search), no prepared values needed
2890 - $total_records = $wpdb->get_var(
2891 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2892 - (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
2893 - );
2894 - } else {
2895 - // No filters
2896 - $total_records = $wpdb->get_var(
2897 - "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
2898 - (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
2899 - );
2900 - }
2901 - $total_pages = ceil($total_records / $per_page);
2902 -
2903 - // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
2904 - if (!empty($where_values)) {
2905 - $query_args = array_merge($where_values, array($per_page, $offset));
2906 - $urls_query = $wpdb->prepare(
2907 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2908 - {$where_sql}
2909 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2910 - ...$query_args
2911 - );
2912 - } else if (!empty($where_sql)) {
2913 - $urls_query = $wpdb->prepare(
2914 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2915 - {$where_sql}
2916 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2917 - $per_page, $offset
2918 - );
2919 - } else {
2920 - $urls_query = $wpdb->prepare(
2921 - "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
2922 - GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
2923 - $per_page, $offset
2924 - );
2925 - }
2926 - $page_urls = $wpdb->get_results($urls_query);
2927 -
2928 - // Step 2: Build list of source_urls to fetch
2929 - $url_list = array();
2930 - $url_order_map = array();
2931 - $order_index = 0;
2932 - foreach ($page_urls as $url_row) {
2933 - $url = $url_row->source_url;
2934 - $url_list[] = $url;
2935 - $url_order_map[$url] = $order_index++;
2936 - }
2937 -
2938 - // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
2939 - $prompts = array();
2940 - if (!empty($url_list)) {
2941 - $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
2942 - if ($search_query) {
2943 - // Include search filter in the final fetch
2944 - $prompts_query = $wpdb->prepare(
2945 - "SELECT id, article_content, source_url, timestamp, role_restriction
2946 - FROM {$table_name}
2947 - WHERE source_url IN ($placeholders) AND article_content LIKE %s
2948 - ORDER BY timestamp DESC",
2949 - ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
2950 - );
2951 - } else {
2952 - $prompts_query = $wpdb->prepare(
2953 - "SELECT id, article_content, source_url, timestamp, role_restriction
2954 - FROM {$table_name}
2955 - WHERE source_url IN ($placeholders)
2956 - ORDER BY timestamp DESC",
2957 - $url_list
2958 - );
2959 - }
2960 - $prompts = $wpdb->get_results($prompts_query);
2961 - }
2962 -
2963 - // Group prompts by source_url for chunk display
2964 - $grouped_prompts = array();
2965 - foreach ($prompts as $prompt) {
2966 - $source_url = $prompt->source_url ?? '';
2967 -
2968 - // Parse chunk metadata using the proper chunker method (same as initial page load)
2969 - if (class_exists('MxChat_Chunker')) {
2970 - $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2971 - $prompt->chunk_metadata = $chunk_meta['metadata'];
2972 - $prompt->display_content = $chunk_meta['text'];
2973 - } else {
2974 - $prompt->chunk_metadata = array();
2975 - $prompt->display_content = $prompt->article_content;
2976 - }
2977 -
2978 - if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2979 - if (!isset($grouped_prompts[$source_url])) {
2980 - $grouped_prompts[$source_url] = array();
2981 - }
2982 - $grouped_prompts[$source_url][] = $prompt;
2983 - } else {
2984 - // Ungrouped entries
2985 - $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2986 - }
2987 - }
2988 -
2989 - // Sort groups by the original URL order (newest first)
2990 - uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2991 - $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2992 - $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2993 - return $order_a - $order_b;
2994 - });
2995 -
2996 - // Sort each group internally by chunk_index
2997 - foreach ($grouped_prompts as $source_url => &$group) {
2998 - usort($group, function($a, $b) {
2999 - $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
3000 - $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
3001 - return $index_a - $index_b;
3002 - });
3003 - }
3004 - unset($group);
3005 -
3006 - // Build HTML for the table rows
3007 - ob_start();
3008 - $display_index = 0;
3009 - $current_page = $page;
3010 - $data_source = 'wordpress';
3011 - $current_bot_id = $bot_id;
3012 - $preview_length = 150;
3013 -
3014 - if (empty($grouped_prompts)) {
3015 - echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
3016 - esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
3017 - echo '</td></tr>';
3018 - } else {
3019 - foreach ($grouped_prompts as $source_url => $group) {
3020 - $chunk_count = count($group);
3021 - $first_prompt = $group[0];
3022 - $display_index++;
3023 -
3024 - if ($chunk_count > 1) {
3025 - // Multiple chunks - show grouped row with expand button
3026 - $group_id = 'group-' . md5($source_url);
3027 - ?>
3028 - <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
3029 - class="mxchat-chunk-group-header"
3030 - data-source="<?php echo esc_attr($data_source); ?>"
3031 - data-group-id="<?php echo esc_attr($group_id); ?>"
3032 - style="border-bottom: 1px solid var(--mxch-card-border);">
3033 - <td style="padding: 12px 16px; text-align: center;">
3034 - <input type="checkbox"
3035 - class="mxchat-entry-checkbox"
3036 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3037 - data-source="<?php echo esc_attr($data_source); ?>"
3038 - data-source-url="<?php echo esc_attr($source_url); ?>"
3039 - data-is-group="true"
3040 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
3041 - </td>
3042 - <td style="padding: 12px 16px; font-size: 13px;">
3043 - <?php echo esc_html($first_prompt->id); ?>
3044 - </td>
3045 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3046 - <div class="mxchat-chunk-group-info">
3047 - <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
3048 - <span class="dashicons dashicons-arrow-right-alt2"></span>
3049 - </button>
3050 - <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
3051 - <span class="mxchat-chunk-preview">
3052 - <?php
3053 - $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
3054 - $content_preview = mb_substr($parent_content, 0, 100);
3055 - echo esc_html($content_preview . '...');
3056 - ?>
3057 - </span>
3058 - </div>
3059 - </td>
3060 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3061 - <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
3062 - <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3063 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3064 - <?php esc_html_e('View Source', 'mxchat'); ?>
3065 - </a>
3066 - <?php else : ?>
3067 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
3068 - <?php endif; ?>
3069 - </td>
3070 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
3071 - <?php if ($data_source !== 'pinecone') : ?>
3072 - <button type="button"
3073 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3074 - data-source-url="<?php echo esc_attr($source_url); ?>"
3075 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
3076 - data-data-source="<?php echo esc_attr($data_source); ?>"
3077 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3078 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3079 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3080 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3081 - </button>
3082 - <?php endif; ?>
3083 - <button type="button"
3084 - class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
3085 - data-source-url="<?php echo esc_attr($source_url); ?>"
3086 - data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
3087 - data-data-source="<?php echo esc_attr($data_source); ?>"
3088 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3089 - data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
3090 - style="color: var(--mxch-error);"
3091 - title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
3092 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3093 - </button>
3094 - </td>
3095 - </tr>
3096 - <?php
3097 - // Render hidden chunk rows
3098 - foreach ($group as $chunk_index => $chunk) {
3099 - $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
3100 - $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
3101 - $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
3102 - $content_preview = mb_strlen($content) > $preview_length
3103 - ? mb_substr($content, 0, $preview_length) . '...'
3104 - : $content;
3105 - ?>
3106 - <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
3107 - class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
3108 - data-source="<?php echo esc_attr($data_source); ?>"
3109 - style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
3110 - <td style="padding: 12px 16px; text-align: center;">
3111 - <!-- Checkbox column placeholder for chunks (managed by group) -->
3112 - </td>
3113 - <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
3114 - <!-- Hidden ID column for chunks -->
3115 - </td>
3116 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3117 - <div class="mxchat-accordion-wrapper">
3118 - <div class="mxchat-content-preview">
3119 - <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
3120 - <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
3121 - </span>
3122 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3123 - <?php if (mb_strlen($content) > $preview_length) : ?>
3124 - <button class="mxchat-expand-toggle" type="button">
3125 - <span class="dashicons dashicons-arrow-down-alt2"></span>
3126 - </button>
3127 - <?php endif; ?>
3128 - </div>
3129 - <div class="mxchat-content-full" style="display: none;">
3130 - <div class="content-view">
3131 - <?php
3132 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3133 - echo '<div dir="rtl" lang="he" class="rtl-content">';
3134 - echo wp_kses_post(wpautop($content));
3135 - echo '</div>';
3136 - } else {
3137 - echo wp_kses_post(wpautop($content));
3138 - }
3139 - ?>
3140 - </div>
3141 - </div>
3142 - </div>
3143 - </td>
3144 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3145 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
3146 - </td>
3147 - <td class="mxchat-actions-cell" style="padding: 12px 16px;">
3148 - <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
3149 - </td>
3150 - </tr>
3151 - <?php
3152 - }
3153 - } else {
3154 - // Single entry - display normally with accordion
3155 - $prompt = $first_prompt;
3156 - $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
3157 - $content_preview = mb_strlen($content) > $preview_length
3158 - ? mb_substr($content, 0, $preview_length) . '...'
3159 - : $content;
3160 - ?>
3161 - <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
3162 - data-source="<?php echo esc_attr($data_source); ?>"
3163 - style="border-bottom: 1px solid var(--mxch-card-border);">
3164 - <td style="padding: 12px 16px; text-align: center;">
3165 - <input type="checkbox"
3166 - class="mxchat-entry-checkbox"
3167 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3168 - data-source="<?php echo esc_attr($data_source); ?>"
3169 - data-source-url="<?php echo esc_attr($source_url); ?>"
3170 - data-is-group="false">
3171 - </td>
3172 - <td style="padding: 12px 16px; font-size: 13px;">
3173 - <?php echo esc_html($prompt->id); ?>
3174 - </td>
3175 - <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
3176 - <div class="mxchat-accordion-wrapper">
3177 - <div class="mxchat-content-preview">
3178 - <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
3179 - <?php if (mb_strlen($content) > $preview_length) : ?>
3180 - <button class="mxchat-expand-toggle" type="button">
3181 - <span class="dashicons dashicons-arrow-down-alt2"></span>
3182 - </button>
3183 - <?php endif; ?>
3184 - </div>
3185 - <div class="mxchat-content-full" style="display: none;">
3186 - <div class="content-view">
3187 - <?php
3188 - if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
3189 - echo '<div dir="rtl" lang="he" class="rtl-content">';
3190 - echo wp_kses_post(wpautop($content));
3191 - echo '</div>';
3192 - } else {
3193 - echo wp_kses_post(wpautop($content));
3194 - }
3195 - ?>
3196 - </div>
3197 - </div>
3198 - </div>
3199 - </td>
3200 - <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
3201 - <?php
3202 - $actual_source = $source_url;
3203 - if (strpos($source_url, '_ungrouped_') === 0) {
3204 - $actual_source = $prompt->source_url ?? '';
3205 - }
3206 - if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
3207 - <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
3208 - <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
3209 - <?php esc_html_e('View', 'mxchat'); ?>
3210 - </a>
3211 - <?php else : ?>
3212 - <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3213 - <?php endif; ?>
3214 - </td>
3215 - <td style="padding: 12px 16px; white-space: nowrap;">
3216 - <button type="button"
3217 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3218 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3219 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3220 - data-data-source="<?php echo esc_attr($data_source); ?>"
3221 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3222 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3223 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3224 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3225 - </button>
3226 - <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);">
3227 - <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3228 - </button>
3229 - </td>
3230 - </tr>
3231 - <?php
3232 - }
3233 - }
3234 - }
3235 - $html = ob_get_clean();
3236 -
3237 - // Generate pagination HTML (include search/filter data for subsequent pages)
3238 - $pagination_html = '';
3239 - if ($total_pages > 1) {
3240 - $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) . '">';
3241 -
3242 - // Previous button
3243 - if ($page > 1) {
3244 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
3245 - }
3246 -
3247 - // Page numbers
3248 - $start_page = max(1, $page - 2);
3249 - $end_page = min($total_pages, $page + 2);
3250 -
3251 - if ($start_page > 1) {
3252 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
3253 - if ($start_page > 2) {
3254 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3255 - }
3256 - }
3257 -
3258 - for ($i = $start_page; $i <= $end_page; $i++) {
3259 - if ($i == $page) {
3260 - $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
3261 - } else {
3262 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
3263 - }
3264 - }
3265 -
3266 - if ($end_page < $total_pages) {
3267 - if ($end_page < $total_pages - 1) {
3268 - $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
3269 - }
3270 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
3271 - }
3272 -
3273 - // Next button
3274 - if ($page < $total_pages) {
3275 - $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
3276 - }
3277 -
3278 - $pagination_html .= '</div>';
3279 - }
3280 -
3281 - wp_send_json_success(array(
3282 - 'html' => $html,
3283 - 'pagination_html' => $pagination_html,
3284 - 'total_count' => $total_records,
3285 - 'total_pages' => $total_pages,
3286 - 'page' => $page,
3287 - 'per_page' => $per_page,
3288 - 'data_source' => 'wordpress'
3289 - ));
3290 -}
3291 -
3292 -/**
3293 - * AJAX handler to detect available sitemaps on the site
3294 - * Optimized for speed - only checks primary sitemap indexes first
3295 - */
3296 -public function ajax_mxchat_detect_sitemaps() {
3297 - check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
3298 -
3299 - if (!current_user_can('manage_options')) {
3300 - wp_send_json_error(array('message' => 'Unauthorized'));
3301 - return;
3302 - }
3303 -
3304 - $site_url = get_site_url();
3305 - $sitemaps = array();
3306 - $found_index = false;
3307 -
3308 - // Only check the main sitemap index files first (much faster)
3309 - // These are the primary entry points that contain sub-sitemaps
3310 - $primary_indexes = array(
3311 - 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
3312 - 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
3313 - 'sitemap.xml' => 'Standard', // Generic/AIOSEO
3314 - );
3315 -
3316 - foreach ($primary_indexes as $path => $source) {
3317 - $url = trailingslashit($site_url) . $path;
3318 -
3319 - $response = wp_remote_head($url, array(
3320 - 'timeout' => 10,
3321 - 'sslverify' => false,
3322 - 'redirection' => 1,
3323 - 'user-agent' => mxchat_ingest_user_agent(),
3324 - ));
3325 -
3326 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3327 - // Found a sitemap index - parse it to get sub-sitemaps
3328 - $sub_sitemaps = $this->parse_sitemap_index($url);
3329 - if (!empty($sub_sitemaps)) {
3330 - $sitemaps[] = array(
3331 - 'url' => $url,
3332 - 'type' => 'index',
3333 - 'source' => $source,
3334 - 'sub_sitemaps' => $sub_sitemaps
3335 - );
3336 - $found_index = true;
3337 - // Found a valid index, no need to check others
3338 - break;
3339 - }
3340 - }
3341 - }
3342 -
3343 - // If no sitemap index found, check for standalone sitemaps
3344 - if (!$found_index) {
3345 - $standalone_sitemaps = array(
3346 - 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3347 - 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
3348 - );
3349 -
3350 - foreach ($standalone_sitemaps as $path => $info) {
3351 - $url = trailingslashit($site_url) . $path;
3352 -
3353 - $response = wp_remote_head($url, array(
3354 - 'timeout' => 2,
3355 - 'sslverify' => false
3356 - ));
3357 -
3358 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3359 - $sitemaps[] = array(
3360 - 'url' => $url,
3361 - 'type' => $info['type'],
3362 - 'source' => $info['source'],
3363 - 'url_count' => 0 // Skip URL count for speed
3364 - );
3365 - }
3366 - }
3367 - }
3368 -
3369 - wp_send_json_success(array(
3370 - 'sitemaps' => $sitemaps,
3371 - 'site_url' => $site_url
3372 - ));
3373 -}
3374 -
3375 -/**
3376 - * Parse a sitemap index to get sub-sitemaps
3377 - * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
3378 - */
3379 -private function parse_sitemap_index($url) {
3380 - $sub_sitemaps = array();
3381 -
3382 - $response = wp_remote_get($url, array(
3383 - 'timeout' => 30,
3384 - 'sslverify' => false,
3385 - 'user-agent' => mxchat_ingest_user_agent(),
3386 - 'headers' => array(
3387 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3388 - ),
3389 - ));
3390 -
3391 - if (is_wp_error($response)) {
3392 - return $sub_sitemaps;
3393 - }
3394 -
3395 - $body = wp_remote_retrieve_body($response);
3396 - if (empty($body)) {
3397 - return $sub_sitemaps;
3398 - }
3399 -
3400 - // Suppress XML errors
3401 - libxml_use_internal_errors(true);
3402 - $xml = simplexml_load_string($body);
3403 - libxml_clear_errors();
3404 -
3405 - if ($xml === false) {
3406 - return $sub_sitemaps;
3407 - }
3408 -
3409 - // Check if it's a sitemap index (contains <sitemap> elements)
3410 - if (isset($xml->sitemap)) {
3411 - foreach ($xml->sitemap as $sitemap) {
3412 - $loc = (string) $sitemap->loc;
3413 - if (!empty($loc)) {
3414 - // Try to determine the type from the URL
3415 - $type = 'content';
3416 - if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
3417 - $type = 'taxonomy';
3418 - } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
3419 - $type = 'author';
3420 - }
3421 -
3422 - // Skip URL count - too slow to fetch for each sitemap
3423 - $sub_sitemaps[] = array(
3424 - 'url' => $loc,
3425 - 'type' => $type,
3426 - 'url_count' => 0, // Don't fetch - takes too long
3427 - 'name' => basename(parse_url($loc, PHP_URL_PATH))
3428 - );
3429 - }
3430 - }
3431 - }
3432 -
3433 - return $sub_sitemaps;
3434 -}
3435 -
3436 -/**
3437 - * Get URL count from a sitemap
3438 - */
3439 -private function get_sitemap_url_count($url) {
3440 - $response = wp_remote_get($url, array(
3441 - 'timeout' => 30,
3442 - 'sslverify' => false,
3443 - 'user-agent' => mxchat_ingest_user_agent(),
3444 - 'headers' => array(
3445 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3446 - ),
3447 - ));
3448 -
3449 - if (is_wp_error($response)) {
3450 - return 0;
3451 - }
3452 -
3453 - $body = wp_remote_retrieve_body($response);
3454 - if (empty($body)) {
3455 - return 0;
3456 - }
3457 -
3458 - // Count <url> or <loc> elements
3459 - $count = preg_match_all('/<url>/i', $body, $matches);
3460 - return $count ?: 0;
3461 -}
3462 -
3463 -/**
3464 - * Get sitemaps declared in robots.txt
3465 - */
3466 -private function get_sitemaps_from_robots($site_url) {
3467 - $sitemaps = array();
3468 - $robots_url = trailingslashit($site_url) . 'robots.txt';
3469 -
3470 - $response = wp_remote_get($robots_url, array(
3471 - 'timeout' => 15,
3472 - 'sslverify' => false,
3473 - 'user-agent' => mxchat_ingest_user_agent(),
3474 - ));
3475 -
3476 - if (is_wp_error($response)) {
3477 - return $sitemaps;
3478 - }
3479 -
3480 - $body = wp_remote_retrieve_body($response);
3481 - if (empty($body)) {
3482 - return $sitemaps;
3483 - }
3484 -
3485 - // Find Sitemap: declarations
3486 - if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
3487 - foreach ($matches[1] as $sitemap_url) {
3488 - $sitemap_url = trim($sitemap_url);
3489 - if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
3490 - $sitemaps[] = $sitemap_url;
3491 - }
3492 - }
3493 - }
3494 -
3495 - return $sitemaps;
3496 -}
3497 -
3498 -public function mxchat_stop_processing() {
3499 - // Verify permissions
3500 - if (!current_user_can('manage_options')) {
3501 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
3502 - }
3503 -
3504 - // Verify nonce
3505 - check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
3506 -
3507 - global $wpdb;
3508 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
3509 -
3510 - // Get active queue IDs
3511 - $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
3512 - $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
3513 -
3514 - // Delete all pending items from active queues
3515 - if ($sitemap_queue_id) {
3516 - $wpdb->delete(
3517 - $table_name,
3518 - array(
3519 - 'queue_id' => $sitemap_queue_id,
3520 - 'status' => 'pending'
3521 - ),
3522 - array('%s', '%s')
3523 - );
3524 -
3525 - delete_transient('mxchat_active_queue_sitemap');
3526 - delete_transient('mxchat_last_sitemap_url');
3527 - }
3528 -
3529 - if ($pdf_queue_id) {
3530 - // Get PDF path before deleting
3531 - $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
3532 -
3533 - $wpdb->delete(
3534 - $table_name,
3535 - array(
3536 - 'queue_id' => $pdf_queue_id,
3537 - 'status' => 'pending'
3538 - ),
3539 - array('%s', '%s')
3540 - );
3541 -
3542 - // Delete PDF file
3543 - if ($pdf_path && file_exists($pdf_path)) {
3544 - wp_delete_file($pdf_path);
3545 - }
3546 -
3547 - delete_transient('mxchat_active_queue_pdf');
3548 - delete_transient('mxchat_last_pdf_url');
3549 - }
3550 -
3551 - // Redirect back with a success message
3552 - set_transient('mxchat_admin_notice_success',
3553 - esc_html__('Processing has been stopped successfully.', 'mxchat'),
3554 - 30
3555 - );
3556 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
3557 - exit;
3558 -}
3559 -
3560 -/**
3561 - * Get content list for processing
3562 - */
3563 -public function ajax_mxchat_get_content_list() {
3564 - // Verify the nonce
3565 - check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
3566 -
3567 - if (!current_user_can('manage_options')) {
3568 - wp_send_json_error(__('Unauthorized access', 'mxchat'));
3569 - }
3570 -
3571 - $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
3572 - $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
3573 - $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
3574 - $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
3575 - $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
3576 - $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
3577 -
3578 - // Build query args
3579 - $args = array(
3580 - 'posts_per_page' => $per_page,
3581 - 'paged' => $page,
3582 - 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
3583 - 'orderby' => 'date',
3584 - 'order' => 'DESC',
3585 - );
3586 -
3587 - // Handle post types - IMPROVED VERSION
3588 - if ($post_type !== 'all') {
3589 - $args['post_type'] = $post_type;
3590 - } else {
3591 - // Get all available post types that might contain content
3592 - $all_post_types = array();
3593 -
3594 - // First get all public post types
3595 - $public_types = get_post_types(array('public' => true), 'names');
3596 - $all_post_types = array_merge($all_post_types, $public_types);
3597 -
3598 - // Add common forum/community post types
3599 - $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
3600 - foreach ($forum_types as $forum_type) {
3601 - if (post_type_exists($forum_type)) {
3602 - $all_post_types[] = $forum_type;
3603 - }
3604 - }
3605 -
3606 - // Add other commonly used post types
3607 - $common_types = array('product', 'job_listing', 'event', 'portfolio');
3608 - foreach ($common_types as $common_type) {
3609 - if (post_type_exists($common_type)) {
3610 - $all_post_types[] = $common_type;
3611 - }
3612 - }
3613 -
3614 - // Remove duplicates and ensure we have at least some post types
3615 - $all_post_types = array_unique($all_post_types);
3616 -
3617 - if (empty($all_post_types)) {
3618 - // Fallback to basic post types
3619 - $all_post_types = array('post', 'page');
3620 - }
3621 -
3622 - $args['post_type'] = $all_post_types;
3623 -
3624 - // Debug logging to see what post types are being queried
3625 - //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
3626 - }
3627 -
3628 - if (!empty($search)) {
3629 - $args['s'] = $search;
3630 - }
3631 -
3632 - // Get processed data from storage
3633 - $processed_data = array();
3634 -
3635 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3636 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3637 -
3638 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3639 - // Get fresh data from Pinecone - no caching
3640 - $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
3641 - } else {
3642 - // WordPress DB checking with better URL matching for all post types
3643 - global $wpdb;
3644 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3645 - $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
3646 -
3647 - // Group items by source_url to count chunks
3648 - $url_chunk_counts = array();
3649 - $url_latest_timestamp = array();
3650 - $url_first_id = array();
3651 -
3652 - if (!empty($processed_items)) {
3653 - foreach ($processed_items as $item) {
3654 - $url = $item->source_url;
3655 - if (empty($url)) continue;
3656 -
3657 - // Count chunks per URL
3658 - if (!isset($url_chunk_counts[$url])) {
3659 - $url_chunk_counts[$url] = 0;
3660 - $url_latest_timestamp[$url] = $item->timestamp;
3661 - $url_first_id[$url] = $item->id;
3662 - }
3663 - $url_chunk_counts[$url]++;
3664 -
3665 - // Track latest timestamp
3666 - if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
3667 - $url_latest_timestamp[$url] = $item->timestamp;
3668 - }
3669 - }
3670 -
3671 - // Now build processed_data with chunk counts
3672 - foreach ($url_chunk_counts as $url => $chunk_count) {
3673 - $post_id = $this->mxchat_url_to_post_id_improved($url);
3674 -
3675 - if ($post_id) {
3676 - $processed_data[$post_id] = array(
3677 - 'db_id' => $url_first_id[$url],
3678 - 'timestamp' => $url_latest_timestamp[$url],
3679 - 'url' => $url,
3680 - 'source' => 'wordpress',
3681 - 'chunk_count' => $chunk_count
3682 - );
3683 - }
3684 - }
3685 - }
3686 - }
3687 -
3688 - // Get processed IDs as a simple array for in_array checks
3689 - $processed_ids = array_keys($processed_data);
3690 -
3691 - // Handle processed/unprocessed filter
3692 - if ($processed_filter === 'processed' && !empty($processed_ids)) {
3693 - $args['post__in'] = $processed_ids;
3694 - } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
3695 - $args['post__not_in'] = $processed_ids;
3696 - }
3697 -
3698 - // Run the query
3699 - $query = new WP_Query($args);
3700 - $content_items = array();
3701 -
3702 - if ($query->have_posts()) {
3703 - while ($query->have_posts()) {
3704 - $query->the_post();
3705 - $id = get_the_ID();
3706 - $post_date = get_the_date();
3707 - $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
3708 - $word_count = str_word_count(strip_tags(get_the_content()));
3709 -
3710 - $is_processed = in_array($id, $processed_ids);
3711 - $processed_date = '';
3712 - $db_record_id = 0;
3713 - $data_source = 'none';
3714 -
3715 - if ($is_processed && isset($processed_data[$id])) {
3716 - $item_data = $processed_data[$id];
3717 - $data_source = $item_data['source'];
3718 -
3719 - if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
3720 - // WordPress DB format
3721 - $timestamp = strtotime($item_data['timestamp']);
3722 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3723 - $db_record_id = $item_data['db_id'];
3724 - } elseif ($data_source === 'pinecone') {
3725 - // Pinecone format
3726 - $processed_date = $item_data['processed_date'];
3727 - $db_record_id = $item_data['db_id'];
3728 - }
3729 - }
3730 -
3731 - // Get chunk count for this item
3732 - $chunk_count = 0;
3733 - if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
3734 - $chunk_count = intval($processed_data[$id]['chunk_count']);
3735 - }
3736 -
3737 - $content_items[] = array(
3738 - 'id' => $id,
3739 - 'title' => get_the_title(),
3740 - 'permalink' => get_permalink(),
3741 - 'date' => $post_date,
3742 - 'type' => get_post_type(),
3743 - 'status' => get_post_status(),
3744 - 'excerpt' => $excerpt,
3745 - 'word_count' => $word_count,
3746 - 'already_processed' => $is_processed,
3747 - 'processed_date' => $processed_date,
3748 - 'db_record_id' => $db_record_id,
3749 - 'data_source' => $data_source,
3750 - 'chunk_count' => $chunk_count
3751 - );
3752 - }
3753 - wp_reset_postdata();
3754 - }
3755 -
3756 - $response = array(
3757 - 'items' => $content_items,
3758 - 'total' => $query->found_posts,
3759 - 'total_pages' => $query->max_num_pages,
3760 - 'current_page' => $page,
3761 - 'processed_count' => count($processed_ids)
3762 - );
3763 -
3764 - wp_send_json_success($response);
3765 - exit;
3766 -}
3767 -
3768 -
3769 -/**
3770 - * This function handles various WooCommerce URL formats and permalink structures
3771 - */
3772 -private function mxchat_url_to_post_id_improved($url) {
3773 - // First try the standard WordPress function
3774 - $post_id = url_to_postid($url);
3775 -
3776 - if ($post_id > 0) {
3777 - return $post_id;
3778 - }
3779 -
3780 - // If that fails, try more aggressive URL matching
3781 - // Remove trailing slashes and query parameters for better matching
3782 - $clean_url = rtrim($url, '/');
3783 - $clean_url = strtok($clean_url, '?'); // Remove query parameters
3784 -
3785 - // Try again with cleaned URL
3786 - $post_id = url_to_postid($clean_url);
3787 - if ($post_id > 0) {
3788 - return $post_id;
3789 - }
3790 -
3791 - // For bbPress forum topics, try extracting slug from URL
3792 - if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
3793 - // Handle bbPress URLs: /forums/topic/topic-name/
3794 - if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
3795 - $topic_slug = $matches[1];
3796 -
3797 - // Look up topic by slug
3798 - $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
3799 - if ($topic) {
3800 - return $topic->ID;
3801 - }
3802 -
3803 - // Alternative method: query by post_name
3804 - global $wpdb;
3805 - $post_id = $wpdb->get_var($wpdb->prepare(
3806 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3807 - $topic_slug
3808 - ));
3809 -
3810 - if ($post_id) {
3811 - return intval($post_id);
3812 - }
3813 - }
3814 -
3815 - // Handle simpler topic URLs: /topic/topic-name/
3816 - if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
3817 - $topic_slug = $matches[1];
3818 -
3819 - global $wpdb;
3820 - $post_id = $wpdb->get_var($wpdb->prepare(
3821 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
3822 - $topic_slug
3823 - ));
3824 -
3825 - if ($post_id) {
3826 - return intval($post_id);
3827 - }
3828 - }
3829 - }
3830 -
3831 - // For WooCommerce products
3832 - if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
3833 - // Extract product slug from various URL formats
3834 - $product_slug = '';
3835 -
3836 - // Handle pretty permalinks: /product/product-name/
3837 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
3838 - $product_slug = $matches[1];
3839 - }
3840 - // Handle query parameters: ?product=product-name
3841 - elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
3842 - $product_slug = $matches[1];
3843 - }
3844 -
3845 - if (!empty($product_slug)) {
3846 - // Look up product by slug
3847 - $product = get_page_by_path($product_slug, OBJECT, 'product');
3848 - if ($product) {
3849 - return $product->ID;
3850 - }
3851 -
3852 - // Alternative method: query by post_name
3853 - global $wpdb;
3854 - $post_id = $wpdb->get_var($wpdb->prepare(
3855 - "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
3856 - $product_slug
3857 - ));
3858 -
3859 - if ($post_id) {
3860 - return intval($post_id);
3861 - }
3862 - }
3863 - }
3864 -
3865 - // Generic approach: try to extract slug and match against all post types
3866 - $parsed_url = wp_parse_url($clean_url);
3867 - $path = $parsed_url['path'] ?? '';
3868 -
3869 - if (!empty($path)) {
3870 - // Get the last part of the path as potential slug
3871 - $path_parts = array_filter(explode('/', trim($path, '/')));
3872 - $potential_slug = end($path_parts);
3873 -
3874 - if (!empty($potential_slug)) {
3875 - global $wpdb;
3876 -
3877 - // Try to find any post with this slug
3878 - $post_id = $wpdb->get_var($wpdb->prepare(
3879 - "SELECT ID FROM {$wpdb->posts}
3880 - WHERE post_name = %s
3881 - AND post_status IN ('publish', 'closed', 'private')
3882 - AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
3883 - ORDER BY CASE
3884 - WHEN post_type = 'post' THEN 1
3885 - WHEN post_type = 'page' THEN 2
3886 - WHEN post_type = 'topic' THEN 3
3887 - WHEN post_type = 'product' THEN 4
3888 - ELSE 5
3889 - END
3890 - LIMIT 1",
3891 - $potential_slug
3892 - ));
3893 -
3894 - if ($post_id) {
3895 - return intval($post_id);
3896 - }
3897 - }
3898 - }
3899 -
3900 - // ADDITIONAL: Try direct database lookup by URL variations
3901 - global $wpdb;
3902 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3903 -
3904 - // Try variations of the URL (with/without trailing slash, http/https)
3905 - $url_variations = array(
3906 - $url,
3907 - rtrim($url, '/'),
3908 - $url . '/',
3909 - str_replace('http://', 'https://', $url),
3910 - str_replace('https://', 'http://', $url),
3911 - str_replace('http://', 'https://', rtrim($url, '/')),
3912 - str_replace('https://', 'http://', rtrim($url, '/'))
3913 - );
3914 -
3915 - // Remove duplicates
3916 - $url_variations = array_unique($url_variations);
3917 -
3918 - foreach ($url_variations as $variation) {
3919 - $existing_record = $wpdb->get_row($wpdb->prepare(
3920 - "SELECT id, source_url FROM $table_name WHERE source_url = %s",
3921 - $variation
3922 - ));
3923 -
3924 - if ($existing_record) {
3925 - // Try to get post ID from this stored URL
3926 - $stored_post_id = url_to_postid($existing_record->source_url);
3927 - if ($stored_post_id > 0) {
3928 - return $stored_post_id;
3929 - }
3930 - }
3931 - }
3932 -
3933 - return 0; // No match found
3934 -}
3935 -/**
3936 - * Process selected content via AJAX
3937 - */
3938 -public function ajax_mxchat_process_selected_content() {
3939 - // Basic request validation
3940 - if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
3941 - wp_send_json_error('Invalid nonce');
3942 - exit;
3943 - }
3944 -
3945 - if (!current_user_can('manage_options')) {
3946 - wp_send_json_error('Unauthorized access');
3947 - exit;
3948 - }
3949 -
3950 - // Get post IDs - safely parse the array
3951 - $post_ids = array();
3952 - if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
3953 - foreach ($_POST['post_ids'] as $id) {
3954 - $post_ids[] = absint($id);
3955 - }
3956 - }
3957 -
3958 - if (empty($post_ids)) {
3959 - wp_send_json_error('No content selected');
3960 - exit;
3961 - }
3962 -
3963 - // Get bot_id from request
3964 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3965 -
3966 - // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
3967 - // plan 11720c). The import modal shows a passive status line pointing
3968 - // there; the old per-batch checkbox and its remembered default are gone.
3969 - $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
3970 -
3971 - // Process only ONE post at a time to avoid request size issues
3972 - $post_id = reset($post_ids);
3973 - $post = get_post($post_id);
3974 -
3975 - if (!$post) {
3976 - wp_send_json_error('Post not found');
3977 - exit;
3978 - }
3979 -
3980 - /**
3981 - * Allow developers to modify post data before processing into the knowledge base.
3982 - * Applied on BOTH content-preparation paths (this manual bulk import and the
3983 - * auto-sync path in mxchat_handle_post_update) with the same signature, so a
3984 - * callback registered once covers every indexing route. Purely additive —
3985 - * zero behaviour change when unhooked.
3986 - *
3987 - * @param WP_Post $post The post about to be indexed.
3988 - * @param string $bot_id Bot context for this import.
3989 - */
3990 - $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
3991 - if (!($post instanceof WP_Post)) {
3992 - $post = get_post($post_id); // defend against a bad callback return
3993 - }
3994 -
3995 - // Get content including title, short description (for WooCommerce), and main content
3996 - $content = $post->post_title . "\n\n";
3997 -
3998 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3999 - // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
4000 - // empty, and testing the raw value emitted a bare "Short Description: " label
4001 - // with no value after it. Matches mxchat_index_published_post.
4002 - // trim() only in the TEST — the emitted value is untouched, so a populated
4003 - // excerpt is byte-identical to before. A whitespace-only excerpt is an empty
4004 - // excerpt and must not produce a labelled line with nothing after it.
4005 - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
4006 - if (trim($clean_excerpt) !== '') {
4007 - $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
4008 - }
4009 -
4010 - // Add main content - remove shortcode tags but preserve content inside them
4011 - $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
4012 - $content .= wp_strip_all_tags($clean_content);
4013 -
4014 - // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
4015 - if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
4016 - $product = wc_get_product($post_id);
4017 -
4018 - if ($product) {
4019 - // Get pricing information
4020 - $regular_price = $product->get_regular_price();
4021 - $sale_price = $product->get_sale_price();
4022 - $price = $product->get_price();
4023 - $sku = $product->get_sku();
4024 -
4025 - // Get currency symbol
4026 - $currency_symbol = get_woocommerce_currency_symbol();
4027 -
4028 - // Add pricing information
4029 - $content .= "\n";
4030 - if (!empty($regular_price)) {
4031 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
4032 - } elseif (!empty($price)) {
4033 - $content .= "Price: " . $currency_symbol . $price . "\n";
4034 - }
4035 -
4036 - if (!empty($sale_price) && $sale_price !== $regular_price) {
4037 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
4038 - }
4039 -
4040 - // Handle variable products - show price range
4041 - if ($product->is_type('variable')) {
4042 - $min_price = $product->get_variation_price('min');
4043 - $max_price = $product->get_variation_price('max');
4044 - if ($min_price !== $max_price) {
4045 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
4046 - }
4047 - }
4048 -
4049 - if (!empty($sku)) {
4050 - $content .= "SKU: " . $sku . "\n";
4051 - }
4052 -
4053 - // Get product categories
4054 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
4055 - if (!empty($categories) && !is_wp_error($categories)) {
4056 - $content .= "Categories: " . implode(', ', $categories) . "\n";
4057 - }
4058 - }
4059 -
4060 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
4061 - $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
4062 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
4063 - foreach ($custom_tabs as $tab) {
4064 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
4065 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
4066 -
4067 - if (!empty($tab_title) && !empty($tab_content)) {
4068 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
4069 - }
4070 - }
4071 - }
4072 -
4073 - // Also check for reusable/saved tabs applied to this product
4074 - $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
4075 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
4076 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
4077 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
4078 - foreach ($applied_saved_tabs as $saved_tab_id) {
4079 - if (isset($saved_tabs[$saved_tab_id])) {
4080 - $tab = $saved_tabs[$saved_tab_id];
4081 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
4082 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
4083 -
4084 - if (!empty($tab_title) && !empty($tab_content)) {
4085 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
4086 - }
4087 - }
4088 - }
4089 - }
4090 - }
4091 - }
4092 -
4093 - // For custom post types like job_listing, include additional fields
4094 - // (verbatim parity with mxchat_index_published_post — a bulk import used to
4095 - // index the body alone, losing location/type/company that auto-sync captured)
4096 - if (get_post_type($post_id) === 'job_listing') {
4097 - // Add job-specific meta if available
4098 - $job_location = get_post_meta($post_id, '_job_location', true);
4099 - if (!empty($job_location)) {
4100 - $content .= "\n\nLocation: " . $job_location;
4101 - }
4102 -
4103 - // Get job type terms
4104 - $job_types = get_the_terms($post_id, 'job_listing_type');
4105 - if (!empty($job_types) && !is_wp_error($job_types)) {
4106 - $types = array();
4107 - foreach ($job_types as $type) {
4108 - $types[] = $type->name;
4109 - }
4110 - $content .= "\n\nJob Type: " . implode(', ', $types);
4111 - }
4112 -
4113 - // Get company name if available
4114 - $company_name = get_post_meta($post_id, '_company_name', true);
4115 - if (!empty($company_name)) {
4116 - $content .= "\n\nCompany: " . $company_name;
4117 - }
4118 - }
4119 -
4120 - // ADD ACF FIELDS SUPPORT
4121 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4122 - $pdf_extracted_count = 0;
4123 - if (!empty($acf_fields)) {
4124 - $acf_content_parts = array();
4125 - $pdf_attachment_ids = array();
4126 -
4127 - foreach ($acf_fields as $field_name => $field_value) {
4128 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
4129 -
4130 - if (!empty($formatted_value)) {
4131 - // Both separators: a hyphenated ACF name should read as words, and
4132 - // this is what mxchat_index_published_post already does.
4133 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
4134 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
4135 - }
4136 -
4137 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
4138 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
4139 - // still lands in the KB but the heavier PDF parsing is skipped.
4140 - if ($extract_acf_pdfs) {
4141 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
4142 - }
4143 - }
4144 -
4145 - if (!empty($acf_content_parts)) {
4146 - $content .= "\n\n" . implode("\n", $acf_content_parts);
4147 - }
4148 -
4149 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
4150 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
4151 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
4152 - $pdf_sections = array();
4153 - foreach ($pdf_attachment_ids as $att_id) {
4154 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
4155 - if (!empty($pdf_text)) {
4156 - $pdf_title = get_the_title($att_id);
4157 - $pdf_url = wp_get_attachment_url($att_id);
4158 - $header = 'PDF Attachment';
4159 - if (!empty($pdf_title)) {
4160 - $header .= ': ' . $pdf_title;
4161 - }
4162 - if (!empty($pdf_url)) {
4163 - $header .= ' (' . $pdf_url . ')';
4164 - }
4165 - $pdf_sections[] = $header . "\n" . $pdf_text;
4166 - $pdf_extracted_count++;
4167 - }
4168 - }
4169 - if (!empty($pdf_sections)) {
4170 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
4171 - }
4172 - }
4173 - }
4174 -
4175 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
4176 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
4177 - if (!empty($custom_meta)) {
4178 - $meta_content_parts = array();
4179 -
4180 - foreach ($custom_meta as $meta_key => $meta_value) {
4181 - // Convert meta key to readable label
4182 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
4183 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
4184 - }
4185 -
4186 - if (!empty($meta_content_parts)) {
4187 - $content .= "\n\n" . implode("\n", $meta_content_parts);
4188 - }
4189 - }
4190 -
4191 - // Debug logging for WordPress Import content
4192 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4193 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4194 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
4195 - //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4196 -
4197 - // Note: Removed 10,000 char limit - chunking now handles large content properly
4198 -
4199 - // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
4200 - $bot_options = $this->get_bot_options($bot_id);
4201 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4202 -
4203 - $preflight = MxChat_Utils::embedding_preflight($options);
4204 - if (!$preflight['ok']) {
4205 - MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4206 - wp_send_json_error($preflight['reason']);
4207 - exit;
4208 - }
4209 - $api_key = $preflight['api_key'];
4210 -
4211 - $source_url = get_permalink($post_id);
4212 - $vector_id = md5($source_url); // Vector ID for Pinecone
4213 -
4214 - // Check for existing content in bot-specific storage
4215 - $is_update = false;
4216 -
4217 - // Get bot-specific Pinecone configuration
4218 - $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4219 - $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
4220 -
4221 - if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
4222 - // Check Pinecone for this bot
4223 - $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
4224 - if (isset($pinecone_data[$post_id])) {
4225 - $is_update = true;
4226 - }
4227 - } else {
4228 - // Check WordPress DB (same as before since it's shared)
4229 - global $wpdb;
4230 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4231 - $existing_record = $wpdb->get_row($wpdb->prepare(
4232 - "SELECT id FROM $table_name WHERE source_url = %s",
4233 - $source_url
4234 - ));
4235 -
4236 - if ($existing_record) {
4237 - $is_update = true;
4238 - }
4239 - }
4240 -
4241 - // UPDATED 2.5.6: Determine content type based on post_type
4242 - $post_type = $post->post_type;
4243 - $content_type = 'content'; // Default fallback
4244 -
4245 - // Map WordPress post types to content types
4246 - switch ($post_type) {
4247 - case 'post':
4248 - $content_type = 'post';
4249 - break;
4250 - case 'page':
4251 - $content_type = 'page';
4252 - break;
4253 - case 'product':
4254 - $content_type = 'product';
4255 - break;
4256 - default:
4257 - // For custom post types, use the post type name
4258 - $content_type = sanitize_key($post_type);
4259 - break;
4260 - }
4261 -
4262 - // Use the centralized utility function with bot_id and content_type
4263 - $result = MxChat_Utils::submit_content_to_db(
4264 - $content,
4265 - $source_url,
4266 - $api_key,
4267 - $vector_id,
4268 - $bot_id,
4269 - $content_type
4270 - );
4271 -
4272 - if (is_wp_error($result)) {
4273 - MxChat_Admin::mxchat_log_debug('storage_error', 'Knowledge storage failed: ' . $result->get_error_message(), array('source_url' => $source_url));
4274 - wp_send_json_error('Storage failed: ' . $result->get_error_message());
4275 - exit;
4276 - }
4277 -
4278 - // Automatically apply role restriction based on tags
4279 - $this->apply_role_restriction_to_post($post_id, $source_url);
4280 -
4281 - $operation_type = $is_update ? 'update' : 'new';
4282 -
4283 - // Count ACF fields for debugging
4284 - $acf_field_count = count($acf_fields);
4285 -
4286 - // Success response with minimal data
4287 - wp_send_json_success(array(
4288 - 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
4289 - 'post_id' => $post_id,
4290 - 'title' => $post->post_title,
4291 - 'operation_type' => $operation_type,
4292 - 'vector_id' => $vector_id,
4293 - 'acf_fields_found' => $acf_field_count,
4294 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4295 - 'content_preview' => substr($content, 0, 100) . '...',
4296 - 'bot_id' => $bot_id
4297 - ));
4298 - exit;
4299 -}
4300 -
4301 -private function apply_role_restriction_to_post($post_id, $source_url) {
4302 - // Get tag-role mappings
4303 - $mappings = get_option('mxchat_tag_role_mappings', array());
4304 -
4305 - if (empty($mappings)) {
4306 - return; // No mappings, leave as public
4307 - }
4308 -
4309 - // Get all tags for the post
4310 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
4311 -
4312 - if (empty($post_tags)) {
4313 - return; // No tags, leave as public
4314 - }
4315 -
4316 - // Determine the highest role restriction based on tags
4317 - $highest_role = 'public';
4318 - $role_hierarchy = array(
4319 - 'public' => 0,
4320 - 'logged_in' => 1,
4321 - 'subscriber' => 2,
4322 - 'contributor' => 3,
4323 - 'author' => 4,
4324 - 'editor' => 5,
4325 - 'administrator' => 6
4326 - );
4327 -
4328 - foreach ($post_tags as $tag_slug) {
4329 - if (isset($mappings[$tag_slug])) {
4330 - $role = $mappings[$tag_slug];
4331 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
4332 - $highest_role = $role;
4333 - }
4334 - }
4335 - }
4336 -
4337 - // If no restricted tags found, return (leave as public)
4338 - if ($highest_role === 'public') {
4339 - return;
4340 - }
4341 -
4342 - // Update the role restriction in the database
4343 - global $wpdb;
4344 -
4345 - // Check if using Pinecone
4346 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4347 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4348 -
4349 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4350 - // Update Pinecone role restriction
4351 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
4352 - $vector_id = md5($source_url);
4353 -
4354 - $wpdb->replace(
4355 - $roles_table,
4356 - array(
4357 - 'vector_id' => $vector_id,
4358 - 'role_restriction' => $highest_role,
4359 - 'updated_at' => current_time('mysql')
4360 - ),
4361 - array('%s', '%s', '%s')
4362 - );
4363 - } else {
4364 - // Update WordPress DB
4365 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4366 -
4367 - $wpdb->update(
4368 - $table_name,
4369 - array('role_restriction' => $highest_role),
4370 - array('source_url' => $source_url),
4371 - array('%s'),
4372 - array('%s')
4373 - );
4374 - }
4375 -}
4376 -
4377 -public function mxchat_get_public_post_types() {
4378 - // Get all public post types
4379 - $post_types = get_post_types(array('public' => true), 'objects');
4380 - $post_type_options = array();
4381 -
4382 - foreach ($post_types as $post_type) {
4383 - $post_type_options[$post_type->name] = $post_type->label;
4384 - }
4385 -
4386 - // Also include common forum/community post types that might not be marked as public
4387 - $additional_types = array(
4388 - 'topic' => 'Forum Topics (bbPress)',
4389 - 'reply' => 'Forum Replies (bbPress)',
4390 - 'forum' => 'Forums (bbPress)',
4391 - 'wpforo_topic' => 'wpForo Topics',
4392 - 'wpforo_post' => 'wpForo Posts'
4393 - );
4394 -
4395 - foreach ($additional_types as $type_name => $type_label) {
4396 - if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
4397 - $post_type_options[$type_name] = $type_label;
4398 - }
4399 - }
4400 -
4401 - return $post_type_options;
4402 -}
4403 -
4404 -/**
4405 - * Retrieves processed content from Pinecone API
4406 - */
4407 -public function mxchat_get_pinecone_processed_content($pinecone_options) {
4408 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4409 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4410 -
4411 - if (empty($api_key) || empty($host)) {
4412 - return array();
4413 - }
4414 -
4415 - $pinecone_data = array();
4416 -
4417 - try {
4418 - // Always get fresh data from Pinecone
4419 - $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
4420 -
4421 - // Method 2: Final fallback - try stats endpoint (if available)
4422 - if (empty($pinecone_data)) {
4423 - $stats_url = "https://{$host}/describe_index_stats";
4424 -
4425 - $response = wp_remote_post($stats_url, array(
4426 - 'headers' => array(
4427 - 'Api-Key' => $api_key,
4428 - 'Content-Type' => 'application/json'
4429 - ),
4430 - 'body' => json_encode(array()),
4431 - 'timeout' => 30
4432 - ));
4433 -
4434 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
4435 - $body = wp_remote_retrieve_body($response);
4436 - $stats_data = json_decode($body, true);
4437 - }
4438 - }
4439 -
4440 - } catch (Exception $e) {
4441 - // Log error but return fresh data only
4442 - }
4443 -
4444 - return $pinecone_data;
4445 -}
4446 -public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
4447 - //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
4448 -
4449 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4450 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4451 -
4452 - if (empty($api_key) || empty($host) || empty($vector_ids)) {
4453 - //error_log('DEBUG: Missing parameters for fetch by IDs');
4454 - return array();
4455 - }
4456 -
4457 - try {
4458 - $fetch_url = "https://{$host}/vectors/fetch";
4459 - //error_log('DEBUG: Fetch URL: ' . $fetch_url);
4460 - //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
4461 -
4462 - // Pinecone fetch API allows fetching specific vectors by ID
4463 - $fetch_data = array(
4464 - 'ids' => array_values($vector_ids)
4465 - );
4466 -
4467 - $response = wp_remote_post($fetch_url, array(
4468 - 'headers' => array(
4469 - 'Api-Key' => $api_key,
4470 - 'Content-Type' => 'application/json'
4471 - ),
4472 - 'body' => json_encode($fetch_data),
4473 - 'timeout' => 30
4474 - ));
4475 -
4476 - if (is_wp_error($response)) {
4477 - //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
4478 - return array();
4479 - }
4480 -
4481 - $response_code = wp_remote_retrieve_response_code($response);
4482 - //error_log('DEBUG: Fetch response code: ' . $response_code);
4483 -
4484 - if ($response_code !== 200) {
4485 - $error_body = wp_remote_retrieve_body($response);
4486 - //error_log('DEBUG: Fetch failed with body: ' . $error_body);
4487 - return array();
4488 - }
4489 -
4490 - $body = wp_remote_retrieve_body($response);
4491 - $data = json_decode($body, true);
4492 -
4493 - //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
4494 -
4495 - if (!isset($data['vectors'])) {
4496 - //error_log('DEBUG: No vectors key in response');
4497 - return array();
4498 - }
4499 -
4500 - $processed_data = array();
4501 -
4502 - foreach ($data['vectors'] as $vector_id => $vector_data) {
4503 - $metadata = $vector_data['metadata'] ?? array();
4504 - $source_url = $metadata['source_url'] ?? '';
4505 -
4506 - if (!empty($source_url)) {
4507 - $post_id = url_to_postid($source_url);
4508 - if ($post_id) {
4509 - $created_at = $metadata['created_at'] ?? '';
4510 - $processed_date = 'Recently';
4511 -
4512 - if (!empty($created_at)) {
4513 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4514 - if ($timestamp) {
4515 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4516 - }
4517 - }
4518 -
4519 - $processed_data[$post_id] = array(
4520 - 'db_id' => $vector_id,
4521 - 'processed_date' => $processed_date,
4522 - 'url' => $source_url,
4523 - 'source' => 'pinecone',
4524 - 'timestamp' => $timestamp ?? current_time('timestamp')
4525 - );
4526 - }
4527 - }
4528 - }
4529 -
4530 - //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
4531 - return $processed_data;
4532 -
4533 - } catch (Exception $e) {
4534 - //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
4535 - return array();
4536 - }
4537 -}
4538 -
4539 -/**
4540 - * Get embedding dimensions based on the selected model.
4541 - */
4542 -private function mxchat_get_embedding_dimensions() {
4543 - $options = get_option('mxchat_options', array());
4544 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4545 -
4546 - $model_dimensions = array(
4547 - 'text-embedding-ada-002' => 1536,
4548 - 'text-embedding-3-small' => 1536,
4549 - 'text-embedding-3-large' => 3072,
4550 - 'voyage-2' => 1024,
4551 - 'voyage-large-2' => 1536,
4552 - 'voyage-3-large' => 2048,
4553 - 'gemini-embedding-001' => 1536,
4554 - );
4555 -
4556 - if (strpos($selected_model, 'voyage-3-large') === 0) {
4557 - $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
4558 - return intval($custom_dimensions);
4559 - }
4560 -
4561 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4562 - $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
4563 - return intval($custom_dimensions);
4564 - }
4565 -
4566 - return $model_dimensions[$selected_model] ?? 1536;
4567 -}
4568 -
4569 -/**
4570 - * Scan Pinecone for processed content
4571 - */
4572 -public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4573 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4574 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4575 -
4576 - if (empty($api_key) || empty($host)) {
4577 - return array();
4578 - }
4579 -
4580 - try {
4581 - // Use multiple random vectors to get better coverage
4582 - $all_matches = array();
4583 - $seen_ids = array();
4584 -
4585 - // Get correct dimensions for the configured embedding model
4586 - $dimensions = $this->mxchat_get_embedding_dimensions();
4587 -
4588 - // Try 3 different random vectors to get better coverage
4589 - for ($i = 0; $i < 3; $i++) {
4590 - $query_url = "https://{$host}/query";
4591 -
4592 - // Generate a random unit vector instead of zeros
4593 - $random_vector = array();
4594 - for ($j = 0; $j < $dimensions; $j++) {
4595 - $random_vector[] = (rand(-1000, 1000) / 1000.0);
4596 - }
4597 -
4598 - // Normalize the vector to unit length
4599 - $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
4600 - if ($magnitude > 0) {
4601 - $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
4602 - }
4603 -
4604 - $query_data = array(
4605 - 'includeMetadata' => true,
4606 - 'includeValues' => false,
4607 - 'topK' => 10000,
4608 - 'vector' => $random_vector
4609 - );
4610 -
4611 - $response = wp_remote_post($query_url, array(
4612 - 'headers' => array(
4613 - 'Api-Key' => $api_key,
4614 - 'Content-Type' => 'application/json'
4615 - ),
4616 - 'body' => json_encode($query_data),
4617 - 'timeout' => 30
4618 - ));
4619 -
4620 - if (is_wp_error($response)) {
4621 - continue;
4622 - }
4623 -
4624 - $response_code = wp_remote_retrieve_response_code($response);
4625 -
4626 - if ($response_code !== 200) {
4627 - continue;
4628 - }
4629 -
4630 - $body = wp_remote_retrieve_body($response);
4631 - $data = json_decode($body, true);
4632 -
4633 - if (isset($data['matches'])) {
4634 - foreach ($data['matches'] as $match) {
4635 - $match_id = $match['id'] ?? '';
4636 - if (!empty($match_id) && !isset($seen_ids[$match_id])) {
4637 - $all_matches[] = $match;
4638 - $seen_ids[$match_id] = true;
4639 - }
4640 - }
4641 - }
4642 - }
4643 -
4644 - // Convert matches to processed data format, grouping by URL to count chunks
4645 - $processed_data = array();
4646 - $url_chunk_counts = array();
4647 -
4648 - foreach ($all_matches as $match) {
4649 - $metadata = $match['metadata'] ?? array();
4650 - $source_url = $metadata['source_url'] ?? '';
4651 - $match_id = $match['id'] ?? '';
4652 -
4653 - if (!empty($source_url) && !empty($match_id)) {
4654 - $post_id = url_to_postid($source_url);
4655 - if ($post_id) {
4656 - // Count chunks per post_id
4657 - if (!isset($url_chunk_counts[$post_id])) {
4658 - $url_chunk_counts[$post_id] = 0;
4659 - }
4660 - $url_chunk_counts[$post_id]++;
4661 -
4662 - $created_at = $metadata['created_at'] ?? '';
4663 - $processed_date = 'Recently';
4664 -
4665 - if (!empty($created_at)) {
4666 - $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
4667 - if ($timestamp) {
4668 - $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
4669 - }
4670 - }
4671 -
4672 - // Only store if not already set, or update with newer timestamp
4673 - if (!isset($processed_data[$post_id]) ||
4674 - ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
4675 - $processed_data[$post_id] = array(
4676 - 'db_id' => $match_id,
4677 - 'processed_date' => $processed_date,
4678 - 'url' => $source_url,
4679 - 'source' => 'pinecone',
4680 - 'timestamp' => $timestamp ?? current_time('timestamp')
4681 - );
4682 - }
4683 - }
4684 - }
4685 - }
4686 -
4687 - // Add chunk counts to processed data
4688 - foreach ($url_chunk_counts as $post_id => $chunk_count) {
4689 - if (isset($processed_data[$post_id])) {
4690 - $processed_data[$post_id]['chunk_count'] = $chunk_count;
4691 - }
4692 - }
4693 -
4694 - return $processed_data;
4695 -
4696 - } catch (Exception $e) {
4697 - return array();
4698 - }
4699 -}
4700 -/**
4701 - * Generate embeddings from input text for MXChat with bot support
4702 - */
4703 -private function mxchat_generate_embedding($text, $bot_id = 'default') {
4704 - // Enable detailed logging for debugging
4705 - //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
4706 - //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
4707 -
4708 - // Get bot-specific options
4709 - $bot_options = $this->get_bot_options($bot_id);
4710 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4711 -
4712 - // Opt-in: when the custom provider is selected for embeddings, index through
4713 - // the same custom endpoint the query path uses so stored vectors and query
4714 - // vectors share a model. Returns the vector array on success, or an error
4715 - // string on failure (this function's existing failure contract).
4716 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4717 - if (!class_exists('MxChat_Utils')) {
4718 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4719 - }
4720 - return MxChat_Utils::generate_embedding_custom($text, $options);
4721 - }
4722 -
4723 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4724 - //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4725 -
4726 - // Determine provider and endpoint
4727 - if (strpos($selected_model, 'voyage') === 0) {
4728 - $api_key = $options['voyage_api_key'] ?? '';
4729 - $endpoint = 'https://api.voyageai.com/v1/embeddings';
4730 - $provider_name = 'Voyage AI';
4731 - //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
4732 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4733 - $api_key = $options['gemini_api_key'] ?? '';
4734 - $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4735 - $provider_name = 'Google Gemini';
4736 - //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
4737 - } else {
4738 - $api_key = $options['api_key'] ?? '';
4739 - $endpoint = 'https://api.openai.com/v1/embeddings';
4740 - $provider_name = 'OpenAI';
4741 - //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
4742 - }
4743 -
4744 - //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
4745 -
4746 - if (empty($api_key)) {
4747 - $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
4748 - //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
4749 - return $error_message;
4750 - }
4751 -
4752 - // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
4753 - $estimated_tokens = ceil(str_word_count($text) / 0.75);
4754 - //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
4755 -
4756 - if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
4757 - //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
4758 - // Consider truncating text here
4759 - }
4760 -
4761 - // Prepare request body based on provider
4762 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4763 - // Gemini API format
4764 - $request_body = array(
4765 - 'model' => 'models/' . $selected_model,
4766 - 'content' => array(
4767 - 'parts' => array(
4768 - array('text' => $text)
4769 - )
4770 - )
4771 - );
4772 -
4773 - // Set output dimensionality to 1536 for consistency with other models
4774 - $request_body['outputDimensionality'] = 1536;
4775 - } else {
4776 - // OpenAI/Voyage API format
4777 - $request_body = array(
4778 - 'model' => $selected_model,
4779 - 'input' => $text
4780 - );
4781 -
4782 - // Add output_dimension for voyage-3-large model
4783 - if ($selected_model === 'voyage-3-large') {
4784 - $request_body['output_dimension'] = 2048;
4785 - }
4786 - }
4787 -
4788 - //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
4789 -
4790 - // Prepare headers based on provider
4791 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4792 - // Gemini uses API key as query parameter
4793 - $endpoint .= '?key=' . $api_key;
4794 - $headers = array(
4795 - 'Content-Type' => 'application/json'
4796 - );
4797 - } else {
4798 - // OpenAI/Voyage use Bearer token
4799 - $headers = array(
4800 - 'Authorization' => 'Bearer ' . $api_key,
4801 - 'Content-Type' => 'application/json'
4802 - );
4803 - }
4804 -
4805 - // Make API request
4806 - //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
4807 - $response = wp_remote_post($endpoint, array(
4808 - 'body' => wp_json_encode($request_body),
4809 - 'headers' => $headers,
4810 - 'timeout' => 60 // Increased timeout for large inputs
4811 - ));
4812 -
4813 - // Handle wp_remote_post errors
4814 - if (is_wp_error($response)) {
4815 - $error_message = $response->get_error_message();
4816 - //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
4817 - return 'Connection error: ' . $error_message;
4818 - }
4819 -
4820 - // Get and check HTTP response code
4821 - $http_code = wp_remote_retrieve_response_code($response);
4822 - //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
4823 -
4824 - if ($http_code !== 200) {
4825 - $error_body = wp_remote_retrieve_body($response);
4826 - //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
4827 -
4828 - // Try to parse error for more details
4829 - $error_json = json_decode($error_body, true);
4830 - if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
4831 - $error_type = $error_json['error']['type'] ?? 'unknown';
4832 - $error_message = $error_json['error']['message'] ?? 'No message';
4833 - //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
4834 - //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
4835 -
4836 - // Keep the provider's own diagnostic — a restricted-key 401 names the
4837 - // exact missing scope, and replacing it with "check your API key" sent
4838 - // a customer to regenerate two keys (plan 46b596). Same shape as
4839 - // MxChat_Utils::embedding_failure_error() so both ingestion paths read
4840 - // identically. Key never appears in provider messages, but scrub anyway.
4841 - if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
4842 - if (is_string($api_key) && $api_key !== '') {
4843 - $error_message = str_replace($api_key, '[redacted]', $error_message);
4844 - }
4845 - $error_message = sprintf(
4846 - 'Embedding failed (%s, HTTP %d): %s',
4847 - $selected_model,
4848 - $http_code,
4849 - substr($error_message, 0, 300)
4850 - );
4851 - }
4852 -
4853 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4854 - return $error_message;
4855 - }
4856 -
4857 - $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
4858 - //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4859 - return $error_message;
4860 - }
4861 -
4862 - // Parse response body
4863 - $response_body = wp_remote_retrieve_body($response);
4864 - //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
4865 -
4866 - $response_data = json_decode($response_body, true);
4867 -
4868 - if (json_last_error() !== JSON_ERROR_NONE) {
4869 - $error = json_last_error_msg();
4870 - //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
4871 - //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
4872 - return "Failed to parse API response: $error";
4873 - }
4874 -
4875 - // Handle different response formats based on provider
4876 - if (strpos($selected_model, 'gemini-embedding') === 0) {
4877 - // Gemini API response format
4878 - if (isset($response_data['embedding']['values'])) {
4879 - $embedding_dimensions = count($response_data['embedding']['values']);
4880 - //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
4881 -
4882 - // Check if embedding dimensions are as expected (should be 1536)
4883 - if ($embedding_dimensions !== 1536) {
4884 - //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
4885 - }
4886 -
4887 - return $response_data['embedding']['values'];
4888 - } else {
4889 - //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
4890 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4891 -
4892 - if (isset($response_data['error'])) {
4893 - $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
4894 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4895 - return $error_message;
4896 - }
4897 -
4898 - $error_message = "Invalid Gemini API response format: No embedding found";
4899 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4900 - return $error_message;
4901 - }
4902 - } else {
4903 - // OpenAI/Voyage API response format
4904 - if (isset($response_data['data'][0]['embedding'])) {
4905 - $embedding_dimensions = count($response_data['data'][0]['embedding']);
4906 - //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
4907 -
4908 - // Check if embedding dimensions are as expected
4909 - if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
4910 - ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
4911 - //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
4912 - }
4913 -
4914 - return $response_data['data'][0]['embedding'];
4915 - } else {
4916 - //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
4917 - //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
4918 -
4919 - if (isset($response_data['error'])) {
4920 - $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
4921 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4922 - return $error_message;
4923 - }
4924 -
4925 - $error_message = "Invalid API response format: No embedding found";
4926 - //error_log('[MXCHAT-EMBED] ' . $error_message);
4927 - return $error_message;
4928 - }
4929 - }
4930 -}
4931 -
4932 -/**
4933 - * Get bot-specific options for multi-bot functionality
4934 - * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
4935 - */
4936 -private function get_bot_options($bot_id = 'default') {
4937 - //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
4938 -
4939 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4940 - //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
4941 - return array();
4942 - }
4943 -
4944 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
4945 -
4946 - if (!empty($bot_options)) {
4947 - //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
4948 - if (isset($bot_options['similarity_threshold'])) {
4949 - //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
4950 - }
4951 - }
4952 -
4953 - return is_array($bot_options) ? $bot_options : array();
4954 -}
4955 -
4956 -/**
4957 - * Get bot-specific Pinecone configuration
4958 - * Used in the knowledge retrieval functions
4959 - */
4960 -// Also add debugging to your get_bot_pinecone_config function
4961 -private function get_bot_pinecone_config($bot_id = 'default') {
4962 - //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4963 -
4964 - // If default bot or multi-bot add-on not active, use default Pinecone config
4965 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4966 - //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4967 - $addon_options = get_option('mxchat_pinecone_addon_options', array());
4968 - $config = array(
4969 - 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4970 - 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4971 - 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4972 - 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4973 - );
4974 - //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4975 - return $config;
4976 - }
4977 -
4978 - //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4979 -
4980 - // Hook for multi-bot add-on to provide bot-specific Pinecone config
4981 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4982 -
4983 - if (!empty($bot_pinecone_config)) {
4984 - //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4985 - //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4986 - //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4987 - //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4988 - } else {
4989 - //error_log("MXCHAT DEBUG: Filter returned empty config!");
4990 - }
4991 -
4992 - return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4993 -}
4994 -
4995 -
4996 -public function mxchat_ajax_dismiss_completed_status() {
4997 - try {
4998 - // Verify the request
4999 - check_ajax_referer('mxchat_status_nonce', 'nonce');
5000 -
5001 - if (!current_user_can('manage_options')) {
5002 - wp_send_json_error('Unauthorized access');
5003 - exit;
5004 - }
5005 -
5006 - $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
5007 -
5008 - if ($card_type === 'pdf') {
5009 - // Clear PDF status
5010 - $pdf_url = get_transient('mxchat_last_pdf_url');
5011 - if ($pdf_url) {
5012 - delete_transient('mxchat_pdf_status_' . md5($pdf_url));
5013 - delete_transient('mxchat_last_pdf_url');
5014 - }
5015 - } elseif ($card_type === 'sitemap') {
5016 - // Clear sitemap status
5017 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
5018 - if ($sitemap_url) {
5019 - delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
5020 - delete_transient('mxchat_last_sitemap_url');
5021 - }
5022 - }
5023 -
5024 - wp_send_json_success(array('message' => 'Status dismissed successfully'));
5025 -
5026 - } catch (Exception $e) {
5027 - wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
5028 - }
5029 -}
5030 -
5031 -/**
5032 - * Render completed status cards on page load
5033 - * This ensures completed processing status persists through page refreshes
5034 - */
5035 -public function mxchat_render_completed_status_cards() {
5036 - $output = '';
5037 -
5038 - // Check for completed PDF status
5039 - $pdf_url = get_transient('mxchat_last_pdf_url');
5040 - if ($pdf_url) {
5041 - $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
5042 - if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
5043 - $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
5044 - }
5045 - }
5046 -
5047 - // Check for completed sitemap status
5048 - $sitemap_url = get_transient('mxchat_last_sitemap_url');
5049 - if ($sitemap_url) {
5050 - $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
5051 - if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
5052 - $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
5053 - }
5054 - }
5055 -
5056 - return $output;
5057 -}
5058 -
5059 -/**
5060 - * Render PDF status card HTML
5061 - */
5062 -private function mxchat_render_pdf_status_card($status, $pdf_url) {
5063 - $html = '<div class="mxchat-status-card" data-card-type="pdf">';
5064 - $html .= '<div class="mxchat-status-header">';
5065 - $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
5066 -
5067 - // Add dismiss button for completed status
5068 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
5069 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5070 - }
5071 -
5072 - // Process Batch button for processing status
5073 - if ($status['status'] === 'processing') {
5074 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
5075 - data-process-type="pdf"
5076 - data-url="' . esc_attr($pdf_url) . '">
5077 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5078 - }
5079 -
5080 - // Add status badges
5081 - if ($status['status'] === 'error') {
5082 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5083 - } elseif ($status['status'] === 'complete') {
5084 - if ($status['failed_pages'] > 0) {
5085 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5086 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
5087 - } else {
5088 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5089 - }
5090 - }
5091 -
5092 - $html .= '</div>'; // End header
5093 -
5094 - // Progress bar
5095 - $html .= '<div class="mxchat-progress-bar">';
5096 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5097 - $html .= '</div>';
5098 -
5099 - // Status details
5100 - $html .= '<div class="mxchat-status-details">';
5101 - $html .= '<p>' . sprintf(
5102 - esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
5103 - $status['processed_pages'],
5104 - $status['total_pages'],
5105 - $status['percentage']
5106 - ) . '</p>';
5107 -
5108 - // Show failed pages count if any
5109 - if ($status['failed_pages'] > 0) {
5110 - $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
5111 - }
5112 -
5113 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5114 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5115 -
5116 - // Add completion summary if available AND it's an array
5117 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5118 - $summary = $status['completion_summary'];
5119 - $html .= '<div class="mxchat-completion-summary">';
5120 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5121 - $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
5122 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
5123 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
5124 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5125 - $html .= '</div>';
5126 - }
5127 -
5128 - // Add failed pages list if any AND it's an array
5129 - if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
5130 - $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
5131 - }
5132 -
5133 - // Add error message if any
5134 - if (isset($status['error']) && !empty($status['error'])) {
5135 - $html .= '<div class="mxchat-error-notice">';
5136 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5137 - $html .= '</div>';
5138 - }
5139 -
5140 - $html .= '</div>'; // End details
5141 - $html .= '</div>'; // End card
5142 -
5143 - return $html;
5144 -}
5145 -/**
5146 - * Render sitemap status card HTML
5147 - */
5148 -private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
5149 - $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
5150 - $html .= '<div class="mxchat-status-header">';
5151 - $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
5152 -
5153 - // Add dismiss button for completed status
5154 - if ($status['status'] === 'complete' || $status['status'] === 'error') {
5155 - $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
5156 - }
5157 -
5158 - // Process Batch button for processing status
5159 - if ($status['status'] === 'processing') {
5160 - $html .= '<button type="button" class="mxchat-manual-batch-btn"
5161 - data-process-type="sitemap"
5162 - data-url="' . esc_attr($sitemap_url) . '">
5163 - ' . esc_html__('Process Batch', 'mxchat') . '</button>';
5164 - }
5165 -
5166 - // Add status badges
5167 - if ($status['status'] === 'error') {
5168 - $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
5169 - } elseif ($status['status'] === 'complete') {
5170 - if ($status['failed_urls'] > 0) {
5171 - $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
5172 - sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
5173 - } else {
5174 - $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
5175 - }
5176 - }
5177 -
5178 - $html .= '</div>'; // End header
5179 -
5180 - // Progress bar
5181 - $html .= '<div class="mxchat-progress-bar">';
5182 - $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
5183 - $html .= '</div>';
5184 -
5185 - // Status details
5186 - $html .= '<div class="mxchat-status-details">';
5187 - $html .= '<p>' . sprintf(
5188 - esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
5189 - $status['processed_urls'],
5190 - $status['total_urls'],
5191 - $status['percentage']
5192 - ) . '</p>';
5193 -
5194 - // Show failed URLs count if any
5195 - if ($status['failed_urls'] > 0) {
5196 - $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
5197 - }
5198 -
5199 - $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
5200 - $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
5201 -
5202 - // Add completion summary if available AND it's an array
5203 - if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
5204 - $summary = $status['completion_summary'];
5205 - $html .= '<div class="mxchat-completion-summary">';
5206 - $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
5207 - $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
5208 - $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
5209 - $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
5210 - $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
5211 - $html .= '</div>';
5212 - }
5213 -
5214 - // Add error messages if any (but not the failed URLs list)
5215 - if (!empty($status['error']) || !empty($status['last_error'])) {
5216 - $html .= '<div class="mxchat-error-notice">';
5217 -
5218 - if (!empty($status['error'])) {
5219 - $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
5220 - }
5221 -
5222 - if (!empty($status['last_error'])) {
5223 - $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
5224 - }
5225 -
5226 - $html .= '</div>';
5227 - }
5228 -
5229 - $html .= '</div>'; // End details
5230 - $html .= '</div>'; // End card
5231 -
5232 - return $html;
5233 -}
5234 -
5235 -
5236 -/**
5237 - * Render failed pages list
5238 - */
5239 -private function mxchat_render_failed_pages_list($failed_pages_list) {
5240 - // Validate that $failed_pages_list is an array and not empty
5241 - if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
5242 - return '';
5243 - }
5244 -
5245 - $html = '<div class="mxchat-error-notice">';
5246 - $html .= '<div class="mxchat-failed-pages-container">';
5247 - $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
5248 - $html .= '<details>';
5249 - $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
5250 - $html .= '<div class="mxchat-failed-pages-list">';
5251 -
5252 - // Create table for failed pages
5253 - $html .= '<table class="widefat striped">';
5254 - $html .= '<thead><tr>';
5255 - $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
5256 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5257 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5258 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5259 - $html .= '</tr></thead><tbody>';
5260 -
5261 - // Sort failed pages by most recent
5262 - $sorted_failed_pages = $failed_pages_list;
5263 - usort($sorted_failed_pages, function($a, $b) {
5264 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5265 - });
5266 -
5267 - foreach ($sorted_failed_pages as $item) {
5268 - // Ensure $item is an array before accessing its elements
5269 - if (!is_array($item)) {
5270 - continue;
5271 - }
5272 -
5273 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5274 - $html .= '<tr>';
5275 - $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
5276 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5277 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5278 - $html .= '<td>' . esc_html($time_ago) . '</td>';
5279 - $html .= '</tr>';
5280 - }
5281 -
5282 - $html .= '</tbody></table>';
5283 - $html .= '</div></details></div></div>';
5284 -
5285 - return $html;
5286 -}
5287 -
5288 -/**
5289 - * Render failed URLs list
5290 - */
5291 -private function mxchat_render_failed_urls_list($failed_urls_list) {
5292 - // Validate that $failed_urls_list is an array and not empty
5293 - if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
5294 - return '';
5295 - }
5296 -
5297 - $html = '<div class="mxchat-failed-urls-container">';
5298 - $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
5299 - $html .= '<details>';
5300 - $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
5301 - $html .= '<div class="mxchat-failed-urls-list">';
5302 -
5303 - // Create table for failed URLs
5304 - $html .= '<table class="widefat striped">';
5305 - $html .= '<thead><tr>';
5306 - $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
5307 - $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
5308 - $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
5309 - $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
5310 - $html .= '</tr></thead><tbody>';
5311 -
5312 - // Sort failed URLs by most recent
5313 - $sorted_failed_urls = $failed_urls_list;
5314 - usort($sorted_failed_urls, function($a, $b) {
5315 - return ($b['time'] ?? 0) - ($a['time'] ?? 0);
5316 - });
5317 -
5318 - // Show up to 50 failed URLs
5319 - $display_urls = array_slice($sorted_failed_urls, 0, 50);
5320 -
5321 - foreach ($display_urls as $item) {
5322 - // Ensure $item is an array before accessing its elements
5323 - if (!is_array($item)) {
5324 - continue;
5325 - }
5326 -
5327 - $url = $item['url'] ?? '';
5328 - $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
5329 -
5330 - // Truncate URL for display
5331 - $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
5332 -
5333 - $html .= '<tr>';
5334 - $html .= '<td style="word-break: break-all;">';
5335 - if (!empty($url)) {
5336 - $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
5337 - } else {
5338 - $html .= esc_html__('Unknown URL', 'mxchat');
5339 - }
5340 - $html .= '</td>';
5341 - $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
5342 - $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
5343 - $html .= '<td>' . esc_html($time_ago) . '</td>';
5344 - $html .= '</tr>';
5345 - }
5346 -
5347 - $html .= '</tbody></table>';
5348 -
5349 - if (count($failed_urls_list) > 50) {
5350 - $html .= '<div class="mxchat-failed-urls-more">+ ' .
5351 - (count($failed_urls_list) - 50) .
5352 - ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
5353 - }
5354 -
5355 - $html .= '</div></details></div>';
5356 -
5357 - return $html;
5358 -}
5359 -
5360 -/**
5361 - * Get all ACF fields for a specific post, excluding any fields the user has disabled
5362 - */
5363 -public function mxchat_get_acf_fields_for_post($post_id) {
5364 - if (!function_exists('get_fields')) {
5365 - return array();
5366 - }
5367 -
5368 - $fields = get_fields($post_id);
5369 - if (!$fields || !is_array($fields)) {
5370 - return array();
5371 - }
5372 -
5373 - // Get excluded fields from settings
5374 - $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5375 - if (!empty($excluded_fields) && is_array($excluded_fields)) {
5376 - foreach ($excluded_fields as $excluded_field) {
5377 - if (isset($fields[$excluded_field])) {
5378 - unset($fields[$excluded_field]);
5379 - }
5380 - }
5381 - }
5382 -
5383 - return $fields;
5384 -}
5385 -
5386 -/**
5387 - * Get all registered ACF field groups and their fields for the settings UI
5388 - */
5389 -public function mxchat_get_all_acf_fields() {
5390 - if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5391 - return array();
5392 - }
5393 -
5394 - $all_fields = array();
5395 - $field_groups = acf_get_field_groups();
5396 -
5397 - if (!empty($field_groups)) {
5398 - foreach ($field_groups as $group) {
5399 - $group_fields = acf_get_fields($group['key']);
5400 - if (!empty($group_fields)) {
5401 - $all_fields[$group['title']] = array();
5402 - foreach ($group_fields as $field) {
5403 - $all_fields[$group['title']][] = array(
5404 - 'name' => $field['name'],
5405 - 'label' => $field['label'],
5406 - 'type' => $field['type']
5407 - );
5408 - }
5409 - }
5410 - }
5411 - }
5412 -
5413 - return $all_fields;
5414 -}
5415 -
5416 -/**
5417 - * Get whitelisted custom post meta for a given post
5418 - * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
5419 - */
5420 -public function mxchat_get_whitelisted_post_meta($post_id) {
5421 - $whitelist = get_option('mxchat_custom_meta_whitelist', '');
5422 -
5423 - if (empty($whitelist)) {
5424 - return array();
5425 - }
5426 -
5427 - // Parse the whitelist - one meta key per line
5428 - $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
5429 -
5430 - if (empty($meta_keys)) {
5431 - return array();
5432 - }
5433 -
5434 - $result = array();
5435 -
5436 - foreach ($meta_keys as $key) {
5437 - // Skip empty keys
5438 - if (empty($key)) {
5439 - continue;
5440 - }
5441 -
5442 - $value = get_post_meta($post_id, $key, true);
5443 -
5444 - // Only include non-empty string values
5445 - if (!empty($value) && is_string($value)) {
5446 - $result[$key] = $value;
5447 - } elseif (!empty($value) && is_array($value)) {
5448 - // Handle array values by joining them
5449 - $flat_value = $this->mxchat_flatten_meta_array($value);
5450 - if (!empty($flat_value)) {
5451 - $result[$key] = $flat_value;
5452 - }
5453 - }
5454 - }
5455 -
5456 - return $result;
5457 -}
5458 -
5459 -/**
5460 - * Flatten array meta values into a readable string
5461 - */
5462 -private function mxchat_flatten_meta_array($array, $depth = 0) {
5463 - if ($depth > 3) {
5464 - return ''; // Prevent infinite recursion
5465 - }
5466 -
5467 - $parts = array();
5468 -
5469 - foreach ($array as $key => $value) {
5470 - if (is_string($value) && !empty($value)) {
5471 - $parts[] = $value;
5472 - } elseif (is_array($value)) {
5473 - $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
5474 - if (!empty($nested)) {
5475 - $parts[] = $nested;
5476 - }
5477 - }
5478 - }
5479 -
5480 - return implode(', ', $parts);
5481 -}
5482 -
5483 -/**
5484 - * Format ACF field values for content extraction
5485 - */
5486 -public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
5487 - if (empty($value)) {
5488 - return '';
5489 - }
5490 -
5491 - // Handle WP_Post objects first (THIS IS THE KEY FIX)
5492 - if ($value instanceof WP_Post) {
5493 - return $value->post_title ?: '';
5494 - }
5495 -
5496 - // Handle other WP objects
5497 - if (is_object($value)) {
5498 - if (isset($value->post_title)) {
5499 - return $value->post_title;
5500 - } elseif (isset($value->display_name)) {
5501 - return $value->display_name;
5502 - } elseif (isset($value->name)) {
5503 - return $value->name;
5504 - } elseif (method_exists($value, '__toString')) {
5505 - try {
5506 - return (string) $value;
5507 - } catch (Exception $e) {
5508 - return '';
5509 - }
5510 - }
5511 - // For any other objects, return empty string
5512 - return '';
5513 - }
5514 -
5515 - // Handle different ACF field types
5516 - if (is_array($value)) {
5517 - // Check if it's an image/file field
5518 - if (isset($value['url'])) {
5519 - // Image field - return alt text, title, or caption
5520 - if (!empty($value['alt'])) {
5521 - return $value['alt'];
5522 - } elseif (!empty($value['title'])) {
5523 - return $value['title'];
5524 - } elseif (!empty($value['caption'])) {
5525 - return $value['caption'];
5526 - } else {
5527 - return ''; // Don't include just the URL
5528 - }
5529 - }
5530 -
5531 - // Check if it's a post object or relationship field
5532 - if (isset($value['post_title'])) {
5533 - return $value['post_title'];
5534 - }
5535 -
5536 - // Check if it's a user field
5537 - if (isset($value['display_name'])) {
5538 - return $value['display_name'];
5539 - }
5540 -
5541 - // Check if it's a taxonomy term
5542 - if (isset($value['name']) && isset($value['taxonomy'])) {
5543 - return $value['name'];
5544 - }
5545 -
5546 - // Check if it's a select field with label
5547 - if (isset($value['label'])) {
5548 - return $value['label'];
5549 - }
5550 -
5551 - // Check for repeater field or flexible content
5552 - if (is_numeric(key($value))) {
5553 - $sub_values = array();
5554 - foreach ($value as $sub_item) {
5555 - if (is_array($sub_item)) {
5556 - // For repeater/flexible content, extract text values
5557 - $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
5558 - if (!empty($sub_text)) {
5559 - $sub_values[] = $sub_text;
5560 - }
5561 - } elseif ($sub_item instanceof WP_Post) {
5562 - // Handle WP_Post objects in arrays
5563 - $sub_values[] = $sub_item->post_title ?: '';
5564 - } else {
5565 - $sub_values[] = (string) $sub_item;
5566 - }
5567 - }
5568 - return implode(', ', array_filter($sub_values));
5569 - }
5570 -
5571 - // For other arrays, try to extract meaningful text
5572 - $text_values = array();
5573 - foreach ($value as $key => $val) {
5574 - if (is_string($val) && !empty(trim($val))) {
5575 - $text_values[] = trim($val);
5576 - } elseif ($val instanceof WP_Post) {
5577 - // Handle WP_Post objects in associative arrays
5578 - $text_values[] = $val->post_title ?: '';
5579 - } elseif (is_array($val) && isset($val['post_title'])) {
5580 - $text_values[] = $val['post_title'];
5581 - } elseif (is_array($val) && isset($val['name'])) {
5582 - $text_values[] = $val['name'];
5583 - }
5584 - }
5585 -
5586 - return implode(', ', array_filter($text_values));
5587 - }
5588 -
5589 - // Handle boolean values
5590 - if (is_bool($value)) {
5591 - return $value ? 'Yes' : 'No';
5592 - }
5593 -
5594 - // Handle numeric values
5595 - if (is_numeric($value)) {
5596 - return (string) $value;
5597 - }
5598 -
5599 - // Handle string values
5600 - if (is_string($value)) {
5601 - return trim($value);
5602 - }
5603 -
5604 - // For anything else that we can't handle, return empty string
5605 - // This prevents the "Object could not be converted to string" error
5606 - return '';
5607 -}
5608 -
5609 -/**
5610 - * Extract text from complex ACF array structures
5611 - */
5612 -private function mxchat_extract_text_from_acf_array($array) {
5613 - if (!is_array($array)) {
5614 - return '';
5615 - }
5616 -
5617 - $text_parts = array();
5618 -
5619 - foreach ($array as $key => $value) {
5620 - if (is_string($value) && !empty(trim($value))) {
5621 - // Skip keys that are likely to be IDs or technical values
5622 - if (!is_numeric($value) || strlen($value) > 10) {
5623 - $text_parts[] = trim($value);
5624 - }
5625 - } elseif ($value instanceof WP_Post) {
5626 - // Handle WP_Post objects
5627 - $text_parts[] = $value->post_title ?: '';
5628 - } elseif (is_array($value)) {
5629 - if (isset($value['post_title'])) {
5630 - $text_parts[] = $value['post_title'];
5631 - } elseif (isset($value['name'])) {
5632 - $text_parts[] = $value['name'];
5633 - } elseif (isset($value['label'])) {
5634 - $text_parts[] = $value['label'];
5635 - }
5636 - } elseif (is_object($value)) {
5637 - // Handle other objects safely
5638 - if (isset($value->post_title)) {
5639 - $text_parts[] = $value->post_title;
5640 - } elseif (isset($value->name)) {
5641 - $text_parts[] = $value->name;
5642 - } elseif (isset($value->display_name)) {
5643 - $text_parts[] = $value->display_name;
5644 - }
5645 - }
5646 - }
5647 -
5648 - return implode(', ', array_filter($text_parts));
5649 -}
5650 -
5651 -/**
5652 - * Walk an ACF field value tree and collect attachment IDs for any value that
5653 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5654 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5655 - * plain URL string), and recurses through repeater/group/flexible content.
5656 - *
5657 - * @param mixed $value The ACF field value (any depth)
5658 - * @param array $out Accumulator (passed by reference) for attachment IDs
5659 - * @param int $depth Recursion guard
5660 - */
5661 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5662 - if ($depth > 6) {
5663 - return; // prevent runaway recursion on circular/very-deep structures
5664 - }
5665 -
5666 - if (empty($value)) {
5667 - return;
5668 - }
5669 -
5670 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5671 - if (is_array($value)) {
5672 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5673 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5674 - if ($looks_like_attachment) {
5675 - $att_id = 0;
5676 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5677 - $att_id = (int) $value['ID'];
5678 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5679 - $att_id = (int) $value['id'];
5680 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5681 - $att_id = (int) attachment_url_to_postid($value['url']);
5682 - }
5683 -
5684 - $is_pdf = false;
5685 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5686 - $is_pdf = true;
5687 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5688 - $is_pdf = true;
5689 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5690 - $is_pdf = true;
5691 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5692 - $is_pdf = true;
5693 - }
5694 -
5695 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5696 - $out[] = $att_id;
5697 - }
5698 - // An array node that represents one attachment doesn't contain other
5699 - // attachments inside it — done with this branch.
5700 - return;
5701 - }
5702 -
5703 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5704 - foreach ($value as $sub) {
5705 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5706 - }
5707 - return;
5708 - }
5709 -
5710 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5711 - if (is_numeric($value)) {
5712 - $att_id = (int) $value;
5713 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5714 - $out[] = $att_id;
5715 - }
5716 - return;
5717 - }
5718 -
5719 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5720 - if (is_string($value)) {
5721 - $trimmed = trim($value);
5722 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5723 - $att_id = (int) attachment_url_to_postid($trimmed);
5724 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5725 - $out[] = $att_id;
5726 - }
5727 - }
5728 - return;
5729 - }
5730 -}
5731 -
5732 -/**
5733 - * Heuristic: does this URL/string look like a PDF reference?
5734 - * Tolerates query strings and fragments (#page=2).
5735 - */
5736 -private function mxchat_url_looks_like_pdf($url) {
5737 - if (!is_string($url) || $url === '') {
5738 - return false;
5739 - }
5740 - // Strip query + fragment before checking extension
5741 - $path = preg_replace('/[?#].*$/', '', $url);
5742 - return (bool) preg_match('/\.pdf$/i', $path);
5743 -}
5744 -
5745 -/**
5746 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5747 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5748 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5749 - * only parse the same PDF once unless the file changes on disk.
5750 - *
5751 - * @param int $attachment_id
5752 - * @return string Extracted plain text, or '' on failure.
5753 - */
5754 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5755 - $attachment_id = (int) $attachment_id;
5756 - if ($attachment_id <= 0) {
5757 - return '';
5758 - }
5759 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5760 - return '';
5761 - }
5762 -
5763 - $pdf_path = get_attached_file($attachment_id);
5764 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5765 - return '';
5766 - }
5767 -
5768 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5769 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5770 - $default_max_bytes = 25 * 1024 * 1024;
5771 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5772 - if ($max_bytes > 0) {
5773 - $file_size = @filesize($pdf_path);
5774 - if ($file_size !== false && $file_size > $max_bytes) {
5775 - error_log(sprintf(
5776 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5777 - $attachment_id,
5778 - basename($pdf_path),
5779 - $file_size,
5780 - $max_bytes
5781 - ));
5782 - return '';
5783 - }
5784 - }
5785 -
5786 - $mtime = @filemtime($pdf_path);
5787 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5788 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5789 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5790 - return (string) $cached['text'];
5791 - }
5792 -
5793 - $text = '';
5794 - try {
5795 - if (function_exists('mxchat_load_pdf_parser')) {
5796 - mxchat_load_pdf_parser();
5797 - }
5798 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5799 - return '';
5800 - }
5801 - $parser = new \Smalot\PdfParser\Parser();
5802 - $pdf = $parser->parseFile($pdf_path);
5803 - $pages = $pdf->getPages();
5804 - $page_texts = array();
5805 - foreach ($pages as $page) {
5806 - $page_text = '';
5807 - try {
5808 - $page_text = $page->getText();
5809 - } catch (\Exception $e) {
5810 - $page_text = '';
5811 - }
5812 - if (!empty($page_text)) {
5813 - $page_texts[] = $page_text;
5814 - }
5815 - }
5816 - $text = trim(implode("\n\n", $page_texts));
5817 - } catch (\Exception $e) {
5818 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5819 - return '';
5820 - } catch (\Throwable $e) {
5821 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5822 - return '';
5823 - }
5824 -
5825 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5826 - // The chunker downstream will still split this into multiple vectors.
5827 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5828 - if ($max_len > 0 && strlen($text) > $max_len) {
5829 - $text = substr($text, 0, $max_len);
5830 - }
5831 -
5832 - update_post_meta($attachment_id, $cache_meta_key, array(
5833 - 'mtime' => (int) $mtime,
5834 - 'text' => $text,
5835 - ));
5836 -
5837 - return $text;
5838 -}
5839 -
5840 -/**
5841 - * Handle ACF save - fires after ACF fields are saved
5842 - * This ensures ACF field data is available when syncing to knowledge base
5843 - */
5844 -public function mxchat_handle_acf_save($post_id) {
5845 - // Skip if not a valid post
5846 - if (!$post_id || $post_id === 'options') {
5847 - return;
5848 - }
5849 -
5850 - // Skip autosaves and revisions
5851 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5852 - return;
5853 - }
5854 -
5855 - $post = get_post($post_id);
5856 - if (!$post) {
5857 - return;
5858 - }
5859 -
5860 - $post_type = $post->post_type;
5861 -
5862 - // Check if sync is enabled for this post type
5863 - $should_sync = false;
5864 -
5865 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5866 - $should_sync = true;
5867 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5868 - $should_sync = true;
5869 - } else if ($post_type === 'product' && class_exists('WooCommerce')) {
5870 - // WooCommerce products - check if WooCommerce integration is enabled
5871 - $options = get_option('mxchat_options', array());
5872 - if (isset($options['enable_woocommerce_integration']) &&
5873 - ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
5874 - $should_sync = true;
5875 - }
5876 - } else {
5877 - // Check custom post types
5878 - $option_name = 'mxchat_auto_sync_' . $post_type;
5879 - if (get_option($option_name) === '1') {
5880 - $should_sync = true;
5881 - }
5882 - }
5883 -
5884 - if (!$should_sync) {
5885 - return;
5886 - }
5887 -
5888 - // Only process published posts
5889 - if ($post->post_status !== 'publish') {
5890 - return;
5891 - }
5892 -
5893 - // Check if this post has any ACF fields - if not, no need to re-sync
5894 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5895 - if (empty($acf_fields)) {
5896 - return;
5897 - }
5898 -
5899 - // Use a transient to prevent duplicate processing (post_updated may have already run)
5900 - $transient_key = 'mxchat_acf_synced_' . $post_id;
5901 - if (get_transient($transient_key)) {
5902 - return;
5903 - }
5904 - set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
5905 -
5906 - // Re-run the sync with ACF data now available
5907 - // We pass $update=true since this is effectively an update with ACF data
5908 - $this->mxchat_handle_post_update($post_id, $post, true);
5909 -}
5910 -
5911 -public function mxchat_handle_post_update($post_id, $post, $update) {
5912 - // The in-flight-update marker has done its job the moment post_updated runs; drop it
5913 - // before any early return so it can never outlive its own save (a failed $wpdb->update
5914 - // inside wp_insert_post returns after pre_post_update but before the transition).
5915 - unset($this->pending_post_update[$post_id]);
5916 -
5917 - // Basic validation checks
5918 - if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5919 - return;
5920 - }
5921 -
5922 - $post_type = $post->post_type;
5923 -
5924 - // Check if sync is enabled for this post type
5925 - $should_sync = false;
5926 -
5927 - // Check built-in post types first
5928 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
5929 - $should_sync = true;
5930 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
5931 - $should_sync = true;
5932 - } else {
5933 - // Check custom post types
5934 - $option_name = 'mxchat_auto_sync_' . $post_type;
5935 - if (get_option($option_name) === '1') {
5936 - $should_sync = true;
5937 - }
5938 - }
5939 -
5940 - if (!$should_sync) {
5941 - return;
5942 - }
5943 -
5944 - // Check if we have stored the previous status and URL in our transients
5945 - $previous_status_key = 'mxchat_prev_status_' . $post_id;
5946 - $previous_status = get_transient($previous_status_key);
5947 -
5948 - $previous_url_key = 'mxchat_prev_url_' . $post_id;
5949 - $previous_url = get_transient($previous_url_key);
5950 -
5951 - // If the post was previously published but is now not published, remove from knowledge base
5952 - if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5953 - // Use the stored URL from when it was published, or fall back to current permalink
5954 - $source_url = $previous_url ?: get_permalink($post_id);
5955 -
5956 - // mxchat_handle_status_transition already deleted for this post earlier in this
5957 - // request (it fires first inside wp_insert_post); skip the redundant round-trip.
5958 - if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
5959 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5960 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5961 - }
5962 -
5963 - // Clean up the transients and exit early
5964 - delete_transient($previous_status_key);
5965 - delete_transient($previous_url_key);
5966 - return;
5967 - }
5968 -
5969 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5970 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5971 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5972 - $current_url = get_permalink($post_id);
5973 - if ($current_url && $current_url !== $previous_url) {
5974 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5975 - }
5976 - }
5977 -
5978 - // Store the current status for next time (if this is an update)
5979 - if ($update) {
5980 - set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5981 -
5982 - // If the post is currently published, also store its URL
5983 - if ($post->post_status === 'publish') {
5984 - $current_url = get_permalink($post_id);
5985 - set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
5986 - }
5987 - }
5988 -
5989 - // Only process currently published content for adding/updating.
5990 - // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
5991 - // already indexed this post earlier in this request (editor publishes fire
5992 - // transition_post_status first, then post_updated) — skip the duplicate embed.
5993 - // Consume-once: the flag is cleared when honoured, so a LATER save of the same
5994 - // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
5995 - if ($post->post_status === 'publish') {
5996 - if (!empty($this->transition_indexed_posts[$post_id])) {
5997 - unset($this->transition_indexed_posts[$post_id]);
5998 - } else {
5999 - $this->mxchat_index_published_post($post_id, $post);
6000 - }
6001 - }
6002 -
6003 - // Clean up the stored previous status if not used above
6004 - if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6005 - delete_transient($previous_status_key);
6006 - delete_transient($previous_url_key);
6007 - }
6008 -}
6009 -
6010 -/**
6011 - * Index a published post into the knowledge base: preprocessing filter, content
6012 - * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
6013 - * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
6014 - * upsert, then tag-based role restriction.
6015 - *
6016 - * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
6017 - * transition_post_status arrival edge (mxchat_handle_status_transition), so
6018 - * scheduled publishes (wp_publish_post) and direct status=publish inserts index
6019 - * identically to editor saves (plan 3055e1). Pure extraction of the former
6020 - * publish branch — body indentation retained to keep the diff reviewable.
6021 - */
6022 -private function mxchat_index_published_post($post_id, $post) {
6023 - $post_type = $post->post_type;
6024 -
6025 - // Get the source URL
6026 - $source_url = get_permalink($post_id);
6027 -
6028 - /**
6029 - * Allow developers to modify post data before processing into the knowledge base.
6030 - * Same filter and signature as the manual bulk-import path
6031 - * (ajax_mxchat_process_selected_content), so a callback registered once covers
6032 - * every indexing route. Purely additive — zero behaviour change when unhooked.
6033 - * Auto-sync runs under the 'default' bot context, matching the rest of this
6034 - * function.
6035 - *
6036 - * @param WP_Post $post The post about to be indexed.
6037 - * @param string $bot_id Bot context ('default' on auto-sync).
6038 - */
6039 - $post = apply_filters('mxchat_before_process_post', $post, 'default');
6040 - if (!($post instanceof WP_Post)) {
6041 - $post = get_post($post_id); // defend against a bad callback return
6042 - }
6043 -
6044 - // Get content with proper formatting (matching ajax_mxchat_process_selected_content),
6045 - // reading from the FILTERED post object — not re-fetched by ID, which would discard it
6046 - // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
6047 - // convert_chars (curly quotes and em-dashes become HTML entities in the
6048 - // embedded string) and prepends the "Protected:" / "Private:" display
6049 - // chrome. The knowledge base stores facts, not display strings — and the
6050 - // bulk-import path has always read the raw title, so this is also what
6051 - // makes the two paths agree.
6052 - $title = $post->post_title;
6053 - $content = get_post_field('post_content', $post);
6054 - $excerpt = get_post_field('post_excerpt', $post);
6055 -
6056 - // Remove shortcode tags but preserve content inside them
6057 - $content = $this->strip_shortcode_tags_preserve_content($content);
6058 - $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
6059 -
6060 - // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
6061 - $content = wp_strip_all_tags($content);
6062 -
6063 - // Combine title, short description (if exists), and content
6064 - $final_content = $title . "\n\n";
6065 -
6066 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
6067 - // trim() only in the TEST — see the matching note on the bulk-import path.
6068 - if (trim($excerpt) !== '') {
6069 - $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
6070 - }
6071 -
6072 - $final_content .= $content;
6073 -
6074 - // For WooCommerce products, include pricing and product details
6075 - if ($post_type === 'product' && class_exists('WooCommerce')) {
6076 - $product = wc_get_product($post_id);
6077 -
6078 - if ($product) {
6079 - // Get pricing information
6080 - $regular_price = $product->get_regular_price();
6081 - $sale_price = $product->get_sale_price();
6082 - $price = $product->get_price();
6083 - $sku = $product->get_sku();
6084 -
6085 - // Get currency symbol
6086 - $currency_symbol = get_woocommerce_currency_symbol();
6087 -
6088 - // Add pricing information
6089 - $final_content .= "\n";
6090 - if (!empty($regular_price)) {
6091 - $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
6092 - } elseif (!empty($price)) {
6093 - $final_content .= "Price: " . $currency_symbol . $price . "\n";
6094 - }
6095 -
6096 - if (!empty($sale_price) && $sale_price !== $regular_price) {
6097 - $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
6098 - }
6099 -
6100 - // Handle variable products - show price range
6101 - if ($product->is_type('variable')) {
6102 - $min_price = $product->get_variation_price('min');
6103 - $max_price = $product->get_variation_price('max');
6104 - if ($min_price !== $max_price) {
6105 - $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
6106 - }
6107 - }
6108 -
6109 - if (!empty($sku)) {
6110 - $final_content .= "SKU: " . $sku . "\n";
6111 - }
6112 -
6113 - // Get product categories
6114 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
6115 - if (!empty($categories) && !is_wp_error($categories)) {
6116 - $final_content .= "Categories: " . implode(', ', $categories) . "\n";
6117 - }
6118 - }
6119 - }
6120 -
6121 - // For custom post types like job_listing, include additional fields
6122 - if ($post_type === 'job_listing') {
6123 - // Add job-specific meta if available
6124 - $job_location = get_post_meta($post_id, '_job_location', true);
6125 - if (!empty($job_location)) {
6126 - $final_content .= "\n\nLocation: " . $job_location;
6127 - }
6128 -
6129 - // Get job type terms
6130 - $job_types = get_the_terms($post_id, 'job_listing_type');
6131 - if (!empty($job_types) && !is_wp_error($job_types)) {
6132 - $types = array();
6133 - foreach ($job_types as $type) {
6134 - $types[] = $type->name;
6135 - }
6136 - $final_content .= "\n\nJob Type: " . implode(', ', $types);
6137 - }
6138 -
6139 - // Get company name if available
6140 - $company_name = get_post_meta($post_id, '_company_name', true);
6141 - if (!empty($company_name)) {
6142 - $final_content .= "\n\nCompany: " . $company_name;
6143 - }
6144 - }
6145 -
6146 - // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
6147 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6148 - if (!empty($acf_fields)) {
6149 - $acf_content_parts = array();
6150 - $pdf_attachment_ids = array();
6151 -
6152 - foreach ($acf_fields as $field_name => $field_value) {
6153 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
6154 - if (!empty($formatted_value)) {
6155 - // Convert field name to readable label
6156 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
6157 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
6158 - }
6159 -
6160 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
6161 - }
6162 -
6163 - if (!empty($acf_content_parts)) {
6164 - $final_content .= "\n\n" . implode("\n", $acf_content_parts);
6165 - }
6166 -
6167 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
6168 - // Mirrors the per-batch checkbox the manual content selector has; the
6169 - // 25 MB size cap lives in the shared extractor so it applies in both
6170 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
6171 - // editor save is expensive and most sites don't want it.
6172 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
6173 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
6174 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
6175 - $pdf_sections = array();
6176 - foreach ($pdf_attachment_ids as $att_id) {
6177 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
6178 - if (!empty($pdf_text)) {
6179 - $pdf_title = get_the_title($att_id);
6180 - $pdf_url = wp_get_attachment_url($att_id);
6181 - $header = 'PDF Attachment';
6182 - if (!empty($pdf_title)) {
6183 - $header .= ': ' . $pdf_title;
6184 - }
6185 - if (!empty($pdf_url)) {
6186 - $header .= ' (' . $pdf_url . ')';
6187 - }
6188 - $pdf_sections[] = $header . "\n" . $pdf_text;
6189 - }
6190 - }
6191 - if (!empty($pdf_sections)) {
6192 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
6193 - }
6194 - }
6195 - }
6196 -
6197 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
6198 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
6199 - if (!empty($custom_meta)) {
6200 - $meta_content_parts = array();
6201 -
6202 - foreach ($custom_meta as $meta_key => $meta_value) {
6203 - // Convert meta key to readable label
6204 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
6205 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
6206 - }
6207 -
6208 - if (!empty($meta_content_parts)) {
6209 - $final_content .= "\n\n" . implode("\n", $meta_content_parts);
6210 - }
6211 - }
6212 -
6213 - // Embedding decision — custom-provider-aware. Gating on a cloud API key
6214 - // here silently killed auto-sync on keyless custom-embeddings sites,
6215 - // because generate_embedding() routes custom FIRST and never needs the
6216 - // key (plan cbd5fd). Silent-return shape preserved.
6217 - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6218 - if (!$preflight['ok']) {
6219 - return;
6220 - }
6221 - $api_key = $preflight['api_key'];
6222 -
6223 - // Use the centralized utility function for storage
6224 - $result = MxChat_Utils::submit_content_to_db(
6225 - $final_content,
6226 - $source_url,
6227 - $api_key,
6228 - md5($source_url) // Vector ID for Pinecone
6229 - );
6230 -
6231 - // After successful storage, apply role restriction based on tags
6232 - if (!is_wp_error($result)) {
6233 - $this->apply_role_restriction_to_post($post_id, $source_url);
6234 - }
6235 -}
6236 -
6237 -/**
6238 - * Store the post status and URL before update to detect status transitions
6239 - * This runs before the post is actually updated in the database
6240 - */
6241 -public function mxchat_store_pre_update_status($post_id, $data) {
6242 - // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
6243 - // this request and can consume the arrival-edge guard (plan a664f3).
6244 - $this->pending_post_update[$post_id] = true;
6245 -
6246 - // Get the current post from database (before update)
6247 - $current_post = get_post($post_id);
6248 -
6249 - if ($current_post) {
6250 - // Store the current status temporarily
6251 - $status_key = 'mxchat_prev_status_' . $post_id;
6252 - set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
6253 -
6254 - // If the post is currently published, also store its URL
6255 - if ($current_post->post_status === 'publish') {
6256 - $url_key = 'mxchat_prev_url_' . $post_id;
6257 - $current_url = get_permalink($post_id);
6258 - set_transient($url_key, $current_url, HOUR_IN_SECONDS);
6259 - }
6260 - }
6261 -}
6262 -
6263 -/**
6264 - * Whether auto-sync is enabled for a post type (mirrors the checks used by the
6265 - * update/delete handlers; kept as one helper so new call sites cannot drift).
6266 - */
6267 -private function mxchat_is_auto_sync_enabled($post_type) {
6268 - if ($post_type === 'post') {
6269 - return get_option('mxchat_auto_sync_posts') === '1';
6270 - }
6271 - if ($post_type === 'page') {
6272 - return get_option('mxchat_auto_sync_pages') === '1';
6273 - }
6274 - return get_option('mxchat_auto_sync_' . $post_type) === '1';
6275 -}
6276 -
6277 -/**
6278 - * Remove a post's vectors the moment it leaves 'publish', using the authoritative
6279 - * old status core passes to transition_post_status — no transient involved (plan 816fb1).
6280 - *
6281 - * Covers status changes that never route through wp_update_post (scheduled-expiry
6282 - * plugins and others that flip post_status directly and call wp_transition_post_status),
6283 - * where neither pre_post_update nor post_updated fires and the old detection missed.
6284 - */
6285 -public function mxchat_handle_status_transition($new_status, $old_status, $post) {
6286 - if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
6287 - return;
6288 - }
6289 -
6290 - // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
6291 - // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
6292 - // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
6293 - // auto-sync ADD path alone misses them. Editor publishes also pass through here;
6294 - // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
6295 - // a second time in the same request.
6296 - if ($new_status === 'publish' && $old_status !== 'publish') {
6297 - if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
6298 - $this->mxchat_index_published_post($post->ID, $post);
6299 -
6300 - // Arm the double-fire guard ONLY when a post_updated is actually coming to
6301 - // consume it (plan a664f3). Two publish paths never fire post_updated at all:
6302 - // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
6303 - // call check_and_publish_future_post() makes for scheduled posts. Arming the
6304 - // guard unconditionally left it set with nothing to consume it, so the NEXT
6305 - // update of that post was swallowed entirely: zero embed calls, no knowledge
6306 - // -base row, silently. Consume-once on this side too, so a guard can never
6307 - // outlive the single save it was armed for.
6308 - if (!empty($this->pending_post_update[$post->ID])) {
6309 - unset($this->pending_post_update[$post->ID]);
6310 - $this->transition_indexed_posts[$post->ID] = true;
6311 - }
6312 - }
6313 - return;
6314 - }
6315 -
6316 - // Only the publish -> not-publish edge matters here.
6317 - if ($old_status !== 'publish' || $new_status === 'publish') {
6318 - return;
6319 - }
6320 - // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
6321 - // resolution; skip to avoid a second network round-trip per trash.
6322 - if ($new_status === 'trash') {
6323 - return;
6324 - }
6325 - if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
6326 - return;
6327 - }
6328 -
6329 - $urls = array();
6330 -
6331 - // The DB may already hold the new status when this fires, so get_permalink() on the
6332 - // live post could build a draft-style URL whose md5 misses the stored vector IDs.
6333 - // Reconstruct the published permalink from a clone instead.
6334 - $published_clone = clone $post;
6335 - $published_clone->post_status = 'publish';
6336 - $published_url = get_permalink($published_clone);
6337 - if ($published_url) {
6338 - $urls[] = $published_url;
6339 - }
6340 -
6341 - // Honour the pre-update capture when present (covers a slug change in the same save).
6342 - $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
6343 - if (!empty($previous_url)) {
6344 - $urls[] = $previous_url;
6345 - }
6346 -
6347 - foreach (array_unique($urls) as $url) {
6348 - MxChat_Utils::delete_chunks_for_url($url, 'default');
6349 - }
6350 -
6351 - if (!empty($urls)) {
6352 - $this->transition_deleted_posts[$post->ID] = true;
6353 - }
6354 -}
6355 -
6356 -/**
6357 - * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
6358 - * trashed, or made private before the transition_post_status handler existed.
6359 - *
6360 - * Walks every auto-synced post type's non-published posts, reconstructs each one's
6361 - * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
6362 - * Deletion is idempotent, so never-indexed posts are a cheap no-op.
6363 - *
6364 - * ## OPTIONS
6365 - *
6366 - * [--dry-run]
6367 - * : Report what would be removed without deleting anything.
6368 - *
6369 - * ## EXAMPLES
6370 - *
6371 - * wp mxchat prune-unpublished --dry-run
6372 - * wp mxchat prune-unpublished
6373 - */
6374 -public function cli_prune_unpublished($args, $assoc_args) {
6375 - global $wpdb;
6376 - $dry_run = !empty($assoc_args['dry-run']);
6377 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6378 -
6379 - $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
6380 - $synced_types = array();
6381 - foreach ($candidate_types as $type) {
6382 - if ($this->mxchat_is_auto_sync_enabled($type)) {
6383 - $synced_types[] = $type;
6384 - }
6385 - }
6386 - if (empty($synced_types)) {
6387 - WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
6388 - return;
6389 - }
6390 -
6391 - $scanned = 0;
6392 - $pruned = 0;
6393 - $paged = 1;
6394 - do {
6395 - $query = new WP_Query(array(
6396 - 'post_type' => $synced_types,
6397 - 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
6398 - 'posts_per_page' => 100,
6399 - 'paged' => $paged,
6400 - 'fields' => 'ids',
6401 - ));
6402 - foreach ($query->posts as $post_id) {
6403 - $post = get_post($post_id);
6404 - if (!$post) {
6405 - continue;
6406 - }
6407 - $scanned++;
6408 -
6409 - // Rebuild the permalink the post had while published: publish-status clone,
6410 - // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
6411 - $clone = clone $post;
6412 - $clone->post_status = 'publish';
6413 - if (substr($clone->post_name, -9) === '__trashed') {
6414 - $clone->post_name = substr($clone->post_name, 0, -9);
6415 - }
6416 - $url = get_permalink($clone);
6417 - if (!$url) {
6418 - continue;
6419 - }
6420 -
6421 - // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
6422 - // reads 0 but the delete below still routes to Pinecone and is idempotent.
6423 - $local_rows = (int) $wpdb->get_var($wpdb->prepare(
6424 - "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
6425 - ));
6426 -
6427 - if ($dry_run) {
6428 - if ($local_rows > 0) {
6429 - WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
6430 - $pruned += $local_rows;
6431 - }
6432 - continue;
6433 - }
6434 -
6435 - MxChat_Utils::delete_chunks_for_url($url, 'default');
6436 - if ($local_rows > 0) {
6437 - WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
6438 - $pruned += $local_rows;
6439 - }
6440 - }
6441 - $more = $paged < $query->max_num_pages;
6442 - $paged++;
6443 - } while ($more);
6444 -
6445 - WP_CLI::success(sprintf(
6446 - '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
6447 - $dry_run ? 'Would remove' : 'Removed',
6448 - $pruned,
6449 - $scanned,
6450 - ' (Pinecone-mode deletions are not counted locally.)'
6451 - ));
6452 -}
6453 -
6454 -public function mxchat_handle_post_delete($post_id) {
6455 - // Get post data before it's deleted
6456 - $post = get_post($post_id);
6457 -
6458 - // Basic validation
6459 - if (!$post || wp_is_post_revision($post_id)) {
6460 - return;
6461 - }
6462 -
6463 - $post_type = $post->post_type;
6464 -
6465 - // Check if sync is enabled for this post type
6466 - $should_sync = false;
6467 -
6468 - // Check built-in post types first
6469 - if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
6470 - $should_sync = true;
6471 - } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
6472 - $should_sync = true;
6473 - } else {
6474 - // Check custom post types
6475 - $option_name = 'mxchat_auto_sync_' . $post_type;
6476 - if (get_option($option_name) === '1') {
6477 - $should_sync = true;
6478 - }
6479 - }
6480 -
6481 - if (!$should_sync) {
6482 - return;
6483 - }
6484 -
6485 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
6486 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
6487 - // real vector IDs stored under the original URL.
6488 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6489 - if (!$source_url) {
6490 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
6491 - return;
6492 - }
6493 -
6494 - // Use chunk-aware deletion (handles both chunked and non-chunked content)
6495 - $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6496 -
6497 - if (is_wp_error($delete_result)) {
6498 - //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
6499 - }
6500 -
6501 - delete_transient('mxchat_prev_url_' . $post_id);
6502 - delete_transient('mxchat_prev_status_' . $post_id);
6503 -}
6504 -
6505 -/**
6506 - * Resolve the source URL for a post being trashed/deleted.
6507 - *
6508 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
6509 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
6510 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
6511 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
6512 - */
6513 -private function mxchat_resolve_pre_trash_url($post_id) {
6514 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
6515 - if (!empty($previous_url)) {
6516 - return $previous_url;
6517 - }
6518 -
6519 - $current = get_permalink($post_id);
6520 - if (!$current) {
6521 - return '';
6522 - }
6523 - return preg_replace('#__trashed(/?)$#', '$1', $current);
6524 -}
6525 -
6526 -
6527 -
6528 -public function mxchat_handle_product_change($post_id, $post, $update) {
6529 - if ($post->post_type !== 'product') {
6530 - return;
6531 - }
6532 -
6533 - if ($post->post_status === 'publish') {
6534 - add_action('shutdown', function() use ($post_id) {
6535 - $product = wc_get_product($post_id);
6536 - if ($product) {
6537 - $this->mxchat_store_product_embedding($product);
6538 - }
6539 - });
6540 - }
6541 -}
6542 -
6543 -/**
6544 - * Store WooCommerce product embeddings
6545 - */
6546 -private function mxchat_store_product_embedding($product) {
6547 - if (!isset($this->options['enable_woocommerce_integration']) ||
6548 - !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6549 - return;
6550 - }
6551 -
6552 - $source_url = get_permalink($product->get_id());
6553 - $product_id = $product->get_id();
6554 -
6555 - // Build product content
6556 - $title = $product->get_name();
6557 - $description = $product->get_description();
6558 - $short_description = $product->get_short_description();
6559 - $regular_price = $product->get_regular_price();
6560 - $sale_price = $product->get_sale_price();
6561 - $price = $product->get_price();
6562 - $sku = $product->get_sku();
6563 -
6564 - // Get currency symbol
6565 - $currency_symbol = get_woocommerce_currency_symbol();
6566 -
6567 - // Format content consistently
6568 - $content = $title . "\n\n";
6569 -
6570 - if (!empty($short_description)) {
6571 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6572 - }
6573 -
6574 - if (!empty($description)) {
6575 - $content .= wp_strip_all_tags($description) . "\n\n";
6576 - }
6577 -
6578 - // Add pricing information
6579 - if (!empty($regular_price)) {
6580 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
6581 - } elseif (!empty($price)) {
6582 - $content .= "Price: " . $currency_symbol . $price . "\n";
6583 - }
6584 -
6585 - if (!empty($sale_price) && $sale_price !== $regular_price) {
6586 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
6587 - }
6588 -
6589 - // Handle variable products - show price range
6590 - if ($product->is_type('variable')) {
6591 - $min_price = $product->get_variation_price('min');
6592 - $max_price = $product->get_variation_price('max');
6593 - if ($min_price !== $max_price) {
6594 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
6595 - }
6596 - }
6597 -
6598 - if (!empty($sku)) {
6599 - $content .= "SKU: " . $sku . "\n";
6600 - }
6601 -
6602 - // Get product categories
6603 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
6604 - if (!empty($categories) && !is_wp_error($categories)) {
6605 - $content .= "Categories: " . implode(', ', $categories) . "\n";
6606 - }
6607 -
6608 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
6609 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6610 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
6611 - foreach ($custom_tabs as $tab) {
6612 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6613 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6614 -
6615 - if (!empty($tab_title) && !empty($tab_content)) {
6616 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6617 - }
6618 - }
6619 - }
6620 -
6621 - // Also check for reusable/saved tabs applied to this product
6622 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6623 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6624 - // Get the saved tabs option
6625 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6626 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
6627 - foreach ($applied_saved_tabs as $saved_tab_id) {
6628 - if (isset($saved_tabs[$saved_tab_id])) {
6629 - $tab = $saved_tabs[$saved_tab_id];
6630 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6631 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6632 -
6633 - if (!empty($tab_title) && !empty($tab_content)) {
6634 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6635 - }
6636 - }
6637 - }
6638 - }
6639 - }
6640 -
6641 - // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
6642 - // shape preserved.
6643 - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6644 - if (!$preflight['ok']) {
6645 - //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
6646 - return;
6647 - }
6648 - $api_key = $preflight['api_key'];
6649 -
6650 - // Use the centralized utility function for storage
6651 - $result = MxChat_Utils::submit_content_to_db(
6652 - $content,
6653 - $source_url,
6654 - $api_key,
6655 - md5($source_url) // Vector ID for Pinecone
6656 - );
6657 -
6658 - // After successful storage, apply role restriction based on tags
6659 - if (!is_wp_error($result)) {
6660 - $this->apply_role_restriction_to_post($product_id, $source_url);
6661 - }
6662 -
6663 - if (is_wp_error($result)) {
6664 - //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
6665 - }
6666 -}
6667 -
6668 -public function mxchat_handle_product_delete($post_id) {
6669 - if (get_post_type($post_id) !== 'product') {
6670 - return;
6671 - }
6672 -
6673 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6674 - if (!$source_url) {
6675 - return;
6676 - }
6677 -
6678 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6679 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6680 -
6681 - delete_transient('mxchat_prev_url_' . $post_id);
6682 - delete_transient('mxchat_prev_status_' . $post_id);
6683 -}
6684 -
6685 -/**
6686 - * Handle individual Pinecone content deletion
6687 - */
6688 -public function mxchat_handle_pinecone_prompt_delete() {
6689 - // Check permissions
6690 - if (!current_user_can('manage_options')) {
6691 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6692 - }
6693 -
6694 - // Verify nonce
6695 - if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
6696 - wp_die(esc_html__('Security check failed.', 'mxchat'));
6697 - }
6698 -
6699 - $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
6700 -
6701 - if (empty($vector_id)) {
6702 - set_transient('mxchat_admin_notice_error',
6703 - esc_html__('Invalid vector ID.', 'mxchat'),
6704 - 30
6705 - );
6706 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6707 - exit;
6708 - }
6709 -
6710 - // Get Pinecone settings
6711 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6712 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6713 -
6714 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6715 - set_transient('mxchat_admin_notice_error',
6716 - esc_html__('Pinecone is not properly configured.', 'mxchat'),
6717 - 30
6718 - );
6719 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6720 - exit;
6721 - }
6722 -
6723 - // Delete from Pinecone
6724 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6725 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
6726 - $vector_id,
6727 - $pinecone_options['mxchat_pinecone_api_key'],
6728 - $pinecone_options['mxchat_pinecone_host']
6729 - );
6730 -
6731 - if ($result['success']) {
6732 - // No cache clearing needed since we removed caching
6733 - set_transient('mxchat_admin_notice_success',
6734 - esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
6735 - 30
6736 - );
6737 - } else {
6738 - set_transient('mxchat_admin_notice_error',
6739 - esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
6740 - 30
6741 - );
6742 - }
6743 -
6744 - wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
6745 - exit;
6746 -}
6747 -/**
6748 - * Handle individual Pinecone content deletion via AJAX
6749 - */
6750 -public function ajax_mxchat_delete_pinecone_prompt() {
6751 - // Verify nonce and permissions
6752 - if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
6753 - wp_send_json_error('Invalid nonce');
6754 - exit;
6755 - }
6756 -
6757 - if (!current_user_can('manage_options')) {
6758 - wp_send_json_error('Unauthorized access');
6759 - exit;
6760 - }
6761 -
6762 - $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
6763 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6764 -
6765 - if (empty($vector_id)) {
6766 - wp_send_json_error('Missing vector ID');
6767 - exit;
6768 - }
6769 -
6770 - // Get bot-specific Pinecone settings
6771 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6772 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6773 -
6774 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6775 -
6776 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6777 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
6778 - exit;
6779 - }
6780 -
6781 - // Delete from the correct Pinecone index
6782 - $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
6783 - $vector_id,
6784 - $pinecone_options['mxchat_pinecone_api_key'],
6785 - $pinecone_options['mxchat_pinecone_host']
6786 - );
6787 -
6788 - if ($result['success']) {
6789 - // No cache clearing needed since we removed caching
6790 - wp_send_json_success(array(
6791 - 'message' => 'Entry deleted successfully from Pinecone',
6792 - 'vector_id' => $vector_id,
6793 - 'bot_id' => $bot_id
6794 - ));
6795 - } else {
6796 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete from Pinecone: ' . $result['message'], array('vector_id' => $vector_id, 'bot_id' => $bot_id));
6797 - wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
6798 - }
6799 -
6800 - exit;
6801 -}
6802 -
6803 -/**
6804 - * Handle deletion of all chunks for a given source URL via AJAX
6805 - * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
6806 - */
6807 -public function ajax_mxchat_delete_chunks_by_url() {
6808 - // Verify nonce and permissions
6809 - if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
6810 - wp_send_json_error('Invalid nonce');
6811 - exit;
6812 - }
6813 -
6814 - if (!current_user_can('manage_options')) {
6815 - wp_send_json_error('Unauthorized access');
6816 - exit;
6817 - }
6818 -
6819 - $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
6820 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
6821 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
6822 -
6823 - if (empty($source_url)) {
6824 - wp_send_json_error('Missing source URL');
6825 - exit;
6826 - }
6827 -
6828 - // Generate the base vector ID from the source URL (same as how chunks are created)
6829 - $base_vector_id = md5($source_url);
6830 -
6831 - if ($data_source === 'pinecone') {
6832 - // Get bot-specific Pinecone settings (same as working delete function)
6833 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
6834 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
6835 -
6836 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6837 -
6838 - if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
6839 - wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
6840 - exit;
6841 - }
6842 -
6843 - $api_key = $pinecone_options['mxchat_pinecone_api_key'];
6844 - $host = $pinecone_options['mxchat_pinecone_host'];
6845 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
6846 -
6847 - // Collect all vector IDs to delete
6848 - $vectors_to_delete = array();
6849 -
6850 - // Add the original single-vector ID (for non-chunked content)
6851 - $vectors_to_delete[] = $base_vector_id;
6852 -
6853 - // Use Pinecone list API to find all chunk vectors with this prefix
6854 - // NOTE: Pinecone List API is a GET request with query parameters, not POST
6855 - $prefix = $base_vector_id . '_chunk_';
6856 -
6857 - $query_params = array(
6858 - 'prefix' => $prefix,
6859 - 'limit' => 100
6860 - );
6861 -
6862 - if (!empty($namespace)) {
6863 - $query_params['namespace'] = $namespace;
6864 - }
6865 -
6866 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
6867 -
6868 - $list_response = wp_remote_get($list_url, array(
6869 - 'headers' => array(
6870 - 'Api-Key' => $api_key,
6871 - 'accept' => 'application/json'
6872 - ),
6873 - 'timeout' => 30
6874 - ));
6875 -
6876 - if (!is_wp_error($list_response)) {
6877 - $list_body_response = wp_remote_retrieve_body($list_response);
6878 - $list_data = json_decode($list_body_response, true);
6879 - if (!empty($list_data['vectors'])) {
6880 - foreach ($list_data['vectors'] as $vector) {
6881 - if (isset($vector['id'])) {
6882 - $vectors_to_delete[] = $vector['id'];
6883 - }
6884 - }
6885 - }
6886 - }
6887 -
6888 - if (empty($vectors_to_delete)) {
6889 - wp_send_json_success(array(
6890 - 'message' => 'No vectors found to delete',
6891 - 'source_url' => $source_url
6892 - ));
6893 - exit;
6894 - }
6895 -
6896 - // Delete all vectors using the same endpoint as the working function
6897 - $delete_url = "https://{$host}/vectors/delete";
6898 -
6899 - $delete_body = array(
6900 - 'ids' => $vectors_to_delete
6901 - );
6902 -
6903 - if (!empty($namespace)) {
6904 - $delete_body['namespace'] = $namespace;
6905 - }
6906 -
6907 - $delete_response = wp_remote_post($delete_url, array(
6908 - 'headers' => array(
6909 - 'Api-Key' => $api_key,
6910 - 'accept' => 'application/json',
6911 - 'content-type' => 'application/json'
6912 - ),
6913 - 'body' => wp_json_encode($delete_body),
6914 - 'timeout' => 30
6915 - ));
6916 -
6917 - if (is_wp_error($delete_response)) {
6918 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to delete chunks from Pinecone: ' . $delete_response->get_error_message(), array('source_url' => $source_url));
6919 - wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
6920 - exit;
6921 - }
6922 -
6923 - $response_code = wp_remote_retrieve_response_code($delete_response);
6924 -
6925 - if ($response_code !== 200) {
6926 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone API error (HTTP ' . $response_code . ')', array('source_url' => $source_url));
6927 - wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
6928 - exit;
6929 - }
6930 -
6931 - wp_send_json_success(array(
6932 - 'message' => 'All chunks deleted successfully from Pinecone',
6933 - 'source_url' => $source_url,
6934 - 'deleted_count' => count($vectors_to_delete)
6935 - ));
6936 -
6937 - } else {
6938 - // WordPress database deletion
6939 - global $wpdb;
6940 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6941 -
6942 - $result = $wpdb->delete(
6943 - $table_name,
6944 - array('source_url' => $source_url),
6945 - array('%s')
6946 - );
6947 -
6948 - if ($result === false) {
6949 - MxChat_Admin::mxchat_log_debug('database_error', 'Failed to delete from database: ' . $wpdb->last_error, array('source_url' => $source_url));
6950 - wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
6951 - exit;
6952 - }
6953 -
6954 - wp_send_json_success(array(
6955 - 'message' => 'All chunks deleted successfully from database',
6956 - 'source_url' => $source_url,
6957 - 'deleted_count' => $result
6958 - ));
6959 - }
6960 -
6961 - exit;
6962 -}
6963 -
6964 -/**
6965 - * Handle individual WordPress database content deletion via AJAX
6966 - * Mirrors the Pinecone delete handler but for WordPress database entries
6967 - */
6968 -public function ajax_mxchat_delete_wordpress_prompt() {
6969 - // Verify nonce and permissions
6970 - if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
6971 - wp_send_json_error('Invalid nonce');
6972 - exit;
6973 - }
6974 -
6975 - if (!current_user_can('manage_options')) {
6976 - wp_send_json_error('Unauthorized access');
6977 - exit;
6978 - }
6979 -
6980 - $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
6981 -
6982 - if (empty($entry_id)) {
6983 - wp_send_json_error('Missing entry ID');
6984 - exit;
6985 - }
6986 -
6987 - global $wpdb;
6988 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6989 -
6990 - // Clear cache for this entry
6991 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6992 -
6993 - // Delete from database
6994 - $result = $wpdb->delete(
6995 - $table_name,
6996 - array('id' => $entry_id),
6997 - array('%d')
6998 - );
6999 -
7000 - if ($result !== false) {
7001 - wp_send_json_success(array(
7002 - 'message' => 'Entry deleted successfully',
7003 - 'entry_id' => $entry_id
7004 - ));
7005 - } else {
7006 - wp_send_json_error('Failed to delete entry from database');
7007 - }
7008 -
7009 - exit;
7010 -}
7011 -
7012 -/**
7013 - * Handle bulk deletion of knowledge entries via AJAX
7014 - * Supports both Pinecone and WordPress database entries
7015 - */
7016 -public function ajax_mxchat_bulk_delete_knowledge() {
7017 - // Verify nonce and permissions
7018 - if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
7019 - wp_send_json_error('Invalid nonce');
7020 - exit;
7021 - }
7022 -
7023 - if (!current_user_can('manage_options')) {
7024 - wp_send_json_error('Unauthorized access');
7025 - exit;
7026 - }
7027 -
7028 - $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
7029 - $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
7030 -
7031 - if (empty($entries) || !is_array($entries)) {
7032 - wp_send_json_error('No entries provided');
7033 - exit;
7034 - }
7035 -
7036 - // Extend execution time — bulk Pinecone operations can take a while
7037 - if (function_exists('set_time_limit')) {
7038 - set_time_limit(120);
7039 - }
7040 -
7041 - $success_ids = array();
7042 - $failed_ids = array();
7043 - $errors = array();
7044 -
7045 - global $wpdb;
7046 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7047 -
7048 - // Get Pinecone manager for Pinecone deletions
7049 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7050 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
7051 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7052 -
7053 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
7054 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
7055 -
7056 - // =============================================
7057 - // PHASE 1: Collect all Pinecone vector IDs
7058 - // and separate WordPress entries
7059 - // =============================================
7060 - $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
7061 - $wordpress_entries = array(); // entries for WordPress DB deletion
7062 - $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
7063 -
7064 - foreach ($entries as $entry) {
7065 - $entry_id = sanitize_text_field($entry['id'] ?? '');
7066 - $source = sanitize_text_field($entry['source'] ?? 'wordpress');
7067 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
7068 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
7069 -
7070 - if (empty($entry_id)) {
7071 - continue;
7072 - }
7073 -
7074 - if ($source === 'pinecone') {
7075 - if (!$use_pinecone || empty($api_key)) {
7076 - $failed_ids[] = $entry_id;
7077 - $errors[] = "Pinecone not configured for entry: $entry_id";
7078 - continue;
7079 - }
7080 -
7081 - $pinecone_entry_ids[] = $entry_id;
7082 -
7083 - if ($is_group && !empty($source_url)) {
7084 - // Grouped/chunked entry: collect base ID + chunk IDs via List API
7085 - $base_vector_id = md5($source_url);
7086 - $all_vector_ids[] = $base_vector_id;
7087 -
7088 - $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
7089 - $list_response = wp_remote_get($list_url, array(
7090 - 'headers' => array(
7091 - 'Api-Key' => $api_key,
7092 - 'accept' => 'application/json'
7093 - ),
7094 - 'timeout' => 30
7095 - ));
7096 -
7097 - if (!is_wp_error($list_response)) {
7098 - $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
7099 - if (!empty($list_body['vectors']) && is_array($list_body['vectors'])) {
7100 - foreach ($list_body['vectors'] as $vector) {
7101 - if (isset($vector['id'])) {
7102 - $all_vector_ids[] = $vector['id'];
7103 - }
7104 - }
7105 - }
7106 - }
7107 - } else {
7108 - // Single entry: the entry_id IS the vector ID
7109 - $all_vector_ids[] = $entry_id;
7110 - }
7111 - } else {
7112 - $wordpress_entries[] = $entry;
7113 - }
7114 - }
7115 -
7116 - // =============================================
7117 - // PHASE 2: Single batch delete to Pinecone
7118 - // =============================================
7119 - if (!empty($all_vector_ids)) {
7120 - $all_vector_ids = array_values(array_unique($all_vector_ids));
7121 - $pinecone_success = true;
7122 - $batches = array_chunk($all_vector_ids, 100);
7123 -
7124 - foreach ($batches as $batch) {
7125 - $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
7126 - 'headers' => array(
7127 - 'Api-Key' => $api_key,
7128 - 'accept' => 'application/json',
7129 - 'content-type' => 'application/json'
7130 - ),
7131 - 'body' => wp_json_encode(array('ids' => $batch)),
7132 - 'timeout' => 60
7133 - ));
7134 -
7135 - if (is_wp_error($delete_response)) {
7136 - $pinecone_success = false;
7137 - $errors[] = 'Pinecone batch deletion failed: ' . $delete_response->get_error_message();
7138 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed: ' . $delete_response->get_error_message());
7139 - } else {
7140 - $response_code = wp_remote_retrieve_response_code($delete_response);
7141 - if ($response_code !== 200) {
7142 - $pinecone_success = false;
7143 - $response_body = wp_remote_retrieve_body($delete_response);
7144 - $errors[] = "Pinecone API error (HTTP $response_code)";
7145 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Bulk delete batch failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
7146 - }
7147 - }
7148 - }
7149 -
7150 - // Mark all pinecone entries based on batch result
7151 - foreach ($pinecone_entry_ids as $eid) {
7152 - if ($pinecone_success) {
7153 - $success_ids[] = $eid;
7154 - } else {
7155 - $failed_ids[] = $eid;
7156 - }
7157 - }
7158 - }
7159 -
7160 - // =============================================
7161 - // PHASE 3: WordPress database deletions
7162 - // =============================================
7163 - foreach ($wordpress_entries as $entry) {
7164 - $entry_id = sanitize_text_field($entry['id'] ?? '');
7165 - $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
7166 - $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
7167 -
7168 - if (empty($entry_id)) {
7169 - continue;
7170 - }
7171 -
7172 - try {
7173 - if ($is_group && !empty($source_url)) {
7174 - $result = $wpdb->delete(
7175 - $table_name,
7176 - array('source_url' => $source_url),
7177 - array('%s')
7178 - );
7179 - } else {
7180 - wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
7181 - $result = $wpdb->delete(
7182 - $table_name,
7183 - array('id' => intval($entry_id)),
7184 - array('%d')
7185 - );
7186 - }
7187 -
7188 - if ($result !== false) {
7189 - $success_ids[] = $entry_id;
7190 - } else {
7191 - $failed_ids[] = $entry_id;
7192 - $errors[] = "Database error for entry: $entry_id";
7193 - }
7194 - } catch (Exception $e) {
7195 - $failed_ids[] = $entry_id;
7196 - $errors[] = $e->getMessage();
7197 - }
7198 - }
7199 -
7200 - wp_send_json_success(array(
7201 - 'success_ids' => $success_ids,
7202 - 'failed_ids' => $failed_ids,
7203 - 'errors' => $errors,
7204 - 'total_processed' => count($success_ids) + count($failed_ids)
7205 - ));
7206 -
7207 - exit;
7208 -}
7209 -
7210 -/**
7211 - * Get hierarchical roles for dropdown
7212 - */
7213 -public function mxchat_get_role_options() {
7214 - return array(
7215 - 'public' => __('Public (Everyone)', 'mxchat'),
7216 - 'logged_in' => __('Logged In Users', 'mxchat'),
7217 - 'subscriber' => __('Subscribers & Above', 'mxchat'),
7218 - 'contributor' => __('Contributors & Above', 'mxchat'),
7219 - 'author' => __('Authors & Above', 'mxchat'),
7220 - 'editor' => __('Editors & Above', 'mxchat'),
7221 - 'administrator' => __('Administrators Only', 'mxchat')
7222 - );
7223 -}
7224 -
7225 -/**
7226 - * Check if user has access to content based on role restriction
7227 - */
7228 -public function mxchat_user_has_content_access($role_restriction) {
7229 - // Public content is always accessible
7230 - if ($role_restriction === 'public' || empty($role_restriction)) {
7231 - return true;
7232 - }
7233 -
7234 - // Check if user is logged in for logged_in restriction
7235 - if ($role_restriction === 'logged_in') {
7236 - return is_user_logged_in();
7237 - }
7238 -
7239 - // If not logged in, no access to role-restricted content
7240 - if (!is_user_logged_in()) {
7241 - return false;
7242 - }
7243 -
7244 - $user = wp_get_current_user();
7245 - $user_roles = $user->roles;
7246 -
7247 - if (empty($user_roles)) {
7248 - return false;
7249 - }
7250 -
7251 - // Define role hierarchy (higher number = higher access)
7252 - $hierarchy = array(
7253 - 'subscriber' => 1,
7254 - 'contributor' => 2,
7255 - 'author' => 3,
7256 - 'editor' => 4,
7257 - 'administrator' => 5
7258 - );
7259 -
7260 - // Get required level
7261 - $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
7262 -
7263 - // Check if user has required level or higher
7264 - foreach ($user_roles as $user_role) {
7265 - $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
7266 - if ($user_level >= $required_level) {
7267 - return true;
7268 - }
7269 - }
7270 -
7271 - return false;
7272 -}
7273 -
7274 -/**
7275 - * Handle role restriction updates via AJAX
7276 - * Removed cache clearing call since we removed caching
7277 - */
7278 -public function ajax_mxchat_update_role_restriction() {
7279 - // Verify nonce and permissions
7280 - if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
7281 - wp_send_json_error('Invalid nonce');
7282 - exit;
7283 - }
7284 -
7285 - if (!current_user_can('manage_options')) {
7286 - wp_send_json_error('Unauthorized access');
7287 - exit;
7288 - }
7289 -
7290 - $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
7291 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
7292 - $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
7293 -
7294 - if (empty($entry_id)) {
7295 - wp_send_json_error('Invalid entry ID');
7296 - exit;
7297 - }
7298 -
7299 - // Get knowledge manager instance to validate role restriction
7300 - $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
7301 - $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
7302 - if (!in_array($role_restriction, $valid_roles)) {
7303 - wp_send_json_error('Invalid role restriction');
7304 - exit;
7305 - }
7306 -
7307 - global $wpdb;
7308 -
7309 - if ($data_source === 'pinecone') {
7310 - // Handle Pinecone role restriction (stored separately in WordPress table)
7311 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7312 -
7313 - // Use REPLACE to insert or update the role restriction
7314 - $result = $wpdb->replace(
7315 - $roles_table,
7316 - array(
7317 - 'vector_id' => $entry_id,
7318 - 'role_restriction' => $role_restriction,
7319 - 'updated_at' => current_time('mysql')
7320 - ),
7321 - array('%s', '%s', '%s')
7322 - );
7323 -
7324 - // No cache clearing needed since we removed caching
7325 -
7326 - } else {
7327 - // Handle WordPress database role restriction (existing functionality)
7328 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7329 -
7330 - $result = $wpdb->update(
7331 - $table_name,
7332 - array('role_restriction' => $role_restriction),
7333 - array('id' => absint($entry_id)),
7334 - array('%s'),
7335 - array('%d')
7336 - );
7337 - }
7338 -
7339 - if ($result === false) {
7340 - wp_send_json_error('Database update failed: ' . $wpdb->last_error);
7341 - exit;
7342 - }
7343 -
7344 - wp_send_json_success(array(
7345 - 'message' => 'Role restriction updated successfully',
7346 - 'role_restriction' => $role_restriction,
7347 - 'data_source' => $data_source,
7348 - 'entry_id' => $entry_id
7349 - ));
7350 - exit;
7351 -}
7352 -
7353 -// ========================================
7354 -// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
7355 -// Add these to your MxChat_Knowledge_Manager class
7356 -// ========================================
7357 -
7358 -/**
7359 - * Initialize role-based content hooks
7360 - * Add this call to your __construct() or mxchat_init_hooks() method
7361 - */
7362 -private function mxchat_init_role_hooks() {
7363 - // AJAX handlers for tag-role mappings
7364 - add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
7365 - add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
7366 - add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
7367 - add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
7368 -
7369 - // Hook to automatically update role restrictions when tags are added/removed
7370 - add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
7371 -
7372 - // Hook to apply role restrictions on auto-sync
7373 - add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
7374 -}
7375 -
7376 -/**
7377 - * Add tag-role mapping via AJAX
7378 - */
7379 -public function ajax_add_tag_role_mapping() {
7380 - // Verify nonce and permissions
7381 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7382 -
7383 - if (!current_user_can('manage_options')) {
7384 - wp_send_json_error('Unauthorized access');
7385 - exit;
7386 - }
7387 -
7388 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
7389 - $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
7390 -
7391 - if (empty($tag_input)) {
7392 - wp_send_json_error('Please enter a tag name or slug');
7393 - exit;
7394 - }
7395 -
7396 - // Validate role restriction
7397 - $valid_roles = array_keys($this->mxchat_get_role_options());
7398 - if (!in_array($role_restriction, $valid_roles)) {
7399 - wp_send_json_error('Invalid role restriction');
7400 - exit;
7401 - }
7402 -
7403 - // Resolve the tag by slug first, then fall back to its display name, so users can
7404 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
7405 - // labeled by name but previously validated by slug only, producing the confusing
7406 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
7407 - $term = get_term_by('slug', $tag_input, 'post_tag');
7408 - if (!$term) {
7409 - $term = get_term_by('name', $tag_input, 'post_tag');
7410 - }
7411 - if (!$term) {
7412 - 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.');
7413 - exit;
7414 - }
7415 -
7416 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
7417 - // compares against each post's tag slugs, so the stored key must be a slug,
7418 - // never the raw (possibly display-name) input.
7419 - $tag_slug = $term->slug;
7420 -
7421 - // Get existing mappings
7422 - $mappings = get_option('mxchat_tag_role_mappings', array());
7423 -
7424 - // Check if mapping already exists
7425 - if (isset($mappings[$tag_slug])) {
7426 - wp_send_json_error('Mapping for this tag already exists');
7427 - exit;
7428 - }
7429 -
7430 - // Add new mapping
7431 - $mappings[$tag_slug] = $role_restriction;
7432 - update_option('mxchat_tag_role_mappings', $mappings);
7433 -
7434 - wp_send_json_success(array(
7435 - 'message' => 'Tag-role mapping added successfully',
7436 - 'tag_slug' => $tag_slug,
7437 - 'role_restriction' => $role_restriction
7438 - ));
7439 - exit;
7440 -}
7441 -
7442 -/**
7443 - * Delete tag-role mapping via AJAX
7444 - */
7445 -public function ajax_delete_tag_role_mapping() {
7446 - // Verify nonce and permissions
7447 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7448 -
7449 - if (!current_user_can('manage_options')) {
7450 - wp_send_json_error('Unauthorized access');
7451 - exit;
7452 - }
7453 -
7454 - $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
7455 -
7456 - if (empty($tag_slug)) {
7457 - wp_send_json_error('Tag slug is required');
7458 - exit;
7459 - }
7460 -
7461 - // Get existing mappings
7462 - $mappings = get_option('mxchat_tag_role_mappings', array());
7463 -
7464 - // Check if mapping exists
7465 - if (!isset($mappings[$tag_slug])) {
7466 - wp_send_json_error('Mapping does not exist');
7467 - exit;
7468 - }
7469 -
7470 - // Remove mapping
7471 - unset($mappings[$tag_slug]);
7472 - update_option('mxchat_tag_role_mappings', $mappings);
7473 -
7474 - wp_send_json_success(array(
7475 - 'message' => 'Tag-role mapping deleted successfully',
7476 - 'tag_slug' => $tag_slug
7477 - ));
7478 - exit;
7479 -}
7480 -
7481 -/**
7482 - * Get all tag-role mappings via AJAX
7483 - */
7484 -public function ajax_get_tag_role_mappings() {
7485 - // Verify nonce and permissions
7486 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7487 -
7488 - if (!current_user_can('manage_options')) {
7489 - wp_send_json_error('Unauthorized access');
7490 - exit;
7491 - }
7492 -
7493 - // Get mappings
7494 - $mappings = get_option('mxchat_tag_role_mappings', array());
7495 - $role_options = $this->mxchat_get_role_options();
7496 -
7497 - $formatted_mappings = array();
7498 -
7499 - foreach ($mappings as $tag_slug => $role_restriction) {
7500 - // Get tag object
7501 - $term = get_term_by('slug', $tag_slug, 'post_tag');
7502 -
7503 - // Count posts with this tag
7504 - $post_count = 0;
7505 - if ($term) {
7506 - $post_count = $term->count;
7507 - }
7508 -
7509 - $formatted_mappings[] = array(
7510 - 'tag_slug' => $tag_slug,
7511 - 'role_restriction' => $role_restriction,
7512 - 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
7513 - 'post_count' => $post_count
7514 - );
7515 - }
7516 -
7517 - wp_send_json_success(array(
7518 - 'mappings' => $formatted_mappings
7519 - ));
7520 - exit;
7521 -}
7522 -
7523 -/**
7524 - * Bulk update role restrictions for all existing content with mapped tags
7525 - */
7526 -public function ajax_bulk_update_tag_roles() {
7527 - // Verify nonce and permissions
7528 - check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
7529 -
7530 - if (!current_user_can('manage_options')) {
7531 - wp_send_json_error('Unauthorized access');
7532 - exit;
7533 - }
7534 -
7535 - // Get mappings
7536 - $mappings = get_option('mxchat_tag_role_mappings', array());
7537 -
7538 - if (empty($mappings)) {
7539 - wp_send_json_error('No tag-role mappings found');
7540 - exit;
7541 - }
7542 -
7543 - global $wpdb;
7544 -
7545 - // Check if using Pinecone
7546 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7547 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7548 -
7549 - $updated_count = 0;
7550 - $details = array();
7551 -
7552 - foreach ($mappings as $tag_slug => $role_restriction) {
7553 - // Get all posts with this tag
7554 - $posts = get_posts(array(
7555 - 'tag' => $tag_slug,
7556 - 'post_type' => 'any',
7557 - 'posts_per_page' => -1,
7558 - 'fields' => 'ids',
7559 - 'post_status' => 'publish'
7560 - ));
7561 -
7562 - if (empty($posts)) {
7563 - continue;
7564 - }
7565 -
7566 - $tag_updated = 0;
7567 -
7568 - foreach ($posts as $post_id) {
7569 - $source_url = get_permalink($post_id);
7570 - if (!$source_url) {
7571 - continue;
7572 - }
7573 -
7574 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7575 - // Update Pinecone role restriction
7576 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7577 - $vector_id = md5($source_url);
7578 -
7579 - $result = $wpdb->replace(
7580 - $roles_table,
7581 - array(
7582 - 'vector_id' => $vector_id,
7583 - 'role_restriction' => $role_restriction,
7584 - 'updated_at' => current_time('mysql')
7585 - ),
7586 - array('%s', '%s', '%s')
7587 - );
7588 - } else {
7589 - // Update WordPress DB
7590 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7591 -
7592 - $result = $wpdb->update(
7593 - $table_name,
7594 - array('role_restriction' => $role_restriction),
7595 - array('source_url' => $source_url),
7596 - array('%s'),
7597 - array('%s')
7598 - );
7599 - }
7600 -
7601 - if ($result !== false) {
7602 - $tag_updated++;
7603 - $updated_count++;
7604 - }
7605 - }
7606 -
7607 - if ($tag_updated > 0) {
7608 - $details[] = sprintf(
7609 - 'Tag "%s" (%s): %d posts updated',
7610 - $tag_slug,
7611 - $role_restriction,
7612 - $tag_updated
7613 - );
7614 - }
7615 - }
7616 -
7617 - wp_send_json_success(array(
7618 - 'message' => 'Bulk update completed',
7619 - 'updated_count' => $updated_count,
7620 - 'tags_processed' => count($mappings),
7621 - 'details' => $details
7622 - ));
7623 - exit;
7624 -}
7625 -
7626 -/**
7627 - * Handle tag changes on posts (when tags are added or removed)
7628 - */
7629 -public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
7630 - // Only process post tags
7631 - if ($taxonomy !== 'post_tag') {
7632 - return;
7633 - }
7634 -
7635 - // Get tag-role mappings
7636 - $mappings = get_option('mxchat_tag_role_mappings', array());
7637 -
7638 - if (empty($mappings)) {
7639 - return;
7640 - }
7641 -
7642 - // Get the post's URL
7643 - $source_url = get_permalink($object_id);
7644 - if (!$source_url) {
7645 - return;
7646 - }
7647 -
7648 - // Determine the highest role restriction based on tags
7649 - $highest_role = 'public';
7650 - $role_hierarchy = array(
7651 - 'public' => 0,
7652 - 'logged_in' => 1,
7653 - 'subscriber' => 2,
7654 - 'contributor' => 3,
7655 - 'author' => 4,
7656 - 'editor' => 5,
7657 - 'administrator' => 6
7658 - );
7659 -
7660 - // Get all current tags for the post
7661 - $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
7662 -
7663 - // Find the highest role restriction among the tags
7664 - foreach ($current_tags as $tag_slug) {
7665 - if (isset($mappings[$tag_slug])) {
7666 - $role = $mappings[$tag_slug];
7667 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
7668 - $highest_role = $role;
7669 - }
7670 - }
7671 - }
7672 -
7673 - // Update the role restriction in the database
7674 - global $wpdb;
7675 -
7676 - // Check if using Pinecone
7677 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7678 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7679 -
7680 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7681 - // Update Pinecone role restriction
7682 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7683 - $vector_id = md5($source_url);
7684 -
7685 - $wpdb->replace(
7686 - $roles_table,
7687 - array(
7688 - 'vector_id' => $vector_id,
7689 - 'role_restriction' => $highest_role,
7690 - 'updated_at' => current_time('mysql')
7691 - ),
7692 - array('%s', '%s', '%s')
7693 - );
7694 - } else {
7695 - // Update WordPress DB
7696 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7697 -
7698 - $wpdb->update(
7699 - $table_name,
7700 - array('role_restriction' => $highest_role),
7701 - array('source_url' => $source_url),
7702 - array('%s'),
7703 - array('%s')
7704 - );
7705 - }
7706 -}
7707 -
7708 -/**
7709 - * Apply role restriction after content is stored (for auto-sync)
7710 - */
7711 -public function apply_role_restriction_after_storage($post_id, $source_url) {
7712 - // Get tag-role mappings
7713 - $mappings = get_option('mxchat_tag_role_mappings', array());
7714 -
7715 - if (empty($mappings)) {
7716 - return;
7717 - }
7718 -
7719 - // Get all tags for the post
7720 - $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
7721 -
7722 - if (empty($post_tags)) {
7723 - return;
7724 - }
7725 -
7726 - // Determine the highest role restriction based on tags
7727 - $highest_role = 'public';
7728 - $role_hierarchy = array(
7729 - 'public' => 0,
7730 - 'logged_in' => 1,
7731 - 'subscriber' => 2,
7732 - 'contributor' => 3,
7733 - 'author' => 4,
7734 - 'editor' => 5,
7735 - 'administrator' => 6
7736 - );
7737 -
7738 - foreach ($post_tags as $tag_slug) {
7739 - if (isset($mappings[$tag_slug])) {
7740 - $role = $mappings[$tag_slug];
7741 - if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
7742 - $highest_role = $role;
7743 - }
7744 - }
7745 - }
7746 -
7747 - // If no restricted tags found, return (leave as public)
7748 - if ($highest_role === 'public') {
7749 - return;
7750 - }
7751 -
7752 - // Update the role restriction
7753 - global $wpdb;
7754 -
7755 - // Check if using Pinecone
7756 - $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
7757 - $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7758 -
7759 - if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
7760 - // Update Pinecone role restriction
7761 - $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7762 - $vector_id = md5($source_url);
7763 -
7764 - $wpdb->replace(
7765 - $roles_table,
7766 - array(
7767 - 'vector_id' => $vector_id,
7768 - 'role_restriction' => $highest_role,
7769 - 'updated_at' => current_time('mysql')
7770 - ),
7771 - array('%s', '%s', '%s')
7772 - );
7773 - } else {
7774 - // Update WordPress DB
7775 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7776 -
7777 - $wpdb->update(
7778 - $table_name,
7779 - array('role_restriction' => $highest_role),
7780 - array('source_url' => $source_url),
7781 - array('%s'),
7782 - array('%s')
7783 - );
7784 - }
7785 -}
7786 -
7787 -
7788 - // ========================================
7789 - // HELPER METHODS
7790 - // ========================================
7791 -
7792 - /**
7793 - * Check if user has required permissions for content processing
7794 - */
7795 - private function mxchat_check_user_permissions() {
7796 - if (!current_user_can('manage_options')) {
7797 - wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
7798 - }
7799 - }
7800 -
7801 - /**
7802 - * Validate nonce for security
7803 - */
7804 - private function mxchat_validate_nonce($nonce_name, $nonce_action) {
7805 - if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
7806 - wp_die(esc_html__('Security check failed.', 'mxchat'));
7807 - }
7808 - }
7809 -
7810 - /**
7811 - * Get embedding API credentials
7812 - */
7813 - private function mxchat_get_embedding_credentials() {
7814 - $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
7815 -
7816 - if (strpos($embedding_model, 'text-embedding-') !== false) {
7817 - return array(
7818 - 'type' => 'openai',
7819 - 'api_key' => $this->options['api_key'] ?? ''
7820 - );
7821 - } elseif (strpos($embedding_model, 'voyage-') !== false) {
7822 - return array(
7823 - 'type' => 'voyage',
7824 - 'api_key' => $this->options['voyage_api_key'] ?? ''
7825 - );
7826 - } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
7827 - return array(
7828 - 'type' => 'gemini',
7829 - 'api_key' => $this->options['gemini_api_key'] ?? ''
7830 - );
7831 - }
7832 -
7833 - return array('type' => 'unknown', 'api_key' => '');
7834 - }
7835 -
7836 - /**
7837 - * Log processing errors
7838 - */
7839 - private function mxchat_log_processing_error($operation, $error_message) {
7840 - //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
7841 - }
7842 -
7843 - /**
7844 - * Set admin notice transient
7845 - */
7846 - private function mxchat_set_admin_notice($type, $message) {
7847 - set_transient("mxchat_admin_notice_{$type}", $message, 30);
7848 - }
7849 -
7850 - /**
7851 - * Get Pinecone manager instance for vector operations
7852 - */
7853 - private function mxchat_get_pinecone_manager() {
7854 - return MxChat_Pinecone_Manager::get_instance();
7855 - }
7856 -
7857 -
7858 - // ========================================
7859 -// DATABASE QUEUE TABLE MANAGEMENT
7860 -// ========================================
7861 -
7862 -/**
7863 - * Create queue table on plugin activation
7864 - * Call this from your plugin activation hook
7865 - */
7866 -public function mxchat_create_queue_table() {
7867 - global $wpdb;
7868 -
7869 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7870 - $charset_collate = $wpdb->get_charset_collate();
7871 -
7872 - $sql = "CREATE TABLE IF NOT EXISTS $table_name (
7873 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
7874 - queue_id varchar(64) NOT NULL,
7875 - item_type varchar(20) NOT NULL,
7876 - item_data longtext NOT NULL,
7877 - status varchar(20) NOT NULL DEFAULT 'pending',
7878 - bot_id varchar(50) NOT NULL DEFAULT 'default',
7879 - priority int(11) NOT NULL DEFAULT 0,
7880 - attempts int(11) NOT NULL DEFAULT 0,
7881 - max_attempts int(11) NOT NULL DEFAULT 3,
7882 - error_message text DEFAULT NULL,
7883 - created_at datetime NOT NULL,
7884 - started_at datetime DEFAULT NULL,
7885 - completed_at datetime DEFAULT NULL,
7886 - PRIMARY KEY (id),
7887 - KEY queue_id (queue_id),
7888 - KEY status (status),
7889 - KEY item_type (item_type),
7890 - KEY priority (priority)
7891 - ) $charset_collate;";
7892 -
7893 - require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
7894 - dbDelta($sql);
7895 -
7896 - // Also create a meta table for queue metadata
7897 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7898 -
7899 - $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
7900 - id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
7901 - queue_id varchar(64) NOT NULL,
7902 - meta_key varchar(255) NOT NULL,
7903 - meta_value longtext,
7904 - PRIMARY KEY (id),
7905 - KEY queue_id (queue_id),
7906 - KEY meta_key (meta_key)
7907 - ) $charset_collate;";
7908 -
7909 - dbDelta($meta_sql);
7910 -}
7911 -
7912 -/**
7913 - * Add items to the processing queue
7914 - *
7915 - * @param string $queue_id Unique identifier for this queue batch
7916 - * @param string $item_type Type of item (url, pdf_page)
7917 - * @param array $items Array of items to queue
7918 - * @param string $bot_id Bot ID for processing
7919 - * @return int Number of items queued
7920 - */
7921 -private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
7922 - global $wpdb;
7923 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7924 -
7925 - $queued_count = 0;
7926 - $priority = 0;
7927 -
7928 - foreach ($items as $item) {
7929 - $result = $wpdb->insert(
7930 - $table_name,
7931 - array(
7932 - 'queue_id' => $queue_id,
7933 - 'item_type' => $item_type,
7934 - 'item_data' => wp_json_encode($item),
7935 - 'status' => 'pending',
7936 - 'bot_id' => $bot_id,
7937 - 'priority' => $priority,
7938 - 'attempts' => 0,
7939 - 'max_attempts' => 3,
7940 - 'created_at' => current_time('mysql')
7941 - ),
7942 - array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
7943 - );
7944 -
7945 - if ($result) {
7946 - $queued_count++;
7947 - }
7948 -
7949 - $priority++; // Process in order
7950 - }
7951 -
7952 - return $queued_count;
7953 -}
7954 -
7955 -/**
7956 - * Store queue metadata (total counts, source URL, etc.)
7957 - */
7958 -private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
7959 - global $wpdb;
7960 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7961 -
7962 - // Check if meta exists
7963 - $existing = $wpdb->get_var($wpdb->prepare(
7964 - "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
7965 - $queue_id,
7966 - $meta_key
7967 - ));
7968 -
7969 - if ($existing) {
7970 - // Update
7971 - $wpdb->update(
7972 - $meta_table,
7973 - array('meta_value' => maybe_serialize($meta_value)),
7974 - array('queue_id' => $queue_id, 'meta_key' => $meta_key),
7975 - array('%s'),
7976 - array('%s', '%s')
7977 - );
7978 - } else {
7979 - // Insert
7980 - $wpdb->insert(
7981 - $meta_table,
7982 - array(
7983 - 'queue_id' => $queue_id,
7984 - 'meta_key' => $meta_key,
7985 - 'meta_value' => maybe_serialize($meta_value)
7986 - ),
7987 - array('%s', '%s', '%s')
7988 - );
7989 - }
7990 -}
7991 -
7992 -/**
7993 - * Get queue metadata
7994 - */
7995 -private function mxchat_get_queue_meta($queue_id, $meta_key) {
7996 - global $wpdb;
7997 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7998 -
7999 - $value = $wpdb->get_var($wpdb->prepare(
8000 - "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
8001 - $queue_id,
8002 - $meta_key
8003 - ));
8004 -
8005 - return maybe_unserialize($value);
8006 -}
8007 -
8008 -// ========================================
8009 -// AJAX QUEUE PROCESSING HANDLERS
8010 -// ========================================
8011 -
8012 -/**
8013 - * AJAX: Get next item from queue to process
8014 - */
8015 -public function ajax_mxchat_get_next_queue_item() {
8016 - // Verify nonce and permissions
8017 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8018 -
8019 - if (!current_user_can('manage_options')) {
8020 - wp_send_json_error('Unauthorized access');
8021 - }
8022 -
8023 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8024 -
8025 - if (empty($queue_id)) {
8026 - wp_send_json_error('Missing queue ID');
8027 - }
8028 -
8029 - global $wpdb;
8030 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8031 -
8032 - // Get next pending item with retry logic for failed items
8033 - $next_item = $wpdb->get_row($wpdb->prepare(
8034 - "SELECT * FROM $table_name
8035 - WHERE queue_id = %s
8036 - AND status IN ('pending', 'failed')
8037 - AND attempts < max_attempts
8038 - ORDER BY priority ASC, id ASC
8039 - LIMIT 1",
8040 - $queue_id
8041 - ));
8042 -
8043 - if (!$next_item) {
8044 - // No more items - queue complete
8045 - wp_send_json_success(array(
8046 - 'complete' => true,
8047 - 'message' => 'Queue processing complete'
8048 - ));
8049 - }
8050 -
8051 - // Mark item as processing
8052 - $wpdb->update(
8053 - $table_name,
8054 - array(
8055 - 'status' => 'processing',
8056 - 'started_at' => current_time('mysql'),
8057 - 'attempts' => $next_item->attempts + 1
8058 - ),
8059 - array('id' => $next_item->id),
8060 - array('%s', '%s', '%d'),
8061 - array('%d')
8062 - );
8063 -
8064 - wp_send_json_success(array(
8065 - 'complete' => false,
8066 - 'item' => array(
8067 - 'id' => $next_item->id,
8068 - 'type' => $next_item->item_type,
8069 - 'data' => json_decode($next_item->item_data, true),
8070 - 'bot_id' => $next_item->bot_id,
8071 - 'attempt' => $next_item->attempts + 1
8072 - )
8073 - ));
8074 -}
8075 -
8076 -/**
8077 - * AJAX: Process a single queue item
8078 - */
8079 -public function ajax_mxchat_process_queue_item() {
8080 - // Verify nonce and permissions
8081 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8082 -
8083 - if (!current_user_can('manage_options')) {
8084 - wp_send_json_error('Unauthorized access');
8085 - }
8086 -
8087 - $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
8088 - $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
8089 - $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
8090 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
8091 -
8092 - if (empty($item_id) || empty($item_type)) {
8093 - wp_send_json_error('Missing item data');
8094 - }
8095 -
8096 - global $wpdb;
8097 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8098 -
8099 - // Process based on item type
8100 - try {
8101 - set_time_limit(60); // Give processing 60 seconds
8102 -
8103 - $result = false;
8104 - $error_message = '';
8105 -
8106 - // Read item directly from DB to get queue_id and preserve special chars in item_data
8107 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
8108 - $db_item = $wpdb->get_row($wpdb->prepare(
8109 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
8110 - $item_id
8111 - ));
8112 - $item_queue_id = $db_item ? $db_item->queue_id : '';
8113 - if ($db_item && !empty($db_item->item_data)) {
8114 - $db_data = json_decode($db_item->item_data, true);
8115 - if (is_array($db_data)) {
8116 - $item_data = $db_data;
8117 - }
8118 - }
8119 -
8120 - switch ($item_type) {
8121 - case 'url':
8122 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
8123 - break;
8124 -
8125 - case 'pdf_page':
8126 - $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
8127 - break;
8128 -
8129 - default:
8130 - throw new Exception('Unknown item type: ' . $item_type);
8131 - }
8132 -
8133 - if (is_wp_error($result)) {
8134 - $error_code = $result->get_error_code();
8135 - // Content errors (empty page, sanitization) are permanent — retrying won't help
8136 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
8137 - if (in_array($error_code, $permanent_codes)) {
8138 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
8139 - $current_item = $wpdb->get_row($wpdb->prepare(
8140 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
8141 - ));
8142 - $wpdb->update(
8143 - $table_name,
8144 - array(
8145 - 'status' => 'failed',
8146 - 'error_message' => $result->get_error_message(),
8147 - 'attempts' => $current_item ? $current_item->max_attempts : 3
8148 - ),
8149 - array('id' => $item_id),
8150 - array('%s', '%s', '%d'),
8151 - array('%d')
8152 - );
8153 - wp_send_json_error(array(
8154 - 'message' => $result->get_error_message(),
8155 - 'permanent_failure' => true,
8156 - 'item_id' => $item_id
8157 - ));
8158 - return;
8159 - }
8160 - throw new Exception($result->get_error_message());
8161 - }
8162 -
8163 - if ($result === false) {
8164 - throw new Exception('Processing returned false - item may be empty or invalid');
8165 - }
8166 -
8167 - // Mark as completed
8168 - $wpdb->update(
8169 - $table_name,
8170 - array(
8171 - 'status' => 'completed',
8172 - 'completed_at' => current_time('mysql'),
8173 - 'error_message' => null
8174 - ),
8175 - array('id' => $item_id),
8176 - array('%s', '%s', '%s'),
8177 - array('%d')
8178 - );
8179 -
8180 - wp_send_json_success(array(
8181 - 'processed' => true,
8182 - 'item_id' => $item_id,
8183 - 'message' => 'Item processed successfully'
8184 - ));
8185 -
8186 - } catch (Exception $e) {
8187 - $error_message = $e->getMessage();
8188 -
8189 - // Get current attempt count
8190 - $item = $wpdb->get_row($wpdb->prepare(
8191 - "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
8192 - $item_id
8193 - ));
8194 -
8195 - // Check if we've exhausted retries
8196 - if ($item && $item->attempts >= $item->max_attempts) {
8197 - // Permanently failed
8198 - $wpdb->update(
8199 - $table_name,
8200 - array(
8201 - 'status' => 'failed',
8202 - 'error_message' => $error_message
8203 - ),
8204 - array('id' => $item_id),
8205 - array('%s', '%s'),
8206 - array('%d')
8207 - );
8208 -
8209 - wp_send_json_error(array(
8210 - 'message' => 'Item failed after maximum attempts: ' . $error_message,
8211 - 'permanent_failure' => true,
8212 - 'item_id' => $item_id
8213 - ));
8214 - } else {
8215 - // Mark for retry
8216 - $wpdb->update(
8217 - $table_name,
8218 - array(
8219 - 'status' => 'failed',
8220 - 'error_message' => $error_message
8221 - ),
8222 - array('id' => $item_id),
8223 - array('%s', '%s'),
8224 - array('%d')
8225 - );
8226 -
8227 - wp_send_json_error(array(
8228 - 'message' => 'Item processing failed, will retry: ' . $error_message,
8229 - 'can_retry' => true,
8230 - 'item_id' => $item_id,
8231 - 'attempts' => $item ? $item->attempts : 0
8232 - ));
8233 - }
8234 - }
8235 -}
8236 -
8237 -/**
8238 - * Process a URL from the queue
8239 - */
8240 -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
8241 - $url = isset($item_data['url']) ? $item_data['url'] : '';
8242 -
8243 - if (empty($url)) {
8244 - return new WP_Error('invalid_url', 'URL is empty');
8245 - }
8246 -
8247 - // Get bot-specific embedding decision early (needed for both paths) —
8248 - // custom-provider-aware (plan cbd5fd). Error code preserved.
8249 - $bot_options = $this->get_bot_options($bot_id);
8250 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
8251 -
8252 - $preflight = MxChat_Utils::embedding_preflight($options);
8253 - if (!$preflight['ok']) {
8254 - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
8255 - }
8256 - $api_key = $preflight['api_key'];
8257 -
8258 - // Check if this is a WooCommerce product URL and WooCommerce is active
8259 - $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
8260 - $content_type = $is_product_url ? 'product' : 'url';
8261 -
8262 - // Try to get WooCommerce product data if it's a product URL
8263 - if ($is_product_url && class_exists('WooCommerce')) {
8264 - $product_content = $this->mxchat_extract_woocommerce_product_content($url);
8265 -
8266 - if (!empty($product_content)) {
8267 - // Successfully extracted WooCommerce product data with pricing
8268 - $result = MxChat_Utils::submit_content_to_db(
8269 - $product_content,
8270 - $url,
8271 - $api_key,
8272 - null,
8273 - $bot_id,
8274 - 'product'
8275 - );
8276 - return $result;
8277 - }
8278 - // If WooCommerce extraction failed, fall through to HTML extraction
8279 - }
8280 -
8281 - // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
8282 - $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
8283 - $response = wp_remote_get($url, array(
8284 - 'timeout' => $is_likely_pdf ? 120 : 30,
8285 - 'redirection' => 5,
8286 - 'user-agent' => mxchat_ingest_user_agent(),
8287 - ));
8288 -
8289 - if (is_wp_error($response)) {
8290 - return $response;
8291 - }
8292 -
8293 - $response_code = wp_remote_retrieve_response_code($response);
8294 - if ($response_code !== 200) {
8295 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
8296 - }
8297 -
8298 - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
8299 - if ($this->mxchat_is_pdf_url($url, $response)) {
8300 - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
8301 - }
8302 -
8303 - $html = wp_remote_retrieve_body($response);
8304 -
8305 - if (empty($html)) {
8306 - return new WP_Error('empty_response', 'Empty response body');
8307 - }
8308 -
8309 - // Extract and sanitize content
8310 - $content = $this->mxchat_extract_main_content($html);
8311 - $sanitized = $this->mxchat_sanitize_content_for_api($content);
8312 -
8313 - if (empty($sanitized)) {
8314 - // Not an error - just no content found (maybe a redirect or empty page)
8315 - return false;
8316 - }
8317 -
8318 - // Submit to database with content_type
8319 - $result = MxChat_Utils::submit_content_to_db(
8320 - $sanitized,
8321 - $url,
8322 - $api_key,
8323 - null,
8324 - $bot_id,
8325 - $content_type
8326 - );
8327 -
8328 - return $result;
8329 -}
8330 -
8331 -/**
8332 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
8333 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
8334 - * and adds pdf_page items to the same queue so they process with full progress tracking.
8335 - */
8336 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
8337 - set_time_limit(120); // PDFs need extra time for download + parsing
8338 -
8339 - $upload_dir = wp_upload_dir();
8340 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
8341 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
8342 -
8343 - $response_body = wp_remote_retrieve_body($response);
8344 - if (empty($response_body)) {
8345 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
8346 - }
8347 -
8348 - if (!wp_mkdir_p(dirname($pdf_path))) {
8349 - return new WP_Error('dir_error', 'Failed to create upload directory');
8350 - }
8351 -
8352 - file_put_contents($pdf_path, $response_body);
8353 -
8354 - if (!file_exists($pdf_path)) {
8355 - return new WP_Error('save_error', 'Failed to save PDF file');
8356 - }
8357 -
8358 - try {
8359 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
8360 -
8361 - if ($total_pages === false || $total_pages < 1) {
8362 - wp_delete_file($pdf_path);
8363 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
8364 - }
8365 -
8366 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
8367 - $pages = array();
8368 - for ($i = 1; $i <= $total_pages; $i++) {
8369 - $pages[] = array(
8370 - 'pdf_path' => $pdf_path,
8371 - 'pdf_url' => $pdf_url,
8372 - 'page_number' => $i,
8373 - 'total_pages' => $total_pages
8374 - );
8375 - }
8376 -
8377 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
8378 - if (!empty($queue_id)) {
8379 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
8380 - } else {
8381 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
8382 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
8383 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
8384 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
8385 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
8386 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
8387 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
8388 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
8389 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
8390 - }
8391 -
8392 - if ($queued_count === 0) {
8393 - wp_delete_file($pdf_path);
8394 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
8395 - }
8396 -
8397 - // Return true so the original URL item is marked complete
8398 - // The new pdf_page items will be processed in subsequent batches
8399 - return true;
8400 -
8401 - } catch (Exception $e) {
8402 - if (file_exists($pdf_path)) {
8403 - wp_delete_file($pdf_path);
8404 - }
8405 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8406 - }
8407 -}
8408 -
8409 -/**
8410 - * Legacy: Process a PDF URL inline during sitemap queue processing.
8411 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
8412 - */
8413 -private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
8414 - set_time_limit(120); // PDFs need more time — downloading + parsing all pages
8415 -
8416 - $upload_dir = wp_upload_dir();
8417 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
8418 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
8419 -
8420 - $response_body = wp_remote_retrieve_body($response);
8421 - if (empty($response_body)) {
8422 - return new WP_Error('empty_pdf', 'Empty PDF response');
8423 - }
8424 -
8425 - if (!wp_mkdir_p(dirname($pdf_path))) {
8426 - return new WP_Error('dir_error', 'Failed to create upload directory');
8427 - }
8428 -
8429 - file_put_contents($pdf_path, $response_body);
8430 -
8431 - if (!file_exists($pdf_path)) {
8432 - return new WP_Error('save_error', 'Failed to save PDF file');
8433 - }
8434 -
8435 - try {
8436 - mxchat_load_pdf_parser();
8437 - $parser = new \Smalot\PdfParser\Parser();
8438 - $pdf = $parser->parseFile($pdf_path);
8439 - $pages = $pdf->getPages();
8440 - $total_pages = count($pages);
8441 -
8442 - if ($total_pages < 1) {
8443 - wp_delete_file($pdf_path);
8444 - return new WP_Error('no_pages', 'PDF has no pages');
8445 - }
8446 -
8447 - $processed = 0;
8448 - $skipped_pages = array();
8449 -
8450 - for ($i = 0; $i < $total_pages; $i++) {
8451 - $page_num = $i + 1;
8452 - $text = $pages[$i]->getText();
8453 - if (empty($text)) {
8454 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
8455 - continue;
8456 - }
8457 -
8458 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
8459 - if (empty($sanitized)) {
8460 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
8461 - continue;
8462 - }
8463 -
8464 - $metadata = array(
8465 - 'document_type' => 'pdf',
8466 - 'total_pages' => $total_pages,
8467 - 'current_page' => $page_num,
8468 - 'source_url' => $pdf_url,
8469 - );
8470 -
8471 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
8472 - $page_url = esc_url($pdf_url . '#page=' . $page_num);
8473 -
8474 - MxChat_Utils::submit_content_to_db(
8475 - $content_with_metadata,
8476 - $page_url,
8477 - $api_key,
8478 - null,
8479 - $bot_id,
8480 - 'pdf'
8481 - );
8482 -
8483 - $processed++;
8484 - }
8485 -
8486 - // Clean up the temp PDF file
8487 - wp_delete_file($pdf_path);
8488 -
8489 - if (!empty($skipped_pages)) {
8490 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
8491 - }
8492 -
8493 - return $processed > 0 ? true : false;
8494 -
8495 - } catch (Exception $e) {
8496 - if (file_exists($pdf_path)) {
8497 - wp_delete_file($pdf_path);
8498 - }
8499 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8500 - }
8501 -}
8502 -
8503 -/**
8504 - * Extract WooCommerce product content including pricing
8505 - *
8506 - * @param string $url The product URL
8507 - * @return string|false Product content with pricing, or false if not found
8508 - */
8509 -private function mxchat_extract_woocommerce_product_content($url) {
8510 - // Try to get product ID from URL
8511 - $product_id = url_to_postid($url);
8512 -
8513 - // If url_to_postid fails, try to extract from URL pattern
8514 - if (!$product_id) {
8515 - $product_slug = '';
8516 -
8517 - // Handle pretty permalinks: /product/product-name/
8518 - if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
8519 - $product_slug = $matches[1];
8520 - }
8521 -
8522 - if (!empty($product_slug)) {
8523 - $product_post = get_page_by_path($product_slug, OBJECT, 'product');
8524 - if ($product_post) {
8525 - $product_id = $product_post->ID;
8526 - }
8527 - }
8528 - }
8529 -
8530 - if (!$product_id) {
8531 - return false;
8532 - }
8533 -
8534 - // Get WooCommerce product object
8535 - $product = wc_get_product($product_id);
8536 -
8537 - if (!$product) {
8538 - return false;
8539 - }
8540 -
8541 - // Build product content with pricing (similar to mxchat_store_product_embedding)
8542 - $title = $product->get_name();
8543 - $description = $product->get_description();
8544 - $short_description = $product->get_short_description();
8545 - $sku = $product->get_sku();
8546 -
8547 - // Get pricing information
8548 - $regular_price = $product->get_regular_price();
8549 - $sale_price = $product->get_sale_price();
8550 - $price = $product->get_price(); // Current active price
8551 -
8552 - // Get currency symbol
8553 - $currency_symbol = get_woocommerce_currency_symbol();
8554 -
8555 - // Format content
8556 - $content = $title . "\n\n";
8557 -
8558 - if (!empty($short_description)) {
8559 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
8560 - }
8561 -
8562 - if (!empty($description)) {
8563 - $content .= wp_strip_all_tags($description) . "\n\n";
8564 - }
8565 -
8566 - // Add pricing information
8567 - if (!empty($regular_price)) {
8568 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
8569 - } elseif (!empty($price)) {
8570 - $content .= "Price: " . $currency_symbol . $price . "\n";
8571 - }
8572 -
8573 - if (!empty($sale_price) && $sale_price !== $regular_price) {
8574 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
8575 - }
8576 -
8577 - // Handle variable products - show price range
8578 - if ($product->is_type('variable')) {
8579 - $min_price = $product->get_variation_price('min');
8580 - $max_price = $product->get_variation_price('max');
8581 - if ($min_price !== $max_price) {
8582 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
8583 - }
8584 - }
8585 -
8586 - if (!empty($sku)) {
8587 - $content .= "SKU: " . $sku . "\n";
8588 - }
8589 -
8590 - // Get product categories
8591 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
8592 - if (!empty($categories) && !is_wp_error($categories)) {
8593 - $content .= "Categories: " . implode(', ', $categories) . "\n";
8594 - }
8595 -
8596 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
8597 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
8598 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
8599 - foreach ($custom_tabs as $tab) {
8600 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
8601 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
8602 -
8603 - if (!empty($tab_title) && !empty($tab_content)) {
8604 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
8605 - }
8606 - }
8607 - }
8608 -
8609 - // Also check for reusable/saved tabs applied to this product
8610 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
8611 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
8612 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
8613 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
8614 - foreach ($applied_saved_tabs as $saved_tab_id) {
8615 - if (isset($saved_tabs[$saved_tab_id])) {
8616 - $tab = $saved_tabs[$saved_tab_id];
8617 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
8618 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
8619 -
8620 - if (!empty($tab_title) && !empty($tab_content)) {
8621 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
8622 - }
8623 - }
8624 - }
8625 - }
8626 - }
8627 -
8628 - return $this->mxchat_sanitize_content_for_api($content);
8629 -}
8630 -
8631 -/**
8632 - * Process a PDF page from the queue
8633 - */
8634 -private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
8635 - $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
8636 - $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
8637 - $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
8638 - $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
8639 -
8640 - if (empty($pdf_path) || !file_exists($pdf_path)) {
8641 - return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
8642 - }
8643 -
8644 - if ($page_number < 1) {
8645 - return new WP_Error('invalid_page', 'Invalid page number');
8646 - }
8647 -
8648 - try {
8649 - mxchat_load_pdf_parser();
8650 - $parser = new \Smalot\PdfParser\Parser();
8651 - $pdf = $parser->parseFile($pdf_path);
8652 - $pages = $pdf->getPages();
8653 -
8654 - if (!isset($pages[$page_number - 1])) {
8655 - return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
8656 - }
8657 -
8658 - $text = $pages[$page_number - 1]->getText();
8659 -
8660 - if (empty($text)) {
8661 - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
8662 - }
8663 -
8664 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
8665 -
8666 - if (empty($sanitized)) {
8667 - 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');
8668 - }
8669 -
8670 - // Create metadata
8671 - $metadata = array(
8672 - 'document_type' => 'pdf',
8673 - 'total_pages' => $total_pages,
8674 - 'current_page' => $page_number,
8675 - 'source_url' => $pdf_url
8676 - );
8677 -
8678 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
8679 - $page_url = esc_url($pdf_url . "#page=" . $page_number);
8680 -
8681 - // Get bot-specific embedding decision — custom-provider-aware
8682 - // (plan cbd5fd). Error code preserved.
8683 - $bot_options = $this->get_bot_options($bot_id);
8684 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
8685 -
8686 - $preflight = MxChat_Utils::embedding_preflight($options);
8687 - if (!$preflight['ok']) {
8688 - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
8689 - }
8690 - $api_key = $preflight['api_key'];
8691 -
8692 - // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
8693 - $result = MxChat_Utils::submit_content_to_db(
8694 - $content_with_metadata,
8695 - $page_url,
8696 - $api_key,
8697 - null,
8698 - $bot_id,
8699 - 'pdf'
8700 - );
8701 -
8702 - return $result;
8703 -
8704 - } catch (Exception $e) {
8705 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
8706 - }
8707 -}
8708 -
8709 -/**
8710 - * AJAX: Get queue processing status
8711 - */
8712 -public function ajax_mxchat_get_queue_status() {
8713 - // Verify nonce and permissions
8714 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8715 -
8716 - if (!current_user_can('manage_options')) {
8717 - wp_send_json_error('Unauthorized access');
8718 - }
8719 -
8720 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8721 -
8722 - if (empty($queue_id)) {
8723 - wp_send_json_error('Missing queue ID');
8724 - }
8725 -
8726 - global $wpdb;
8727 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8728 -
8729 - // Get counts by status
8730 - $counts = $wpdb->get_results($wpdb->prepare(
8731 - "SELECT status, COUNT(*) as count
8732 - FROM $table_name
8733 - WHERE queue_id = %s
8734 - GROUP BY status",
8735 - $queue_id
8736 - ), OBJECT_K);
8737 -
8738 - $total = 0;
8739 - $completed = 0;
8740 - $failed = 0;
8741 - $processing = 0;
8742 - $pending = 0;
8743 -
8744 - foreach ($counts as $status => $data) {
8745 - $count = absint($data->count);
8746 - $total += $count;
8747 -
8748 - switch ($status) {
8749 - case 'completed':
8750 - $completed = $count;
8751 - break;
8752 - case 'failed':
8753 - $failed = $count;
8754 - break;
8755 - case 'processing':
8756 - $processing = $count;
8757 - break;
8758 - case 'pending':
8759 - $pending = $count;
8760 - break;
8761 - }
8762 - }
8763 -
8764 - // Calculate percentage
8765 - $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
8766 -
8767 - // Get failed items details (include all failed items, not just those that exhausted retries)
8768 - $failed_items = array();
8769 - if ($failed > 0) {
8770 - $failed_items = $wpdb->get_results($wpdb->prepare(
8771 - "SELECT item_type, item_data, error_message, attempts
8772 - FROM $table_name
8773 - WHERE queue_id = %s
8774 - AND status = 'failed'
8775 - ORDER BY id DESC
8776 - LIMIT 50",
8777 - $queue_id
8778 - ));
8779 - }
8780 -
8781 - // Get queue metadata
8782 - $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
8783 - $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
8784 -
8785 - // Determine if queue is complete
8786 - $is_complete = ($pending === 0 && $processing === 0);
8787 -
8788 - wp_send_json_success(array(
8789 - 'queue_id' => $queue_id,
8790 - 'queue_type' => $queue_type,
8791 - 'source_url' => $source_url,
8792 - 'total' => $total,
8793 - 'completed' => $completed,
8794 - 'failed' => $failed,
8795 - 'processing' => $processing,
8796 - 'pending' => $pending,
8797 - 'percentage' => $percentage,
8798 - 'is_complete' => $is_complete,
8799 - 'failed_items' => $failed_items,
8800 - 'status' => $is_complete ? 'complete' : 'processing'
8801 - ));
8802 -}
8803 -
8804 -/**
8805 - * AJAX: Clear completed queue
8806 - */
8807 -public function ajax_mxchat_clear_queue() {
8808 - // Verify nonce and permissions
8809 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8810 -
8811 - if (!current_user_can('manage_options')) {
8812 - wp_send_json_error('Unauthorized access');
8813 - }
8814 -
8815 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8816 -
8817 - if (empty($queue_id)) {
8818 - wp_send_json_error('Missing queue ID');
8819 - }
8820 -
8821 - global $wpdb;
8822 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8823 - $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
8824 -
8825 - // Delete queue items
8826 - $wpdb->delete(
8827 - $table_name,
8828 - array('queue_id' => $queue_id),
8829 - array('%s')
8830 - );
8831 -
8832 - // Delete queue metadata
8833 - $wpdb->delete(
8834 - $meta_table,
8835 - array('queue_id' => $queue_id),
8836 - array('%s')
8837 - );
8838 -
8839 - wp_send_json_success(array(
8840 - 'message' => 'Queue cleared successfully'
8841 - ));
8842 -}
8843 -
8844 -/**
8845 - * AJAX: Retry failed items in queue
8846 - */
8847 -public function ajax_mxchat_retry_failed() {
8848 - // Verify nonce and permissions
8849 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8850 -
8851 - if (!current_user_can('manage_options')) {
8852 - wp_send_json_error('Unauthorized access');
8853 - }
8854 -
8855 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8856 -
8857 - if (empty($queue_id)) {
8858 - wp_send_json_error('Missing queue ID');
8859 - }
8860 -
8861 - global $wpdb;
8862 - $table_name = $wpdb->prefix . 'mxchat_processing_queue';
8863 -
8864 - // Reset failed items to pending and reset attempt count
8865 - $updated = $wpdb->update(
8866 - $table_name,
8867 - array(
8868 - 'status' => 'pending',
8869 - 'attempts' => 0,
8870 - 'error_message' => null
8871 - ),
8872 - array(
8873 - 'queue_id' => $queue_id,
8874 - 'status' => 'failed'
8875 - ),
8876 - array('%s', '%d', '%s'),
8877 - array('%s', '%s')
8878 - );
8879 -
8880 - wp_send_json_success(array(
8881 - 'message' => 'Reset ' . $updated . ' failed items for retry',
8882 - 'reset_count' => $updated
8883 - ));
8884 -}
8885 -
8886 -
8887 -public function ajax_mxchat_mark_queue_complete() {
8888 - check_ajax_referer('mxchat_queue_nonce', 'nonce');
8889 -
8890 - if (!current_user_can('manage_options')) {
8891 - wp_send_json_error('Unauthorized access');
8892 - }
8893 -
8894 - $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
8895 -
8896 - if (empty($queue_id)) {
8897 - wp_send_json_error('Missing queue ID');
8898 - }
8899 -
8900 - // Clear active queue transients
8901 - if (strpos($queue_id, 'sitemap_') === 0) {
8902 - delete_transient('mxchat_active_queue_sitemap');
8903 - } else if (strpos($queue_id, 'pdf_') === 0) {
8904 - delete_transient('mxchat_active_queue_pdf');
8905 - }
8906 -
8907 - wp_send_json_success(array('message' => 'Queue marked as complete'));
8908 -}
8909 -
8910 -
8911 - // ========================================
8912 - // STATIC ACCESS METHODS
8913 - // ========================================
8914 -
8915 - /**
8916 - * Get singleton instance
8917 - */
8918 - public static function get_instance() {
8919 - static $instance = null;
8920 - if ($instance === null) {
8921 - $instance = new self();
8922 - }
8923 - return $instance;
8924 - }
8925 -}
8926 -
8927 -// Initialize the Knowledge manager
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 + /**
17 + * Constructor - Register hooks for content processing
18 + */
19 +public function __construct() {
20 + $this->options = get_option('mxchat_options', array());
21 + $this->mxchat_init_hooks();
22 +
23 + $this->mxchat_init_role_hooks();
24 +}
25 +
26 +/**
27 + * Initialize WordPress hooks for content processing
28 + *
29 + */
30 +private function mxchat_init_hooks() {
31 + // Admin post handlers for form submissions
32 + add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 + add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 + add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
35 +
36 + // AJAX handlers for real-time processing and status updates
37 + add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
38 + add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
39 + add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
40 + add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
41 + add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
42 + add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
43 + add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
44 + add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
45 + add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
46 + add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
47 + add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
48 +
49 + // Queue-based processing AJAX handlers
50 + add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
51 + add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
52 + add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
53 + add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
54 + add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
55 + add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
56 + add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
57 + add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
58 + add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
59 +
60 + // Hook for content deletion
61 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
62 +
63 + // WordPress post management hooks
64 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
65 + add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
66 + add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
67 + add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
68 +
69 + // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
70 + // Priority 20 to run after ACF's own save (which runs at priority 10)
71 + add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
72 +
73 + add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
74 +
75 + // WooCommerce product hooks (if WooCommerce is active)
76 + if (class_exists('WooCommerce')) {
77 + add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
78 + add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
79 + add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
80 + add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
81 + }
82 +}
83 +
84 + /**
85 + * Get current options (refreshed)
86 + */
87 + private function mxchat_get_options() {
88 + if (empty($this->options)) {
89 + $this->options = get_option('mxchat_options', array());
90 + }
91 + return $this->options;
92 + }
93 +
94 +
95 + // ========================================
96 + // MAIN CONTENT SUBMISSION HANDLERS
97 + // ========================================
98 +
99 +public function mxchat_handle_content_submission() {
100 + // Check if the form was submitted and the user has permission.
101 + if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
102 + return;
103 + }
104 +
105 + // Verify the nonce.
106 + $nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
107 + if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
108 + wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
109 + }
110 +
111 + // Sanitize the inputs.
112 + // Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
113 + $article_content = wp_kses_post(wp_unslash($_POST['article_content']));
114 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
115 +
116 + // Get bot_id from form submission
117 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
118 +
119 + // Get bot-specific options and API key
120 + $bot_options = $this->get_bot_options($bot_id);
121 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
122 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
123 +
124 + if (strpos($selected_model, 'voyage') === 0) {
125 + $api_key = $options['voyage_api_key'] ?? '';
126 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
127 + $api_key = $options['gemini_api_key'] ?? '';
128 + } else {
129 + $api_key = $options['api_key'] ?? '';
130 + }
131 +
132 + if (empty($api_key)) {
133 + set_transient('mxchat_admin_notice_error',
134 + esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
135 + 30
136 + );
137 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
138 + exit;
139 + }
140 +
141 + // Use centralized utility function with bot_id
142 + $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
143 +
144 + if (is_wp_error($result)) {
145 + set_transient('mxchat_admin_notice_error',
146 + esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
147 + 30
148 + );
149 + } else {
150 + set_transient('mxchat_admin_notice_success',
151 + esc_html__('Content successfully submitted!', 'mxchat'),
152 + 30
153 + );
154 + }
155 +
156 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
157 + exit;
158 +}
159 +
160 +public function mxchat_is_pdf_url($url, $response) {
161 + $content_type = wp_remote_retrieve_header($response, 'content-type');
162 + $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
163 +
164 + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf';
165 +}
166 +
167 +
168 +public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
169 + if (!current_user_can('manage_options')) {
170 + return false;
171 + }
172 +
173 + $pdf_url = esc_url_raw($pdf_url);
174 + $upload_dir = wp_upload_dir();
175 +
176 + if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
177 + return false;
178 + }
179 +
180 + $pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
181 + $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
182 +
183 + $response_body = wp_remote_retrieve_body($response);
184 + if (empty($response_body)) {
185 + return false;
186 + }
187 +
188 + if (!wp_mkdir_p(dirname($pdf_path))) {
189 + return false;
190 + }
191 +
192 + try {
193 + file_put_contents($pdf_path, $response_body);
194 +
195 + if (!file_exists($pdf_path)) {
196 + throw new Exception(__('Failed to save PDF file', 'mxchat'));
197 + }
198 +
199 + $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
200 +
201 + if ($total_pages === false || $total_pages < 1) {
202 + throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
203 + }
204 +
205 + // Create unique queue ID
206 + $queue_id = 'pdf_' . md5($pdf_url . time());
207 +
208 + // Create array of pages to process
209 + $pages = array();
210 + for ($i = 1; $i <= $total_pages; $i++) {
211 + $pages[] = array(
212 + 'pdf_path' => $pdf_path,
213 + 'pdf_url' => $pdf_url,
214 + 'page_number' => $i,
215 + 'total_pages' => $total_pages
216 + );
217 + }
218 +
219 + // Add pages to queue
220 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
221 +
222 + if ($queued_count === 0) {
223 + wp_delete_file($pdf_path);
224 + throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
225 + }
226 +
227 + // Store queue metadata
228 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
229 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
230 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
231 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
232 + $this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
233 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
234 +
235 + // Store queue ID in transient for status tracking
236 + set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
237 + set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
238 +
239 + return 'queued';
240 +
241 + } catch (Exception $e) {
242 + if (file_exists($pdf_path)) {
243 + wp_delete_file($pdf_path);
244 + }
245 + return $e->getMessage();
246 + }
247 +}
248 +
249 +/**
250 + * Validate PDF and count pages with multiple parser attempts
251 + */
252 +private function mxchat_validate_and_count_pdf_pages($pdf_path) {
253 + // Method 1: Try with Smalot PDF Parser (your current method)
254 + try {
255 + $parser = new \Smalot\PdfParser\Parser();
256 + $pdf = $parser->parseFile($pdf_path);
257 + $pages = $pdf->getPages();
258 + $page_count = count($pages);
259 +
260 + if ($page_count > 0) {
261 + //error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
262 + return $page_count;
263 + }
264 + } catch (Exception $e) {
265 + //error_log('Smalot PDF parser failed: ' . $e->getMessage());
266 + }
267 +
268 + // Method 2: Try with pdfinfo command (if available)
269 + if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
270 + try {
271 + $command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
272 + $output = shell_exec($command);
273 +
274 + if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
275 + $page_count = intval($matches[1]);
276 + if ($page_count > 0) {
277 + //error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
278 + return $page_count;
279 + }
280 + }
281 + } catch (Exception $e) {
282 + //error_log('pdfinfo command failed: ' . $e->getMessage());
283 + }
284 + }
285 +
286 + // Method 3: Try to repair PDF and parse again
287 + try {
288 + $repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
289 + if ($repaired_path && $repaired_path !== $pdf_path) {
290 + $parser = new \Smalot\PdfParser\Parser();
291 + $pdf = $parser->parseFile($repaired_path);
292 + $pages = $pdf->getPages();
293 + $page_count = count($pages);
294 +
295 + if ($page_count > 0) {
296 + // Replace original with repaired version
297 + copy($repaired_path, $pdf_path);
298 + unlink($repaired_path);
299 + //error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
300 + return $page_count;
301 + }
302 +
303 + // Clean up repaired file if it didn't work
304 + unlink($repaired_path);
305 + }
306 + } catch (Exception $e) {
307 + //error_log('PDF repair attempt failed: ' . $e->getMessage());
308 + }
309 +
310 + // Method 4: Manual PDF structure analysis (basic page count)
311 + try {
312 + $page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
313 + if ($page_count > 0) {
314 + //error_log('PDF page count determined manually: ' . $page_count . ' pages');
315 + return $page_count;
316 + }
317 + } catch (Exception $e) {
318 + //error_log('Manual PDF analysis failed: ' . $e->getMessage());
319 + }
320 +
321 + //error_log('All PDF parsing methods failed for: ' . $pdf_path);
322 + return false;
323 +}
324 +
325 +/**
326 + * Check if shell_exec is disabled
327 + */
328 +private function mxchat_is_shell_disabled() {
329 + $disabled = explode(',', ini_get('disable_functions'));
330 + return in_array('shell_exec', $disabled);
331 +}
332 +
333 +/**
334 + * Attempt to repair PDF using basic methods
335 + */
336 +private function mxchat_attempt_pdf_repair($pdf_path) {
337 + try {
338 + $content = file_get_contents($pdf_path);
339 + if (!$content) {
340 + return false;
341 + }
342 +
343 + // Check if PDF starts with proper header
344 + if (substr($content, 0, 4) !== '%PDF') {
345 + // Try to find PDF header in the content
346 + $header_pos = strpos($content, '%PDF');
347 + if ($header_pos !== false && $header_pos < 1024) {
348 + // Remove junk before PDF header
349 + $content = substr($content, $header_pos);
350 + $repaired_path = $pdf_path . '.repaired';
351 + file_put_contents($repaired_path, $content);
352 + return $repaired_path;
353 + }
354 + }
355 +
356 + // Check for EOF marker
357 + $content = rtrim($content);
358 + if (!preg_match('/%%EOF\s*$/', $content)) {
359 + // Add EOF marker if missing
360 + $content .= "\n%%EOF";
361 + $repaired_path = $pdf_path . '.repaired';
362 + file_put_contents($repaired_path, $content);
363 + return $repaired_path;
364 + }
365 +
366 + } catch (Exception $e) {
367 + //error_log('PDF repair error: ' . $e->getMessage());
368 + }
369 +
370 + return false;
371 +}
372 +
373 +/**
374 + * Manual PDF page counting by analyzing PDF structure
375 + */
376 +private function mxchat_manual_pdf_page_count($pdf_path) {
377 + try {
378 + $content = file_get_contents($pdf_path);
379 + if (!$content) {
380 + return 0;
381 + }
382 +
383 + // Method 1: Count /Type /Page objects
384 + $page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
385 + if ($page_count > 0) {
386 + return $page_count;
387 + }
388 +
389 + // Method 2: Look for /Count in pages object
390 + if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
391 + return intval($matches[1]);
392 + }
393 +
394 + // Method 3: Count page references
395 + $page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
396 + if ($page_count > 0) {
397 + return $page_count;
398 + }
399 +
400 + } catch (Exception $e) {
401 + //error_log('Manual PDF analysis error: ' . $e->getMessage());
402 + }
403 +
404 + return 0;
405 +}
406 +
407 +
408 +public function mxchat_save_inline_prompt() {
409 + // DEBUG: Log what we're receiving
410 + //error_log('=== MXCHAT DEBUG ===');
411 + //error_log('POST data: ' . print_r($_POST, true));
412 + //error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
413 +
414 + // Check for nonce security
415 + check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
416 +
417 + // If we get here, nonce passed
418 + //error_log('Nonce verification PASSED');
419 +
420 + // Verify permissions
421 + if (!current_user_can('manage_options')) {
422 + wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
423 + return;
424 + }
425 +
426 + global $wpdb;
427 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
428 +
429 + // Validate and sanitize input data
430 + $prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
431 + $article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
432 + $article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
433 +
434 + if ($prompt_id > 0 && !empty($article_content)) {
435 + // Re-generate the embedding vector for the updated content
436 + $embedding_vector = $this->mxchat_generate_embedding($article_content);
437 + if (is_array($embedding_vector)) {
438 + // Serialize the embedding vector before storing it
439 + $embedding_vector_serialized = serialize($embedding_vector);
440 + // Update the prompt in the database
441 + $updated = $wpdb->update(
442 + $table_name,
443 + array(
444 + 'article_content' => $article_content,
445 + 'embedding_vector' => $embedding_vector_serialized,
446 + 'source_url' => $article_url,
447 + ),
448 + array('id' => $prompt_id),
449 + array('%s', '%s', '%s'),
450 + array('%d')
451 + );
452 + if ($updated !== false) {
453 + wp_send_json_success();
454 + } else {
455 + wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
456 + }
457 + } else {
458 + wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
459 + }
460 + } else {
461 + wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
462 + }
463 +}
464 +
465 +
466 +public function mxchat_get_pdf_processing_status($pdf_url) {
467 + $pdf_url = esc_url_raw($pdf_url);
468 + $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
469 +
470 + if (!$status || !is_array($status)) {
471 + return false;
472 + }
473 +
474 + // Check for stalled processing (no updates for 5 minutes)
475 + if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
476 + $status['status'] = 'error';
477 + $status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
478 +
479 + // Save the updated status
480 + set_transient(
481 + sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
482 + array_map('sanitize_text_field', $status),
483 + DAY_IN_SECONDS
484 + );
485 + }
486 +
487 + $result = array(
488 + 'total_pages' => absint($status['total_pages']),
489 + 'processed_pages' => absint($status['processed_pages']),
490 + 'failed_pages' => absint($status['failed_pages'] ?? 0),
491 + 'percentage' => ($status['total_pages'] > 0)
492 + ? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
493 + : 0,
494 + 'status' => sanitize_text_field($status['status']),
495 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
496 + 'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
497 + 'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
498 + );
499 +
500 + // Add error message if present
501 + if (isset($status['error']) && !empty($status['error'])) {
502 + $result['error'] = sanitize_text_field($status['error']);
503 + }
504 +
505 + return $result;
506 +}
507 +
508 +
509 +public function mxchat_handle_sitemap_submission() {
510 + // Check if the form was submitted and verify permissions
511 + if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
512 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
513 + }
514 +
515 + // Verify nonce
516 + check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
517 +
518 + // Validate URL
519 + if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
520 + set_transient('mxchat_admin_notice_error',
521 + esc_html__('Please provide a valid URL.', 'mxchat'),
522 + 30
523 + );
524 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
525 + exit;
526 + }
527 +
528 + $submitted_url = esc_url_raw($_POST['sitemap_url']);
529 +
530 + // Get bot_id from form submission
531 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
532 +
533 + // Get bot-specific options and validate API key
534 + $bot_options = $this->get_bot_options($bot_id);
535 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
536 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
537 +
538 + if (strpos($selected_model, 'voyage') === 0) {
539 + $api_key = $options['voyage_api_key'] ?? '';
540 + $provider_name = 'Voyage AI';
541 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
542 + $api_key = $options['gemini_api_key'] ?? '';
543 + $provider_name = 'Google Gemini';
544 + } else {
545 + $api_key = $options['api_key'] ?? '';
546 + $provider_name = 'OpenAI';
547 + }
548 +
549 + if (empty($api_key)) {
550 + $error_message = sprintf(
551 + esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
552 + $provider_name
553 + );
554 + set_transient('mxchat_admin_notice_error', $error_message, 30);
555 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
556 + exit;
557 + }
558 +
559 + // Fetch URL
560 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
561 +
562 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
563 + $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
564 + set_transient('mxchat_admin_notice_error',
565 + sprintf(
566 + esc_html__('Failed to fetch the URL: %s', 'mxchat'),
567 + esc_html($error_message)
568 + ),
569 + 30
570 + );
571 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
572 + exit;
573 + }
574 +
575 + $content_type = wp_remote_retrieve_header($response, 'content-type');
576 + $body_content = wp_remote_retrieve_body($response);
577 +
578 + if (empty($body_content)) {
579 + set_transient('mxchat_admin_notice_error',
580 + esc_html__('Empty response received from URL.', 'mxchat'),
581 + 30
582 + );
583 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
584 + exit;
585 + }
586 +
587 + // Handle PDF URL
588 + if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
589 + $result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
590 +
591 + if ($result === 'queued') {
592 + set_transient('mxchat_admin_notice_success',
593 + esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
594 + 30
595 + );
596 + } else {
597 + set_transient('mxchat_admin_notice_error',
598 + esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
599 + 30
600 + );
601 + }
602 +
603 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
604 + exit;
605 + }
606 +
607 + // Handle Sitemap XML
608 + if (strpos($content_type, 'xml') !== false || strpos($body_content, '<urlset') !== false) {
609 + libxml_use_internal_errors(true);
610 + $xml = simplexml_load_string($body_content);
611 + $xml_errors = libxml_get_errors();
612 + libxml_clear_errors();
613 +
614 + if ($xml === false || !empty($xml_errors)) {
615 + set_transient('mxchat_admin_notice_error',
616 + esc_html__('Invalid sitemap XML. Please provide a valid sitemap.', 'mxchat'),
617 + 30
618 + );
619 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
620 + exit;
621 + }
622 +
623 + $result = $this->mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
624 +
625 + if ($result === 'queued') {
626 + set_transient('mxchat_admin_notice_success',
627 + esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
628 + 30
629 + );
630 + } else {
631 + set_transient('mxchat_admin_notice_error',
632 + esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
633 + 30
634 + );
635 + }
636 +
637 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
638 + exit;
639 + }
640 +
641 + // Handle Regular URL (single page)
642 + $page_content = $this->mxchat_extract_main_content($body_content);
643 + $sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
644 +
645 + error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
646 + error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
647 + error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
648 + error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
649 +
650 + if (empty($sanitized_content)) {
651 + set_transient('mxchat_admin_notice_error',
652 + esc_html__('No valid content found on the provided URL.', 'mxchat'),
653 + 30
654 + );
655 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
656 + exit;
657 + }
658 +
659 + // For single URLs, process immediately using submit_content_to_db
660 + // This handles chunking automatically for large content
661 + $db_result = MxChat_Utils::submit_content_to_db(
662 + $sanitized_content,
663 + $submitted_url,
664 + $api_key,
665 + null,
666 + $bot_id,
667 + 'url' // content_type
668 + );
669 +
670 + if (is_wp_error($db_result)) {
671 + $error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
672 + set_transient('mxchat_admin_notice_error', $error_message, 30);
673 + } else {
674 + $success_message = esc_html__('URL content successfully submitted!', 'mxchat');
675 + set_transient('mxchat_admin_notice_success', $success_message, 30);
676 + }
677 +
678 + wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
679 + exit;
680 +}
681 +
682 +
683 +public function mxchat_get_single_url_status() {
684 + $status = get_transient('mxchat_single_url_status');
685 + if (!$status) {
686 + return null;
687 + }
688 +
689 + // Add human-readable time
690 + if (isset($status['timestamp'])) {
691 + $status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
692 + }
693 +
694 + return $status;
695 +}
696 +
697 +public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
698 + if (!current_user_can('manage_options')) {
699 + return false;
700 + }
701 +
702 + try {
703 + $sitemap_url = esc_url_raw($sitemap_url);
704 +
705 + if (!$xml || !is_object($xml)) {
706 + throw new Exception(__('Invalid XML object provided', 'mxchat'));
707 + }
708 +
709 + // Get bot-specific embedding API for validation
710 + $bot_options = $this->get_bot_options($bot_id);
711 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
712 +
713 + // Test the embedding API before processing
714 + $test_phrase = "Test embedding generation for MxChat";
715 + $test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
716 +
717 + if (is_string($test_result)) {
718 + throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
719 + }
720 +
721 + if (!is_array($test_result)) {
722 + throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
723 + }
724 +
725 + // Extract URLs from sitemap
726 + $urls = array();
727 + foreach ($xml->url as $url_element) {
728 + $url = esc_url_raw((string)$url_element->loc);
729 + if ($url) {
730 + $urls[] = array('url' => $url);
731 + }
732 + }
733 +
734 + $total_urls = count($urls);
735 +
736 + if ($total_urls < 1) {
737 + throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
738 + }
739 +
740 + // Create unique queue ID
741 + $queue_id = 'sitemap_' . md5($sitemap_url . time());
742 +
743 + // Add URLs to queue
744 + $queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
745 +
746 + if ($queued_count === 0) {
747 + throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
748 + }
749 +
750 + // Store queue metadata
751 + $this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
752 + $this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
753 + $this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
754 + $this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
755 + $this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
756 +
757 + // Store queue ID in transient for status tracking
758 + set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
759 + set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
760 +
761 + return 'queued';
762 +
763 + } catch (Exception $e) {
764 + $error_message = $e->getMessage();
765 + error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
766 +
767 + return $error_message;
768 + }
769 +
770 +}
771 +
772 +/**
773 + * Remove shortcode tags but preserve the content inside them
774 + * Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
775 + *
776 + * @param string $content The content containing shortcodes
777 + * @return string Content with shortcode tags removed but inner content preserved
778 + */
779 +private function strip_shortcode_tags_preserve_content($content) {
780 + // Handle nested shortcodes by running multiple passes
781 + $prev_content = '';
782 + $max_iterations = 10; // Prevent infinite loops
783 + $iteration = 0;
784 + while ($prev_content !== $content && $iteration < $max_iterations) {
785 + $prev_content = $content;
786 + // Replace paired shortcodes [tag]content[/tag] with just the content
787 + $content = preg_replace('/\[([a-zA-Z0-9_-]+)[^\]]*\](.*?)\[\/\1\]/s', '$2', $content);
788 + $iteration++;
789 + }
790 + // Remove self-closing shortcodes [tag /] or [tag attr="val" /]
791 + $content = preg_replace('/\[[a-zA-Z0-9_-]+[^\]]*\/\]/', '', $content);
792 + // Remove any remaining opening shortcode tags [tag] or [tag attr="val"]
793 + $content = preg_replace('/\[[a-zA-Z0-9_-]+[^\]]*\]/', '', $content);
794 +
795 + return $content;
796 +}
797 +
798 +public function mxchat_sanitize_content_for_api($content) {
799 + //error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
800 +
801 + // Remove shortcode tags but PRESERVE content inside them
802 + $content = $this->strip_shortcode_tags_preserve_content($content);
803 +
804 + // Remove script, style tags, and HTML comments
805 + $content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $content);
806 + $content = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $content);
807 + $content = preg_replace('/<!--(.|\s)*?-->/', '', $content);
808 +
809 + // Remove all HTML tags and decode HTML entities
810 + $content = wp_strip_all_tags($content);
811 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5);
812 +
813 + // Normalize whitespace but preserve paragraph breaks
814 + // First, normalize line endings to \n
815 + $content = str_replace(["\r\n", "\r"], "\n", $content);
816 + // Replace multiple spaces/tabs with single space, but preserve newlines
817 + $content = preg_replace('/[ \t]+/', ' ', $content);
818 + // Replace 3+ newlines with 2 newlines (max 2 blank lines)
819 + $content = preg_replace('/\n{3,}/', "\n\n", $content);
820 + // Trim each line
821 + $lines = explode("\n", $content);
822 + $lines = array_map('trim', $lines);
823 + $content = implode("\n", $lines);
824 + // Final trim
825 + $content = trim($content);
826 +
827 + // Remove control characters (which can cause database issues) but preserve \n (0x0A) and \r (0x0D)
828 + $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $content);
829 +
830 + // Remove NULL bytes which can cause database errors
831 + $content = str_replace("\0", "", $content);
832 +
833 + // Ensure valid UTF-8 encoding
834 + $content = wp_check_invalid_utf8($content);
835 +
836 + // Remove any extremely long strings without spaces (often garbage)
837 + $content = preg_replace('/\S{300,}/', ' ', $content);
838 +
839 + // Replace problematic characters that often cause database issues
840 + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
841 +
842 + // Replace any remaining potentially problematic characters with spaces
843 + // BUT preserve newlines by temporarily replacing them
844 + $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
845 + $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
846 + $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
847 +
848 + // Limit to reasonable length if needed
849 + $max_length = 65000; // Just under MySQL TEXT field limit
850 + if (strlen($content) > $max_length) {
851 + $content = substr($content, 0, $max_length);
852 + }
853 +
854 + //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
855 + return $content;
856 +}
857 +public function mxchat_extract_main_content($html) {
858 + if (empty($html)) {
859 + return '';
860 + }
861 + try {
862 + $dom = new DOMDocument;
863 + libxml_use_internal_errors(true); // Suppress HTML parsing errors
864 + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
865 + $xpath = new DOMXPath($dom);
866 +
867 + // For debugging purposes
868 + $debugEnabled = true; // Set to true to enable debugging output
869 + $debug = function($message) use ($debugEnabled) {
870 + if ($debugEnabled) {
871 + error_log('[MXCHAT-EXTRACT-DEBUG] ' . $message);
872 + }
873 + };
874 +
875 + // Direct targeting for Gerow theme posts
876 + $post_text = $xpath->query('//div[contains(@class, "post-text")]');
877 + if ($post_text && $post_text->length > 0) {
878 + $debug("Found post-text directly");
879 + $content = '';
880 + foreach ($post_text as $node) {
881 + $content .= $dom->saveHTML($node);
882 + }
883 + if (!empty($content)) {
884 + $debug("Returning post-text content");
885 + return $content;
886 + }
887 + }
888 +
889 + // Try to get the blog details content which contains the post-text
890 + $blog_details = $xpath->query('//div[contains(@class, "blog-details-content")]');
891 + if ($blog_details && $blog_details->length > 0) {
892 + $debug("Found blog-details-content");
893 + $content = '';
894 + foreach ($blog_details as $node) {
895 + $content .= $dom->saveHTML($node);
896 + }
897 + if (!empty($content)) {
898 + $debug("Returning blog-details-content");
899 + return $content;
900 + }
901 + }
902 +
903 + // Try to get the article which contains the blog details
904 + $article = $xpath->query('//article[contains(@class, "blog-details-wrap")]');
905 + if ($article && $article->length > 0) {
906 + $debug("Found article with blog-details-wrap");
907 + $content = '';
908 + foreach ($article as $node) {
909 + $content .= $dom->saveHTML($node);
910 + }
911 + if (!empty($content)) {
912 + $debug("Returning article content");
913 + return $content;
914 + }
915 + }
916 +
917 + // Try even broader with the blog-item-wrap
918 + $blog_item = $xpath->query('//div[contains(@class, "blog-item-wrap")]');
919 + if ($blog_item && $blog_item->length > 0) {
920 + $debug("Found blog-item-wrap");
921 + $content = '';
922 + foreach ($blog_item as $node) {
923 + $content .= $dom->saveHTML($node);
924 + }
925 + if (!empty($content)) {
926 + $debug("Returning blog-item-wrap content");
927 + return $content;
928 + }
929 + }
930 +
931 + // Specific Gerow theme path
932 + $gerow_path = $xpath->query('//section[contains(@class, "blog-area")]//div[contains(@class, "post-text")]');
933 + if ($gerow_path && $gerow_path->length > 0) {
934 + $debug("Found Gerow theme path to post-text");
935 + $content = '';
936 + foreach ($gerow_path as $node) {
937 + $content .= $dom->saveHTML($node);
938 + }
939 + if (!empty($content)) {
940 + $debug("Returning Gerow post-text content");
941 + return $content;
942 + }
943 + }
944 +
945 + // Generic blog post selectors
946 + $selectors = [
947 + // Blog post specific selectors
948 + '//div[contains(@class, "post-text")]',
949 + '//article[contains(@class, "blog-post-item")]//div[contains(@class, "post-text")]',
950 + '//div[contains(@class, "blog-details-content")]',
951 + '//article[contains(@class, "blog-details-wrap")]',
952 + '//div[contains(@class, "entry-content")]',
953 + '//div[contains(@class, "blog-content")]',
954 + '//div[contains(@class, "blog-item-wrap")]',
955 +
956 + // More general content selectors
957 + '//div[contains(@class, "page__content")]',
958 + '//div[contains(@class, "elementor-widget-container")]',
959 + '//div[contains(@class, "elementor-text-editor")]',
960 + '//div[contains(@class, "elementor-widget-text-editor")]',
961 + '//*[contains(@class, "entry-content")]',
962 + '//*[contains(@class, "post-content")]',
963 + '//*[contains(@class, "article-content")]',
964 + '//*[@id="content"]',
965 + '//*[@id="main-content"]',
966 + '//section[contains(@class, "blog-area")]',
967 + '//article',
968 + '//main',
969 + '//div[contains(@class, "content")]'
970 + ];
971 +
972 + // First handle Elementor content - get only leaf widget containers to avoid duplicates
973 + $debug("Checking for Elementor content");
974 + // Get widget containers that are direct children of widgets (not nested inside other widget containers)
975 + $elementor_widgets = $xpath->query('//div[contains(@class, "elementor-widget")]//div[contains(@class, "elementor-widget-container")]');
976 + if ($elementor_widgets && $elementor_widgets->length > 0) {
977 + $debug("Found Elementor widgets");
978 + $seen_content = array(); // Track seen content to avoid duplicates
979 + $combined_content = '';
980 + foreach ($elementor_widgets as $widget) {
981 + $widget_content = $dom->saveHTML($widget);
982 + if (!empty($widget_content)) {
983 + // Create a hash of the content to detect duplicates
984 + $content_hash = md5($widget_content);
985 + if (!isset($seen_content[$content_hash])) {
986 + $seen_content[$content_hash] = true;
987 + $combined_content .= $widget_content;
988 + }
989 + }
990 + }
991 + if (!empty($combined_content)) {
992 + $debug("Returning Elementor content");
993 + return $combined_content;
994 + }
995 + }
996 +
997 + // Try standard selectors one by one
998 + foreach ($selectors as $selector) {
999 + $debug("Trying selector: " . $selector);
1000 + $nodes = $xpath->query($selector);
1001 + if ($nodes && $nodes->length > 0) {
1002 + $debug("Found " . $nodes->length . " matches for selector: " . $selector);
1003 + // Only take the FIRST matching node to avoid duplicate content
1004 + // (pages often have nested or multiple containers with same class)
1005 + $content = $dom->saveHTML($nodes->item(0));
1006 + if (!empty($content)) {
1007 + $debug("Returning content from selector: " . $selector . " (first match only)");
1008 + return $content;
1009 + }
1010 + }
1011 + }
1012 +
1013 + // Manual regex fallback for post-text if DOM methods fail
1014 + $debug("Trying regex fallback");
1015 + if (preg_match('/<div class="post-text">(.*?)<\/div>\s*<\/div>\s*<\/div>/s', $html, $matches)) {
1016 + $debug("Found post-text via regex");
1017 + return '<div class="post-text">' . $matches[1] . '</div>';
1018 + }
1019 +
1020 + // Try to extract the blog section as a whole
1021 + $blog_section = $xpath->query('//section[contains(@class, "blog-area")]');
1022 + if ($blog_section && $blog_section->length > 0) {
1023 + $debug("Found blog-area section");
1024 + $content = '';
1025 + foreach ($blog_section as $node) {
1026 + $content .= $dom->saveHTML($node);
1027 + }
1028 + if (!empty($content)) {
1029 + $debug("Returning blog-area section content");
1030 + return $content;
1031 + }
1032 + }
1033 +
1034 + // Generic container selectors for non-CMS sites (like .asp pages)
1035 + $debug("Trying generic container selectors");
1036 + $generic_selectors = [
1037 + '//div[@id="main"]',
1038 + '//div[@id="wrapper"]',
1039 + '//div[@id="page"]',
1040 + '//div[@id="site-content"]',
1041 + '//div[contains(@class, "main-content")]',
1042 + '//div[contains(@class, "page-content")]',
1043 + '//div[contains(@class, "site-content")]',
1044 + ];
1045 +
1046 + foreach ($generic_selectors as $selector) {
1047 + $debug("Trying generic selector: " . $selector);
1048 + $nodes = $xpath->query($selector);
1049 + if ($nodes && $nodes->length > 0) {
1050 + $content = $dom->saveHTML($nodes->item(0));
1051 + if (!empty($content)) {
1052 + $debug("Returning content from generic selector: " . $selector);
1053 + return $content;
1054 + }
1055 + }
1056 + }
1057 +
1058 + // Paragraph-based content detection - find regions with substantial text
1059 + $debug("Trying paragraph-based content detection");
1060 + $paragraphs = $xpath->query('//p[string-length(normalize-space()) > 50]');
1061 + if ($paragraphs && $paragraphs->length >= 3) {
1062 + $debug("Found " . $paragraphs->length . " substantial paragraphs");
1063 + // Collect all substantial paragraphs and their content
1064 + $paragraph_content = '';
1065 + foreach ($paragraphs as $p) {
1066 + $paragraph_content .= $dom->saveHTML($p) . "\n";
1067 + }
1068 + if (!empty($paragraph_content)) {
1069 + $debug("Returning paragraph-based content");
1070 + return $paragraph_content;
1071 + }
1072 + }
1073 +
1074 + // Improved body fallback - strip nav/header/footer elements first
1075 + $debug("Using improved body fallback");
1076 + $body = $dom->getElementsByTagName('body');
1077 + if ($body->length > 0) {
1078 + // Clone the body to avoid modifying the original DOM
1079 + $body_clone = $body->item(0)->cloneNode(true);
1080 +
1081 + // Remove common non-content elements by tag name
1082 + $remove_tags = ['nav', 'header', 'footer', 'aside', 'script', 'style', 'noscript'];
1083 + foreach ($remove_tags as $tag) {
1084 + $elements = $body_clone->getElementsByTagName($tag);
1085 + // Iterate backwards to safely remove elements
1086 + for ($i = $elements->length - 1; $i >= 0; $i--) {
1087 + $el = $elements->item($i);
1088 + if ($el && $el->parentNode) {
1089 + $el->parentNode->removeChild($el);
1090 + }
1091 + }
1092 + }
1093 +
1094 + // Remove elements with common non-content class names using XPath on the cloned body
1095 + $temp_dom = new DOMDocument();
1096 + @$temp_dom->appendChild($temp_dom->importNode($body_clone, true));
1097 + $temp_xpath = new DOMXPath($temp_dom);
1098 +
1099 + $remove_class_patterns = [
1100 + '//*[contains(@class, "nav")]',
1101 + '//*[contains(@class, "menu")]',
1102 + '//*[contains(@class, "sidebar")]',
1103 + '//*[contains(@class, "footer")]',
1104 + '//*[contains(@class, "header")]',
1105 + '//*[contains(@id, "nav")]',
1106 + '//*[contains(@id, "menu")]',
1107 + '//*[contains(@id, "sidebar")]',
1108 + '//*[contains(@id, "footer")]',
1109 + '//*[contains(@id, "header")]',
1110 + ];
1111 +
1112 + foreach ($remove_class_patterns as $pattern) {
1113 + $elements = $temp_xpath->query($pattern);
1114 + if ($elements) {
1115 + for ($i = $elements->length - 1; $i >= 0; $i--) {
1116 + $el = $elements->item($i);
1117 + if ($el && $el->parentNode) {
1118 + $el->parentNode->removeChild($el);
1119 + }
1120 + }
1121 + }
1122 + }
1123 +
1124 + $cleaned_content = $temp_dom->saveHTML();
1125 + if (!empty($cleaned_content)) {
1126 + $debug("Returning cleaned body content");
1127 + return $cleaned_content;
1128 + }
1129 + }
1130 +
1131 + // Last resort: return the original HTML
1132 + $debug("Returning original HTML");
1133 + return $html;
1134 + } catch (Exception $e) {
1135 + //error_log('[MXCHAT-ERROR] Content extraction failed: ' . $e->getMessage());
1136 + return $html; // Return original HTML if parsing fails
1137 + } finally {
1138 + libxml_clear_errors();
1139 + }
1140 +}
1141 +public function mxchat_get_sitemap_processing_status($sitemap_url) {
1142 + $sitemap_url = esc_url_raw($sitemap_url);
1143 + $status_key = sanitize_key('mxchat_sitemap_status_' . md5($sitemap_url));
1144 + $status = get_transient($status_key);
1145 +
1146 + if (!$status || !is_array($status)) {
1147 + return false;
1148 + }
1149 +
1150 + // Auto-complete check: if all URLs are processed but status isn't complete
1151 + if (isset($status['processed_urls']) && isset($status['total_urls']) &&
1152 + $status['processed_urls'] >= $status['total_urls'] &&
1153 + isset($status['status']) && $status['status'] !== 'complete' &&
1154 + $status['status'] !== 'error') {
1155 +
1156 + // Mark as complete
1157 + $status['status'] = 'complete';
1158 + $status['processed_urls'] = $status['total_urls']; // Ensure exact match
1159 +
1160 + // Update the transient with the corrected status
1161 + set_transient($status_key, $status, DAY_IN_SECONDS);
1162 + }
1163 +
1164 + return array(
1165 + 'total_urls' => absint($status['total_urls']),
1166 + 'processed_urls' => absint($status['processed_urls']),
1167 + 'failed_urls' => absint($status['failed_urls'] ?? 0),
1168 + 'percentage' => ($status['total_urls'] > 0)
1169 + ? round((absint($status['processed_urls']) / absint($status['total_urls'])) * 100)
1170 + : 0,
1171 + 'status' => sanitize_text_field($status['status']),
1172 + 'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
1173 + 'error' => isset($status['error']) ? sanitize_text_field($status['error']) : '',
1174 + 'last_error' => isset($status['last_error']) ? sanitize_text_field($status['last_error']) : '',
1175 + 'failed_urls_list' => isset($status['failed_urls_list']) ? $status['failed_urls_list'] : array()
1176 + );
1177 +}
1178 +
1179 +public function mxchat_ajax_get_status_updates() {
1180 + try {
1181 + // Verify the request
1182 + check_ajax_referer('mxchat_status_nonce', 'nonce');
1183 +
1184 + // Get active queue IDs
1185 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1186 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1187 +
1188 + $sitemap_status = false;
1189 + $pdf_status = false;
1190 +
1191 + // Get sitemap queue status
1192 + if ($sitemap_queue_id) {
1193 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1194 + }
1195 +
1196 + // Get PDF queue status
1197 + if ($pdf_queue_id) {
1198 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1199 + }
1200 +
1201 + $is_active_processing =
1202 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1203 + ($pdf_status && $pdf_status['status'] === 'processing');
1204 +
1205 + // Return JSON response with the status data
1206 + wp_send_json(array(
1207 + 'pdf_status' => $pdf_status,
1208 + 'sitemap_status' => $sitemap_status,
1209 + 'is_processing' => $is_active_processing,
1210 + 'sitemap_queue_id' => $sitemap_queue_id,
1211 + 'pdf_queue_id' => $pdf_queue_id
1212 + ));
1213 +
1214 + } catch (Exception $e) {
1215 + error_log('MxChat Status Update Error: ' . $e->getMessage());
1216 +
1217 + wp_send_json_error(array(
1218 + 'message' => 'Error getting status updates: ' . $e->getMessage(),
1219 + 'status' => 'error'
1220 + ));
1221 + }
1222 +}
1223 +
1224 +/**
1225 + * Helper function to get queue status data
1226 + */
1227 +private function mxchat_get_queue_status_data($queue_id, $type = 'sitemap') {
1228 + global $wpdb;
1229 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
1230 +
1231 + // Get counts by status
1232 + $counts = $wpdb->get_results($wpdb->prepare(
1233 + "SELECT status, COUNT(*) as count
1234 + FROM $table_name
1235 + WHERE queue_id = %s
1236 + GROUP BY status",
1237 + $queue_id
1238 + ), OBJECT_K);
1239 +
1240 + $total = 0;
1241 + $completed = 0;
1242 + $failed = 0;
1243 + $processing = 0;
1244 + $pending = 0;
1245 +
1246 + foreach ($counts as $status => $data) {
1247 + $count = absint($data->count);
1248 + $total += $count;
1249 +
1250 + switch ($status) {
1251 + case 'completed':
1252 + $completed = $count;
1253 + break;
1254 + case 'failed':
1255 + $failed = $count;
1256 + break;
1257 + case 'processing':
1258 + $processing = $count;
1259 + break;
1260 + case 'pending':
1261 + $pending = $count;
1262 + break;
1263 + }
1264 + }
1265 +
1266 + if ($total === 0) {
1267 + return false;
1268 + }
1269 +
1270 + // Calculate percentage
1271 + $percentage = round((($completed + $failed) / $total) * 100);
1272 +
1273 + // Get failed items details (limit to 50)
1274 + $failed_items = array();
1275 + if ($failed > 0) {
1276 + $failed_results = $wpdb->get_results($wpdb->prepare(
1277 + "SELECT item_type, item_data, error_message, attempts, completed_at
1278 + FROM $table_name
1279 + WHERE queue_id = %s
1280 + AND status = 'failed'
1281 + AND attempts >= max_attempts
1282 + ORDER BY id DESC
1283 + LIMIT 50",
1284 + $queue_id
1285 + ));
1286 +
1287 + foreach ($failed_results as $item) {
1288 + $data = json_decode($item->item_data, true);
1289 + $url = $type === 'pdf' ? ($data['pdf_url'] ?? '') : ($data['url'] ?? '');
1290 +
1291 + $failed_items[] = array(
1292 + 'url' => $url,
1293 + 'page' => $type === 'pdf' ? ($data['page_number'] ?? 0) : 0,
1294 + 'error' => $item->error_message,
1295 + 'retries' => $item->attempts,
1296 + 'time' => strtotime($item->completed_at)
1297 + );
1298 + }
1299 + }
1300 +
1301 + // Get queue metadata
1302 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
1303 +
1304 + // Determine if queue is complete
1305 + $is_complete = ($pending === 0 && $processing === 0);
1306 +
1307 + // Get last update time
1308 + $last_update = $wpdb->get_var($wpdb->prepare(
1309 + "SELECT MAX(UNIX_TIMESTAMP(COALESCE(completed_at, started_at, created_at)))
1310 + FROM $table_name
1311 + WHERE queue_id = %s",
1312 + $queue_id
1313 + ));
1314 +
1315 + $last_update_text = $last_update ? human_time_diff($last_update, time()) . ' ' . __('ago', 'mxchat') : __('Just now', 'mxchat');
1316 +
1317 + // Format based on type
1318 + if ($type === 'pdf') {
1319 + return array(
1320 + 'total_pages' => $total,
1321 + 'processed_pages' => $completed + $failed,
1322 + 'failed_pages' => $failed,
1323 + 'percentage' => $percentage,
1324 + 'status' => $is_complete ? 'complete' : 'processing',
1325 + 'last_update' => $last_update_text,
1326 + 'failed_pages_list' => $failed_items,
1327 + 'pdf_url' => $source_url,
1328 + 'queue_id' => $queue_id
1329 + );
1330 + } else {
1331 + return array(
1332 + 'total_urls' => $total,
1333 + 'processed_urls' => $completed + $failed,
1334 + 'failed_urls' => $failed,
1335 + 'percentage' => $percentage,
1336 + 'status' => $is_complete ? 'complete' : 'processing',
1337 + 'last_update' => $last_update_text,
1338 + 'failed_urls_list' => $failed_items,
1339 + 'sitemap_url' => $source_url,
1340 + 'queue_id' => $queue_id
1341 + );
1342 + }
1343 +}
1344 +
1345 +/**
1346 + * Public method to get processing status for both sitemap and PDF queues
1347 + * Used by admin pages to display processing status
1348 + *
1349 + * @return array Array with 'sitemap_status', 'pdf_status', and 'is_processing' keys
1350 + */
1351 +public function mxchat_get_processing_statuses() {
1352 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
1353 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
1354 +
1355 + $sitemap_status = false;
1356 + $pdf_status = false;
1357 +
1358 + if ($sitemap_queue_id) {
1359 + $sitemap_status = $this->mxchat_get_queue_status_data($sitemap_queue_id, 'sitemap');
1360 + }
1361 +
1362 + if ($pdf_queue_id) {
1363 + $pdf_status = $this->mxchat_get_queue_status_data($pdf_queue_id, 'pdf');
1364 + }
1365 +
1366 + $is_processing =
1367 + ($sitemap_status && $sitemap_status['status'] === 'processing') ||
1368 + ($pdf_status && $pdf_status['status'] === 'processing');
1369 +
1370 + return array(
1371 + 'sitemap_status' => $sitemap_status,
1372 + 'pdf_status' => $pdf_status,
1373 + 'is_processing' => $is_processing
1374 + );
1375 +}
1376 +
1377 +/**
1378 + * AJAX handler to get recent knowledge entries for real-time table updates
1379 + * UPDATED: Now supports both WordPress DB and Pinecone data sources
1380 + */
1381 +public function ajax_mxchat_get_recent_entries() {
1382 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
1383 +
1384 + if (!current_user_can('manage_options')) {
1385 + wp_send_json_error(array('message' => 'Unauthorized'));
1386 + return;
1387 + }
1388 +
1389 + global $wpdb;
1390 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1391 +
1392 + // Get parameters
1393 + $last_id = isset($_POST['last_id']) ? absint($_POST['last_id']) : 0;
1394 + $limit = isset($_POST['limit']) ? min(absint($_POST['limit']), 50) : 10;
1395 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1396 +
1397 + // Check if Pinecone is enabled for this bot
1398 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
1399 + $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
1400 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1401 + $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
1402 +
1403 + if ($use_pinecone && $has_pinecone_api) {
1404 + // PINECONE DATA SOURCE - Get grouped entry count (not raw vector count)
1405 + // Use mxchat_fetch_pinecone_records which returns total_unique_entries
1406 + $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', 1, 10, $bot_id, '');
1407 + $total_count = $records['total'] ?? 0;
1408 +
1409 + // For Pinecone, we don't return individual entries during polling
1410 + // (entries are already displayed on page load via mxchat_fetch_pinecone_records)
1411 + // We just return the updated count
1412 + wp_send_json_success(array(
1413 + 'entries' => array(),
1414 + 'total_count' => absint($total_count),
1415 + 'max_id' => $last_id,
1416 + 'data_source' => 'pinecone'
1417 + ));
1418 + return;
1419 + }
1420 +
1421 + // WORDPRESS DB DATA SOURCE
1422 + // Build query to get entries newer than last_id
1423 + $where_clauses = array('1=1');
1424 + $where_values = array();
1425 +
1426 + if ($last_id > 0) {
1427 + $where_clauses[] = 'id > %d';
1428 + $where_values[] = $last_id;
1429 + }
1430 +
1431 + // Note: WordPress DB table doesn't have bot_id column
1432 + // Multi-bot filtering is handled via Pinecone namespaces
1433 +
1434 + $where_sql = implode(' AND ', $where_clauses);
1435 +
1436 + // Get recent entries
1437 + $query = "SELECT id, article_content, source_url, timestamp
1438 + FROM $table_name
1439 + WHERE $where_sql
1440 + ORDER BY id DESC
1441 + LIMIT %d";
1442 +
1443 + $where_values[] = $limit;
1444 +
1445 + $entries = $wpdb->get_results($wpdb->prepare($query, $where_values));
1446 +
1447 + // Get total count of GROUPED entries (by source_url) - matches pagination display
1448 + // Count unique source_urls (excluding mxchat:// internal refs) + count of ungrouped rows
1449 + $total_count = $wpdb->get_var(
1450 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1451 + (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
1452 + );
1453 +
1454 + // Format entries for response
1455 + $formatted_entries = array();
1456 + $preview_length = 150;
1457 + foreach ($entries as $entry) {
1458 + // Parse chunk metadata using the proper chunker method (same as initial page load)
1459 + if (class_exists('MxChat_Chunker')) {
1460 + $chunk_meta = MxChat_Chunker::parse_stored_chunk($entry->article_content);
1461 + $display_content = $chunk_meta['text'];
1462 + $chunk_metadata = $chunk_meta['metadata'];
1463 + } else {
1464 + $display_content = $entry->article_content;
1465 + $chunk_metadata = array();
1466 + }
1467 +
1468 + $content_preview = mb_strlen($display_content) > $preview_length
1469 + ? mb_substr($display_content, 0, $preview_length) . '...'
1470 + : $display_content;
1471 +
1472 + $formatted_entries[] = array(
1473 + 'id' => $entry->id,
1474 + 'preview' => esc_html($content_preview),
1475 + 'full_content' => wp_kses_post(wpautop($display_content)),
1476 + 'content_length' => mb_strlen($display_content),
1477 + 'preview_length' => $preview_length,
1478 + 'source_url' => $entry->source_url,
1479 + 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
1480 + 'chunk_metadata' => $chunk_metadata,
1481 + 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
1482 + );
1483 + }
1484 +
1485 + wp_send_json_success(array(
1486 + 'entries' => $formatted_entries,
1487 + 'total_count' => absint($total_count),
1488 + 'max_id' => !empty($entries) ? $entries[0]->id : $last_id,
1489 + 'data_source' => 'wordpress'
1490 + ));
1491 +}
1492 +
1493 +/**
1494 + * Get Pinecone total count from stats API
1495 + * Helper function for ajax_mxchat_get_recent_entries
1496 + */
1497 +private function mxchat_get_pinecone_count_from_stats($pinecone_options) {
1498 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1499 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1500 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1501 +
1502 + if (empty($api_key) || empty($host)) {
1503 + return 0;
1504 + }
1505 +
1506 + try {
1507 + $stats_url = "https://{$host}/describe_index_stats";
1508 +
1509 + $response = wp_remote_post($stats_url, array(
1510 + 'headers' => array(
1511 + 'Api-Key' => $api_key,
1512 + 'Content-Type' => 'application/json'
1513 + ),
1514 + 'body' => '{}',
1515 + 'timeout' => 10
1516 + ));
1517 +
1518 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1519 + $body = wp_remote_retrieve_body($response);
1520 + $stats_data = json_decode($body, true);
1521 +
1522 + // If namespace is specified, get count from that specific namespace
1523 + if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
1524 + return intval($stats_data['namespaces'][$namespace]['vectorCount']);
1525 + }
1526 +
1527 + // If no namespace specified or namespace not found in response, use total
1528 + return intval($stats_data['totalVectorCount'] ?? 0);
1529 + }
1530 +
1531 + return 0;
1532 +
1533 + } catch (Exception $e) {
1534 + return 0;
1535 + }
1536 +}
1537 +
1538 +/**
1539 + * AJAX handler to refresh Pinecone entries table via AJAX
1540 + * Returns the table HTML for updating the UI without a full page reload
1541 + */
1542 +public function ajax_mxchat_refresh_pinecone_entries() {
1543 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
1544 +
1545 + if (!current_user_can('manage_options')) {
1546 + wp_send_json_error(array('message' => 'Unauthorized'));
1547 + return;
1548 + }
1549 +
1550 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1551 + $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
1552 + $per_page = 10;
1553 +
1554 + // Get Pinecone manager and options
1555 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
1556 + if (!$pinecone_manager) {
1557 + wp_send_json_error(array('message' => 'Pinecone manager not available'));
1558 + return;
1559 + }
1560 +
1561 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
1562 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1563 + $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1564 +
1565 + if (!$use_pinecone || empty($pinecone_api_key)) {
1566 + wp_send_json_error(array('message' => 'Pinecone not configured'));
1567 + return;
1568 + }
1569 +
1570 + // Fetch records from Pinecone
1571 + $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, '', $page, $per_page, $bot_id, '');
1572 + $prompts = $records['data'] ?? array();
1573 + $total_records = $records['total'] ?? 0;
1574 +
1575 + // Group prompts by source_url
1576 + $grouped_prompts = array();
1577 + foreach ($prompts as $prompt) {
1578 + $source_url = '';
1579 + if (!empty($prompt->chunk_metadata['source_url'])) {
1580 + $source_url = $prompt->chunk_metadata['source_url'];
1581 + } elseif (!empty($prompt->source_url)) {
1582 + $source_url = $prompt->source_url;
1583 + }
1584 +
1585 + if (!empty($source_url)) {
1586 + if (!isset($grouped_prompts[$source_url])) {
1587 + $grouped_prompts[$source_url] = array();
1588 + }
1589 + $grouped_prompts[$source_url][] = $prompt;
1590 + } else {
1591 + $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
1592 + }
1593 + }
1594 +
1595 + // Sort each group by chunk_index
1596 + foreach ($grouped_prompts as $source_url => &$group) {
1597 + usort($group, function($a, $b) {
1598 + $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
1599 + $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
1600 + return $index_a - $index_b;
1601 + });
1602 + }
1603 + unset($group);
1604 +
1605 + // Build HTML for the table rows - must match admin-knowledge-page.php structure exactly
1606 + ob_start();
1607 + $display_index = 0;
1608 + $current_page = $page;
1609 + $data_source = 'pinecone';
1610 + $current_bot_id = $bot_id;
1611 + $preview_length = 150;
1612 +
1613 + if (empty($grouped_prompts)) {
1614 + echo '<tr><td colspan="4" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
1615 + esc_html_e('No knowledge entries found in Pinecone.', 'mxchat');
1616 + echo '</td></tr>';
1617 + } else {
1618 + foreach ($grouped_prompts as $source_url => $group) {
1619 + $chunk_count = count($group);
1620 + $first_prompt = $group[0];
1621 + $display_index++;
1622 +
1623 + if ($chunk_count > 1) {
1624 + // Multiple chunks - show grouped row with expand button
1625 + $group_id = 'group-' . md5($source_url);
1626 + ?>
1627 + <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
1628 + class="mxchat-chunk-group-header"
1629 + data-source="<?php echo esc_attr($data_source); ?>"
1630 + data-group-id="<?php echo esc_attr($group_id); ?>"
1631 + style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
1632 + <td style="padding: 12px 16px; font-size: 13px;">
1633 + <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
1634 + </td>
1635 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1636 + <div class="mxchat-chunk-group-info">
1637 + <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
1638 + <span class="dashicons dashicons-arrow-right-alt2"></span>
1639 + </button>
1640 + <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
1641 + <span class="mxchat-chunk-preview">
1642 + <?php
1643 + $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : (isset($first_prompt->article_content) ? $first_prompt->article_content : '');
1644 + $content_preview = mb_substr($parent_content, 0, 100);
1645 + echo esc_html($content_preview . '...');
1646 + ?>
1647 + </span>
1648 + </div>
1649 + </td>
1650 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1651 + <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
1652 + <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
1653 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
1654 + <?php esc_html_e('View Source', 'mxchat'); ?>
1655 + </a>
1656 + <?php else : ?>
1657 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
1658 + <?php endif; ?>
1659 + </td>
1660 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
1661 + <button type="button"
1662 + class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
1663 + data-source-url="<?php echo esc_attr($source_url); ?>"
1664 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
1665 + data-data-source="<?php echo esc_attr($data_source); ?>"
1666 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
1667 + data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
1668 + style="color: var(--mxch-error);"
1669 + title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
1670 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
1671 + </button>
1672 + </td>
1673 + </tr>
1674 + <?php
1675 + // Render hidden chunk rows
1676 + foreach ($group as $chunk_index => $chunk) {
1677 + $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
1678 + $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
1679 + $content = isset($chunk->display_content) ? $chunk->display_content : (isset($chunk->article_content) ? $chunk->article_content : '');
1680 + $content_preview = mb_strlen($content) > $preview_length
1681 + ? mb_substr($content, 0, $preview_length) . '...'
1682 + : $content;
1683 + ?>
1684 + <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
1685 + class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
1686 + data-source="<?php echo esc_attr($data_source); ?>"
1687 + style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
1688 + <td style="padding: 12px 16px; text-align: center;">
1689 + <!-- Checkbox column placeholder for chunks (managed by group) -->
1690 + </td>
1691 + <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
1692 + <!-- Hidden ID column for chunks -->
1693 + </td>
1694 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1695 + <div class="mxchat-accordion-wrapper">
1696 + <div class="mxchat-content-preview">
1697 + <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
1698 + <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
1699 + </span>
1700 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
1701 + <?php if (mb_strlen($content) > $preview_length) : ?>
1702 + <button class="mxchat-expand-toggle" type="button">
1703 + <span class="dashicons dashicons-arrow-down-alt2"></span>
1704 + </button>
1705 + <?php endif; ?>
1706 + </div>
1707 + <div class="mxchat-content-full" style="display: none;">
1708 + <div class="content-view">
1709 + <?php
1710 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
1711 + echo '<div dir="rtl" lang="he" class="rtl-content">';
1712 + echo wp_kses_post(wpautop($content));
1713 + echo '</div>';
1714 + } else {
1715 + echo wp_kses_post(wpautop($content));
1716 + }
1717 + ?>
1718 + </div>
1719 + </div>
1720 + </div>
1721 + </td>
1722 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1723 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
1724 + </td>
1725 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
1726 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
1727 + </td>
1728 + </tr>
1729 + <?php
1730 + }
1731 + } else {
1732 + // Single entry - display normally with accordion
1733 + $prompt = $first_prompt;
1734 + $content = isset($prompt->display_content) ? $prompt->display_content : (isset($prompt->article_content) ? $prompt->article_content : '');
1735 + $content_preview = mb_strlen($content) > $preview_length
1736 + ? mb_substr($content, 0, $preview_length) . '...'
1737 + : $content;
1738 + ?>
1739 + <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
1740 + data-source="<?php echo esc_attr($data_source); ?>"
1741 + style="border-bottom: 1px solid var(--mxch-card-border); background: rgba(33, 150, 243, 0.02);">
1742 + <td style="padding: 12px 16px; font-size: 13px;">
1743 + <?php echo esc_html($display_index + (($current_page - 1) * $per_page)); ?>
1744 + </td>
1745 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
1746 + <div class="mxchat-accordion-wrapper">
1747 + <div class="mxchat-content-preview">
1748 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
1749 + <?php if (mb_strlen($content) > $preview_length) : ?>
1750 + <button class="mxchat-expand-toggle" type="button">
1751 + <span class="dashicons dashicons-arrow-down-alt2"></span>
1752 + </button>
1753 + <?php endif; ?>
1754 + </div>
1755 + <div class="mxchat-content-full" style="display: none;">
1756 + <div class="content-view">
1757 + <?php
1758 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
1759 + echo '<div dir="rtl" lang="he" class="rtl-content">';
1760 + echo wp_kses_post(wpautop($content));
1761 + echo '</div>';
1762 + } else {
1763 + echo wp_kses_post(wpautop($content));
1764 + }
1765 + ?>
1766 + </div>
1767 + </div>
1768 + </div>
1769 + </td>
1770 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
1771 + <?php
1772 + $actual_source = $source_url;
1773 + if (strpos($source_url, '_ungrouped_') === 0) {
1774 + $actual_source = $prompt->source_url ?? '';
1775 + }
1776 + if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
1777 + <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
1778 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
1779 + <?php esc_html_e('View', 'mxchat'); ?>
1780 + </a>
1781 + <?php else : ?>
1782 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
1783 + <?php endif; ?>
1784 + </td>
1785 + <td style="padding: 12px 16px;">
1786 + <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);">
1787 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
1788 + </button>
1789 + </td>
1790 + </tr>
1791 + <?php
1792 + }
1793 + }
1794 + }
1795 + $html = ob_get_clean();
1796 +
1797 + // Generate pagination HTML for Pinecone
1798 + $total_pages = ceil($total_records / $per_page);
1799 + $pagination_html = '';
1800 + if ($total_pages > 1) {
1801 + $pagination_html = '<div class="mxchat-ajax-pagination" data-current-page="' . esc_attr($page) . '" data-total-pages="' . esc_attr($total_pages) . '">';
1802 +
1803 + // Previous button
1804 + if ($page > 1) {
1805 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
1806 + }
1807 +
1808 + // Page numbers
1809 + $start_page = max(1, $page - 2);
1810 + $end_page = min($total_pages, $page + 2);
1811 +
1812 + if ($start_page > 1) {
1813 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
1814 + if ($start_page > 2) {
1815 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
1816 + }
1817 + }
1818 +
1819 + for ($i = $start_page; $i <= $end_page; $i++) {
1820 + if ($i == $page) {
1821 + $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
1822 + } else {
1823 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
1824 + }
1825 + }
1826 +
1827 + if ($end_page < $total_pages) {
1828 + if ($end_page < $total_pages - 1) {
1829 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
1830 + }
1831 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
1832 + }
1833 +
1834 + // Next button
1835 + if ($page < $total_pages) {
1836 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
1837 + }
1838 +
1839 + $pagination_html .= '</div>';
1840 + }
1841 +
1842 + wp_send_json_success(array(
1843 + 'html' => $html,
1844 + 'pagination_html' => $pagination_html,
1845 + 'total_count' => $total_records,
1846 + 'total_pages' => $total_pages,
1847 + 'page' => $page,
1848 + 'per_page' => $per_page,
1849 + 'data_source' => 'pinecone'
1850 + ));
1851 +}
1852 +
1853 +/**
1854 + * AJAX handler for pagination - handles both WordPress DB and Pinecone data sources
1855 + * Returns paginated entries without requiring a full page reload
1856 + */
1857 +public function ajax_mxchat_paginate_entries() {
1858 + check_ajax_referer('mxchat_entries_nonce', 'nonce');
1859 +
1860 + if (!current_user_can('manage_options')) {
1861 + wp_send_json_error(array('message' => 'Unauthorized'));
1862 + return;
1863 + }
1864 +
1865 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
1866 + $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
1867 + $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
1868 + $content_type_filter = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : '';
1869 + $per_page = 25;
1870 +
1871 + // Check if Pinecone is enabled for this bot
1872 + $pinecone_manager = $this->mxchat_get_pinecone_manager();
1873 + $pinecone_options = $pinecone_manager ? $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id) : array();
1874 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
1875 + $has_pinecone_api = !empty($pinecone_options['mxchat_pinecone_api_key']);
1876 +
1877 + if ($use_pinecone && $has_pinecone_api) {
1878 + // Delegate to Pinecone pagination handler (pass search params)
1879 + $_POST['page'] = $page;
1880 + $_POST['search'] = $search_query;
1881 + $_POST['content_type'] = $content_type_filter;
1882 + $this->ajax_mxchat_refresh_pinecone_entries();
1883 + return;
1884 + }
1885 +
1886 + // WordPress DB pagination - MUST match initial page load logic exactly
1887 + global $wpdb;
1888 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1889 + $offset = ($page - 1) * $per_page;
1890 +
1891 + // Build WHERE clause for search and content type filtering
1892 + $where_clauses = array();
1893 + $where_values = array();
1894 +
1895 + if ($search_query) {
1896 + $where_clauses[] = "article_content LIKE %s";
1897 + $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
1898 + }
1899 +
1900 + if ($content_type_filter) {
1901 + switch ($content_type_filter) {
1902 + case 'manual':
1903 + $where_clauses[] = "(source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')";
1904 + break;
1905 + case 'pdf':
1906 + $where_clauses[] = "source_url LIKE '%.pdf'";
1907 + break;
1908 + case 'url':
1909 + $where_clauses[] = "source_url != '' AND source_url IS NOT NULL AND source_url NOT LIKE 'mxchat://%' AND source_url NOT LIKE '%.pdf'";
1910 + break;
1911 + }
1912 + }
1913 +
1914 + $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
1915 +
1916 + // Count grouped entries with filters applied
1917 + if (!empty($where_values)) {
1918 + $count_args = array_merge($where_values, $where_values);
1919 + $count_query = $wpdb->prepare(
1920 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1921 + (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))",
1922 + ...$count_args
1923 + );
1924 + $total_records = $wpdb->get_var($count_query);
1925 + } else if (!empty($where_sql)) {
1926 + // Content type filter only (no search), no prepared values needed
1927 + $total_records = $wpdb->get_var(
1928 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$where_sql} AND source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1929 + (SELECT COUNT(*) FROM {$table_name} {$where_sql} AND (source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%'))"
1930 + );
1931 + } else {
1932 + // No filters
1933 + $total_records = $wpdb->get_var(
1934 + "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '' AND source_url NOT LIKE 'mxchat://%') +
1935 + (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL OR source_url LIKE 'mxchat://%')"
1936 + );
1937 + }
1938 + $total_pages = ceil($total_records / $per_page);
1939 +
1940 + // Step 1: Get unique source_urls for this page (ordered by latest timestamp) with filters
1941 + if (!empty($where_values)) {
1942 + $query_args = array_merge($where_values, array($per_page, $offset));
1943 + $urls_query = $wpdb->prepare(
1944 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
1945 + {$where_sql}
1946 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
1947 + ...$query_args
1948 + );
1949 + } else if (!empty($where_sql)) {
1950 + $urls_query = $wpdb->prepare(
1951 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
1952 + {$where_sql}
1953 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
1954 + $per_page, $offset
1955 + );
1956 + } else {
1957 + $urls_query = $wpdb->prepare(
1958 + "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
1959 + GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
1960 + $per_page, $offset
1961 + );
1962 + }
1963 + $page_urls = $wpdb->get_results($urls_query);
1964 +
1965 + // Step 2: Build list of source_urls to fetch
1966 + $url_list = array();
1967 + $url_order_map = array();
1968 + $order_index = 0;
1969 + foreach ($page_urls as $url_row) {
1970 + $url = $url_row->source_url;
1971 + $url_list[] = $url;
1972 + $url_order_map[$url] = $order_index++;
1973 + }
1974 +
1975 + // Step 3: Fetch all rows for these source_urls (with search filter if applicable)
1976 + $prompts = array();
1977 + if (!empty($url_list)) {
1978 + $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
1979 + if ($search_query) {
1980 + // Include search filter in the final fetch
1981 + $prompts_query = $wpdb->prepare(
1982 + "SELECT id, article_content, source_url, timestamp, role_restriction
1983 + FROM {$table_name}
1984 + WHERE source_url IN ($placeholders) AND article_content LIKE %s
1985 + ORDER BY timestamp DESC",
1986 + ...array_merge($url_list, ['%' . $wpdb->esc_like($search_query) . '%'])
1987 + );
1988 + } else {
1989 + $prompts_query = $wpdb->prepare(
1990 + "SELECT id, article_content, source_url, timestamp, role_restriction
1991 + FROM {$table_name}
1992 + WHERE source_url IN ($placeholders)
1993 + ORDER BY timestamp DESC",
1994 + $url_list
1995 + );
1996 + }
1997 + $prompts = $wpdb->get_results($prompts_query);
1998 + }
1999 +
2000 + // Group prompts by source_url for chunk display
2001 + $grouped_prompts = array();
2002 + foreach ($prompts as $prompt) {
2003 + $source_url = $prompt->source_url ?? '';
2004 +
2005 + // Parse chunk metadata using the proper chunker method (same as initial page load)
2006 + if (class_exists('MxChat_Chunker')) {
2007 + $chunk_meta = MxChat_Chunker::parse_stored_chunk($prompt->article_content);
2008 + $prompt->chunk_metadata = $chunk_meta['metadata'];
2009 + $prompt->display_content = $chunk_meta['text'];
2010 + } else {
2011 + $prompt->chunk_metadata = array();
2012 + $prompt->display_content = $prompt->article_content;
2013 + }
2014 +
2015 + if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0) {
2016 + if (!isset($grouped_prompts[$source_url])) {
2017 + $grouped_prompts[$source_url] = array();
2018 + }
2019 + $grouped_prompts[$source_url][] = $prompt;
2020 + } else {
2021 + // Ungrouped entries
2022 + $grouped_prompts['_ungrouped_' . $prompt->id] = array($prompt);
2023 + }
2024 + }
2025 +
2026 + // Sort groups by the original URL order (newest first)
2027 + uksort($grouped_prompts, function($a, $b) use ($url_order_map) {
2028 + $order_a = isset($url_order_map[$a]) ? $url_order_map[$a] : PHP_INT_MAX;
2029 + $order_b = isset($url_order_map[$b]) ? $url_order_map[$b] : PHP_INT_MAX;
2030 + return $order_a - $order_b;
2031 + });
2032 +
2033 + // Sort each group internally by chunk_index
2034 + foreach ($grouped_prompts as $source_url => &$group) {
2035 + usort($group, function($a, $b) {
2036 + $index_a = isset($a->chunk_metadata['chunk_index']) ? intval($a->chunk_metadata['chunk_index']) : 0;
2037 + $index_b = isset($b->chunk_metadata['chunk_index']) ? intval($b->chunk_metadata['chunk_index']) : 0;
2038 + return $index_a - $index_b;
2039 + });
2040 + }
2041 + unset($group);
2042 +
2043 + // Build HTML for the table rows
2044 + ob_start();
2045 + $display_index = 0;
2046 + $current_page = $page;
2047 + $data_source = 'wordpress';
2048 + $current_bot_id = $bot_id;
2049 + $preview_length = 150;
2050 +
2051 + if (empty($grouped_prompts)) {
2052 + echo '<tr><td colspan="5" style="padding: 40px; text-align: center; color: var(--mxch-text-muted);">';
2053 + esc_html_e('No knowledge entries found. Use the Import Options to add content.', 'mxchat');
2054 + echo '</td></tr>';
2055 + } else {
2056 + foreach ($grouped_prompts as $source_url => $group) {
2057 + $chunk_count = count($group);
2058 + $first_prompt = $group[0];
2059 + $display_index++;
2060 +
2061 + if ($chunk_count > 1) {
2062 + // Multiple chunks - show grouped row with expand button
2063 + $group_id = 'group-' . md5($source_url);
2064 + ?>
2065 + <tr id="prompt-<?php echo esc_attr($first_prompt->id); ?>"
2066 + class="mxchat-chunk-group-header"
2067 + data-source="<?php echo esc_attr($data_source); ?>"
2068 + data-group-id="<?php echo esc_attr($group_id); ?>"
2069 + style="border-bottom: 1px solid var(--mxch-card-border);">
2070 + <td style="padding: 12px 16px; text-align: center;">
2071 + <input type="checkbox"
2072 + class="mxchat-entry-checkbox"
2073 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2074 + data-source="<?php echo esc_attr($data_source); ?>"
2075 + data-source-url="<?php echo esc_attr($source_url); ?>"
2076 + data-is-group="true"
2077 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>">
2078 + </td>
2079 + <td style="padding: 12px 16px; font-size: 13px;">
2080 + <?php echo esc_html($first_prompt->id); ?>
2081 + </td>
2082 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2083 + <div class="mxchat-chunk-group-info">
2084 + <button type="button" class="mxchat-chunk-toggle" data-group-id="<?php echo esc_attr($group_id); ?>">
2085 + <span class="dashicons dashicons-arrow-right-alt2"></span>
2086 + </button>
2087 + <span class="mxchat-chunk-badge"><?php echo esc_html($chunk_count); ?> <?php esc_html_e('chunks', 'mxchat'); ?></span>
2088 + <span class="mxchat-chunk-preview">
2089 + <?php
2090 + $parent_content = isset($first_prompt->display_content) ? $first_prompt->display_content : $first_prompt->article_content;
2091 + $content_preview = mb_substr($parent_content, 0, 100);
2092 + echo esc_html($content_preview . '...');
2093 + ?>
2094 + </span>
2095 + </div>
2096 + </td>
2097 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2098 + <?php if (!empty($source_url) && strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, '_ungrouped_') !== 0) : ?>
2099 + <a href="<?php echo esc_url($source_url); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2100 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2101 + <?php esc_html_e('View Source', 'mxchat'); ?>
2102 + </a>
2103 + <?php else : ?>
2104 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2105 + <?php endif; ?>
2106 + </td>
2107 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2108 + <button type="button"
2109 + class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2110 + data-source-url="<?php echo esc_attr($source_url); ?>"
2111 + data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2112 + data-data-source="<?php echo esc_attr($data_source); ?>"
2113 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2114 + data-nonce="<?php echo wp_create_nonce('mxchat_delete_chunks_nonce'); ?>"
2115 + style="color: var(--mxch-error);"
2116 + title="<?php esc_attr_e('Delete all chunks', 'mxchat'); ?>">
2117 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2118 + </button>
2119 + </td>
2120 + </tr>
2121 + <?php
2122 + // Render hidden chunk rows
2123 + foreach ($group as $chunk_index => $chunk) {
2124 + $meta_chunk_index = isset($chunk->chunk_metadata['chunk_index']) ? intval($chunk->chunk_metadata['chunk_index']) : $chunk_index;
2125 + $meta_total_chunks = isset($chunk->chunk_metadata['total_chunks']) ? intval($chunk->chunk_metadata['total_chunks']) : $chunk_count;
2126 + $content = isset($chunk->display_content) ? $chunk->display_content : $chunk->article_content;
2127 + $content_preview = mb_strlen($content) > $preview_length
2128 + ? mb_substr($content, 0, $preview_length) . '...'
2129 + : $content;
2130 + ?>
2131 + <tr id="prompt-<?php echo esc_attr($chunk->id); ?>"
2132 + class="mxchat-chunk-row <?php echo esc_attr($group_id); ?>"
2133 + data-source="<?php echo esc_attr($data_source); ?>"
2134 + style="display: none; background: #f8f9fa; border-bottom: 1px solid var(--mxch-card-border);">
2135 + <td style="padding: 12px 16px; text-align: center;">
2136 + <!-- Checkbox column placeholder for chunks (managed by group) -->
2137 + </td>
2138 + <td style="padding: 12px 16px 12px 30px; font-size: 13px;">
2139 + <!-- Hidden ID column for chunks -->
2140 + </td>
2141 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2142 + <div class="mxchat-accordion-wrapper">
2143 + <div class="mxchat-content-preview">
2144 + <span class="mxchat-chunk-indicator" style="margin-right: 10px; color: var(--mxch-text-secondary); font-size: 12px;">
2145 + <?php printf(esc_html__('Chunk %d of %d', 'mxchat'), $meta_chunk_index + 1, $meta_total_chunks); ?>
2146 + </span>
2147 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2148 + <?php if (mb_strlen($content) > $preview_length) : ?>
2149 + <button class="mxchat-expand-toggle" type="button">
2150 + <span class="dashicons dashicons-arrow-down-alt2"></span>
2151 + </button>
2152 + <?php endif; ?>
2153 + </div>
2154 + <div class="mxchat-content-full" style="display: none;">
2155 + <div class="content-view">
2156 + <?php
2157 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2158 + echo '<div dir="rtl" lang="he" class="rtl-content">';
2159 + echo wp_kses_post(wpautop($content));
2160 + echo '</div>';
2161 + } else {
2162 + echo wp_kses_post(wpautop($content));
2163 + }
2164 + ?>
2165 + </div>
2166 + </div>
2167 + </div>
2168 + </td>
2169 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2170 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Same as parent', 'mxchat'); ?></span>
2171 + </td>
2172 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2173 + <span class="mxchat-chunk-label" style="color: var(--mxch-text-muted);"><?php esc_html_e('Managed by group', 'mxchat'); ?></span>
2174 + </td>
2175 + </tr>
2176 + <?php
2177 + }
2178 + } else {
2179 + // Single entry - display normally with accordion
2180 + $prompt = $first_prompt;
2181 + $content = isset($prompt->display_content) ? $prompt->display_content : $prompt->article_content;
2182 + $content_preview = mb_strlen($content) > $preview_length
2183 + ? mb_substr($content, 0, $preview_length) . '...'
2184 + : $content;
2185 + ?>
2186 + <tr id="prompt-<?php echo esc_attr($prompt->id); ?>"
2187 + data-source="<?php echo esc_attr($data_source); ?>"
2188 + style="border-bottom: 1px solid var(--mxch-card-border);">
2189 + <td style="padding: 12px 16px; text-align: center;">
2190 + <input type="checkbox"
2191 + class="mxchat-entry-checkbox"
2192 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2193 + data-source="<?php echo esc_attr($data_source); ?>"
2194 + data-source-url="<?php echo esc_attr($source_url); ?>"
2195 + data-is-group="false">
2196 + </td>
2197 + <td style="padding: 12px 16px; font-size: 13px;">
2198 + <?php echo esc_html($prompt->id); ?>
2199 + </td>
2200 + <td class="mxchat-content-cell" style="padding: 12px 16px; font-size: 13px;">
2201 + <div class="mxchat-accordion-wrapper">
2202 + <div class="mxchat-content-preview">
2203 + <span class="preview-text"><?php echo esc_html($content_preview); ?></span>
2204 + <?php if (mb_strlen($content) > $preview_length) : ?>
2205 + <button class="mxchat-expand-toggle" type="button">
2206 + <span class="dashicons dashicons-arrow-down-alt2"></span>
2207 + </button>
2208 + <?php endif; ?>
2209 + </div>
2210 + <div class="mxchat-content-full" style="display: none;">
2211 + <div class="content-view">
2212 + <?php
2213 + if (preg_match('/[\x{0590}-\x{05FF}]/u', $content)) {
2214 + echo '<div dir="rtl" lang="he" class="rtl-content">';
2215 + echo wp_kses_post(wpautop($content));
2216 + echo '</div>';
2217 + } else {
2218 + echo wp_kses_post(wpautop($content));
2219 + }
2220 + ?>
2221 + </div>
2222 + </div>
2223 + </div>
2224 + </td>
2225 + <td class="mxchat-url-cell" style="padding: 12px 16px; font-size: 13px;">
2226 + <?php
2227 + $actual_source = $source_url;
2228 + if (strpos($source_url, '_ungrouped_') === 0) {
2229 + $actual_source = $prompt->source_url ?? '';
2230 + }
2231 + if (!empty($actual_source) && strpos($actual_source, 'mxchat://') !== 0) : ?>
2232 + <a href="<?php echo esc_url($actual_source); ?>" target="_blank" style="color: var(--mxch-primary); text-decoration: none;">
2233 + <span class="dashicons dashicons-external" style="font-size: 14px;"></span>
2234 + <?php esc_html_e('View', 'mxchat'); ?>
2235 + </a>
2236 + <?php else : ?>
2237 + <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2238 + <?php endif; ?>
2239 + </td>
2240 + <td style="padding: 12px 16px;">
2241 + <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);">
2242 + <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2243 + </button>
2244 + </td>
2245 + </tr>
2246 + <?php
2247 + }
2248 + }
2249 + }
2250 + $html = ob_get_clean();
2251 +
2252 + // Generate pagination HTML (include search/filter data for subsequent pages)
2253 + $pagination_html = '';
2254 + if ($total_pages > 1) {
2255 + $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) . '">';
2256 +
2257 + // Previous button
2258 + if ($page > 1) {
2259 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page - 1) . '">' . esc_html__('&laquo; Previous', 'mxchat') . '</a> ';
2260 + }
2261 +
2262 + // Page numbers
2263 + $start_page = max(1, $page - 2);
2264 + $end_page = min($total_pages, $page + 2);
2265 +
2266 + if ($start_page > 1) {
2267 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="1">1</a> ';
2268 + if ($start_page > 2) {
2269 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2270 + }
2271 + }
2272 +
2273 + for ($i = $start_page; $i <= $end_page; $i++) {
2274 + if ($i == $page) {
2275 + $pagination_html .= '<span class="mxchat-page-current">' . $i . '</span> ';
2276 + } else {
2277 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $i . '">' . $i . '</a> ';
2278 + }
2279 + }
2280 +
2281 + if ($end_page < $total_pages) {
2282 + if ($end_page < $total_pages - 1) {
2283 + $pagination_html .= '<span class="mxchat-page-dots">...</span> ';
2284 + }
2285 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . $total_pages . '">' . $total_pages . '</a> ';
2286 + }
2287 +
2288 + // Next button
2289 + if ($page < $total_pages) {
2290 + $pagination_html .= '<a href="#" class="mxchat-page-link" data-page="' . ($page + 1) . '">' . esc_html__('Next &raquo;', 'mxchat') . '</a>';
2291 + }
2292 +
2293 + $pagination_html .= '</div>';
2294 + }
2295 +
2296 + wp_send_json_success(array(
2297 + 'html' => $html,
2298 + 'pagination_html' => $pagination_html,
2299 + 'total_count' => $total_records,
2300 + 'total_pages' => $total_pages,
2301 + 'page' => $page,
2302 + 'per_page' => $per_page,
2303 + 'data_source' => 'wordpress'
2304 + ));
2305 +}
2306 +
2307 +/**
2308 + * AJAX handler to detect available sitemaps on the site
2309 + * Optimized for speed - only checks primary sitemap indexes first
2310 + */
2311 +public function ajax_mxchat_detect_sitemaps() {
2312 + check_ajax_referer('mxchat_detect_sitemaps_nonce', 'nonce');
2313 +
2314 + if (!current_user_can('manage_options')) {
2315 + wp_send_json_error(array('message' => 'Unauthorized'));
2316 + return;
2317 + }
2318 +
2319 + $site_url = get_site_url();
2320 + $sitemaps = array();
2321 + $found_index = false;
2322 +
2323 + // Only check the main sitemap index files first (much faster)
2324 + // These are the primary entry points that contain sub-sitemaps
2325 + $primary_indexes = array(
2326 + 'sitemap_index.xml' => 'Yoast SEO / Rank Math', // Yoast & Rank Math
2327 + 'wp-sitemap.xml' => 'WordPress Core', // WordPress Core
2328 + 'sitemap.xml' => 'Standard', // Generic/AIOSEO
2329 + );
2330 +
2331 + foreach ($primary_indexes as $path => $source) {
2332 + $url = trailingslashit($site_url) . $path;
2333 +
2334 + $response = wp_remote_head($url, array(
2335 + 'timeout' => 3, // Short timeout
2336 + 'sslverify' => false,
2337 + 'redirection' => 1
2338 + ));
2339 +
2340 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2341 + // Found a sitemap index - parse it to get sub-sitemaps
2342 + $sub_sitemaps = $this->parse_sitemap_index($url);
2343 + if (!empty($sub_sitemaps)) {
2344 + $sitemaps[] = array(
2345 + 'url' => $url,
2346 + 'type' => 'index',
2347 + 'source' => $source,
2348 + 'sub_sitemaps' => $sub_sitemaps
2349 + );
2350 + $found_index = true;
2351 + // Found a valid index, no need to check others
2352 + break;
2353 + }
2354 + }
2355 + }
2356 +
2357 + // If no sitemap index found, check for standalone sitemaps
2358 + if (!$found_index) {
2359 + $standalone_sitemaps = array(
2360 + 'post-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2361 + 'page-sitemap.xml' => array('type' => 'content', 'source' => 'Yoast SEO'),
2362 + );
2363 +
2364 + foreach ($standalone_sitemaps as $path => $info) {
2365 + $url = trailingslashit($site_url) . $path;
2366 +
2367 + $response = wp_remote_head($url, array(
2368 + 'timeout' => 2,
2369 + 'sslverify' => false
2370 + ));
2371 +
2372 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2373 + $sitemaps[] = array(
2374 + 'url' => $url,
2375 + 'type' => $info['type'],
2376 + 'source' => $info['source'],
2377 + 'url_count' => 0 // Skip URL count for speed
2378 + );
2379 + }
2380 + }
2381 + }
2382 +
2383 + wp_send_json_success(array(
2384 + 'sitemaps' => $sitemaps,
2385 + 'site_url' => $site_url
2386 + ));
2387 +}
2388 +
2389 +/**
2390 + * Parse a sitemap index to get sub-sitemaps
2391 + * Optimized: doesn't fetch URL count for each sub-sitemap (too slow)
2392 + */
2393 +private function parse_sitemap_index($url) {
2394 + $sub_sitemaps = array();
2395 +
2396 + $response = wp_remote_get($url, array(
2397 + 'timeout' => 5,
2398 + 'sslverify' => false
2399 + ));
2400 +
2401 + if (is_wp_error($response)) {
2402 + return $sub_sitemaps;
2403 + }
2404 +
2405 + $body = wp_remote_retrieve_body($response);
2406 + if (empty($body)) {
2407 + return $sub_sitemaps;
2408 + }
2409 +
2410 + // Suppress XML errors
2411 + libxml_use_internal_errors(true);
2412 + $xml = simplexml_load_string($body);
2413 + libxml_clear_errors();
2414 +
2415 + if ($xml === false) {
2416 + return $sub_sitemaps;
2417 + }
2418 +
2419 + // Check if it's a sitemap index (contains <sitemap> elements)
2420 + if (isset($xml->sitemap)) {
2421 + foreach ($xml->sitemap as $sitemap) {
2422 + $loc = (string) $sitemap->loc;
2423 + if (!empty($loc)) {
2424 + // Try to determine the type from the URL
2425 + $type = 'content';
2426 + if (strpos($loc, 'category') !== false || strpos($loc, 'tag') !== false || strpos($loc, 'taxonomy') !== false) {
2427 + $type = 'taxonomy';
2428 + } elseif (strpos($loc, 'author') !== false || strpos($loc, 'user') !== false) {
2429 + $type = 'author';
2430 + }
2431 +
2432 + // Skip URL count - too slow to fetch for each sitemap
2433 + $sub_sitemaps[] = array(
2434 + 'url' => $loc,
2435 + 'type' => $type,
2436 + 'url_count' => 0, // Don't fetch - takes too long
2437 + 'name' => basename(parse_url($loc, PHP_URL_PATH))
2438 + );
2439 + }
2440 + }
2441 + }
2442 +
2443 + return $sub_sitemaps;
2444 +}
2445 +
2446 +/**
2447 + * Get URL count from a sitemap
2448 + */
2449 +private function get_sitemap_url_count($url) {
2450 + $response = wp_remote_get($url, array(
2451 + 'timeout' => 10,
2452 + 'sslverify' => false
2453 + ));
2454 +
2455 + if (is_wp_error($response)) {
2456 + return 0;
2457 + }
2458 +
2459 + $body = wp_remote_retrieve_body($response);
2460 + if (empty($body)) {
2461 + return 0;
2462 + }
2463 +
2464 + // Count <url> or <loc> elements
2465 + $count = preg_match_all('/<url>/i', $body, $matches);
2466 + return $count ?: 0;
2467 +}
2468 +
2469 +/**
2470 + * Get sitemaps declared in robots.txt
2471 + */
2472 +private function get_sitemaps_from_robots($site_url) {
2473 + $sitemaps = array();
2474 + $robots_url = trailingslashit($site_url) . 'robots.txt';
2475 +
2476 + $response = wp_remote_get($robots_url, array(
2477 + 'timeout' => 5,
2478 + 'sslverify' => false
2479 + ));
2480 +
2481 + if (is_wp_error($response)) {
2482 + return $sitemaps;
2483 + }
2484 +
2485 + $body = wp_remote_retrieve_body($response);
2486 + if (empty($body)) {
2487 + return $sitemaps;
2488 + }
2489 +
2490 + // Find Sitemap: declarations
2491 + if (preg_match_all('/^Sitemap:\s*(.+)$/mi', $body, $matches)) {
2492 + foreach ($matches[1] as $sitemap_url) {
2493 + $sitemap_url = trim($sitemap_url);
2494 + if (filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
2495 + $sitemaps[] = $sitemap_url;
2496 + }
2497 + }
2498 + }
2499 +
2500 + return $sitemaps;
2501 +}
2502 +
2503 +public function mxchat_stop_processing() {
2504 + // Verify permissions
2505 + if (!current_user_can('manage_options')) {
2506 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
2507 + }
2508 +
2509 + // Verify nonce
2510 + check_admin_referer('mxchat_stop_processing_action', 'mxchat_stop_processing_nonce');
2511 +
2512 + global $wpdb;
2513 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
2514 +
2515 + // Get active queue IDs
2516 + $sitemap_queue_id = get_transient('mxchat_active_queue_sitemap');
2517 + $pdf_queue_id = get_transient('mxchat_active_queue_pdf');
2518 +
2519 + // Delete all pending items from active queues
2520 + if ($sitemap_queue_id) {
2521 + $wpdb->delete(
2522 + $table_name,
2523 + array(
2524 + 'queue_id' => $sitemap_queue_id,
2525 + 'status' => 'pending'
2526 + ),
2527 + array('%s', '%s')
2528 + );
2529 +
2530 + delete_transient('mxchat_active_queue_sitemap');
2531 + delete_transient('mxchat_last_sitemap_url');
2532 + }
2533 +
2534 + if ($pdf_queue_id) {
2535 + // Get PDF path before deleting
2536 + $pdf_path = $this->mxchat_get_queue_meta($pdf_queue_id, 'pdf_path');
2537 +
2538 + $wpdb->delete(
2539 + $table_name,
2540 + array(
2541 + 'queue_id' => $pdf_queue_id,
2542 + 'status' => 'pending'
2543 + ),
2544 + array('%s', '%s')
2545 + );
2546 +
2547 + // Delete PDF file
2548 + if ($pdf_path && file_exists($pdf_path)) {
2549 + wp_delete_file($pdf_path);
2550 + }
2551 +
2552 + delete_transient('mxchat_active_queue_pdf');
2553 + delete_transient('mxchat_last_pdf_url');
2554 + }
2555 +
2556 + // Redirect back with a success message
2557 + set_transient('mxchat_admin_notice_success',
2558 + esc_html__('Processing has been stopped successfully.', 'mxchat'),
2559 + 30
2560 + );
2561 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
2562 + exit;
2563 +}
2564 +
2565 +/**
2566 + * Get content list for processing
2567 + */
2568 +public function ajax_mxchat_get_content_list() {
2569 + // Verify the nonce
2570 + check_ajax_referer('mxchat_content_selector_nonce', 'nonce');
2571 +
2572 + if (!current_user_can('manage_options')) {
2573 + wp_send_json_error(__('Unauthorized access', 'mxchat'));
2574 + }
2575 +
2576 + $page = isset($_GET['page']) ? absint($_GET['page']) : 1;
2577 + $per_page = isset($_GET['per_page']) ? absint($_GET['per_page']) : 100;
2578 + $search = isset($_GET['search']) ? sanitize_text_field($_GET['search']) : '';
2579 + $post_type = isset($_GET['post_type']) ? sanitize_text_field($_GET['post_type']) : 'all';
2580 + $post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : 'publish';
2581 + $processed_filter = isset($_GET['processed_filter']) ? sanitize_text_field($_GET['processed_filter']) : 'all';
2582 +
2583 + // Build query args
2584 + $args = array(
2585 + 'posts_per_page' => $per_page,
2586 + 'paged' => $page,
2587 + 'post_status' => $post_status !== 'all' ? $post_status : array('publish', 'draft', 'pending'),
2588 + 'orderby' => 'date',
2589 + 'order' => 'DESC',
2590 + );
2591 +
2592 + // Handle post types - IMPROVED VERSION
2593 + if ($post_type !== 'all') {
2594 + $args['post_type'] = $post_type;
2595 + } else {
2596 + // Get all available post types that might contain content
2597 + $all_post_types = array();
2598 +
2599 + // First get all public post types
2600 + $public_types = get_post_types(array('public' => true), 'names');
2601 + $all_post_types = array_merge($all_post_types, $public_types);
2602 +
2603 + // Add common forum/community post types
2604 + $forum_types = array('topic', 'reply', 'forum', 'wpforo_topic', 'wpforo_post');
2605 + foreach ($forum_types as $forum_type) {
2606 + if (post_type_exists($forum_type)) {
2607 + $all_post_types[] = $forum_type;
2608 + }
2609 + }
2610 +
2611 + // Add other commonly used post types
2612 + $common_types = array('product', 'job_listing', 'event', 'portfolio');
2613 + foreach ($common_types as $common_type) {
2614 + if (post_type_exists($common_type)) {
2615 + $all_post_types[] = $common_type;
2616 + }
2617 + }
2618 +
2619 + // Remove duplicates and ensure we have at least some post types
2620 + $all_post_types = array_unique($all_post_types);
2621 +
2622 + if (empty($all_post_types)) {
2623 + // Fallback to basic post types
2624 + $all_post_types = array('post', 'page');
2625 + }
2626 +
2627 + $args['post_type'] = $all_post_types;
2628 +
2629 + // Debug logging to see what post types are being queried
2630 + //error_log('MxChat Debug: Querying post types: ' . implode(', ', $all_post_types));
2631 + }
2632 +
2633 + if (!empty($search)) {
2634 + $args['s'] = $search;
2635 + }
2636 +
2637 + // Get processed data from storage
2638 + $processed_data = array();
2639 +
2640 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
2641 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
2642 +
2643 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
2644 + // Get fresh data from Pinecone - no caching
2645 + $processed_data = $this->mxchat_get_pinecone_processed_content($pinecone_options);
2646 + } else {
2647 + // WordPress DB checking with better URL matching for all post types
2648 + global $wpdb;
2649 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2650 + $processed_items = $wpdb->get_results("SELECT id, source_url, article_content, timestamp FROM {$table_name}");
2651 +
2652 + // Group items by source_url to count chunks
2653 + $url_chunk_counts = array();
2654 + $url_latest_timestamp = array();
2655 + $url_first_id = array();
2656 +
2657 + if (!empty($processed_items)) {
2658 + foreach ($processed_items as $item) {
2659 + $url = $item->source_url;
2660 + if (empty($url)) continue;
2661 +
2662 + // Count chunks per URL
2663 + if (!isset($url_chunk_counts[$url])) {
2664 + $url_chunk_counts[$url] = 0;
2665 + $url_latest_timestamp[$url] = $item->timestamp;
2666 + $url_first_id[$url] = $item->id;
2667 + }
2668 + $url_chunk_counts[$url]++;
2669 +
2670 + // Track latest timestamp
2671 + if (strtotime($item->timestamp) > strtotime($url_latest_timestamp[$url])) {
2672 + $url_latest_timestamp[$url] = $item->timestamp;
2673 + }
2674 + }
2675 +
2676 + // Now build processed_data with chunk counts
2677 + foreach ($url_chunk_counts as $url => $chunk_count) {
2678 + $post_id = $this->mxchat_url_to_post_id_improved($url);
2679 +
2680 + if ($post_id) {
2681 + $processed_data[$post_id] = array(
2682 + 'db_id' => $url_first_id[$url],
2683 + 'timestamp' => $url_latest_timestamp[$url],
2684 + 'url' => $url,
2685 + 'source' => 'wordpress',
2686 + 'chunk_count' => $chunk_count
2687 + );
2688 + }
2689 + }
2690 + }
2691 + }
2692 +
2693 + // Get processed IDs as a simple array for in_array checks
2694 + $processed_ids = array_keys($processed_data);
2695 +
2696 + // Handle processed/unprocessed filter
2697 + if ($processed_filter === 'processed' && !empty($processed_ids)) {
2698 + $args['post__in'] = $processed_ids;
2699 + } elseif ($processed_filter === 'unprocessed' && !empty($processed_ids)) {
2700 + $args['post__not_in'] = $processed_ids;
2701 + }
2702 +
2703 + // Run the query
2704 + $query = new WP_Query($args);
2705 + $content_items = array();
2706 +
2707 + if ($query->have_posts()) {
2708 + while ($query->have_posts()) {
2709 + $query->the_post();
2710 + $id = get_the_ID();
2711 + $post_date = get_the_date();
2712 + $excerpt = wp_trim_words(get_the_excerpt(), 20, '...');
2713 + $word_count = str_word_count(strip_tags(get_the_content()));
2714 +
2715 + $is_processed = in_array($id, $processed_ids);
2716 + $processed_date = '';
2717 + $db_record_id = 0;
2718 + $data_source = 'none';
2719 +
2720 + if ($is_processed && isset($processed_data[$id])) {
2721 + $item_data = $processed_data[$id];
2722 + $data_source = $item_data['source'];
2723 +
2724 + if ($data_source === 'wordpress' && isset($item_data['timestamp'])) {
2725 + // WordPress DB format
2726 + $timestamp = strtotime($item_data['timestamp']);
2727 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
2728 + $db_record_id = $item_data['db_id'];
2729 + } elseif ($data_source === 'pinecone') {
2730 + // Pinecone format
2731 + $processed_date = $item_data['processed_date'];
2732 + $db_record_id = $item_data['db_id'];
2733 + }
2734 + }
2735 +
2736 + // Get chunk count for this item
2737 + $chunk_count = 0;
2738 + if ($is_processed && isset($processed_data[$id]['chunk_count'])) {
2739 + $chunk_count = intval($processed_data[$id]['chunk_count']);
2740 + }
2741 +
2742 + $content_items[] = array(
2743 + 'id' => $id,
2744 + 'title' => get_the_title(),
2745 + 'permalink' => get_permalink(),
2746 + 'date' => $post_date,
2747 + 'type' => get_post_type(),
2748 + 'status' => get_post_status(),
2749 + 'excerpt' => $excerpt,
2750 + 'word_count' => $word_count,
2751 + 'already_processed' => $is_processed,
2752 + 'processed_date' => $processed_date,
2753 + 'db_record_id' => $db_record_id,
2754 + 'data_source' => $data_source,
2755 + 'chunk_count' => $chunk_count
2756 + );
2757 + }
2758 + wp_reset_postdata();
2759 + }
2760 +
2761 + $response = array(
2762 + 'items' => $content_items,
2763 + 'total' => $query->found_posts,
2764 + 'total_pages' => $query->max_num_pages,
2765 + 'current_page' => $page,
2766 + 'processed_count' => count($processed_ids)
2767 + );
2768 +
2769 + wp_send_json_success($response);
2770 + exit;
2771 +}
2772 +
2773 +
2774 +/**
2775 + * This function handles various WooCommerce URL formats and permalink structures
2776 + */
2777 +private function mxchat_url_to_post_id_improved($url) {
2778 + // First try the standard WordPress function
2779 + $post_id = url_to_postid($url);
2780 +
2781 + if ($post_id > 0) {
2782 + return $post_id;
2783 + }
2784 +
2785 + // If that fails, try more aggressive URL matching
2786 + // Remove trailing slashes and query parameters for better matching
2787 + $clean_url = rtrim($url, '/');
2788 + $clean_url = strtok($clean_url, '?'); // Remove query parameters
2789 +
2790 + // Try again with cleaned URL
2791 + $post_id = url_to_postid($clean_url);
2792 + if ($post_id > 0) {
2793 + return $post_id;
2794 + }
2795 +
2796 + // For bbPress forum topics, try extracting slug from URL
2797 + if (strpos($url, '/topic/') !== false || strpos($url, '/forums/') !== false) {
2798 + // Handle bbPress URLs: /forums/topic/topic-name/
2799 + if (preg_match('/\/forums\/topic\/([^\/\?]+)/', $url, $matches)) {
2800 + $topic_slug = $matches[1];
2801 +
2802 + // Look up topic by slug
2803 + $topic = get_page_by_path($topic_slug, OBJECT, 'topic');
2804 + if ($topic) {
2805 + return $topic->ID;
2806 + }
2807 +
2808 + // Alternative method: query by post_name
2809 + global $wpdb;
2810 + $post_id = $wpdb->get_var($wpdb->prepare(
2811 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
2812 + $topic_slug
2813 + ));
2814 +
2815 + if ($post_id) {
2816 + return intval($post_id);
2817 + }
2818 + }
2819 +
2820 + // Handle simpler topic URLs: /topic/topic-name/
2821 + if (preg_match('/\/topic\/([^\/\?]+)/', $url, $matches)) {
2822 + $topic_slug = $matches[1];
2823 +
2824 + global $wpdb;
2825 + $post_id = $wpdb->get_var($wpdb->prepare(
2826 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'topic' AND post_status IN ('publish', 'closed')",
2827 + $topic_slug
2828 + ));
2829 +
2830 + if ($post_id) {
2831 + return intval($post_id);
2832 + }
2833 + }
2834 + }
2835 +
2836 + // For WooCommerce products
2837 + if (strpos($url, '/product/') !== false || strpos($url, 'product=') !== false) {
2838 + // Extract product slug from various URL formats
2839 + $product_slug = '';
2840 +
2841 + // Handle pretty permalinks: /product/product-name/
2842 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
2843 + $product_slug = $matches[1];
2844 + }
2845 + // Handle query parameters: ?product=product-name
2846 + elseif (preg_match('/[\?&]product=([^&]+)/', $url, $matches)) {
2847 + $product_slug = $matches[1];
2848 + }
2849 +
2850 + if (!empty($product_slug)) {
2851 + // Look up product by slug
2852 + $product = get_page_by_path($product_slug, OBJECT, 'product');
2853 + if ($product) {
2854 + return $product->ID;
2855 + }
2856 +
2857 + // Alternative method: query by post_name
2858 + global $wpdb;
2859 + $post_id = $wpdb->get_var($wpdb->prepare(
2860 + "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = 'product' AND post_status = 'publish'",
2861 + $product_slug
2862 + ));
2863 +
2864 + if ($post_id) {
2865 + return intval($post_id);
2866 + }
2867 + }
2868 + }
2869 +
2870 + // Generic approach: try to extract slug and match against all post types
2871 + $parsed_url = wp_parse_url($clean_url);
2872 + $path = $parsed_url['path'] ?? '';
2873 +
2874 + if (!empty($path)) {
2875 + // Get the last part of the path as potential slug
2876 + $path_parts = array_filter(explode('/', trim($path, '/')));
2877 + $potential_slug = end($path_parts);
2878 +
2879 + if (!empty($potential_slug)) {
2880 + global $wpdb;
2881 +
2882 + // Try to find any post with this slug
2883 + $post_id = $wpdb->get_var($wpdb->prepare(
2884 + "SELECT ID FROM {$wpdb->posts}
2885 + WHERE post_name = %s
2886 + AND post_status IN ('publish', 'closed', 'private')
2887 + AND post_type NOT IN ('revision', 'attachment', 'nav_menu_item')
2888 + ORDER BY CASE
2889 + WHEN post_type = 'post' THEN 1
2890 + WHEN post_type = 'page' THEN 2
2891 + WHEN post_type = 'topic' THEN 3
2892 + WHEN post_type = 'product' THEN 4
2893 + ELSE 5
2894 + END
2895 + LIMIT 1",
2896 + $potential_slug
2897 + ));
2898 +
2899 + if ($post_id) {
2900 + return intval($post_id);
2901 + }
2902 + }
2903 + }
2904 +
2905 + // ADDITIONAL: Try direct database lookup by URL variations
2906 + global $wpdb;
2907 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2908 +
2909 + // Try variations of the URL (with/without trailing slash, http/https)
2910 + $url_variations = array(
2911 + $url,
2912 + rtrim($url, '/'),
2913 + $url . '/',
2914 + str_replace('http://', 'https://', $url),
2915 + str_replace('https://', 'http://', $url),
2916 + str_replace('http://', 'https://', rtrim($url, '/')),
2917 + str_replace('https://', 'http://', rtrim($url, '/'))
2918 + );
2919 +
2920 + // Remove duplicates
2921 + $url_variations = array_unique($url_variations);
2922 +
2923 + foreach ($url_variations as $variation) {
2924 + $existing_record = $wpdb->get_row($wpdb->prepare(
2925 + "SELECT id, source_url FROM $table_name WHERE source_url = %s",
2926 + $variation
2927 + ));
2928 +
2929 + if ($existing_record) {
2930 + // Try to get post ID from this stored URL
2931 + $stored_post_id = url_to_postid($existing_record->source_url);
2932 + if ($stored_post_id > 0) {
2933 + return $stored_post_id;
2934 + }
2935 + }
2936 + }
2937 +
2938 + return 0; // No match found
2939 +}
2940 +/**
2941 + * Process selected content via AJAX
2942 + */
2943 +public function ajax_mxchat_process_selected_content() {
2944 + // Basic request validation
2945 + if (!check_ajax_referer('mxchat_content_selector_nonce', 'nonce', false)) {
2946 + wp_send_json_error('Invalid nonce');
2947 + exit;
2948 + }
2949 +
2950 + if (!current_user_can('manage_options')) {
2951 + wp_send_json_error('Unauthorized access');
2952 + exit;
2953 + }
2954 +
2955 + // Get post IDs - safely parse the array
2956 + $post_ids = array();
2957 + if (isset($_POST['post_ids']) && is_array($_POST['post_ids'])) {
2958 + foreach ($_POST['post_ids'] as $id) {
2959 + $post_ids[] = absint($id);
2960 + }
2961 + }
2962 +
2963 + if (empty($post_ids)) {
2964 + wp_send_json_error('No content selected');
2965 + exit;
2966 + }
2967 +
2968 + // Get bot_id from request
2969 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
2970 +
2971 + // Process only ONE post at a time to avoid request size issues
2972 + $post_id = reset($post_ids);
2973 + $post = get_post($post_id);
2974 +
2975 + if (!$post) {
2976 + wp_send_json_error('Post not found');
2977 + exit;
2978 + }
2979 +
2980 + // Get content including title, short description (for WooCommerce), and main content
2981 + $content = $post->post_title . "\n\n";
2982 +
2983 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
2984 + if (!empty($post->post_excerpt)) {
2985 + // Remove shortcode tags but preserve content inside them
2986 + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
2987 + $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
2988 + }
2989 +
2990 + // Add main content - remove shortcode tags but preserve content inside them
2991 + $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
2992 + $content .= wp_strip_all_tags($clean_content);
2993 +
2994 + // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
2995 + if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
2996 + $product = wc_get_product($post_id);
2997 +
2998 + if ($product) {
2999 + // Get pricing information
3000 + $regular_price = $product->get_regular_price();
3001 + $sale_price = $product->get_sale_price();
3002 + $price = $product->get_price();
3003 + $sku = $product->get_sku();
3004 +
3005 + // Get currency symbol
3006 + $currency_symbol = get_woocommerce_currency_symbol();
3007 +
3008 + // Add pricing information
3009 + $content .= "\n";
3010 + if (!empty($regular_price)) {
3011 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3012 + } elseif (!empty($price)) {
3013 + $content .= "Price: " . $currency_symbol . $price . "\n";
3014 + }
3015 +
3016 + if (!empty($sale_price) && $sale_price !== $regular_price) {
3017 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3018 + }
3019 +
3020 + // Handle variable products - show price range
3021 + if ($product->is_type('variable')) {
3022 + $min_price = $product->get_variation_price('min');
3023 + $max_price = $product->get_variation_price('max');
3024 + if ($min_price !== $max_price) {
3025 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3026 + }
3027 + }
3028 +
3029 + if (!empty($sku)) {
3030 + $content .= "SKU: " . $sku . "\n";
3031 + }
3032 +
3033 + // Get product categories
3034 + $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3035 + if (!empty($categories) && !is_wp_error($categories)) {
3036 + $content .= "Categories: " . implode(', ', $categories) . "\n";
3037 + }
3038 + }
3039 +
3040 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3041 + $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3042 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
3043 + foreach ($custom_tabs as $tab) {
3044 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3045 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
3046 +
3047 + if (!empty($tab_title) && !empty($tab_content)) {
3048 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3049 + }
3050 + }
3051 + }
3052 +
3053 + // Also check for reusable/saved tabs applied to this product
3054 + $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3055 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3056 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3057 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
3058 + foreach ($applied_saved_tabs as $saved_tab_id) {
3059 + if (isset($saved_tabs[$saved_tab_id])) {
3060 + $tab = $saved_tabs[$saved_tab_id];
3061 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3062 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
3063 +
3064 + if (!empty($tab_title) && !empty($tab_content)) {
3065 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3066 + }
3067 + }
3068 + }
3069 + }
3070 + }
3071 + }
3072 +
3073 + // ADD ACF FIELDS SUPPORT
3074 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3075 + if (!empty($acf_fields)) {
3076 + $acf_content_parts = array();
3077 +
3078 + foreach ($acf_fields as $field_name => $field_value) {
3079 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3080 +
3081 + if (!empty($formatted_value)) {
3082 + $field_label = ucwords(str_replace('_', ' ', $field_name));
3083 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
3084 + }
3085 + }
3086 +
3087 + if (!empty($acf_content_parts)) {
3088 + $content .= "\n\n" . implode("\n", $acf_content_parts);
3089 + }
3090 + }
3091 +
3092 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3093 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3094 + if (!empty($custom_meta)) {
3095 + $meta_content_parts = array();
3096 +
3097 + foreach ($custom_meta as $meta_key => $meta_value) {
3098 + // Convert meta key to readable label
3099 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3100 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
3101 + }
3102 +
3103 + if (!empty($meta_content_parts)) {
3104 + $content .= "\n\n" . implode("\n", $meta_content_parts);
3105 + }
3106 + }
3107 +
3108 + // Debug logging for WordPress Import content
3109 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
3110 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
3111 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
3112 + error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
3113 +
3114 + // Note: Removed 10,000 char limit - chunking now handles large content properly
3115 +
3116 + // Get bot-specific API key
3117 + $bot_options = $this->get_bot_options($bot_id);
3118 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3119 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3120 +
3121 + if (strpos($selected_model, 'voyage') === 0) {
3122 + $api_key = $options['voyage_api_key'] ?? '';
3123 + $provider_name = 'Voyage AI';
3124 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3125 + $api_key = $options['gemini_api_key'] ?? '';
3126 + $provider_name = 'Google Gemini';
3127 + } else {
3128 + $api_key = $options['api_key'] ?? '';
3129 + $provider_name = 'OpenAI';
3130 + }
3131 +
3132 + if (empty($api_key)) {
3133 + wp_send_json_error($provider_name . ' API key not configured');
3134 + exit;
3135 + }
3136 +
3137 + $source_url = get_permalink($post_id);
3138 + $vector_id = md5($source_url); // Vector ID for Pinecone
3139 +
3140 + // Check for existing content in bot-specific storage
3141 + $is_update = false;
3142 +
3143 + // Get bot-specific Pinecone configuration
3144 + $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
3145 + $use_pinecone = !empty($bot_pinecone_config) && ($bot_pinecone_config['use_pinecone'] ?? false);
3146 +
3147 + if ($use_pinecone && !empty($bot_pinecone_config['api_key'])) {
3148 + // Check Pinecone for this bot
3149 + $pinecone_data = $this->mxchat_get_pinecone_processed_content($bot_pinecone_config);
3150 + if (isset($pinecone_data[$post_id])) {
3151 + $is_update = true;
3152 + }
3153 + } else {
3154 + // Check WordPress DB (same as before since it's shared)
3155 + global $wpdb;
3156 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3157 + $existing_record = $wpdb->get_row($wpdb->prepare(
3158 + "SELECT id FROM $table_name WHERE source_url = %s",
3159 + $source_url
3160 + ));
3161 +
3162 + if ($existing_record) {
3163 + $is_update = true;
3164 + }
3165 + }
3166 +
3167 + // UPDATED 2.5.6: Determine content type based on post_type
3168 + $post_type = $post->post_type;
3169 + $content_type = 'content'; // Default fallback
3170 +
3171 + // Map WordPress post types to content types
3172 + switch ($post_type) {
3173 + case 'post':
3174 + $content_type = 'post';
3175 + break;
3176 + case 'page':
3177 + $content_type = 'page';
3178 + break;
3179 + case 'product':
3180 + $content_type = 'product';
3181 + break;
3182 + default:
3183 + // For custom post types, use the post type name
3184 + $content_type = sanitize_key($post_type);
3185 + break;
3186 + }
3187 +
3188 + // Use the centralized utility function with bot_id and content_type
3189 + $result = MxChat_Utils::submit_content_to_db(
3190 + $content,
3191 + $source_url,
3192 + $api_key,
3193 + $vector_id,
3194 + $bot_id,
3195 + $content_type
3196 + );
3197 +
3198 + if (is_wp_error($result)) {
3199 + wp_send_json_error('Storage failed: ' . $result->get_error_message());
3200 + exit;
3201 + }
3202 +
3203 + // Automatically apply role restriction based on tags
3204 + $this->apply_role_restriction_to_post($post_id, $source_url);
3205 +
3206 + $operation_type = $is_update ? 'update' : 'new';
3207 +
3208 + // Count ACF fields for debugging
3209 + $acf_field_count = count($acf_fields);
3210 +
3211 + // Success response with minimal data
3212 + wp_send_json_success(array(
3213 + 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
3214 + 'post_id' => $post_id,
3215 + 'title' => $post->post_title,
3216 + 'operation_type' => $operation_type,
3217 + 'vector_id' => $vector_id,
3218 + 'acf_fields_found' => $acf_field_count,
3219 + 'content_preview' => substr($content, 0, 100) . '...',
3220 + 'bot_id' => $bot_id
3221 + ));
3222 + exit;
3223 +}
3224 +
3225 +private function apply_role_restriction_to_post($post_id, $source_url) {
3226 + // Get tag-role mappings
3227 + $mappings = get_option('mxchat_tag_role_mappings', array());
3228 +
3229 + if (empty($mappings)) {
3230 + return; // No mappings, leave as public
3231 + }
3232 +
3233 + // Get all tags for the post
3234 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
3235 +
3236 + if (empty($post_tags)) {
3237 + return; // No tags, leave as public
3238 + }
3239 +
3240 + // Determine the highest role restriction based on tags
3241 + $highest_role = 'public';
3242 + $role_hierarchy = array(
3243 + 'public' => 0,
3244 + 'logged_in' => 1,
3245 + 'subscriber' => 2,
3246 + 'contributor' => 3,
3247 + 'author' => 4,
3248 + 'editor' => 5,
3249 + 'administrator' => 6
3250 + );
3251 +
3252 + foreach ($post_tags as $tag_slug) {
3253 + if (isset($mappings[$tag_slug])) {
3254 + $role = $mappings[$tag_slug];
3255 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
3256 + $highest_role = $role;
3257 + }
3258 + }
3259 + }
3260 +
3261 + // If no restricted tags found, return (leave as public)
3262 + if ($highest_role === 'public') {
3263 + return;
3264 + }
3265 +
3266 + // Update the role restriction in the database
3267 + global $wpdb;
3268 +
3269 + // Check if using Pinecone
3270 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
3271 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3272 +
3273 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
3274 + // Update Pinecone role restriction
3275 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
3276 + $vector_id = md5($source_url);
3277 +
3278 + $wpdb->replace(
3279 + $roles_table,
3280 + array(
3281 + 'vector_id' => $vector_id,
3282 + 'role_restriction' => $highest_role,
3283 + 'updated_at' => current_time('mysql')
3284 + ),
3285 + array('%s', '%s', '%s')
3286 + );
3287 + } else {
3288 + // Update WordPress DB
3289 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3290 +
3291 + $wpdb->update(
3292 + $table_name,
3293 + array('role_restriction' => $highest_role),
3294 + array('source_url' => $source_url),
3295 + array('%s'),
3296 + array('%s')
3297 + );
3298 + }
3299 +}
3300 +
3301 +public function mxchat_get_public_post_types() {
3302 + // Get all public post types
3303 + $post_types = get_post_types(array('public' => true), 'objects');
3304 + $post_type_options = array();
3305 +
3306 + foreach ($post_types as $post_type) {
3307 + $post_type_options[$post_type->name] = $post_type->label;
3308 + }
3309 +
3310 + // Also include common forum/community post types that might not be marked as public
3311 + $additional_types = array(
3312 + 'topic' => 'Forum Topics (bbPress)',
3313 + 'reply' => 'Forum Replies (bbPress)',
3314 + 'forum' => 'Forums (bbPress)',
3315 + 'wpforo_topic' => 'wpForo Topics',
3316 + 'wpforo_post' => 'wpForo Posts'
3317 + );
3318 +
3319 + foreach ($additional_types as $type_name => $type_label) {
3320 + if (post_type_exists($type_name) && !isset($post_type_options[$type_name])) {
3321 + $post_type_options[$type_name] = $type_label;
3322 + }
3323 + }
3324 +
3325 + return $post_type_options;
3326 +}
3327 +
3328 +/**
3329 + * Retrieves processed content from Pinecone API
3330 + */
3331 +public function mxchat_get_pinecone_processed_content($pinecone_options) {
3332 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3333 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3334 +
3335 + if (empty($api_key) || empty($host)) {
3336 + return array();
3337 + }
3338 +
3339 + $pinecone_data = array();
3340 +
3341 + try {
3342 + // Always get fresh data from Pinecone
3343 + $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options);
3344 +
3345 + // Method 2: Final fallback - try stats endpoint (if available)
3346 + if (empty($pinecone_data)) {
3347 + $stats_url = "https://{$host}/describe_index_stats";
3348 +
3349 + $response = wp_remote_post($stats_url, array(
3350 + 'headers' => array(
3351 + 'Api-Key' => $api_key,
3352 + 'Content-Type' => 'application/json'
3353 + ),
3354 + 'body' => json_encode(array()),
3355 + 'timeout' => 30
3356 + ));
3357 +
3358 + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3359 + $body = wp_remote_retrieve_body($response);
3360 + $stats_data = json_decode($body, true);
3361 + }
3362 + }
3363 +
3364 + } catch (Exception $e) {
3365 + // Log error but return fresh data only
3366 + }
3367 +
3368 + return $pinecone_data;
3369 +}
3370 +public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
3371 + //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
3372 +
3373 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3374 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3375 +
3376 + if (empty($api_key) || empty($host) || empty($vector_ids)) {
3377 + //error_log('DEBUG: Missing parameters for fetch by IDs');
3378 + return array();
3379 + }
3380 +
3381 + try {
3382 + $fetch_url = "https://{$host}/vectors/fetch";
3383 + //error_log('DEBUG: Fetch URL: ' . $fetch_url);
3384 + //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
3385 +
3386 + // Pinecone fetch API allows fetching specific vectors by ID
3387 + $fetch_data = array(
3388 + 'ids' => array_values($vector_ids)
3389 + );
3390 +
3391 + $response = wp_remote_post($fetch_url, array(
3392 + 'headers' => array(
3393 + 'Api-Key' => $api_key,
3394 + 'Content-Type' => 'application/json'
3395 + ),
3396 + 'body' => json_encode($fetch_data),
3397 + 'timeout' => 30
3398 + ));
3399 +
3400 + if (is_wp_error($response)) {
3401 + //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
3402 + return array();
3403 + }
3404 +
3405 + $response_code = wp_remote_retrieve_response_code($response);
3406 + //error_log('DEBUG: Fetch response code: ' . $response_code);
3407 +
3408 + if ($response_code !== 200) {
3409 + $error_body = wp_remote_retrieve_body($response);
3410 + //error_log('DEBUG: Fetch failed with body: ' . $error_body);
3411 + return array();
3412 + }
3413 +
3414 + $body = wp_remote_retrieve_body($response);
3415 + $data = json_decode($body, true);
3416 +
3417 + //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
3418 +
3419 + if (!isset($data['vectors'])) {
3420 + //error_log('DEBUG: No vectors key in response');
3421 + return array();
3422 + }
3423 +
3424 + $processed_data = array();
3425 +
3426 + foreach ($data['vectors'] as $vector_id => $vector_data) {
3427 + $metadata = $vector_data['metadata'] ?? array();
3428 + $source_url = $metadata['source_url'] ?? '';
3429 +
3430 + if (!empty($source_url)) {
3431 + $post_id = url_to_postid($source_url);
3432 + if ($post_id) {
3433 + $created_at = $metadata['created_at'] ?? '';
3434 + $processed_date = 'Recently';
3435 +
3436 + if (!empty($created_at)) {
3437 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3438 + if ($timestamp) {
3439 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3440 + }
3441 + }
3442 +
3443 + $processed_data[$post_id] = array(
3444 + 'db_id' => $vector_id,
3445 + 'processed_date' => $processed_date,
3446 + 'url' => $source_url,
3447 + 'source' => 'pinecone',
3448 + 'timestamp' => $timestamp ?? current_time('timestamp')
3449 + );
3450 + }
3451 + }
3452 + }
3453 +
3454 + //error_log('DEBUG: Processed ' . count($processed_data) . ' vectors from fetch');
3455 + return $processed_data;
3456 +
3457 + } catch (Exception $e) {
3458 + //error_log('DEBUG: Exception in fetch_pinecone_vectors_by_ids: ' . $e->getMessage());
3459 + return array();
3460 + }
3461 +}
3462 +
3463 +/**
3464 + * Scan Pinecone for processed content
3465 + */
3466 +public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
3467 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3468 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
3469 +
3470 + if (empty($api_key) || empty($host)) {
3471 + return array();
3472 + }
3473 +
3474 + try {
3475 + // Use multiple random vectors to get better coverage
3476 + $all_matches = array();
3477 + $seen_ids = array();
3478 +
3479 + // Try 3 different random vectors to get better coverage
3480 + for ($i = 0; $i < 3; $i++) {
3481 + $query_url = "https://{$host}/query";
3482 +
3483 + // Generate a random unit vector instead of zeros
3484 + $random_vector = array();
3485 + for ($j = 0; $j < 1536; $j++) {
3486 + $random_vector[] = (rand(-1000, 1000) / 1000.0);
3487 + }
3488 +
3489 + // Normalize the vector to unit length
3490 + $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
3491 + if ($magnitude > 0) {
3492 + $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
3493 + }
3494 +
3495 + $query_data = array(
3496 + 'includeMetadata' => true,
3497 + 'includeValues' => false,
3498 + 'topK' => 10000,
3499 + 'vector' => $random_vector
3500 + );
3501 +
3502 + $response = wp_remote_post($query_url, array(
3503 + 'headers' => array(
3504 + 'Api-Key' => $api_key,
3505 + 'Content-Type' => 'application/json'
3506 + ),
3507 + 'body' => json_encode($query_data),
3508 + 'timeout' => 30
3509 + ));
3510 +
3511 + if (is_wp_error($response)) {
3512 + continue;
3513 + }
3514 +
3515 + $response_code = wp_remote_retrieve_response_code($response);
3516 +
3517 + if ($response_code !== 200) {
3518 + continue;
3519 + }
3520 +
3521 + $body = wp_remote_retrieve_body($response);
3522 + $data = json_decode($body, true);
3523 +
3524 + if (isset($data['matches'])) {
3525 + foreach ($data['matches'] as $match) {
3526 + $match_id = $match['id'] ?? '';
3527 + if (!empty($match_id) && !isset($seen_ids[$match_id])) {
3528 + $all_matches[] = $match;
3529 + $seen_ids[$match_id] = true;
3530 + }
3531 + }
3532 + }
3533 + }
3534 +
3535 + // Convert matches to processed data format, grouping by URL to count chunks
3536 + $processed_data = array();
3537 + $url_chunk_counts = array();
3538 +
3539 + foreach ($all_matches as $match) {
3540 + $metadata = $match['metadata'] ?? array();
3541 + $source_url = $metadata['source_url'] ?? '';
3542 + $match_id = $match['id'] ?? '';
3543 +
3544 + if (!empty($source_url) && !empty($match_id)) {
3545 + $post_id = url_to_postid($source_url);
3546 + if ($post_id) {
3547 + // Count chunks per post_id
3548 + if (!isset($url_chunk_counts[$post_id])) {
3549 + $url_chunk_counts[$post_id] = 0;
3550 + }
3551 + $url_chunk_counts[$post_id]++;
3552 +
3553 + $created_at = $metadata['created_at'] ?? '';
3554 + $processed_date = 'Recently';
3555 +
3556 + if (!empty($created_at)) {
3557 + $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
3558 + if ($timestamp) {
3559 + $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
3560 + }
3561 + }
3562 +
3563 + // Only store if not already set, or update with newer timestamp
3564 + if (!isset($processed_data[$post_id]) ||
3565 + ($timestamp ?? 0) > ($processed_data[$post_id]['timestamp'] ?? 0)) {
3566 + $processed_data[$post_id] = array(
3567 + 'db_id' => $match_id,
3568 + 'processed_date' => $processed_date,
3569 + 'url' => $source_url,
3570 + 'source' => 'pinecone',
3571 + 'timestamp' => $timestamp ?? current_time('timestamp')
3572 + );
3573 + }
3574 + }
3575 + }
3576 + }
3577 +
3578 + // Add chunk counts to processed data
3579 + foreach ($url_chunk_counts as $post_id => $chunk_count) {
3580 + if (isset($processed_data[$post_id])) {
3581 + $processed_data[$post_id]['chunk_count'] = $chunk_count;
3582 + }
3583 + }
3584 +
3585 + return $processed_data;
3586 +
3587 + } catch (Exception $e) {
3588 + return array();
3589 + }
3590 +}
3591 +/**
3592 + * Generate embeddings from input text for MXChat with bot support
3593 + */
3594 +private function mxchat_generate_embedding($text, $bot_id = 'default') {
3595 + // Enable detailed logging for debugging
3596 + //error_log('[MXCHAT-EMBED] Starting embedding generation for bot: ' . $bot_id . '. Text length: ' . strlen($text) . ' bytes');
3597 + //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
3598 +
3599 + // Get bot-specific options
3600 + $bot_options = $this->get_bot_options($bot_id);
3601 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3602 +
3603 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3604 + //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
3605 +
3606 + // Determine provider and endpoint
3607 + if (strpos($selected_model, 'voyage') === 0) {
3608 + $api_key = $options['voyage_api_key'] ?? '';
3609 + $endpoint = 'https://api.voyageai.com/v1/embeddings';
3610 + $provider_name = 'Voyage AI';
3611 + //error_log('[MXCHAT-EMBED] Using Voyage AI API for bot ' . $bot_id);
3612 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3613 + $api_key = $options['gemini_api_key'] ?? '';
3614 + $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
3615 + $provider_name = 'Google Gemini';
3616 + //error_log('[MXCHAT-EMBED] Using Google Gemini API for bot ' . $bot_id);
3617 + } else {
3618 + $api_key = $options['api_key'] ?? '';
3619 + $endpoint = 'https://api.openai.com/v1/embeddings';
3620 + $provider_name = 'OpenAI';
3621 + //error_log('[MXCHAT-EMBED] Using OpenAI API for bot ' . $bot_id);
3622 + }
3623 +
3624 + //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
3625 +
3626 + if (empty($api_key)) {
3627 + $error_message = sprintf('Missing %s API key for bot %s. Please configure your API key in the bot settings.', $provider_name, $bot_id);
3628 + //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
3629 + return $error_message;
3630 + }
3631 +
3632 + // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
3633 + $estimated_tokens = ceil(str_word_count($text) / 0.75);
3634 + //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
3635 +
3636 + if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
3637 + //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
3638 + // Consider truncating text here
3639 + }
3640 +
3641 + // Prepare request body based on provider
3642 + if (strpos($selected_model, 'gemini-embedding') === 0) {
3643 + // Gemini API format
3644 + $request_body = array(
3645 + 'model' => 'models/' . $selected_model,
3646 + 'content' => array(
3647 + 'parts' => array(
3648 + array('text' => $text)
3649 + )
3650 + )
3651 + );
3652 +
3653 + // Set output dimensionality to 1536 for consistency with other models
3654 + $request_body['outputDimensionality'] = 1536;
3655 + } else {
3656 + // OpenAI/Voyage API format
3657 + $request_body = array(
3658 + 'model' => $selected_model,
3659 + 'input' => $text
3660 + );
3661 +
3662 + // Add output_dimension for voyage-3-large model
3663 + if ($selected_model === 'voyage-3-large') {
3664 + $request_body['output_dimension'] = 2048;
3665 + }
3666 + }
3667 +
3668 + //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
3669 +
3670 + // Prepare headers based on provider
3671 + if (strpos($selected_model, 'gemini-embedding') === 0) {
3672 + // Gemini uses API key as query parameter
3673 + $endpoint .= '?key=' . $api_key;
3674 + $headers = array(
3675 + 'Content-Type' => 'application/json'
3676 + );
3677 + } else {
3678 + // OpenAI/Voyage use Bearer token
3679 + $headers = array(
3680 + 'Authorization' => 'Bearer ' . $api_key,
3681 + 'Content-Type' => 'application/json'
3682 + );
3683 + }
3684 +
3685 + // Make API request
3686 + //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
3687 + $response = wp_remote_post($endpoint, array(
3688 + 'body' => wp_json_encode($request_body),
3689 + 'headers' => $headers,
3690 + 'timeout' => 60 // Increased timeout for large inputs
3691 + ));
3692 +
3693 + // Handle wp_remote_post errors
3694 + if (is_wp_error($response)) {
3695 + $error_message = $response->get_error_message();
3696 + //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
3697 + return 'Connection error: ' . $error_message;
3698 + }
3699 +
3700 + // Get and check HTTP response code
3701 + $http_code = wp_remote_retrieve_response_code($response);
3702 + //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
3703 +
3704 + if ($http_code !== 200) {
3705 + $error_body = wp_remote_retrieve_body($response);
3706 + //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
3707 +
3708 + // Try to parse error for more details
3709 + $error_json = json_decode($error_body, true);
3710 + if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
3711 + $error_type = $error_json['error']['type'] ?? 'unknown';
3712 + $error_message = $error_json['error']['message'] ?? 'No message';
3713 + //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
3714 + //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
3715 +
3716 + // Customize error message for common API errors
3717 + if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
3718 + $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
3719 + } elseif ($error_type === 'authentication_error') {
3720 + $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
3721 + }
3722 +
3723 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
3724 + return $error_message;
3725 + }
3726 +
3727 + $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding for bot %s", $http_code, $bot_id);
3728 + //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
3729 + return $error_message;
3730 + }
3731 +
3732 + // Parse response body
3733 + $response_body = wp_remote_retrieve_body($response);
3734 + //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
3735 +
3736 + $response_data = json_decode($response_body, true);
3737 +
3738 + if (json_last_error() !== JSON_ERROR_NONE) {
3739 + $error = json_last_error_msg();
3740 + //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
3741 + //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
3742 + return "Failed to parse API response: $error";
3743 + }
3744 +
3745 + // Handle different response formats based on provider
3746 + if (strpos($selected_model, 'gemini-embedding') === 0) {
3747 + // Gemini API response format
3748 + if (isset($response_data['embedding']['values'])) {
3749 + $embedding_dimensions = count($response_data['embedding']['values']);
3750 + //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
3751 +
3752 + // Check if embedding dimensions are as expected (should be 1536)
3753 + if ($embedding_dimensions !== 1536) {
3754 + //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
3755 + }
3756 +
3757 + return $response_data['embedding']['values'];
3758 + } else {
3759 + //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
3760 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
3761 +
3762 + if (isset($response_data['error'])) {
3763 + $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
3764 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3765 + return $error_message;
3766 + }
3767 +
3768 + $error_message = "Invalid Gemini API response format: No embedding found";
3769 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3770 + return $error_message;
3771 + }
3772 + } else {
3773 + // OpenAI/Voyage API response format
3774 + if (isset($response_data['data'][0]['embedding'])) {
3775 + $embedding_dimensions = count($response_data['data'][0]['embedding']);
3776 + //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
3777 +
3778 + // Check if embedding dimensions are as expected
3779 + if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
3780 + ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
3781 + //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
3782 + }
3783 +
3784 + return $response_data['data'][0]['embedding'];
3785 + } else {
3786 + //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
3787 + //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
3788 +
3789 + if (isset($response_data['error'])) {
3790 + $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
3791 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3792 + return $error_message;
3793 + }
3794 +
3795 + $error_message = "Invalid API response format: No embedding found";
3796 + //error_log('[MXCHAT-EMBED] ' . $error_message);
3797 + return $error_message;
3798 + }
3799 + }
3800 +}
3801 +
3802 +/**
3803 + * Get bot-specific options for multi-bot functionality
3804 + * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
3805 + */
3806 +private function get_bot_options($bot_id = 'default') {
3807 + //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
3808 +
3809 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
3810 + //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
3811 + return array();
3812 + }
3813 +
3814 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
3815 +
3816 + if (!empty($bot_options)) {
3817 + //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
3818 + if (isset($bot_options['similarity_threshold'])) {
3819 + //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
3820 + }
3821 + }
3822 +
3823 + return is_array($bot_options) ? $bot_options : array();
3824 +}
3825 +
3826 +/**
3827 + * Get bot-specific Pinecone configuration
3828 + * Used in the knowledge retrieval functions
3829 + */
3830 +// Also add debugging to your get_bot_pinecone_config function
3831 +private function get_bot_pinecone_config($bot_id = 'default') {
3832 + //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
3833 +
3834 + // If default bot or multi-bot add-on not active, use default Pinecone config
3835 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
3836 + //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
3837 + $addon_options = get_option('mxchat_pinecone_addon_options', array());
3838 + $config = array(
3839 + 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
3840 + 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
3841 + 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
3842 + 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
3843 + );
3844 + //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
3845 + return $config;
3846 + }
3847 +
3848 + //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
3849 +
3850 + // Hook for multi-bot add-on to provide bot-specific Pinecone config
3851 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
3852 +
3853 + if (!empty($bot_pinecone_config)) {
3854 + //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
3855 + //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
3856 + //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
3857 + //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
3858 + } else {
3859 + //error_log("MXCHAT DEBUG: Filter returned empty config!");
3860 + }
3861 +
3862 + return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
3863 +}
3864 +
3865 +
3866 +public function mxchat_ajax_dismiss_completed_status() {
3867 + try {
3868 + // Verify the request
3869 + check_ajax_referer('mxchat_status_nonce', 'nonce');
3870 +
3871 + if (!current_user_can('manage_options')) {
3872 + wp_send_json_error('Unauthorized access');
3873 + exit;
3874 + }
3875 +
3876 + $card_type = isset($_POST['card_type']) ? sanitize_text_field($_POST['card_type']) : '';
3877 +
3878 + if ($card_type === 'pdf') {
3879 + // Clear PDF status
3880 + $pdf_url = get_transient('mxchat_last_pdf_url');
3881 + if ($pdf_url) {
3882 + delete_transient('mxchat_pdf_status_' . md5($pdf_url));
3883 + delete_transient('mxchat_last_pdf_url');
3884 + }
3885 + } elseif ($card_type === 'sitemap') {
3886 + // Clear sitemap status
3887 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
3888 + if ($sitemap_url) {
3889 + delete_transient('mxchat_sitemap_status_' . md5($sitemap_url));
3890 + delete_transient('mxchat_last_sitemap_url');
3891 + }
3892 + }
3893 +
3894 + wp_send_json_success(array('message' => 'Status dismissed successfully'));
3895 +
3896 + } catch (Exception $e) {
3897 + wp_send_json_error(array('message' => 'Error dismissing status: ' . $e->getMessage()));
3898 + }
3899 +}
3900 +
3901 +/**
3902 + * Render completed status cards on page load
3903 + * This ensures completed processing status persists through page refreshes
3904 + */
3905 +public function mxchat_render_completed_status_cards() {
3906 + $output = '';
3907 +
3908 + // Check for completed PDF status
3909 + $pdf_url = get_transient('mxchat_last_pdf_url');
3910 + if ($pdf_url) {
3911 + $pdf_status = $this->mxchat_get_pdf_processing_status($pdf_url);
3912 + if ($pdf_status && ($pdf_status['status'] === 'complete' || $pdf_status['status'] === 'error')) {
3913 + $output .= $this->mxchat_render_pdf_status_card($pdf_status, $pdf_url);
3914 + }
3915 + }
3916 +
3917 + // Check for completed sitemap status
3918 + $sitemap_url = get_transient('mxchat_last_sitemap_url');
3919 + if ($sitemap_url) {
3920 + $sitemap_status = $this->mxchat_get_sitemap_processing_status($sitemap_url);
3921 + if ($sitemap_status && ($sitemap_status['status'] === 'complete' || $sitemap_status['status'] === 'error')) {
3922 + $output .= $this->mxchat_render_sitemap_status_card($sitemap_status, $sitemap_url);
3923 + }
3924 + }
3925 +
3926 + return $output;
3927 +}
3928 +
3929 +/**
3930 + * Render PDF status card HTML
3931 + */
3932 +private function mxchat_render_pdf_status_card($status, $pdf_url) {
3933 + $html = '<div class="mxchat-status-card" data-card-type="pdf">';
3934 + $html .= '<div class="mxchat-status-header">';
3935 + $html .= '<h4>' . esc_html__('PDF Processing Status', 'mxchat') . '</h4>';
3936 +
3937 + // Add dismiss button for completed status
3938 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
3939 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
3940 + }
3941 +
3942 + // Process Batch button for processing status
3943 + if ($status['status'] === 'processing') {
3944 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
3945 + data-process-type="pdf"
3946 + data-url="' . esc_attr($pdf_url) . '">
3947 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
3948 + }
3949 +
3950 + // Add status badges
3951 + if ($status['status'] === 'error') {
3952 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
3953 + } elseif ($status['status'] === 'complete') {
3954 + if ($status['failed_pages'] > 0) {
3955 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
3956 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_pages']) . '</span>';
3957 + } else {
3958 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
3959 + }
3960 + }
3961 +
3962 + $html .= '</div>'; // End header
3963 +
3964 + // Progress bar
3965 + $html .= '<div class="mxchat-progress-bar">';
3966 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
3967 + $html .= '</div>';
3968 +
3969 + // Status details
3970 + $html .= '<div class="mxchat-status-details">';
3971 + $html .= '<p>' . sprintf(
3972 + esc_html__('Progress: %d of %d pages (%d%%)', 'mxchat'),
3973 + $status['processed_pages'],
3974 + $status['total_pages'],
3975 + $status['percentage']
3976 + ) . '</p>';
3977 +
3978 + // Show failed pages count if any
3979 + if ($status['failed_pages'] > 0) {
3980 + $html .= '<p><strong>' . esc_html__('Failed pages:', 'mxchat') . '</strong> ' . esc_html($status['failed_pages']) . '</p>';
3981 + }
3982 +
3983 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
3984 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
3985 +
3986 + // Add completion summary if available AND it's an array
3987 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
3988 + $summary = $status['completion_summary'];
3989 + $html .= '<div class="mxchat-completion-summary">';
3990 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
3991 + $html .= '<p><strong>' . esc_html__('Total Pages:', 'mxchat') . '</strong> ' . esc_html($summary['total_pages']) . '</p>';
3992 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_pages']) . '</p>';
3993 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_pages']) . '</p>';
3994 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
3995 + $html .= '</div>';
3996 + }
3997 +
3998 + // Add failed pages list if any AND it's an array
3999 + if (isset($status['failed_pages_list']) && is_array($status['failed_pages_list']) && !empty($status['failed_pages_list'])) {
4000 + $html .= $this->mxchat_render_failed_pages_list($status['failed_pages_list']);
4001 + }
4002 +
4003 + // Add error message if any
4004 + if (isset($status['error']) && !empty($status['error'])) {
4005 + $html .= '<div class="mxchat-error-notice">';
4006 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4007 + $html .= '</div>';
4008 + }
4009 +
4010 + $html .= '</div>'; // End details
4011 + $html .= '</div>'; // End card
4012 +
4013 + return $html;
4014 +}
4015 +/**
4016 + * Render sitemap status card HTML
4017 + */
4018 +private function mxchat_render_sitemap_status_card($status, $sitemap_url) {
4019 + $html = '<div class="mxchat-status-card" data-card-type="sitemap">';
4020 + $html .= '<div class="mxchat-status-header">';
4021 + $html .= '<h4>' . esc_html__('Sitemap Processing Status', 'mxchat') . '</h4>';
4022 +
4023 + // Add dismiss button for completed status
4024 + if ($status['status'] === 'complete' || $status['status'] === 'error') {
4025 + $html .= '<button type="button" class="mxchat-dismiss-button">' . esc_html__('Dismiss', 'mxchat') . '</button>';
4026 + }
4027 +
4028 + // Process Batch button for processing status
4029 + if ($status['status'] === 'processing') {
4030 + $html .= '<button type="button" class="mxchat-manual-batch-btn"
4031 + data-process-type="sitemap"
4032 + data-url="' . esc_attr($sitemap_url) . '">
4033 + ' . esc_html__('Process Batch', 'mxchat') . '</button>';
4034 + }
4035 +
4036 + // Add status badges
4037 + if ($status['status'] === 'error') {
4038 + $html .= '<span class="mxchat-status-badge mxchat-status-failed">' . esc_html__('Error', 'mxchat') . '</span>';
4039 + } elseif ($status['status'] === 'complete') {
4040 + if ($status['failed_urls'] > 0) {
4041 + $html .= '<span class="mxchat-status-badge mxchat-status-warning">' .
4042 + sprintf(esc_html__('Completed with %d failures', 'mxchat'), $status['failed_urls']) . '</span>';
4043 + } else {
4044 + $html .= '<span class="mxchat-status-badge mxchat-status-success">' . esc_html__('Complete', 'mxchat') . '</span>';
4045 + }
4046 + }
4047 +
4048 + $html .= '</div>'; // End header
4049 +
4050 + // Progress bar
4051 + $html .= '<div class="mxchat-progress-bar">';
4052 + $html .= '<div class="mxchat-progress-fill" style="width: ' . esc_attr($status['percentage']) . '%"></div>';
4053 + $html .= '</div>';
4054 +
4055 + // Status details
4056 + $html .= '<div class="mxchat-status-details">';
4057 + $html .= '<p>' . sprintf(
4058 + esc_html__('Progress: %d of %d URLs (%d%%)', 'mxchat'),
4059 + $status['processed_urls'],
4060 + $status['total_urls'],
4061 + $status['percentage']
4062 + ) . '</p>';
4063 +
4064 + // Show failed URLs count if any
4065 + if ($status['failed_urls'] > 0) {
4066 + $html .= '<p><strong>' . esc_html__('Failed URLs:', 'mxchat') . '</strong> ' . esc_html($status['failed_urls']) . '</p>';
4067 + }
4068 +
4069 + $html .= '<p><strong>' . esc_html__('Status:', 'mxchat') . '</strong> ' . esc_html(ucfirst($status['status'])) . '</p>';
4070 + $html .= '<p><strong>' . esc_html__('Last update:', 'mxchat') . '</strong> ' . esc_html($status['last_update']) . '</p>';
4071 +
4072 + // Add completion summary if available AND it's an array
4073 + if (isset($status['completion_summary']) && is_array($status['completion_summary']) && !empty($status['completion_summary'])) {
4074 + $summary = $status['completion_summary'];
4075 + $html .= '<div class="mxchat-completion-summary">';
4076 + $html .= '<h5>' . esc_html__('Processing Summary', 'mxchat') . '</h5>';
4077 + $html .= '<p><strong>' . esc_html__('Total URLs:', 'mxchat') . '</strong> ' . esc_html($summary['total_urls']) . '</p>';
4078 + $html .= '<p><strong>' . esc_html__('Successful:', 'mxchat') . '</strong> ' . esc_html($summary['successful_urls']) . '</p>';
4079 + $html .= '<p><strong>' . esc_html__('Failed:', 'mxchat') . '</strong> ' . esc_html($summary['failed_urls']) . '</p>';
4080 + $html .= '<p><strong>' . esc_html__('Completed:', 'mxchat') . '</strong> ' . esc_html($summary['completion_time']) . '</p>';
4081 + $html .= '</div>';
4082 + }
4083 +
4084 + // Add error messages if any (but not the failed URLs list)
4085 + if (!empty($status['error']) || !empty($status['last_error'])) {
4086 + $html .= '<div class="mxchat-error-notice">';
4087 +
4088 + if (!empty($status['error'])) {
4089 + $html .= '<p class="error">' . esc_html($status['error']) . '</p>';
4090 + }
4091 +
4092 + if (!empty($status['last_error'])) {
4093 + $html .= '<p class="last-error">' . esc_html__('Last error:', 'mxchat') . ' ' . esc_html($status['last_error']) . '</p>';
4094 + }
4095 +
4096 + $html .= '</div>';
4097 + }
4098 +
4099 + $html .= '</div>'; // End details
4100 + $html .= '</div>'; // End card
4101 +
4102 + return $html;
4103 +}
4104 +
4105 +
4106 +/**
4107 + * Render failed pages list
4108 + */
4109 +private function mxchat_render_failed_pages_list($failed_pages_list) {
4110 + // Validate that $failed_pages_list is an array and not empty
4111 + if (!is_array($failed_pages_list) || empty($failed_pages_list)) {
4112 + return '';
4113 + }
4114 +
4115 + $html = '<div class="mxchat-error-notice">';
4116 + $html .= '<div class="mxchat-failed-pages-container">';
4117 + $html .= '<h5>' . sprintf(esc_html__('Failed Pages (%d)', 'mxchat'), count($failed_pages_list)) . '</h5>';
4118 + $html .= '<details>';
4119 + $html .= '<summary>' . esc_html__('Show Failed Pages', 'mxchat') . '</summary>';
4120 + $html .= '<div class="mxchat-failed-pages-list">';
4121 +
4122 + // Create table for failed pages
4123 + $html .= '<table class="widefat striped">';
4124 + $html .= '<thead><tr>';
4125 + $html .= '<th>' . esc_html__('Page', 'mxchat') . '</th>';
4126 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4127 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4128 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4129 + $html .= '</tr></thead><tbody>';
4130 +
4131 + // Sort failed pages by most recent
4132 + $sorted_failed_pages = $failed_pages_list;
4133 + usort($sorted_failed_pages, function($a, $b) {
4134 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4135 + });
4136 +
4137 + foreach ($sorted_failed_pages as $item) {
4138 + // Ensure $item is an array before accessing its elements
4139 + if (!is_array($item)) {
4140 + continue;
4141 + }
4142 +
4143 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4144 + $html .= '<tr>';
4145 + $html .= '<td>' . esc_html__('Page', 'mxchat') . ' ' . esc_html($item['page'] ?? 'Unknown') . '</td>';
4146 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4147 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4148 + $html .= '<td>' . esc_html($time_ago) . '</td>';
4149 + $html .= '</tr>';
4150 + }
4151 +
4152 + $html .= '</tbody></table>';
4153 + $html .= '</div></details></div></div>';
4154 +
4155 + return $html;
4156 +}
4157 +
4158 +/**
4159 + * Render failed URLs list
4160 + */
4161 +private function mxchat_render_failed_urls_list($failed_urls_list) {
4162 + // Validate that $failed_urls_list is an array and not empty
4163 + if (!is_array($failed_urls_list) || empty($failed_urls_list)) {
4164 + return '';
4165 + }
4166 +
4167 + $html = '<div class="mxchat-failed-urls-container">';
4168 + $html .= '<h5>' . sprintf(esc_html__('Failed URLs (%d)', 'mxchat'), count($failed_urls_list)) . '</h5>';
4169 + $html .= '<details>';
4170 + $html .= '<summary>' . esc_html__('Show Failed URLs', 'mxchat') . '</summary>';
4171 + $html .= '<div class="mxchat-failed-urls-list">';
4172 +
4173 + // Create table for failed URLs
4174 + $html .= '<table class="widefat striped">';
4175 + $html .= '<thead><tr>';
4176 + $html .= '<th>' . esc_html__('URL', 'mxchat') . '</th>';
4177 + $html .= '<th>' . esc_html__('Error', 'mxchat') . '</th>';
4178 + $html .= '<th>' . esc_html__('Retries', 'mxchat') . '</th>';
4179 + $html .= '<th>' . esc_html__('Time', 'mxchat') . '</th>';
4180 + $html .= '</tr></thead><tbody>';
4181 +
4182 + // Sort failed URLs by most recent
4183 + $sorted_failed_urls = $failed_urls_list;
4184 + usort($sorted_failed_urls, function($a, $b) {
4185 + return ($b['time'] ?? 0) - ($a['time'] ?? 0);
4186 + });
4187 +
4188 + // Show up to 50 failed URLs
4189 + $display_urls = array_slice($sorted_failed_urls, 0, 50);
4190 +
4191 + foreach ($display_urls as $item) {
4192 + // Ensure $item is an array before accessing its elements
4193 + if (!is_array($item)) {
4194 + continue;
4195 + }
4196 +
4197 + $url = $item['url'] ?? '';
4198 + $time_ago = isset($item['time']) ? human_time_diff($item['time'], current_time('timestamp')) . ' ' . esc_html__('ago', 'mxchat') : 'Unknown';
4199 +
4200 + // Truncate URL for display
4201 + $display_url = strlen($url) > 50 ? substr($url, 0, 47) . '...' : $url;
4202 +
4203 + $html .= '<tr>';
4204 + $html .= '<td style="word-break: break-all;">';
4205 + if (!empty($url)) {
4206 + $html .= '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($display_url) . '</a>';
4207 + } else {
4208 + $html .= esc_html__('Unknown URL', 'mxchat');
4209 + }
4210 + $html .= '</td>';
4211 + $html .= '<td style="word-break: break-word;">' . esc_html($item['error'] ?? 'Unknown error') . '</td>';
4212 + $html .= '<td>' . esc_html($item['retries'] ?? 'N/A') . '</td>';
4213 + $html .= '<td>' . esc_html($time_ago) . '</td>';
4214 + $html .= '</tr>';
4215 + }
4216 +
4217 + $html .= '</tbody></table>';
4218 +
4219 + if (count($failed_urls_list) > 50) {
4220 + $html .= '<div class="mxchat-failed-urls-more">+ ' .
4221 + (count($failed_urls_list) - 50) .
4222 + ' ' . esc_html__('more failed URLs not shown', 'mxchat') . '</div>';
4223 + }
4224 +
4225 + $html .= '</div></details></div>';
4226 +
4227 + return $html;
4228 +}
4229 +
4230 +/**
4231 + * Get all ACF fields for a specific post, excluding any fields the user has disabled
4232 + */
4233 +public function mxchat_get_acf_fields_for_post($post_id) {
4234 + if (!function_exists('get_fields')) {
4235 + return array();
4236 + }
4237 +
4238 + $fields = get_fields($post_id);
4239 + if (!$fields || !is_array($fields)) {
4240 + return array();
4241 + }
4242 +
4243 + // Get excluded fields from settings
4244 + $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
4245 + if (!empty($excluded_fields) && is_array($excluded_fields)) {
4246 + foreach ($excluded_fields as $excluded_field) {
4247 + if (isset($fields[$excluded_field])) {
4248 + unset($fields[$excluded_field]);
4249 + }
4250 + }
4251 + }
4252 +
4253 + return $fields;
4254 +}
4255 +
4256 +/**
4257 + * Get all registered ACF field groups and their fields for the settings UI
4258 + */
4259 +public function mxchat_get_all_acf_fields() {
4260 + if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
4261 + return array();
4262 + }
4263 +
4264 + $all_fields = array();
4265 + $field_groups = acf_get_field_groups();
4266 +
4267 + if (!empty($field_groups)) {
4268 + foreach ($field_groups as $group) {
4269 + $group_fields = acf_get_fields($group['key']);
4270 + if (!empty($group_fields)) {
4271 + $all_fields[$group['title']] = array();
4272 + foreach ($group_fields as $field) {
4273 + $all_fields[$group['title']][] = array(
4274 + 'name' => $field['name'],
4275 + 'label' => $field['label'],
4276 + 'type' => $field['type']
4277 + );
4278 + }
4279 + }
4280 + }
4281 + }
4282 +
4283 + return $all_fields;
4284 +}
4285 +
4286 +/**
4287 + * Get whitelisted custom post meta for a given post
4288 + * This allows non-ACF meta fields (like OptionTree, theme meta boxes) to be included in embeddings
4289 + */
4290 +public function mxchat_get_whitelisted_post_meta($post_id) {
4291 + $whitelist = get_option('mxchat_custom_meta_whitelist', '');
4292 +
4293 + if (empty($whitelist)) {
4294 + return array();
4295 + }
4296 +
4297 + // Parse the whitelist - one meta key per line
4298 + $meta_keys = array_filter(array_map('trim', explode("\n", $whitelist)));
4299 +
4300 + if (empty($meta_keys)) {
4301 + return array();
4302 + }
4303 +
4304 + $result = array();
4305 +
4306 + foreach ($meta_keys as $key) {
4307 + // Skip empty keys
4308 + if (empty($key)) {
4309 + continue;
4310 + }
4311 +
4312 + $value = get_post_meta($post_id, $key, true);
4313 +
4314 + // Only include non-empty string values
4315 + if (!empty($value) && is_string($value)) {
4316 + $result[$key] = $value;
4317 + } elseif (!empty($value) && is_array($value)) {
4318 + // Handle array values by joining them
4319 + $flat_value = $this->mxchat_flatten_meta_array($value);
4320 + if (!empty($flat_value)) {
4321 + $result[$key] = $flat_value;
4322 + }
4323 + }
4324 + }
4325 +
4326 + return $result;
4327 +}
4328 +
4329 +/**
4330 + * Flatten array meta values into a readable string
4331 + */
4332 +private function mxchat_flatten_meta_array($array, $depth = 0) {
4333 + if ($depth > 3) {
4334 + return ''; // Prevent infinite recursion
4335 + }
4336 +
4337 + $parts = array();
4338 +
4339 + foreach ($array as $key => $value) {
4340 + if (is_string($value) && !empty($value)) {
4341 + $parts[] = $value;
4342 + } elseif (is_array($value)) {
4343 + $nested = $this->mxchat_flatten_meta_array($value, $depth + 1);
4344 + if (!empty($nested)) {
4345 + $parts[] = $nested;
4346 + }
4347 + }
4348 + }
4349 +
4350 + return implode(', ', $parts);
4351 +}
4352 +
4353 +/**
4354 + * Format ACF field values for content extraction
4355 + */
4356 +public function mxchat_format_acf_field_value($value, $field_name = '', $post_id = 0) {
4357 + if (empty($value)) {
4358 + return '';
4359 + }
4360 +
4361 + // Handle WP_Post objects first (THIS IS THE KEY FIX)
4362 + if ($value instanceof WP_Post) {
4363 + return $value->post_title ?: '';
4364 + }
4365 +
4366 + // Handle other WP objects
4367 + if (is_object($value)) {
4368 + if (isset($value->post_title)) {
4369 + return $value->post_title;
4370 + } elseif (isset($value->display_name)) {
4371 + return $value->display_name;
4372 + } elseif (isset($value->name)) {
4373 + return $value->name;
4374 + } elseif (method_exists($value, '__toString')) {
4375 + try {
4376 + return (string) $value;
4377 + } catch (Exception $e) {
4378 + return '';
4379 + }
4380 + }
4381 + // For any other objects, return empty string
4382 + return '';
4383 + }
4384 +
4385 + // Handle different ACF field types
4386 + if (is_array($value)) {
4387 + // Check if it's an image/file field
4388 + if (isset($value['url'])) {
4389 + // Image field - return alt text, title, or caption
4390 + if (!empty($value['alt'])) {
4391 + return $value['alt'];
4392 + } elseif (!empty($value['title'])) {
4393 + return $value['title'];
4394 + } elseif (!empty($value['caption'])) {
4395 + return $value['caption'];
4396 + } else {
4397 + return ''; // Don't include just the URL
4398 + }
4399 + }
4400 +
4401 + // Check if it's a post object or relationship field
4402 + if (isset($value['post_title'])) {
4403 + return $value['post_title'];
4404 + }
4405 +
4406 + // Check if it's a user field
4407 + if (isset($value['display_name'])) {
4408 + return $value['display_name'];
4409 + }
4410 +
4411 + // Check if it's a taxonomy term
4412 + if (isset($value['name']) && isset($value['taxonomy'])) {
4413 + return $value['name'];
4414 + }
4415 +
4416 + // Check if it's a select field with label
4417 + if (isset($value['label'])) {
4418 + return $value['label'];
4419 + }
4420 +
4421 + // Check for repeater field or flexible content
4422 + if (is_numeric(key($value))) {
4423 + $sub_values = array();
4424 + foreach ($value as $sub_item) {
4425 + if (is_array($sub_item)) {
4426 + // For repeater/flexible content, extract text values
4427 + $sub_text = $this->mxchat_extract_text_from_acf_array($sub_item);
4428 + if (!empty($sub_text)) {
4429 + $sub_values[] = $sub_text;
4430 + }
4431 + } elseif ($sub_item instanceof WP_Post) {
4432 + // Handle WP_Post objects in arrays
4433 + $sub_values[] = $sub_item->post_title ?: '';
4434 + } else {
4435 + $sub_values[] = (string) $sub_item;
4436 + }
4437 + }
4438 + return implode(', ', array_filter($sub_values));
4439 + }
4440 +
4441 + // For other arrays, try to extract meaningful text
4442 + $text_values = array();
4443 + foreach ($value as $key => $val) {
4444 + if (is_string($val) && !empty(trim($val))) {
4445 + $text_values[] = trim($val);
4446 + } elseif ($val instanceof WP_Post) {
4447 + // Handle WP_Post objects in associative arrays
4448 + $text_values[] = $val->post_title ?: '';
4449 + } elseif (is_array($val) && isset($val['post_title'])) {
4450 + $text_values[] = $val['post_title'];
4451 + } elseif (is_array($val) && isset($val['name'])) {
4452 + $text_values[] = $val['name'];
4453 + }
4454 + }
4455 +
4456 + return implode(', ', array_filter($text_values));
4457 + }
4458 +
4459 + // Handle boolean values
4460 + if (is_bool($value)) {
4461 + return $value ? 'Yes' : 'No';
4462 + }
4463 +
4464 + // Handle numeric values
4465 + if (is_numeric($value)) {
4466 + return (string) $value;
4467 + }
4468 +
4469 + // Handle string values
4470 + if (is_string($value)) {
4471 + return trim($value);
4472 + }
4473 +
4474 + // For anything else that we can't handle, return empty string
4475 + // This prevents the "Object could not be converted to string" error
4476 + return '';
4477 +}
4478 +
4479 +/**
4480 + * Extract text from complex ACF array structures
4481 + */
4482 +private function mxchat_extract_text_from_acf_array($array) {
4483 + if (!is_array($array)) {
4484 + return '';
4485 + }
4486 +
4487 + $text_parts = array();
4488 +
4489 + foreach ($array as $key => $value) {
4490 + if (is_string($value) && !empty(trim($value))) {
4491 + // Skip keys that are likely to be IDs or technical values
4492 + if (!is_numeric($value) || strlen($value) > 10) {
4493 + $text_parts[] = trim($value);
4494 + }
4495 + } elseif ($value instanceof WP_Post) {
4496 + // Handle WP_Post objects
4497 + $text_parts[] = $value->post_title ?: '';
4498 + } elseif (is_array($value)) {
4499 + if (isset($value['post_title'])) {
4500 + $text_parts[] = $value['post_title'];
4501 + } elseif (isset($value['name'])) {
4502 + $text_parts[] = $value['name'];
4503 + } elseif (isset($value['label'])) {
4504 + $text_parts[] = $value['label'];
4505 + }
4506 + } elseif (is_object($value)) {
4507 + // Handle other objects safely
4508 + if (isset($value->post_title)) {
4509 + $text_parts[] = $value->post_title;
4510 + } elseif (isset($value->name)) {
4511 + $text_parts[] = $value->name;
4512 + } elseif (isset($value->display_name)) {
4513 + $text_parts[] = $value->display_name;
4514 + }
4515 + }
4516 + }
4517 +
4518 + return implode(', ', array_filter($text_parts));
4519 +}
4520 +
4521 +/**
4522 + * Handle ACF save - fires after ACF fields are saved
4523 + * This ensures ACF field data is available when syncing to knowledge base
4524 + */
4525 +public function mxchat_handle_acf_save($post_id) {
4526 + // Skip if not a valid post
4527 + if (!$post_id || $post_id === 'options') {
4528 + return;
4529 + }
4530 +
4531 + // Skip autosaves and revisions
4532 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
4533 + return;
4534 + }
4535 +
4536 + $post = get_post($post_id);
4537 + if (!$post) {
4538 + return;
4539 + }
4540 +
4541 + $post_type = $post->post_type;
4542 +
4543 + // Check if sync is enabled for this post type
4544 + $should_sync = false;
4545 +
4546 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4547 + $should_sync = true;
4548 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4549 + $should_sync = true;
4550 + } else if ($post_type === 'product' && class_exists('WooCommerce')) {
4551 + // WooCommerce products - check if WooCommerce integration is enabled
4552 + $options = get_option('mxchat_options', array());
4553 + if (isset($options['enable_woocommerce_integration']) &&
4554 + ($options['enable_woocommerce_integration'] === '1' || $options['enable_woocommerce_integration'] === 'on')) {
4555 + $should_sync = true;
4556 + }
4557 + } else {
4558 + // Check custom post types
4559 + $option_name = 'mxchat_auto_sync_' . $post_type;
4560 + if (get_option($option_name) === '1') {
4561 + $should_sync = true;
4562 + }
4563 + }
4564 +
4565 + if (!$should_sync) {
4566 + return;
4567 + }
4568 +
4569 + // Only process published posts
4570 + if ($post->post_status !== 'publish') {
4571 + return;
4572 + }
4573 +
4574 + // Check if this post has any ACF fields - if not, no need to re-sync
4575 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4576 + if (empty($acf_fields)) {
4577 + return;
4578 + }
4579 +
4580 + // Use a transient to prevent duplicate processing (post_updated may have already run)
4581 + $transient_key = 'mxchat_acf_synced_' . $post_id;
4582 + if (get_transient($transient_key)) {
4583 + return;
4584 + }
4585 + set_transient($transient_key, true, 60); // Prevent re-processing for 60 seconds
4586 +
4587 + // Re-run the sync with ACF data now available
4588 + // We pass $update=true since this is effectively an update with ACF data
4589 + $this->mxchat_handle_post_update($post_id, $post, true);
4590 +}
4591 +
4592 +public function mxchat_handle_post_update($post_id, $post, $update) {
4593 + // Basic validation checks
4594 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
4595 + return;
4596 + }
4597 +
4598 + $post_type = $post->post_type;
4599 +
4600 + // Check if sync is enabled for this post type
4601 + $should_sync = false;
4602 +
4603 + // Check built-in post types first
4604 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4605 + $should_sync = true;
4606 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4607 + $should_sync = true;
4608 + } else {
4609 + // Check custom post types
4610 + $option_name = 'mxchat_auto_sync_' . $post_type;
4611 + if (get_option($option_name) === '1') {
4612 + $should_sync = true;
4613 + }
4614 + }
4615 +
4616 + if (!$should_sync) {
4617 + return;
4618 + }
4619 +
4620 + // Check if we have stored the previous status and URL in our transients
4621 + $previous_status_key = 'mxchat_prev_status_' . $post_id;
4622 + $previous_status = get_transient($previous_status_key);
4623 +
4624 + $previous_url_key = 'mxchat_prev_url_' . $post_id;
4625 + $previous_url = get_transient($previous_url_key);
4626 +
4627 + // If the post was previously published but is now not published, remove from knowledge base
4628 + if ($previous_status === 'publish' && $post->post_status !== 'publish') {
4629 + // Use the stored URL from when it was published, or fall back to current permalink
4630 + $source_url = $previous_url ?: get_permalink($post_id);
4631 +
4632 + if ($source_url) {
4633 + // Check if Pinecone is enabled
4634 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4635 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4636 +
4637 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4638 + // Delete from Pinecone
4639 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
4640 + } else {
4641 + // Delete from WordPress DB
4642 + global $wpdb;
4643 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4644 +
4645 + $result = $wpdb->delete(
4646 + $table_name,
4647 + array('source_url' => $source_url),
4648 + array('%s')
4649 + );
4650 + }
4651 + }
4652 +
4653 + // Clean up the transients and exit early
4654 + delete_transient($previous_status_key);
4655 + delete_transient($previous_url_key);
4656 + return;
4657 + }
4658 +
4659 + // Store the current status for next time (if this is an update)
4660 + if ($update) {
4661 + set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
4662 +
4663 + // If the post is currently published, also store its URL
4664 + if ($post->post_status === 'publish') {
4665 + $current_url = get_permalink($post_id);
4666 + set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
4667 + }
4668 + }
4669 +
4670 + // Only process currently published content for adding/updating
4671 + if ($post->post_status === 'publish') {
4672 + // Get the source URL
4673 + $source_url = get_permalink($post_id);
4674 +
4675 + // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
4676 + $title = get_the_title($post_id);
4677 + $content = get_post_field('post_content', $post_id);
4678 + $excerpt = get_post_field('post_excerpt', $post_id);
4679 +
4680 + // Remove shortcode tags but preserve content inside them
4681 + $content = $this->strip_shortcode_tags_preserve_content($content);
4682 + $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
4683 +
4684 + // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
4685 + $content = wp_strip_all_tags($content);
4686 +
4687 + // Combine title, short description (if exists), and content
4688 + $final_content = $title . "\n\n";
4689 +
4690 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
4691 + if (!empty($excerpt)) {
4692 + $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
4693 + }
4694 +
4695 + $final_content .= $content;
4696 +
4697 + // For WooCommerce products, include pricing and product details
4698 + if ($post_type === 'product' && class_exists('WooCommerce')) {
4699 + $product = wc_get_product($post_id);
4700 +
4701 + if ($product) {
4702 + // Get pricing information
4703 + $regular_price = $product->get_regular_price();
4704 + $sale_price = $product->get_sale_price();
4705 + $price = $product->get_price();
4706 + $sku = $product->get_sku();
4707 +
4708 + // Get currency symbol
4709 + $currency_symbol = get_woocommerce_currency_symbol();
4710 +
4711 + // Add pricing information
4712 + $final_content .= "\n";
4713 + if (!empty($regular_price)) {
4714 + $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
4715 + } elseif (!empty($price)) {
4716 + $final_content .= "Price: " . $currency_symbol . $price . "\n";
4717 + }
4718 +
4719 + if (!empty($sale_price) && $sale_price !== $regular_price) {
4720 + $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
4721 + }
4722 +
4723 + // Handle variable products - show price range
4724 + if ($product->is_type('variable')) {
4725 + $min_price = $product->get_variation_price('min');
4726 + $max_price = $product->get_variation_price('max');
4727 + if ($min_price !== $max_price) {
4728 + $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
4729 + }
4730 + }
4731 +
4732 + if (!empty($sku)) {
4733 + $final_content .= "SKU: " . $sku . "\n";
4734 + }
4735 +
4736 + // Get product categories
4737 + $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
4738 + if (!empty($categories) && !is_wp_error($categories)) {
4739 + $final_content .= "Categories: " . implode(', ', $categories) . "\n";
4740 + }
4741 + }
4742 + }
4743 +
4744 + // For custom post types like job_listing, include additional fields
4745 + if ($post_type === 'job_listing') {
4746 + // Add job-specific meta if available
4747 + $job_location = get_post_meta($post_id, '_job_location', true);
4748 + if (!empty($job_location)) {
4749 + $final_content .= "\n\nLocation: " . $job_location;
4750 + }
4751 +
4752 + // Get job type terms
4753 + $job_types = get_the_terms($post_id, 'job_listing_type');
4754 + if (!empty($job_types) && !is_wp_error($job_types)) {
4755 + $types = array();
4756 + foreach ($job_types as $type) {
4757 + $types[] = $type->name;
4758 + }
4759 + $final_content .= "\n\nJob Type: " . implode(', ', $types);
4760 + }
4761 +
4762 + // Get company name if available
4763 + $company_name = get_post_meta($post_id, '_company_name', true);
4764 + if (!empty($company_name)) {
4765 + $final_content .= "\n\nCompany: " . $company_name;
4766 + }
4767 + }
4768 +
4769 + // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
4770 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4771 + if (!empty($acf_fields)) {
4772 + $acf_content_parts = array();
4773 +
4774 + foreach ($acf_fields as $field_name => $field_value) {
4775 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
4776 + if (!empty($formatted_value)) {
4777 + // Convert field name to readable label
4778 + $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
4779 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
4780 + }
4781 + }
4782 +
4783 + if (!empty($acf_content_parts)) {
4784 + $final_content .= "\n\n" . implode("\n", $acf_content_parts);
4785 + }
4786 + }
4787 +
4788 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
4789 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
4790 + if (!empty($custom_meta)) {
4791 + $meta_content_parts = array();
4792 +
4793 + foreach ($custom_meta as $meta_key => $meta_value) {
4794 + // Convert meta key to readable label
4795 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
4796 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
4797 + }
4798 +
4799 + if (!empty($meta_content_parts)) {
4800 + $final_content .= "\n\n" . implode("\n", $meta_content_parts);
4801 + }
4802 + }
4803 +
4804 + // Get API key with proper model detection
4805 + $options = get_option('mxchat_options');
4806 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4807 +
4808 + if (strpos($selected_model, 'voyage') === 0) {
4809 + $api_key = $options['voyage_api_key'] ?? '';
4810 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4811 + $api_key = $options['gemini_api_key'] ?? '';
4812 + } else {
4813 + $api_key = $options['api_key'] ?? '';
4814 + }
4815 +
4816 + if (empty($api_key)) {
4817 + return;
4818 + }
4819 +
4820 + // Use the centralized utility function for storage
4821 + $result = MxChat_Utils::submit_content_to_db(
4822 + $final_content,
4823 + $source_url,
4824 + $api_key,
4825 + md5($source_url) // Vector ID for Pinecone
4826 + );
4827 +
4828 + // After successful storage, apply role restriction based on tags
4829 + if (!is_wp_error($result)) {
4830 + $this->apply_role_restriction_to_post($post_id, $source_url);
4831 + }
4832 + }
4833 +
4834 + // Clean up the stored previous status if not used above
4835 + if ($previous_status !== 'publish' || $post->post_status === 'publish') {
4836 + delete_transient($previous_status_key);
4837 + delete_transient($previous_url_key);
4838 + }
4839 +}
4840 +
4841 +/**
4842 + * Store the post status and URL before update to detect status transitions
4843 + * This runs before the post is actually updated in the database
4844 + */
4845 +public function mxchat_store_pre_update_status($post_id, $data) {
4846 + // Get the current post from database (before update)
4847 + $current_post = get_post($post_id);
4848 +
4849 + if ($current_post) {
4850 + // Store the current status temporarily
4851 + $status_key = 'mxchat_prev_status_' . $post_id;
4852 + set_transient($status_key, $current_post->post_status, HOUR_IN_SECONDS);
4853 +
4854 + // If the post is currently published, also store its URL
4855 + if ($current_post->post_status === 'publish') {
4856 + $url_key = 'mxchat_prev_url_' . $post_id;
4857 + $current_url = get_permalink($post_id);
4858 + set_transient($url_key, $current_url, HOUR_IN_SECONDS);
4859 + }
4860 + }
4861 +}
4862 +
4863 +public function mxchat_handle_post_delete($post_id) {
4864 + // Get post data before it's deleted
4865 + $post = get_post($post_id);
4866 +
4867 + // Basic validation
4868 + if (!$post || wp_is_post_revision($post_id)) {
4869 + return;
4870 + }
4871 +
4872 + $post_type = $post->post_type;
4873 +
4874 + // Check if sync is enabled for this post type
4875 + $should_sync = false;
4876 +
4877 + // Check built-in post types first
4878 + if ($post_type === 'post' && get_option('mxchat_auto_sync_posts') === '1') {
4879 + $should_sync = true;
4880 + } else if ($post_type === 'page' && get_option('mxchat_auto_sync_pages') === '1') {
4881 + $should_sync = true;
4882 + } else {
4883 + // Check custom post types
4884 + $option_name = 'mxchat_auto_sync_' . $post_type;
4885 + if (get_option($option_name) === '1') {
4886 + $should_sync = true;
4887 + }
4888 + }
4889 +
4890 + if (!$should_sync) {
4891 + return;
4892 + }
4893 +
4894 + // Get the URL before post is deleted
4895 + $source_url = get_permalink($post_id);
4896 + if (!$source_url) {
4897 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
4898 + return;
4899 + }
4900 +
4901 + // Use chunk-aware deletion (handles both chunked and non-chunked content)
4902 + $delete_result = MxChat_Utils::delete_chunks_for_url($source_url, 'default');
4903 +
4904 + if (is_wp_error($delete_result)) {
4905 + //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
4906 + }
4907 +}
4908 +
4909 +
4910 + /**
4911 + * Deletes data from Pinecone using a source URL
4912 + */
4913 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
4914 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4915 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4916 +
4917 + if (empty($host) || empty($api_key)) {
4918 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
4919 + return false;
4920 + }
4921 +
4922 + $api_endpoint = "https://{$host}/vectors/delete";
4923 + $vector_id = md5($source_url);
4924 +
4925 + $request_body = array(
4926 + 'ids' => array($vector_id)
4927 + );
4928 +
4929 + $response = wp_remote_post($api_endpoint, array(
4930 + 'headers' => array(
4931 + 'Api-Key' => $api_key,
4932 + 'accept' => 'application/json',
4933 + 'content-type' => 'application/json'
4934 + ),
4935 + 'body' => wp_json_encode($request_body),
4936 + 'timeout' => 30
4937 + ));
4938 +
4939 + if (is_wp_error($response)) {
4940 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
4941 + return false;
4942 + }
4943 +
4944 + $response_code = wp_remote_retrieve_response_code($response);
4945 + if ($response_code !== 200) {
4946 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
4947 + return false;
4948 + }
4949 +
4950 + return true;
4951 + }
4952 +
4953 +
4954 +
4955 +public function mxchat_handle_product_change($post_id, $post, $update) {
4956 + if ($post->post_type !== 'product') {
4957 + return;
4958 + }
4959 +
4960 + if ($post->post_status === 'publish') {
4961 + add_action('shutdown', function() use ($post_id) {
4962 + $product = wc_get_product($post_id);
4963 + if ($product) {
4964 + $this->mxchat_store_product_embedding($product);
4965 + }
4966 + });
4967 + }
4968 +}
4969 +
4970 +/**
4971 + * Store WooCommerce product embeddings
4972 + */
4973 +private function mxchat_store_product_embedding($product) {
4974 + if (!isset($this->options['enable_woocommerce_integration']) ||
4975 + !in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
4976 + return;
4977 + }
4978 +
4979 + $source_url = get_permalink($product->get_id());
4980 + $product_id = $product->get_id();
4981 +
4982 + // Build product content
4983 + $title = $product->get_name();
4984 + $description = $product->get_description();
4985 + $short_description = $product->get_short_description();
4986 + $regular_price = $product->get_regular_price();
4987 + $sale_price = $product->get_sale_price();
4988 + $price = $product->get_price();
4989 + $sku = $product->get_sku();
4990 +
4991 + // Get currency symbol
4992 + $currency_symbol = get_woocommerce_currency_symbol();
4993 +
4994 + // Format content consistently
4995 + $content = $title . "\n\n";
4996 +
4997 + if (!empty($short_description)) {
4998 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
4999 + }
5000 +
5001 + if (!empty($description)) {
5002 + $content .= wp_strip_all_tags($description) . "\n\n";
5003 + }
5004 +
5005 + // Add pricing information
5006 + if (!empty($regular_price)) {
5007 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
5008 + } elseif (!empty($price)) {
5009 + $content .= "Price: " . $currency_symbol . $price . "\n";
5010 + }
5011 +
5012 + if (!empty($sale_price) && $sale_price !== $regular_price) {
5013 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5014 + }
5015 +
5016 + // Handle variable products - show price range
5017 + if ($product->is_type('variable')) {
5018 + $min_price = $product->get_variation_price('min');
5019 + $max_price = $product->get_variation_price('max');
5020 + if ($min_price !== $max_price) {
5021 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5022 + }
5023 + }
5024 +
5025 + if (!empty($sku)) {
5026 + $content .= "SKU: " . $sku . "\n";
5027 + }
5028 +
5029 + // Get product categories
5030 + $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
5031 + if (!empty($categories) && !is_wp_error($categories)) {
5032 + $content .= "Categories: " . implode(', ', $categories) . "\n";
5033 + }
5034 +
5035 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
5036 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
5037 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
5038 + foreach ($custom_tabs as $tab) {
5039 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5040 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
5041 +
5042 + if (!empty($tab_title) && !empty($tab_content)) {
5043 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5044 + }
5045 + }
5046 + }
5047 +
5048 + // Also check for reusable/saved tabs applied to this product
5049 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
5050 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
5051 + // Get the saved tabs option
5052 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
5053 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
5054 + foreach ($applied_saved_tabs as $saved_tab_id) {
5055 + if (isset($saved_tabs[$saved_tab_id])) {
5056 + $tab = $saved_tabs[$saved_tab_id];
5057 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5058 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
5059 +
5060 + if (!empty($tab_title) && !empty($tab_content)) {
5061 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5062 + }
5063 + }
5064 + }
5065 + }
5066 + }
5067 +
5068 + // Get API key with proper model detection
5069 + $options = get_option('mxchat_options');
5070 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5071 +
5072 + if (strpos($selected_model, 'voyage') === 0) {
5073 + $api_key = $options['voyage_api_key'] ?? '';
5074 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5075 + $api_key = $options['gemini_api_key'] ?? '';
5076 + } else {
5077 + $api_key = $options['api_key'] ?? '';
5078 + }
5079 +
5080 + if (empty($api_key)) {
5081 + //error_log('MxChat Auto-sync: No API key configured for embedding model');
5082 + return;
5083 + }
5084 +
5085 + // Use the centralized utility function for storage
5086 + $result = MxChat_Utils::submit_content_to_db(
5087 + $content,
5088 + $source_url,
5089 + $api_key,
5090 + md5($source_url) // Vector ID for Pinecone
5091 + );
5092 +
5093 + // After successful storage, apply role restriction based on tags
5094 + if (!is_wp_error($result)) {
5095 + $this->apply_role_restriction_to_post($product_id, $source_url);
5096 + }
5097 +
5098 + if (is_wp_error($result)) {
5099 + //error_log('MxChat WooCommerce sync failed for product ' . $product_id . ': ' . $result->get_error_message());
5100 + }
5101 +}
5102 +
5103 +public function mxchat_handle_product_delete($post_id) {
5104 + if (get_post_type($post_id) !== 'product') {
5105 + return;
5106 + }
5107 +
5108 + $source_url = get_permalink($post_id);
5109 +
5110 + // Check if Pinecone is enabled
5111 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5112 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5113 +
5114 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5115 + // Delete from Pinecone
5116 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5117 + } else {
5118 + // Delete from WordPress DB
5119 + global $wpdb;
5120 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5121 +
5122 + $wpdb->delete(
5123 + $table_name,
5124 + array('source_url' => $source_url),
5125 + array('%s')
5126 + );
5127 + }
5128 +}
5129 +
5130 +/**
5131 + * Handle individual Pinecone content deletion
5132 + */
5133 +public function mxchat_handle_pinecone_prompt_delete() {
5134 + // Check permissions
5135 + if (!current_user_can('manage_options')) {
5136 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
5137 + }
5138 +
5139 + // Verify nonce
5140 + if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'mxchat_delete_pinecone_prompt_nonce')) {
5141 + wp_die(esc_html__('Security check failed.', 'mxchat'));
5142 + }
5143 +
5144 + $vector_id = isset($_GET['vector_id']) ? sanitize_text_field($_GET['vector_id']) : '';
5145 +
5146 + if (empty($vector_id)) {
5147 + set_transient('mxchat_admin_notice_error',
5148 + esc_html__('Invalid vector ID.', 'mxchat'),
5149 + 30
5150 + );
5151 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5152 + exit;
5153 + }
5154 +
5155 + // Get Pinecone settings
5156 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5157 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5158 +
5159 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5160 + set_transient('mxchat_admin_notice_error',
5161 + esc_html__('Pinecone is not properly configured.', 'mxchat'),
5162 + 30
5163 + );
5164 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5165 + exit;
5166 + }
5167 +
5168 + // Delete from Pinecone
5169 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5170 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5171 + $vector_id,
5172 + $pinecone_options['mxchat_pinecone_api_key'],
5173 + $pinecone_options['mxchat_pinecone_host']
5174 + );
5175 +
5176 + if ($result['success']) {
5177 + // No cache clearing needed since we removed caching
5178 + set_transient('mxchat_admin_notice_success',
5179 + esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
5180 + 30
5181 + );
5182 + } else {
5183 + set_transient('mxchat_admin_notice_error',
5184 + esc_html__('Failed to delete entry: ', 'mxchat') . $result['message'],
5185 + 30
5186 + );
5187 + }
5188 +
5189 + wp_safe_redirect(admin_url('admin.php?page=mxchat-prompts'));
5190 + exit;
5191 +}
5192 +/**
5193 + * Handle individual Pinecone content deletion via AJAX
5194 + */
5195 +public function ajax_mxchat_delete_pinecone_prompt() {
5196 + // Verify nonce and permissions
5197 + if (!check_ajax_referer('mxchat_delete_pinecone_prompt_nonce', 'nonce', false)) {
5198 + wp_send_json_error('Invalid nonce');
5199 + exit;
5200 + }
5201 +
5202 + if (!current_user_can('manage_options')) {
5203 + wp_send_json_error('Unauthorized access');
5204 + exit;
5205 + }
5206 +
5207 + $vector_id = isset($_POST['vector_id']) ? sanitize_text_field($_POST['vector_id']) : '';
5208 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5209 +
5210 + if (empty($vector_id)) {
5211 + wp_send_json_error('Missing vector ID');
5212 + exit;
5213 + }
5214 +
5215 + // Get bot-specific Pinecone settings
5216 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5217 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5218 +
5219 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5220 +
5221 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5222 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5223 + exit;
5224 + }
5225 +
5226 + // Delete from the correct Pinecone index
5227 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5228 + $vector_id,
5229 + $pinecone_options['mxchat_pinecone_api_key'],
5230 + $pinecone_options['mxchat_pinecone_host']
5231 + );
5232 +
5233 + if ($result['success']) {
5234 + // No cache clearing needed since we removed caching
5235 + wp_send_json_success(array(
5236 + 'message' => 'Entry deleted successfully from Pinecone',
5237 + 'vector_id' => $vector_id,
5238 + 'bot_id' => $bot_id
5239 + ));
5240 + } else {
5241 + wp_send_json_error('Failed to delete from Pinecone: ' . $result['message']);
5242 + }
5243 +
5244 + exit;
5245 +}
5246 +
5247 +/**
5248 + * Handle deletion of all chunks for a given source URL via AJAX
5249 + * Follows the same pattern as ajax_mxchat_delete_pinecone_prompt
5250 + */
5251 +public function ajax_mxchat_delete_chunks_by_url() {
5252 + // Verify nonce and permissions
5253 + if (!check_ajax_referer('mxchat_delete_chunks_nonce', 'nonce', false)) {
5254 + wp_send_json_error('Invalid nonce');
5255 + exit;
5256 + }
5257 +
5258 + if (!current_user_can('manage_options')) {
5259 + wp_send_json_error('Unauthorized access');
5260 + exit;
5261 + }
5262 +
5263 + $source_url = isset($_POST['source_url']) ? esc_url_raw($_POST['source_url']) : '';
5264 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5265 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5266 +
5267 + if (empty($source_url)) {
5268 + wp_send_json_error('Missing source URL');
5269 + exit;
5270 + }
5271 +
5272 + // Generate the base vector ID from the source URL (same as how chunks are created)
5273 + $base_vector_id = md5($source_url);
5274 +
5275 + if ($data_source === 'pinecone') {
5276 + // Get bot-specific Pinecone settings (same as working delete function)
5277 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5278 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5279 +
5280 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5281 +
5282 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5283 + wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5284 + exit;
5285 + }
5286 +
5287 + $api_key = $pinecone_options['mxchat_pinecone_api_key'];
5288 + $host = $pinecone_options['mxchat_pinecone_host'];
5289 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
5290 +
5291 + // Collect all vector IDs to delete
5292 + $vectors_to_delete = array();
5293 +
5294 + // Add the original single-vector ID (for non-chunked content)
5295 + $vectors_to_delete[] = $base_vector_id;
5296 +
5297 + // Use Pinecone list API to find all chunk vectors with this prefix
5298 + // NOTE: Pinecone List API is a GET request with query parameters, not POST
5299 + $prefix = $base_vector_id . '_chunk_';
5300 +
5301 + $query_params = array(
5302 + 'prefix' => $prefix,
5303 + 'limit' => 100
5304 + );
5305 +
5306 + if (!empty($namespace)) {
5307 + $query_params['namespace'] = $namespace;
5308 + }
5309 +
5310 + $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
5311 +
5312 + $list_response = wp_remote_get($list_url, array(
5313 + 'headers' => array(
5314 + 'Api-Key' => $api_key,
5315 + 'accept' => 'application/json'
5316 + ),
5317 + 'timeout' => 30
5318 + ));
5319 +
5320 + if (!is_wp_error($list_response)) {
5321 + $list_body_response = wp_remote_retrieve_body($list_response);
5322 + $list_data = json_decode($list_body_response, true);
5323 + if (!empty($list_data['vectors'])) {
5324 + foreach ($list_data['vectors'] as $vector) {
5325 + if (isset($vector['id'])) {
5326 + $vectors_to_delete[] = $vector['id'];
5327 + }
5328 + }
5329 + }
5330 + }
5331 +
5332 + if (empty($vectors_to_delete)) {
5333 + wp_send_json_success(array(
5334 + 'message' => 'No vectors found to delete',
5335 + 'source_url' => $source_url
5336 + ));
5337 + exit;
5338 + }
5339 +
5340 + // Delete all vectors using the same endpoint as the working function
5341 + $delete_url = "https://{$host}/vectors/delete";
5342 +
5343 + $delete_body = array(
5344 + 'ids' => $vectors_to_delete
5345 + );
5346 +
5347 + if (!empty($namespace)) {
5348 + $delete_body['namespace'] = $namespace;
5349 + }
5350 +
5351 + $delete_response = wp_remote_post($delete_url, array(
5352 + 'headers' => array(
5353 + 'Api-Key' => $api_key,
5354 + 'accept' => 'application/json',
5355 + 'content-type' => 'application/json'
5356 + ),
5357 + 'body' => wp_json_encode($delete_body),
5358 + 'timeout' => 30
5359 + ));
5360 +
5361 + if (is_wp_error($delete_response)) {
5362 + wp_send_json_error('Failed to delete from Pinecone: ' . $delete_response->get_error_message());
5363 + exit;
5364 + }
5365 +
5366 + $response_code = wp_remote_retrieve_response_code($delete_response);
5367 +
5368 + if ($response_code !== 200) {
5369 + wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
5370 + exit;
5371 + }
5372 +
5373 + wp_send_json_success(array(
5374 + 'message' => 'All chunks deleted successfully from Pinecone',
5375 + 'source_url' => $source_url,
5376 + 'deleted_count' => count($vectors_to_delete)
5377 + ));
5378 +
5379 + } else {
5380 + // WordPress database deletion
5381 + global $wpdb;
5382 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5383 +
5384 + $result = $wpdb->delete(
5385 + $table_name,
5386 + array('source_url' => $source_url),
5387 + array('%s')
5388 + );
5389 +
5390 + if ($result === false) {
5391 + wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
5392 + exit;
5393 + }
5394 +
5395 + wp_send_json_success(array(
5396 + 'message' => 'All chunks deleted successfully from database',
5397 + 'source_url' => $source_url,
5398 + 'deleted_count' => $result
5399 + ));
5400 + }
5401 +
5402 + exit;
5403 +}
5404 +
5405 +/**
5406 + * Handle individual WordPress database content deletion via AJAX
5407 + * Mirrors the Pinecone delete handler but for WordPress database entries
5408 + */
5409 +public function ajax_mxchat_delete_wordpress_prompt() {
5410 + // Verify nonce and permissions
5411 + if (!check_ajax_referer('mxchat_delete_wordpress_prompt_nonce', 'nonce', false)) {
5412 + wp_send_json_error('Invalid nonce');
5413 + exit;
5414 + }
5415 +
5416 + if (!current_user_can('manage_options')) {
5417 + wp_send_json_error('Unauthorized access');
5418 + exit;
5419 + }
5420 +
5421 + $entry_id = isset($_POST['entry_id']) ? intval($_POST['entry_id']) : 0;
5422 +
5423 + if (empty($entry_id)) {
5424 + wp_send_json_error('Missing entry ID');
5425 + exit;
5426 + }
5427 +
5428 + global $wpdb;
5429 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5430 +
5431 + // Clear cache for this entry
5432 + wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5433 +
5434 + // Delete from database
5435 + $result = $wpdb->delete(
5436 + $table_name,
5437 + array('id' => $entry_id),
5438 + array('%d')
5439 + );
5440 +
5441 + if ($result !== false) {
5442 + wp_send_json_success(array(
5443 + 'message' => 'Entry deleted successfully',
5444 + 'entry_id' => $entry_id
5445 + ));
5446 + } else {
5447 + wp_send_json_error('Failed to delete entry from database');
5448 + }
5449 +
5450 + exit;
5451 +}
5452 +
5453 +/**
5454 + * Handle bulk deletion of knowledge entries via AJAX
5455 + * Supports both Pinecone and WordPress database entries
5456 + */
5457 +public function ajax_mxchat_bulk_delete_knowledge() {
5458 + // Verify nonce and permissions
5459 + if (!check_ajax_referer('mxchat_bulk_delete_knowledge_nonce', 'nonce', false)) {
5460 + wp_send_json_error('Invalid nonce');
5461 + exit;
5462 + }
5463 +
5464 + if (!current_user_can('manage_options')) {
5465 + wp_send_json_error('Unauthorized access');
5466 + exit;
5467 + }
5468 +
5469 + $entries = isset($_POST['entries']) ? $_POST['entries'] : array();
5470 + $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
5471 +
5472 + if (empty($entries) || !is_array($entries)) {
5473 + wp_send_json_error('No entries provided');
5474 + exit;
5475 + }
5476 +
5477 + $success_ids = array();
5478 + $failed_ids = array();
5479 + $errors = array();
5480 +
5481 + global $wpdb;
5482 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5483 +
5484 + // Get Pinecone manager for Pinecone deletions
5485 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5486 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
5487 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5488 +
5489 + foreach ($entries as $entry) {
5490 + $entry_id = sanitize_text_field($entry['id'] ?? '');
5491 + $source = sanitize_text_field($entry['source'] ?? 'wordpress');
5492 + $source_url = isset($entry['sourceUrl']) ? esc_url_raw($entry['sourceUrl']) : '';
5493 + $is_group = isset($entry['isGroup']) && ($entry['isGroup'] === true || $entry['isGroup'] === 'true');
5494 +
5495 + if (empty($entry_id)) {
5496 + continue;
5497 + }
5498 +
5499 + try {
5500 + if ($source === 'pinecone') {
5501 + // Handle Pinecone deletion
5502 + if (!$use_pinecone || empty($pinecone_options['mxchat_pinecone_api_key'])) {
5503 + $failed_ids[] = $entry_id;
5504 + $errors[] = "Pinecone not configured for entry: $entry_id";
5505 + continue;
5506 + }
5507 +
5508 + if ($is_group && !empty($source_url)) {
5509 + // Delete all chunks for this URL
5510 + $base_vector_id = md5($source_url);
5511 + $api_key = $pinecone_options['mxchat_pinecone_api_key'];
5512 + $host = $pinecone_options['mxchat_pinecone_host'];
5513 +
5514 + // List all vectors with this prefix
5515 + $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
5516 + $list_response = wp_remote_get($list_url, array(
5517 + 'headers' => array(
5518 + 'Api-Key' => $api_key,
5519 + 'Content-Type' => 'application/json'
5520 + ),
5521 + 'timeout' => 30
5522 + ));
5523 +
5524 + $vector_ids = array($base_vector_id); // Include base ID
5525 +
5526 + if (!is_wp_error($list_response)) {
5527 + $list_body = json_decode(wp_remote_retrieve_body($list_response), true);
5528 + if (isset($list_body['vectors']) && is_array($list_body['vectors'])) {
5529 + foreach ($list_body['vectors'] as $vector) {
5530 + if (isset($vector['id'])) {
5531 + $vector_ids[] = $vector['id'];
5532 + }
5533 + }
5534 + }
5535 + }
5536 +
5537 + // Delete all vectors
5538 + $delete_result = $pinecone_manager->mxchat_delete_pinecone_batch(
5539 + $vector_ids,
5540 + $api_key,
5541 + $host
5542 + );
5543 +
5544 + if ($delete_result['success']) {
5545 + $success_ids[] = $entry_id;
5546 + } else {
5547 + $failed_ids[] = $entry_id;
5548 + $errors[] = $delete_result['message'] ?? "Failed to delete group: $entry_id";
5549 + }
5550 + } else {
5551 + // Delete single vector
5552 + $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5553 + $entry_id,
5554 + $pinecone_options['mxchat_pinecone_api_key'],
5555 + $pinecone_options['mxchat_pinecone_host']
5556 + );
5557 +
5558 + if ($result['success']) {
5559 + $success_ids[] = $entry_id;
5560 + } else {
5561 + $failed_ids[] = $entry_id;
5562 + $errors[] = $result['message'] ?? "Failed to delete: $entry_id";
5563 + }
5564 + }
5565 + } else {
5566 + // Handle WordPress database deletion
5567 + if ($is_group && !empty($source_url)) {
5568 + // Delete all entries with this source URL
5569 + $result = $wpdb->delete(
5570 + $table_name,
5571 + array('source_url' => $source_url),
5572 + array('%s')
5573 + );
5574 + } else {
5575 + // Delete single entry
5576 + wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5577 + $result = $wpdb->delete(
5578 + $table_name,
5579 + array('id' => intval($entry_id)),
5580 + array('%d')
5581 + );
5582 + }
5583 +
5584 + if ($result !== false) {
5585 + $success_ids[] = $entry_id;
5586 + } else {
5587 + $failed_ids[] = $entry_id;
5588 + $errors[] = "Database error for entry: $entry_id";
5589 + }
5590 + }
5591 + } catch (Exception $e) {
5592 + $failed_ids[] = $entry_id;
5593 + $errors[] = $e->getMessage();
5594 + }
5595 + }
5596 +
5597 + wp_send_json_success(array(
5598 + 'success_ids' => $success_ids,
5599 + 'failed_ids' => $failed_ids,
5600 + 'errors' => $errors,
5601 + 'total_processed' => count($success_ids) + count($failed_ids)
5602 + ));
5603 +
5604 + exit;
5605 +}
5606 +
5607 +/**
5608 + * Get hierarchical roles for dropdown
5609 + */
5610 +public function mxchat_get_role_options() {
5611 + return array(
5612 + 'public' => __('Public (Everyone)', 'mxchat'),
5613 + 'logged_in' => __('Logged In Users', 'mxchat'),
5614 + 'subscriber' => __('Subscribers & Above', 'mxchat'),
5615 + 'contributor' => __('Contributors & Above', 'mxchat'),
5616 + 'author' => __('Authors & Above', 'mxchat'),
5617 + 'editor' => __('Editors & Above', 'mxchat'),
5618 + 'administrator' => __('Administrators Only', 'mxchat')
5619 + );
5620 +}
5621 +
5622 +/**
5623 + * Check if user has access to content based on role restriction
5624 + */
5625 +public function mxchat_user_has_content_access($role_restriction) {
5626 + // Public content is always accessible
5627 + if ($role_restriction === 'public' || empty($role_restriction)) {
5628 + return true;
5629 + }
5630 +
5631 + // Check if user is logged in for logged_in restriction
5632 + if ($role_restriction === 'logged_in') {
5633 + return is_user_logged_in();
5634 + }
5635 +
5636 + // If not logged in, no access to role-restricted content
5637 + if (!is_user_logged_in()) {
5638 + return false;
5639 + }
5640 +
5641 + $user = wp_get_current_user();
5642 + $user_roles = $user->roles;
5643 +
5644 + if (empty($user_roles)) {
5645 + return false;
5646 + }
5647 +
5648 + // Define role hierarchy (higher number = higher access)
5649 + $hierarchy = array(
5650 + 'subscriber' => 1,
5651 + 'contributor' => 2,
5652 + 'author' => 3,
5653 + 'editor' => 4,
5654 + 'administrator' => 5
5655 + );
5656 +
5657 + // Get required level
5658 + $required_level = isset($hierarchy[$role_restriction]) ? $hierarchy[$role_restriction] : 0;
5659 +
5660 + // Check if user has required level or higher
5661 + foreach ($user_roles as $user_role) {
5662 + $user_level = isset($hierarchy[$user_role]) ? $hierarchy[$user_role] : 0;
5663 + if ($user_level >= $required_level) {
5664 + return true;
5665 + }
5666 + }
5667 +
5668 + return false;
5669 +}
5670 +
5671 +/**
5672 + * Handle role restriction updates via AJAX
5673 + * Removed cache clearing call since we removed caching
5674 + */
5675 +public function ajax_mxchat_update_role_restriction() {
5676 + // Verify nonce and permissions
5677 + if (!check_ajax_referer('mxchat_update_role_nonce', 'nonce', false)) {
5678 + wp_send_json_error('Invalid nonce');
5679 + exit;
5680 + }
5681 +
5682 + if (!current_user_can('manage_options')) {
5683 + wp_send_json_error('Unauthorized access');
5684 + exit;
5685 + }
5686 +
5687 + $entry_id = isset($_POST['entry_id']) ? sanitize_text_field($_POST['entry_id']) : '';
5688 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
5689 + $data_source = isset($_POST['data_source']) ? sanitize_text_field($_POST['data_source']) : 'wordpress';
5690 +
5691 + if (empty($entry_id)) {
5692 + wp_send_json_error('Invalid entry ID');
5693 + exit;
5694 + }
5695 +
5696 + // Get knowledge manager instance to validate role restriction
5697 + $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5698 + $valid_roles = array_keys($knowledge_manager->mxchat_get_role_options());
5699 + if (!in_array($role_restriction, $valid_roles)) {
5700 + wp_send_json_error('Invalid role restriction');
5701 + exit;
5702 + }
5703 +
5704 + global $wpdb;
5705 +
5706 + if ($data_source === 'pinecone') {
5707 + // Handle Pinecone role restriction (stored separately in WordPress table)
5708 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5709 +
5710 + // Use REPLACE to insert or update the role restriction
5711 + $result = $wpdb->replace(
5712 + $roles_table,
5713 + array(
5714 + 'vector_id' => $entry_id,
5715 + 'role_restriction' => $role_restriction,
5716 + 'updated_at' => current_time('mysql')
5717 + ),
5718 + array('%s', '%s', '%s')
5719 + );
5720 +
5721 + // No cache clearing needed since we removed caching
5722 +
5723 + } else {
5724 + // Handle WordPress database role restriction (existing functionality)
5725 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5726 +
5727 + $result = $wpdb->update(
5728 + $table_name,
5729 + array('role_restriction' => $role_restriction),
5730 + array('id' => absint($entry_id)),
5731 + array('%s'),
5732 + array('%d')
5733 + );
5734 + }
5735 +
5736 + if ($result === false) {
5737 + wp_send_json_error('Database update failed: ' . $wpdb->last_error);
5738 + exit;
5739 + }
5740 +
5741 + wp_send_json_success(array(
5742 + 'message' => 'Role restriction updated successfully',
5743 + 'role_restriction' => $role_restriction,
5744 + 'data_source' => $data_source,
5745 + 'entry_id' => $entry_id
5746 + ));
5747 + exit;
5748 +}
5749 +
5750 +// ========================================
5751 +// ROLE-BASED CONTENT RESTRICTIONS - NEW FUNCTIONS
5752 +// Add these to your MxChat_Knowledge_Manager class
5753 +// ========================================
5754 +
5755 +/**
5756 + * Initialize role-based content hooks
5757 + * Add this call to your __construct() or mxchat_init_hooks() method
5758 + */
5759 +private function mxchat_init_role_hooks() {
5760 + // AJAX handlers for tag-role mappings
5761 + add_action('wp_ajax_mxchat_add_tag_role_mapping', array($this, 'ajax_add_tag_role_mapping'));
5762 + add_action('wp_ajax_mxchat_delete_tag_role_mapping', array($this, 'ajax_delete_tag_role_mapping'));
5763 + add_action('wp_ajax_mxchat_get_tag_role_mappings', array($this, 'ajax_get_tag_role_mappings'));
5764 + add_action('wp_ajax_mxchat_bulk_update_tag_roles', array($this, 'ajax_bulk_update_tag_roles'));
5765 +
5766 + // Hook to automatically update role restrictions when tags are added/removed
5767 + add_action('set_object_terms', array($this, 'handle_tag_change'), 10, 6);
5768 +
5769 + // Hook to apply role restrictions on auto-sync
5770 + add_action('mxchat_content_stored', array($this, 'apply_role_restriction_after_storage'), 10, 2);
5771 +}
5772 +
5773 +/**
5774 + * Add tag-role mapping via AJAX
5775 + */
5776 +public function ajax_add_tag_role_mapping() {
5777 + // Verify nonce and permissions
5778 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5779 +
5780 + if (!current_user_can('manage_options')) {
5781 + wp_send_json_error('Unauthorized access');
5782 + exit;
5783 + }
5784 +
5785 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
5786 + $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
5787 +
5788 + if (empty($tag_slug)) {
5789 + wp_send_json_error('Tag slug is required');
5790 + exit;
5791 + }
5792 +
5793 + // Validate role restriction
5794 + $valid_roles = array_keys($this->mxchat_get_role_options());
5795 + if (!in_array($role_restriction, $valid_roles)) {
5796 + wp_send_json_error('Invalid role restriction');
5797 + exit;
5798 + }
5799 +
5800 + // Check if tag exists in WordPress
5801 + $term = get_term_by('slug', $tag_slug, 'post_tag');
5802 + if (!$term) {
5803 + wp_send_json_error('Tag does not exist in WordPress');
5804 + exit;
5805 + }
5806 +
5807 + // Get existing mappings
5808 + $mappings = get_option('mxchat_tag_role_mappings', array());
5809 +
5810 + // Check if mapping already exists
5811 + if (isset($mappings[$tag_slug])) {
5812 + wp_send_json_error('Mapping for this tag already exists');
5813 + exit;
5814 + }
5815 +
5816 + // Add new mapping
5817 + $mappings[$tag_slug] = $role_restriction;
5818 + update_option('mxchat_tag_role_mappings', $mappings);
5819 +
5820 + wp_send_json_success(array(
5821 + 'message' => 'Tag-role mapping added successfully',
5822 + 'tag_slug' => $tag_slug,
5823 + 'role_restriction' => $role_restriction
5824 + ));
5825 + exit;
5826 +}
5827 +
5828 +/**
5829 + * Delete tag-role mapping via AJAX
5830 + */
5831 +public function ajax_delete_tag_role_mapping() {
5832 + // Verify nonce and permissions
5833 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5834 +
5835 + if (!current_user_can('manage_options')) {
5836 + wp_send_json_error('Unauthorized access');
5837 + exit;
5838 + }
5839 +
5840 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
5841 +
5842 + if (empty($tag_slug)) {
5843 + wp_send_json_error('Tag slug is required');
5844 + exit;
5845 + }
5846 +
5847 + // Get existing mappings
5848 + $mappings = get_option('mxchat_tag_role_mappings', array());
5849 +
5850 + // Check if mapping exists
5851 + if (!isset($mappings[$tag_slug])) {
5852 + wp_send_json_error('Mapping does not exist');
5853 + exit;
5854 + }
5855 +
5856 + // Remove mapping
5857 + unset($mappings[$tag_slug]);
5858 + update_option('mxchat_tag_role_mappings', $mappings);
5859 +
5860 + wp_send_json_success(array(
5861 + 'message' => 'Tag-role mapping deleted successfully',
5862 + 'tag_slug' => $tag_slug
5863 + ));
5864 + exit;
5865 +}
5866 +
5867 +/**
5868 + * Get all tag-role mappings via AJAX
5869 + */
5870 +public function ajax_get_tag_role_mappings() {
5871 + // Verify nonce and permissions
5872 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5873 +
5874 + if (!current_user_can('manage_options')) {
5875 + wp_send_json_error('Unauthorized access');
5876 + exit;
5877 + }
5878 +
5879 + // Get mappings
5880 + $mappings = get_option('mxchat_tag_role_mappings', array());
5881 + $role_options = $this->mxchat_get_role_options();
5882 +
5883 + $formatted_mappings = array();
5884 +
5885 + foreach ($mappings as $tag_slug => $role_restriction) {
5886 + // Get tag object
5887 + $term = get_term_by('slug', $tag_slug, 'post_tag');
5888 +
5889 + // Count posts with this tag
5890 + $post_count = 0;
5891 + if ($term) {
5892 + $post_count = $term->count;
5893 + }
5894 +
5895 + $formatted_mappings[] = array(
5896 + 'tag_slug' => $tag_slug,
5897 + 'role_restriction' => $role_restriction,
5898 + 'role_label' => $role_options[$role_restriction] ?? $role_restriction,
5899 + 'post_count' => $post_count
5900 + );
5901 + }
5902 +
5903 + wp_send_json_success(array(
5904 + 'mappings' => $formatted_mappings
5905 + ));
5906 + exit;
5907 +}
5908 +
5909 +/**
5910 + * Bulk update role restrictions for all existing content with mapped tags
5911 + */
5912 +public function ajax_bulk_update_tag_roles() {
5913 + // Verify nonce and permissions
5914 + check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
5915 +
5916 + if (!current_user_can('manage_options')) {
5917 + wp_send_json_error('Unauthorized access');
5918 + exit;
5919 + }
5920 +
5921 + // Get mappings
5922 + $mappings = get_option('mxchat_tag_role_mappings', array());
5923 +
5924 + if (empty($mappings)) {
5925 + wp_send_json_error('No tag-role mappings found');
5926 + exit;
5927 + }
5928 +
5929 + global $wpdb;
5930 +
5931 + // Check if using Pinecone
5932 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5933 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5934 +
5935 + $updated_count = 0;
5936 + $details = array();
5937 +
5938 + foreach ($mappings as $tag_slug => $role_restriction) {
5939 + // Get all posts with this tag
5940 + $posts = get_posts(array(
5941 + 'tag' => $tag_slug,
5942 + 'post_type' => 'any',
5943 + 'posts_per_page' => -1,
5944 + 'fields' => 'ids',
5945 + 'post_status' => 'publish'
5946 + ));
5947 +
5948 + if (empty($posts)) {
5949 + continue;
5950 + }
5951 +
5952 + $tag_updated = 0;
5953 +
5954 + foreach ($posts as $post_id) {
5955 + $source_url = get_permalink($post_id);
5956 + if (!$source_url) {
5957 + continue;
5958 + }
5959 +
5960 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5961 + // Update Pinecone role restriction
5962 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5963 + $vector_id = md5($source_url);
5964 +
5965 + $result = $wpdb->replace(
5966 + $roles_table,
5967 + array(
5968 + 'vector_id' => $vector_id,
5969 + 'role_restriction' => $role_restriction,
5970 + 'updated_at' => current_time('mysql')
5971 + ),
5972 + array('%s', '%s', '%s')
5973 + );
5974 + } else {
5975 + // Update WordPress DB
5976 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5977 +
5978 + $result = $wpdb->update(
5979 + $table_name,
5980 + array('role_restriction' => $role_restriction),
5981 + array('source_url' => $source_url),
5982 + array('%s'),
5983 + array('%s')
5984 + );
5985 + }
5986 +
5987 + if ($result !== false) {
5988 + $tag_updated++;
5989 + $updated_count++;
5990 + }
5991 + }
5992 +
5993 + if ($tag_updated > 0) {
5994 + $details[] = sprintf(
5995 + 'Tag "%s" (%s): %d posts updated',
5996 + $tag_slug,
5997 + $role_restriction,
5998 + $tag_updated
5999 + );
6000 + }
6001 + }
6002 +
6003 + wp_send_json_success(array(
6004 + 'message' => 'Bulk update completed',
6005 + 'updated_count' => $updated_count,
6006 + 'tags_processed' => count($mappings),
6007 + 'details' => $details
6008 + ));
6009 + exit;
6010 +}
6011 +
6012 +/**
6013 + * Handle tag changes on posts (when tags are added or removed)
6014 + */
6015 +public function handle_tag_change($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
6016 + // Only process post tags
6017 + if ($taxonomy !== 'post_tag') {
6018 + return;
6019 + }
6020 +
6021 + // Get tag-role mappings
6022 + $mappings = get_option('mxchat_tag_role_mappings', array());
6023 +
6024 + if (empty($mappings)) {
6025 + return;
6026 + }
6027 +
6028 + // Get the post's URL
6029 + $source_url = get_permalink($object_id);
6030 + if (!$source_url) {
6031 + return;
6032 + }
6033 +
6034 + // Determine the highest role restriction based on tags
6035 + $highest_role = 'public';
6036 + $role_hierarchy = array(
6037 + 'public' => 0,
6038 + 'logged_in' => 1,
6039 + 'subscriber' => 2,
6040 + 'contributor' => 3,
6041 + 'author' => 4,
6042 + 'editor' => 5,
6043 + 'administrator' => 6
6044 + );
6045 +
6046 + // Get all current tags for the post
6047 + $current_tags = wp_get_post_tags($object_id, array('fields' => 'slugs'));
6048 +
6049 + // Find the highest role restriction among the tags
6050 + foreach ($current_tags as $tag_slug) {
6051 + if (isset($mappings[$tag_slug])) {
6052 + $role = $mappings[$tag_slug];
6053 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6054 + $highest_role = $role;
6055 + }
6056 + }
6057 + }
6058 +
6059 + // Update the role restriction in the database
6060 + global $wpdb;
6061 +
6062 + // Check if using Pinecone
6063 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6064 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6065 +
6066 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6067 + // Update Pinecone role restriction
6068 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6069 + $vector_id = md5($source_url);
6070 +
6071 + $wpdb->replace(
6072 + $roles_table,
6073 + array(
6074 + 'vector_id' => $vector_id,
6075 + 'role_restriction' => $highest_role,
6076 + 'updated_at' => current_time('mysql')
6077 + ),
6078 + array('%s', '%s', '%s')
6079 + );
6080 + } else {
6081 + // Update WordPress DB
6082 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6083 +
6084 + $wpdb->update(
6085 + $table_name,
6086 + array('role_restriction' => $highest_role),
6087 + array('source_url' => $source_url),
6088 + array('%s'),
6089 + array('%s')
6090 + );
6091 + }
6092 +}
6093 +
6094 +/**
6095 + * Apply role restriction after content is stored (for auto-sync)
6096 + */
6097 +public function apply_role_restriction_after_storage($post_id, $source_url) {
6098 + // Get tag-role mappings
6099 + $mappings = get_option('mxchat_tag_role_mappings', array());
6100 +
6101 + if (empty($mappings)) {
6102 + return;
6103 + }
6104 +
6105 + // Get all tags for the post
6106 + $post_tags = wp_get_post_tags($post_id, array('fields' => 'slugs'));
6107 +
6108 + if (empty($post_tags)) {
6109 + return;
6110 + }
6111 +
6112 + // Determine the highest role restriction based on tags
6113 + $highest_role = 'public';
6114 + $role_hierarchy = array(
6115 + 'public' => 0,
6116 + 'logged_in' => 1,
6117 + 'subscriber' => 2,
6118 + 'contributor' => 3,
6119 + 'author' => 4,
6120 + 'editor' => 5,
6121 + 'administrator' => 6
6122 + );
6123 +
6124 + foreach ($post_tags as $tag_slug) {
6125 + if (isset($mappings[$tag_slug])) {
6126 + $role = $mappings[$tag_slug];
6127 + if (isset($role_hierarchy[$role]) && $role_hierarchy[$role] > $role_hierarchy[$highest_role]) {
6128 + $highest_role = $role;
6129 + }
6130 + }
6131 + }
6132 +
6133 + // If no restricted tags found, return (leave as public)
6134 + if ($highest_role === 'public') {
6135 + return;
6136 + }
6137 +
6138 + // Update the role restriction
6139 + global $wpdb;
6140 +
6141 + // Check if using Pinecone
6142 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
6143 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6144 +
6145 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
6146 + // Update Pinecone role restriction
6147 + $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6148 + $vector_id = md5($source_url);
6149 +
6150 + $wpdb->replace(
6151 + $roles_table,
6152 + array(
6153 + 'vector_id' => $vector_id,
6154 + 'role_restriction' => $highest_role,
6155 + 'updated_at' => current_time('mysql')
6156 + ),
6157 + array('%s', '%s', '%s')
6158 + );
6159 + } else {
6160 + // Update WordPress DB
6161 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6162 +
6163 + $wpdb->update(
6164 + $table_name,
6165 + array('role_restriction' => $highest_role),
6166 + array('source_url' => $source_url),
6167 + array('%s'),
6168 + array('%s')
6169 + );
6170 + }
6171 +}
6172 +
6173 +
6174 + // ========================================
6175 + // HELPER METHODS
6176 + // ========================================
6177 +
6178 + /**
6179 + * Check if user has required permissions for content processing
6180 + */
6181 + private function mxchat_check_user_permissions() {
6182 + if (!current_user_can('manage_options')) {
6183 + wp_die(esc_html__('You do not have sufficient permissions.', 'mxchat'));
6184 + }
6185 + }
6186 +
6187 + /**
6188 + * Validate nonce for security
6189 + */
6190 + private function mxchat_validate_nonce($nonce_name, $nonce_action) {
6191 + if (!isset($_POST[$nonce_name]) || !wp_verify_nonce($_POST[$nonce_name], $nonce_action)) {
6192 + wp_die(esc_html__('Security check failed.', 'mxchat'));
6193 + }
6194 + }
6195 +
6196 + /**
6197 + * Get embedding API credentials
6198 + */
6199 + private function mxchat_get_embedding_credentials() {
6200 + $embedding_model = $this->options['embedding_model'] ?? 'text-embedding-ada-002';
6201 +
6202 + if (strpos($embedding_model, 'text-embedding-') !== false) {
6203 + return array(
6204 + 'type' => 'openai',
6205 + 'api_key' => $this->options['api_key'] ?? ''
6206 + );
6207 + } elseif (strpos($embedding_model, 'voyage-') !== false) {
6208 + return array(
6209 + 'type' => 'voyage',
6210 + 'api_key' => $this->options['voyage_api_key'] ?? ''
6211 + );
6212 + } elseif (strpos($embedding_model, 'gemini-embedding-') !== false) {
6213 + return array(
6214 + 'type' => 'gemini',
6215 + 'api_key' => $this->options['gemini_api_key'] ?? ''
6216 + );
6217 + }
6218 +
6219 + return array('type' => 'unknown', 'api_key' => '');
6220 + }
6221 +
6222 + /**
6223 + * Log processing errors
6224 + */
6225 + private function mxchat_log_processing_error($operation, $error_message) {
6226 + //error_log("MxChat Knowledge Processing {$operation} Error: " . $error_message);
6227 + }
6228 +
6229 + /**
6230 + * Set admin notice transient
6231 + */
6232 + private function mxchat_set_admin_notice($type, $message) {
6233 + set_transient("mxchat_admin_notice_{$type}", $message, 30);
6234 + }
6235 +
6236 + /**
6237 + * Get Pinecone manager instance for vector operations
6238 + */
6239 + private function mxchat_get_pinecone_manager() {
6240 + return MxChat_Pinecone_Manager::get_instance();
6241 + }
6242 +
6243 +
6244 + // ========================================
6245 +// DATABASE QUEUE TABLE MANAGEMENT
6246 +// ========================================
6247 +
6248 +/**
6249 + * Create queue table on plugin activation
6250 + * Call this from your plugin activation hook
6251 + */
6252 +public function mxchat_create_queue_table() {
6253 + global $wpdb;
6254 +
6255 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6256 + $charset_collate = $wpdb->get_charset_collate();
6257 +
6258 + $sql = "CREATE TABLE IF NOT EXISTS $table_name (
6259 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6260 + queue_id varchar(64) NOT NULL,
6261 + item_type varchar(20) NOT NULL,
6262 + item_data longtext NOT NULL,
6263 + status varchar(20) NOT NULL DEFAULT 'pending',
6264 + bot_id varchar(50) NOT NULL DEFAULT 'default',
6265 + priority int(11) NOT NULL DEFAULT 0,
6266 + attempts int(11) NOT NULL DEFAULT 0,
6267 + max_attempts int(11) NOT NULL DEFAULT 3,
6268 + error_message text DEFAULT NULL,
6269 + created_at datetime NOT NULL,
6270 + started_at datetime DEFAULT NULL,
6271 + completed_at datetime DEFAULT NULL,
6272 + PRIMARY KEY (id),
6273 + KEY queue_id (queue_id),
6274 + KEY status (status),
6275 + KEY item_type (item_type),
6276 + KEY priority (priority)
6277 + ) $charset_collate;";
6278 +
6279 + require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
6280 + dbDelta($sql);
6281 +
6282 + // Also create a meta table for queue metadata
6283 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6284 +
6285 + $meta_sql = "CREATE TABLE IF NOT EXISTS $meta_table (
6286 + id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
6287 + queue_id varchar(64) NOT NULL,
6288 + meta_key varchar(255) NOT NULL,
6289 + meta_value longtext,
6290 + PRIMARY KEY (id),
6291 + KEY queue_id (queue_id),
6292 + KEY meta_key (meta_key)
6293 + ) $charset_collate;";
6294 +
6295 + dbDelta($meta_sql);
6296 +}
6297 +
6298 +/**
6299 + * Add items to the processing queue
6300 + *
6301 + * @param string $queue_id Unique identifier for this queue batch
6302 + * @param string $item_type Type of item (url, pdf_page)
6303 + * @param array $items Array of items to queue
6304 + * @param string $bot_id Bot ID for processing
6305 + * @return int Number of items queued
6306 + */
6307 +private function mxchat_add_to_queue($queue_id, $item_type, $items, $bot_id = 'default') {
6308 + global $wpdb;
6309 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6310 +
6311 + $queued_count = 0;
6312 + $priority = 0;
6313 +
6314 + foreach ($items as $item) {
6315 + $result = $wpdb->insert(
6316 + $table_name,
6317 + array(
6318 + 'queue_id' => $queue_id,
6319 + 'item_type' => $item_type,
6320 + 'item_data' => wp_json_encode($item),
6321 + 'status' => 'pending',
6322 + 'bot_id' => $bot_id,
6323 + 'priority' => $priority,
6324 + 'attempts' => 0,
6325 + 'max_attempts' => 3,
6326 + 'created_at' => current_time('mysql')
6327 + ),
6328 + array('%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
6329 + );
6330 +
6331 + if ($result) {
6332 + $queued_count++;
6333 + }
6334 +
6335 + $priority++; // Process in order
6336 + }
6337 +
6338 + return $queued_count;
6339 +}
6340 +
6341 +/**
6342 + * Store queue metadata (total counts, source URL, etc.)
6343 + */
6344 +private function mxchat_set_queue_meta($queue_id, $meta_key, $meta_value) {
6345 + global $wpdb;
6346 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6347 +
6348 + // Check if meta exists
6349 + $existing = $wpdb->get_var($wpdb->prepare(
6350 + "SELECT id FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6351 + $queue_id,
6352 + $meta_key
6353 + ));
6354 +
6355 + if ($existing) {
6356 + // Update
6357 + $wpdb->update(
6358 + $meta_table,
6359 + array('meta_value' => maybe_serialize($meta_value)),
6360 + array('queue_id' => $queue_id, 'meta_key' => $meta_key),
6361 + array('%s'),
6362 + array('%s', '%s')
6363 + );
6364 + } else {
6365 + // Insert
6366 + $wpdb->insert(
6367 + $meta_table,
6368 + array(
6369 + 'queue_id' => $queue_id,
6370 + 'meta_key' => $meta_key,
6371 + 'meta_value' => maybe_serialize($meta_value)
6372 + ),
6373 + array('%s', '%s', '%s')
6374 + );
6375 + }
6376 +}
6377 +
6378 +/**
6379 + * Get queue metadata
6380 + */
6381 +private function mxchat_get_queue_meta($queue_id, $meta_key) {
6382 + global $wpdb;
6383 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
6384 +
6385 + $value = $wpdb->get_var($wpdb->prepare(
6386 + "SELECT meta_value FROM $meta_table WHERE queue_id = %s AND meta_key = %s",
6387 + $queue_id,
6388 + $meta_key
6389 + ));
6390 +
6391 + return maybe_unserialize($value);
6392 +}
6393 +
6394 +// ========================================
6395 +// AJAX QUEUE PROCESSING HANDLERS
6396 +// ========================================
6397 +
6398 +/**
6399 + * AJAX: Get next item from queue to process
6400 + */
6401 +public function ajax_mxchat_get_next_queue_item() {
6402 + // Verify nonce and permissions
6403 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6404 +
6405 + if (!current_user_can('manage_options')) {
6406 + wp_send_json_error('Unauthorized access');
6407 + }
6408 +
6409 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6410 +
6411 + if (empty($queue_id)) {
6412 + wp_send_json_error('Missing queue ID');
6413 + }
6414 +
6415 + global $wpdb;
6416 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6417 +
6418 + // Get next pending item with retry logic for failed items
6419 + $next_item = $wpdb->get_row($wpdb->prepare(
6420 + "SELECT * FROM $table_name
6421 + WHERE queue_id = %s
6422 + AND status IN ('pending', 'failed')
6423 + AND attempts < max_attempts
6424 + ORDER BY priority ASC, id ASC
6425 + LIMIT 1",
6426 + $queue_id
6427 + ));
6428 +
6429 + if (!$next_item) {
6430 + // No more items - queue complete
6431 + wp_send_json_success(array(
6432 + 'complete' => true,
6433 + 'message' => 'Queue processing complete'
6434 + ));
6435 + }
6436 +
6437 + // Mark item as processing
6438 + $wpdb->update(
6439 + $table_name,
6440 + array(
6441 + 'status' => 'processing',
6442 + 'started_at' => current_time('mysql'),
6443 + 'attempts' => $next_item->attempts + 1
6444 + ),
6445 + array('id' => $next_item->id),
6446 + array('%s', '%s', '%d'),
6447 + array('%d')
6448 + );
6449 +
6450 + wp_send_json_success(array(
6451 + 'complete' => false,
6452 + 'item' => array(
6453 + 'id' => $next_item->id,
6454 + 'type' => $next_item->item_type,
6455 + 'data' => json_decode($next_item->item_data, true),
6456 + 'bot_id' => $next_item->bot_id,
6457 + 'attempt' => $next_item->attempts + 1
6458 + )
6459 + ));
6460 +}
6461 +
6462 +/**
6463 + * AJAX: Process a single queue item
6464 + */
6465 +public function ajax_mxchat_process_queue_item() {
6466 + // Verify nonce and permissions
6467 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6468 +
6469 + if (!current_user_can('manage_options')) {
6470 + wp_send_json_error('Unauthorized access');
6471 + }
6472 +
6473 + $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
6474 + $item_type = isset($_POST['item_type']) ? sanitize_text_field($_POST['item_type']) : '';
6475 + $item_data = isset($_POST['item_data']) ? $_POST['item_data'] : array();
6476 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
6477 +
6478 + if (empty($item_id) || empty($item_type)) {
6479 + wp_send_json_error('Missing item data');
6480 + }
6481 +
6482 + global $wpdb;
6483 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6484 +
6485 + // Process based on item type
6486 + try {
6487 + set_time_limit(60); // Give processing 60 seconds
6488 +
6489 + $result = false;
6490 + $error_message = '';
6491 +
6492 + switch ($item_type) {
6493 + case 'url':
6494 + $result = $this->mxchat_process_queue_url($item_data, $bot_id);
6495 + break;
6496 +
6497 + case 'pdf_page':
6498 + $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
6499 + break;
6500 +
6501 + default:
6502 + throw new Exception('Unknown item type: ' . $item_type);
6503 + }
6504 +
6505 + if (is_wp_error($result)) {
6506 + throw new Exception($result->get_error_message());
6507 + }
6508 +
6509 + if ($result === false) {
6510 + throw new Exception('Processing returned false - item may be empty or invalid');
6511 + }
6512 +
6513 + // Mark as completed
6514 + $wpdb->update(
6515 + $table_name,
6516 + array(
6517 + 'status' => 'completed',
6518 + 'completed_at' => current_time('mysql'),
6519 + 'error_message' => null
6520 + ),
6521 + array('id' => $item_id),
6522 + array('%s', '%s', '%s'),
6523 + array('%d')
6524 + );
6525 +
6526 + wp_send_json_success(array(
6527 + 'processed' => true,
6528 + 'item_id' => $item_id,
6529 + 'message' => 'Item processed successfully'
6530 + ));
6531 +
6532 + } catch (Exception $e) {
6533 + $error_message = $e->getMessage();
6534 +
6535 + // Get current attempt count
6536 + $item = $wpdb->get_row($wpdb->prepare(
6537 + "SELECT attempts, max_attempts FROM $table_name WHERE id = %d",
6538 + $item_id
6539 + ));
6540 +
6541 + // Check if we've exhausted retries
6542 + if ($item && $item->attempts >= $item->max_attempts) {
6543 + // Permanently failed
6544 + $wpdb->update(
6545 + $table_name,
6546 + array(
6547 + 'status' => 'failed',
6548 + 'error_message' => $error_message
6549 + ),
6550 + array('id' => $item_id),
6551 + array('%s', '%s'),
6552 + array('%d')
6553 + );
6554 +
6555 + wp_send_json_error(array(
6556 + 'message' => 'Item failed after maximum attempts: ' . $error_message,
6557 + 'permanent_failure' => true,
6558 + 'item_id' => $item_id
6559 + ));
6560 + } else {
6561 + // Mark for retry
6562 + $wpdb->update(
6563 + $table_name,
6564 + array(
6565 + 'status' => 'failed',
6566 + 'error_message' => $error_message
6567 + ),
6568 + array('id' => $item_id),
6569 + array('%s', '%s'),
6570 + array('%d')
6571 + );
6572 +
6573 + wp_send_json_error(array(
6574 + 'message' => 'Item processing failed, will retry: ' . $error_message,
6575 + 'can_retry' => true,
6576 + 'item_id' => $item_id,
6577 + 'attempts' => $item ? $item->attempts : 0
6578 + ));
6579 + }
6580 + }
6581 +}
6582 +
6583 +/**
6584 + * Process a URL from the queue
6585 + */
6586 +private function mxchat_process_queue_url($item_data, $bot_id = 'default') {
6587 + $url = isset($item_data['url']) ? $item_data['url'] : '';
6588 +
6589 + if (empty($url)) {
6590 + return new WP_Error('invalid_url', 'URL is empty');
6591 + }
6592 +
6593 + // Get bot-specific API key early (needed for both paths)
6594 + $bot_options = $this->get_bot_options($bot_id);
6595 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6596 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6597 +
6598 + if (strpos($selected_model, 'voyage') === 0) {
6599 + $api_key = $options['voyage_api_key'] ?? '';
6600 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6601 + $api_key = $options['gemini_api_key'] ?? '';
6602 + } else {
6603 + $api_key = $options['api_key'] ?? '';
6604 + }
6605 +
6606 + if (empty($api_key)) {
6607 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
6608 + }
6609 +
6610 + // Check if this is a WooCommerce product URL and WooCommerce is active
6611 + $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
6612 + $content_type = $is_product_url ? 'product' : 'url';
6613 +
6614 + // Try to get WooCommerce product data if it's a product URL
6615 + if ($is_product_url && class_exists('WooCommerce')) {
6616 + $product_content = $this->mxchat_extract_woocommerce_product_content($url);
6617 +
6618 + if (!empty($product_content)) {
6619 + // Successfully extracted WooCommerce product data with pricing
6620 + $result = MxChat_Utils::submit_content_to_db(
6621 + $product_content,
6622 + $url,
6623 + $api_key,
6624 + null,
6625 + $bot_id,
6626 + 'product'
6627 + );
6628 + return $result;
6629 + }
6630 + // If WooCommerce extraction failed, fall through to HTML extraction
6631 + }
6632 +
6633 + // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
6634 + $response = wp_remote_get($url, array(
6635 + 'timeout' => 30,
6636 + 'redirection' => 5,
6637 + 'user-agent' => 'MxChat/1.0'
6638 + ));
6639 +
6640 + if (is_wp_error($response)) {
6641 + return $response;
6642 + }
6643 +
6644 + $response_code = wp_remote_retrieve_response_code($response);
6645 + if ($response_code !== 200) {
6646 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
6647 + }
6648 +
6649 + $html = wp_remote_retrieve_body($response);
6650 +
6651 + if (empty($html)) {
6652 + return new WP_Error('empty_response', 'Empty response body');
6653 + }
6654 +
6655 + // Extract and sanitize content
6656 + $content = $this->mxchat_extract_main_content($html);
6657 + $sanitized = $this->mxchat_sanitize_content_for_api($content);
6658 +
6659 + if (empty($sanitized)) {
6660 + // Not an error - just no content found (maybe a redirect or empty page)
6661 + return false;
6662 + }
6663 +
6664 + // Submit to database with content_type
6665 + $result = MxChat_Utils::submit_content_to_db(
6666 + $sanitized,
6667 + $url,
6668 + $api_key,
6669 + null,
6670 + $bot_id,
6671 + $content_type
6672 + );
6673 +
6674 + return $result;
6675 +}
6676 +
6677 +/**
6678 + * Extract WooCommerce product content including pricing
6679 + *
6680 + * @param string $url The product URL
6681 + * @return string|false Product content with pricing, or false if not found
6682 + */
6683 +private function mxchat_extract_woocommerce_product_content($url) {
6684 + // Try to get product ID from URL
6685 + $product_id = url_to_postid($url);
6686 +
6687 + // If url_to_postid fails, try to extract from URL pattern
6688 + if (!$product_id) {
6689 + $product_slug = '';
6690 +
6691 + // Handle pretty permalinks: /product/product-name/
6692 + if (preg_match('/\/product\/([^\/\?]+)/', $url, $matches)) {
6693 + $product_slug = $matches[1];
6694 + }
6695 +
6696 + if (!empty($product_slug)) {
6697 + $product_post = get_page_by_path($product_slug, OBJECT, 'product');
6698 + if ($product_post) {
6699 + $product_id = $product_post->ID;
6700 + }
6701 + }
6702 + }
6703 +
6704 + if (!$product_id) {
6705 + return false;
6706 + }
6707 +
6708 + // Get WooCommerce product object
6709 + $product = wc_get_product($product_id);
6710 +
6711 + if (!$product) {
6712 + return false;
6713 + }
6714 +
6715 + // Build product content with pricing (similar to mxchat_store_product_embedding)
6716 + $title = $product->get_name();
6717 + $description = $product->get_description();
6718 + $short_description = $product->get_short_description();
6719 + $sku = $product->get_sku();
6720 +
6721 + // Get pricing information
6722 + $regular_price = $product->get_regular_price();
6723 + $sale_price = $product->get_sale_price();
6724 + $price = $product->get_price(); // Current active price
6725 +
6726 + // Get currency symbol
6727 + $currency_symbol = get_woocommerce_currency_symbol();
6728 +
6729 + // Format content
6730 + $content = $title . "\n\n";
6731 +
6732 + if (!empty($short_description)) {
6733 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6734 + }
6735 +
6736 + if (!empty($description)) {
6737 + $content .= wp_strip_all_tags($description) . "\n\n";
6738 + }
6739 +
6740 + // Add pricing information
6741 + if (!empty($regular_price)) {
6742 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
6743 + } elseif (!empty($price)) {
6744 + $content .= "Price: " . $currency_symbol . $price . "\n";
6745 + }
6746 +
6747 + if (!empty($sale_price) && $sale_price !== $regular_price) {
6748 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
6749 + }
6750 +
6751 + // Handle variable products - show price range
6752 + if ($product->is_type('variable')) {
6753 + $min_price = $product->get_variation_price('min');
6754 + $max_price = $product->get_variation_price('max');
6755 + if ($min_price !== $max_price) {
6756 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
6757 + }
6758 + }
6759 +
6760 + if (!empty($sku)) {
6761 + $content .= "SKU: " . $sku . "\n";
6762 + }
6763 +
6764 + // Get product categories
6765 + $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
6766 + if (!empty($categories) && !is_wp_error($categories)) {
6767 + $content .= "Categories: " . implode(', ', $categories) . "\n";
6768 + }
6769 +
6770 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
6771 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6772 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
6773 + foreach ($custom_tabs as $tab) {
6774 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6775 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
6776 +
6777 + if (!empty($tab_title) && !empty($tab_content)) {
6778 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6779 + }
6780 + }
6781 + }
6782 +
6783 + // Also check for reusable/saved tabs applied to this product
6784 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6785 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6786 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6787 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
6788 + foreach ($applied_saved_tabs as $saved_tab_id) {
6789 + if (isset($saved_tabs[$saved_tab_id])) {
6790 + $tab = $saved_tabs[$saved_tab_id];
6791 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6792 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
6793 +
6794 + if (!empty($tab_title) && !empty($tab_content)) {
6795 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6796 + }
6797 + }
6798 + }
6799 + }
6800 + }
6801 +
6802 + return $this->mxchat_sanitize_content_for_api($content);
6803 +}
6804 +
6805 +/**
6806 + * Process a PDF page from the queue
6807 + */
6808 +private function mxchat_process_queue_pdf_page($item_data, $bot_id = 'default') {
6809 + $pdf_path = isset($item_data['pdf_path']) ? $item_data['pdf_path'] : '';
6810 + $pdf_url = isset($item_data['pdf_url']) ? $item_data['pdf_url'] : '';
6811 + $page_number = isset($item_data['page_number']) ? absint($item_data['page_number']) : 0;
6812 + $total_pages = isset($item_data['total_pages']) ? absint($item_data['total_pages']) : 0;
6813 +
6814 + if (empty($pdf_path) || !file_exists($pdf_path)) {
6815 + return new WP_Error('pdf_not_found', 'PDF file not found: ' . $pdf_path);
6816 + }
6817 +
6818 + if ($page_number < 1) {
6819 + return new WP_Error('invalid_page', 'Invalid page number');
6820 + }
6821 +
6822 + try {
6823 + $parser = new \Smalot\PdfParser\Parser();
6824 + $pdf = $parser->parseFile($pdf_path);
6825 + $pages = $pdf->getPages();
6826 +
6827 + if (!isset($pages[$page_number - 1])) {
6828 + return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
6829 + }
6830 +
6831 + $text = $pages[$page_number - 1]->getText();
6832 +
6833 + if (empty($text)) {
6834 + // Not an error - just an empty page
6835 + return false;
6836 + }
6837 +
6838 + $sanitized = $this->mxchat_sanitize_content_for_api($text);
6839 +
6840 + if (empty($sanitized)) {
6841 + return false;
6842 + }
6843 +
6844 + // Create metadata
6845 + $metadata = array(
6846 + 'document_type' => 'pdf',
6847 + 'total_pages' => $total_pages,
6848 + 'current_page' => $page_number,
6849 + 'source_url' => $pdf_url
6850 + );
6851 +
6852 + $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
6853 + $page_url = esc_url($pdf_url . "#page=" . $page_number);
6854 +
6855 + // Get bot-specific API key
6856 + $bot_options = $this->get_bot_options($bot_id);
6857 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6858 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
6859 +
6860 + if (strpos($selected_model, 'voyage') === 0) {
6861 + $api_key = $options['voyage_api_key'] ?? '';
6862 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
6863 + $api_key = $options['gemini_api_key'] ?? '';
6864 + } else {
6865 + $api_key = $options['api_key'] ?? '';
6866 + }
6867 +
6868 + if (empty($api_key)) {
6869 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
6870 + }
6871 +
6872 + // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
6873 + $result = MxChat_Utils::submit_content_to_db(
6874 + $content_with_metadata,
6875 + $page_url,
6876 + $api_key,
6877 + null,
6878 + $bot_id,
6879 + 'pdf'
6880 + );
6881 +
6882 + return $result;
6883 +
6884 + } catch (Exception $e) {
6885 + return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
6886 + }
6887 +}
6888 +
6889 +/**
6890 + * AJAX: Get queue processing status
6891 + */
6892 +public function ajax_mxchat_get_queue_status() {
6893 + // Verify nonce and permissions
6894 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6895 +
6896 + if (!current_user_can('manage_options')) {
6897 + wp_send_json_error('Unauthorized access');
6898 + }
6899 +
6900 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6901 +
6902 + if (empty($queue_id)) {
6903 + wp_send_json_error('Missing queue ID');
6904 + }
6905 +
6906 + global $wpdb;
6907 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
6908 +
6909 + // Get counts by status
6910 + $counts = $wpdb->get_results($wpdb->prepare(
6911 + "SELECT status, COUNT(*) as count
6912 + FROM $table_name
6913 + WHERE queue_id = %s
6914 + GROUP BY status",
6915 + $queue_id
6916 + ), OBJECT_K);
6917 +
6918 + $total = 0;
6919 + $completed = 0;
6920 + $failed = 0;
6921 + $processing = 0;
6922 + $pending = 0;
6923 +
6924 + foreach ($counts as $status => $data) {
6925 + $count = absint($data->count);
6926 + $total += $count;
6927 +
6928 + switch ($status) {
6929 + case 'completed':
6930 + $completed = $count;
6931 + break;
6932 + case 'failed':
6933 + $failed = $count;
6934 + break;
6935 + case 'processing':
6936 + $processing = $count;
6937 + break;
6938 + case 'pending':
6939 + $pending = $count;
6940 + break;
6941 + }
6942 + }
6943 +
6944 + // Calculate percentage
6945 + $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
6946 +
6947 + // Get failed items details
6948 + $failed_items = array();
6949 + if ($failed > 0) {
6950 + $failed_items = $wpdb->get_results($wpdb->prepare(
6951 + "SELECT item_type, item_data, error_message, attempts
6952 + FROM $table_name
6953 + WHERE queue_id = %s
6954 + AND status = 'failed'
6955 + AND attempts >= max_attempts
6956 + ORDER BY id DESC
6957 + LIMIT 50",
6958 + $queue_id
6959 + ));
6960 + }
6961 +
6962 + // Get queue metadata
6963 + $source_url = $this->mxchat_get_queue_meta($queue_id, 'source_url');
6964 + $queue_type = $this->mxchat_get_queue_meta($queue_id, 'queue_type');
6965 +
6966 + // Determine if queue is complete
6967 + $is_complete = ($pending === 0 && $processing === 0);
6968 +
6969 + wp_send_json_success(array(
6970 + 'queue_id' => $queue_id,
6971 + 'queue_type' => $queue_type,
6972 + 'source_url' => $source_url,
6973 + 'total' => $total,
6974 + 'completed' => $completed,
6975 + 'failed' => $failed,
6976 + 'processing' => $processing,
6977 + 'pending' => $pending,
6978 + 'percentage' => $percentage,
6979 + 'is_complete' => $is_complete,
6980 + 'failed_items' => $failed_items,
6981 + 'status' => $is_complete ? 'complete' : 'processing'
6982 + ));
6983 +}
6984 +
6985 +/**
6986 + * AJAX: Clear completed queue
6987 + */
6988 +public function ajax_mxchat_clear_queue() {
6989 + // Verify nonce and permissions
6990 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
6991 +
6992 + if (!current_user_can('manage_options')) {
6993 + wp_send_json_error('Unauthorized access');
6994 + }
6995 +
6996 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
6997 +
6998 + if (empty($queue_id)) {
6999 + wp_send_json_error('Missing queue ID');
7000 + }
7001 +
7002 + global $wpdb;
7003 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7004 + $meta_table = $wpdb->prefix . 'mxchat_queue_meta';
7005 +
7006 + // Delete queue items
7007 + $wpdb->delete(
7008 + $table_name,
7009 + array('queue_id' => $queue_id),
7010 + array('%s')
7011 + );
7012 +
7013 + // Delete queue metadata
7014 + $wpdb->delete(
7015 + $meta_table,
7016 + array('queue_id' => $queue_id),
7017 + array('%s')
7018 + );
7019 +
7020 + wp_send_json_success(array(
7021 + 'message' => 'Queue cleared successfully'
7022 + ));
7023 +}
7024 +
7025 +/**
7026 + * AJAX: Retry failed items in queue
7027 + */
7028 +public function ajax_mxchat_retry_failed() {
7029 + // Verify nonce and permissions
7030 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
7031 +
7032 + if (!current_user_can('manage_options')) {
7033 + wp_send_json_error('Unauthorized access');
7034 + }
7035 +
7036 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7037 +
7038 + if (empty($queue_id)) {
7039 + wp_send_json_error('Missing queue ID');
7040 + }
7041 +
7042 + global $wpdb;
7043 + $table_name = $wpdb->prefix . 'mxchat_processing_queue';
7044 +
7045 + // Reset failed items to pending and reset attempt count
7046 + $updated = $wpdb->update(
7047 + $table_name,
7048 + array(
7049 + 'status' => 'pending',
7050 + 'attempts' => 0,
7051 + 'error_message' => null
7052 + ),
7053 + array(
7054 + 'queue_id' => $queue_id,
7055 + 'status' => 'failed'
7056 + ),
7057 + array('%s', '%d', '%s'),
7058 + array('%s', '%s')
7059 + );
7060 +
7061 + wp_send_json_success(array(
7062 + 'message' => 'Reset ' . $updated . ' failed items for retry',
7063 + 'reset_count' => $updated
7064 + ));
7065 +}
7066 +
7067 +
7068 +public function ajax_mxchat_mark_queue_complete() {
7069 + check_ajax_referer('mxchat_queue_nonce', 'nonce');
7070 +
7071 + if (!current_user_can('manage_options')) {
7072 + wp_send_json_error('Unauthorized access');
7073 + }
7074 +
7075 + $queue_id = isset($_POST['queue_id']) ? sanitize_text_field($_POST['queue_id']) : '';
7076 +
7077 + if (empty($queue_id)) {
7078 + wp_send_json_error('Missing queue ID');
7079 + }
7080 +
7081 + // Clear active queue transients
7082 + if (strpos($queue_id, 'sitemap_') === 0) {
7083 + delete_transient('mxchat_active_queue_sitemap');
7084 + } else if (strpos($queue_id, 'pdf_') === 0) {
7085 + delete_transient('mxchat_active_queue_pdf');
7086 + }
7087 +
7088 + wp_send_json_success(array('message' => 'Queue marked as complete'));
7089 +}
7090 +
7091 +
7092 + // ========================================
7093 + // STATIC ACCESS METHODS
7094 + // ========================================
7095 +
7096 + /**
7097 + * Get singleton instance
7098 + */
7099 + public static function get_instance() {
7100 + static $instance = null;
7101 + if ($instance === null) {
7102 + $instance = new self();
7103 + }
7104 + return $instance;
7105 + }
7106 +}
7107 +
7108 +// Initialize the Knowledge manager
8928 7109 $mxchat_knowledge_manager = MxChat_Knowledge_Manager::get_instance();