PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.8
MxChat – AI Chatbot & Content Generation for WordPress v3.2.8
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 +760 -2082 3.2.213.2.8 View file →
@@ -9,27 +9,11 @@
9 9 exit; // Exit if accessed directly
10 10 }
11 11
12 12 class MxChat_Knowledge_Manager {
13 -
13 +
14 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 -
15 +
32 16 /**
33 17 * Constructor - Register hooks for content processing
34 18 */
35 19 public function __construct() {
@@ -47,10 +31,8 @@
47 31 // Admin post handlers for form submissions
48 32 add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
49 33 add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
50 34 add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
51 - add_action('admin_post_mxchat_submit_document_file', array($this, 'mxchat_handle_document_file_submission'));
52 - add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
53 35 add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
54 36
55 37 // AJAX handlers for real-time processing and status updates
56 38 add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
@@ -76,9 +58,8 @@
76 58 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
77 59 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
78 60 add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
79 61 add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
80 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
81 62
82 63 // WordPress post management hooks
83 64 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
84 65 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
@@ -83,27 +64,13 @@
83 64 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
84 65 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
85 66 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
86 67 add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
87 - // Authoritative unpublish detection: core hands this hook the REAL previous status, so
88 - // removal no longer depends on the mxchat_prev_status_* transients (evictable by persistent
89 - // object caches, never written by paths that bypass wp_update_post, e.g. plugins flipping
90 - // post_status directly and calling wp_transition_post_status themselves).
91 - add_action('transition_post_status', array($this, 'mxchat_handle_status_transition'), 10, 3);
92 68
93 69 // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
94 70 // Priority 20 to run after ACF's own save (which runs at priority 10)
95 71 add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
96 72
97 - // One-time cleanup for vectors orphaned by unpublishes that predate the
98 - // transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished
99 - if (defined('WP_CLI') && WP_CLI) {
100 - WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished'));
101 - // In-place repair for RTL KB rows imported in visual order before the
102 - // 32bf9e normalizer existed: wp mxchat rtl-repair (plan d1e6f7)
103 - WP_CLI::add_command('mxchat rtl-repair', array($this, 'cli_rtl_repair'));
104 - }
105 -
106 73 add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
107 74
108 75 // WooCommerce product hooks (if WooCommerce is active)
109 76 if (class_exists('WooCommerce')) {
@@ -151,17 +118,26 @@
151 118
152 119 // Get bot-specific options and API key
153 120 $bot_options = $this->get_bot_options($bot_id);
154 121 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
155 -
156 - // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
157 - $preflight = MxChat_Utils::embedding_preflight($options);
158 - if (!$preflight['ok']) {
159 - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
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 + );
160 137 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
161 138 exit;
162 139 }
163 - $api_key = $preflight['api_key'];
164 140
165 141 // Use centralized utility function with bot_id
166 142 $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
167 143
@@ -180,238 +156,8 @@
180 156 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
181 157 exit;
182 158 }
183 159
184 -/**
185 - * Handle the "YouTube" KB import source (admin-post form submission).
186 - *
187 - * Per-video description mode:
188 - * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
189 - * If no usable transcript, index the metadata anyway, tell the admin,
190 - * and bounce back with the manual box pre-filled (never fail silently).
191 - * - manual: the admin's own description is what gets indexed; metadata rides along.
192 - *
193 - * The row is stored with content_type 'youtube' and source_url = the canonical
194 - * watch URL, so re-importing the same video UPDATES the entry (source_url
195 - * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
196 - * "augment a metadata-only entry" path.
197 - */
198 -public function mxchat_handle_youtube_submission() {
199 - if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
200 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
201 - }
202 -
203 - check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
204 -
205 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
206 -
207 - $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
208 - $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
209 -
210 - if (empty($video_id)) {
211 - set_transient('mxchat_admin_notice_error',
212 - esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
213 - 30
214 - );
215 - wp_safe_redirect(esc_url($redirect_url));
216 - exit;
217 - }
218 -
219 - $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
220 -
221 - $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
222 - $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
223 -
224 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
225 -
226 - // Resolve the embedding decision exactly like the sibling handlers —
227 - // custom-provider-aware (plan cbd5fd).
228 - $bot_options = $this->get_bot_options($bot_id);
229 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
230 -
231 - $preflight = MxChat_Utils::embedding_preflight($options);
232 - if (!$preflight['ok']) {
233 - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
234 - wp_safe_redirect(esc_url($redirect_url));
235 - exit;
236 - }
237 - $api_key = $preflight['api_key'];
238 -
239 - // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
240 - // manual mode it enriches the indexed text with the real title/channel.
241 - $meta = $this->mxchat_fetch_youtube_oembed($video_id);
242 - $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
243 - $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
244 -
245 - $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
246 - if ($video_channel !== '') {
247 - $header_lines .= 'Channel: ' . $video_channel . "\n";
248 - }
249 - $header_lines .= 'URL: ' . $canonical_url . "\n\n";
250 -
251 - $transcript_missing = false;
252 -
253 - if ($description_mode === 'manual') {
254 - if ($manual_description === '') {
255 - set_transient('mxchat_admin_notice_error',
256 - esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
257 - 30
258 - );
259 - wp_safe_redirect(esc_url($redirect_url));
260 - exit;
261 - }
262 - $indexed_text = $header_lines . $manual_description;
263 - } else {
264 - $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
265 -
266 - if (strlen($transcript) >= 200) {
267 - $indexed_text = $header_lines . $transcript;
268 - } else {
269 - // Graceful fallback: captions disabled / blocked / no speech. Auto
270 - // reliably gets metadata; it does NOT guarantee a transcript.
271 - $transcript_missing = true;
272 -
273 - if ($video_title === '' && $video_channel === '') {
274 - // Both halves failed — nothing meaningful to index.
275 - set_transient('mxchat_admin_notice_error',
276 - 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'),
277 - 30
278 - );
279 - wp_safe_redirect(esc_url($redirect_url));
280 - exit;
281 - }
282 -
283 - $indexed_text = $header_lines . sprintf(
284 - /* translators: 1: video title, 2: channel name */
285 - __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
286 - $video_title !== '' ? $video_title : $canonical_url,
287 - $video_channel !== '' ? $video_channel : 'YouTube'
288 - );
289 - }
290 - }
291 -
292 - $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
293 -
294 - if (is_wp_error($result)) {
295 - set_transient('mxchat_admin_notice_error',
296 - esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
297 - 30
298 - );
299 - wp_safe_redirect(esc_url($redirect_url));
300 - exit;
301 - }
302 -
303 - if ($transcript_missing) {
304 - set_transient('mxchat_admin_notice_success',
305 - 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'),
306 - 30
307 - );
308 - // Bounce back with prefill args so the page reopens the YouTube form in
309 - // manual mode with the URL + fetched title ready to augment.
310 - $redirect_url = add_query_arg(array(
311 - 'mxchat_yt_prefill' => '1',
312 - 'yt_url' => rawurlencode($canonical_url),
313 - 'yt_title' => rawurlencode($video_title),
314 - ), $redirect_url);
315 - } else {
316 - set_transient('mxchat_admin_notice_success',
317 - esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
318 - 30
319 - );
320 - }
321 -
322 - wp_safe_redirect(esc_url_raw($redirect_url));
323 - exit;
324 -}
325 -
326 -/**
327 - * Fetch YouTube oEmbed metadata for a video (no API key required).
328 - * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
329 - */
330 -private function mxchat_fetch_youtube_oembed($video_id) {
331 - $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
332 - $response = wp_remote_get($oembed_url, array('timeout' => 15));
333 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
334 - return array();
335 - }
336 - $data = json_decode(wp_remote_retrieve_body($response), true);
337 - return is_array($data) ? $data : array();
338 -}
339 -
340 -/**
341 - * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
342 - * YouTube's unofficial timedtext route (the caption track list embedded in the
343 - * watch page), which YouTube has broken before and will break again. Every
344 - * failure mode returns '' so a break degrades to the metadata-only import path
345 - * instead of erroring the whole submission. Do not let anything in here throw.
346 - */
347 -private function mxchat_fetch_youtube_transcript($video_id) {
348 - $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
349 -
350 - // First try the honest ingest UA; some responses omit the player config for
351 - // bot UAs, so retry once with a browser UA before giving up.
352 - $user_agents = array(
353 - mxchat_ingest_user_agent(),
354 - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
355 - );
356 -
357 - $tracks = array();
358 - foreach ($user_agents as $ua) {
359 - $response = wp_remote_get($watch_url, array(
360 - 'timeout' => 20,
361 - 'user-agent' => $ua,
362 - ));
363 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
364 - continue;
365 - }
366 - $body = wp_remote_retrieve_body($response);
367 - if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
368 - continue;
369 - }
370 - $decoded = json_decode($m[1], true);
371 - if (is_array($decoded) && !empty($decoded)) {
372 - $tracks = $decoded;
373 - break;
374 - }
375 - }
376 -
377 - if (empty($tracks)) {
378 - return '';
379 - }
380 -
381 - // Prefer an English track, else take the first offered.
382 - $chosen = null;
383 - foreach ($tracks as $track) {
384 - if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
385 - $chosen = $track;
386 - break;
387 - }
388 - }
389 - if ($chosen === null) {
390 - $chosen = $tracks[0];
391 - }
392 - if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
393 - return '';
394 - }
395 -
396 - $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
397 - if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
398 - return '';
399 - }
400 - $xml = wp_remote_retrieve_body($timedtext);
401 - if (!is_string($xml) || strpos($xml, '<text') === false) {
402 - return '';
403 - }
404 -
405 - // <text start=".." dur="..">caption</text> — strip tags, decode the
406 - // double-encoded entities timedtext ships, collapse whitespace.
407 - $text = preg_replace('/<[^>]+>/', ' ', $xml);
408 - $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
409 - $text = trim(preg_replace('/\s+/u', ' ', $text));
410 -
411 - return $text;
412 -}
413 -
414 160 public function mxchat_is_pdf_url($url, $response) {
415 161 $content_type = wp_remote_retrieve_header($response, 'content-type');
416 162 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
417 163
@@ -657,160 +403,8 @@
657 403 exit;
658 404 }
659 405
660 406 /**
661 - * Handle direct document upload (.docx / .txt / .md) from the knowledge base
662 - * page (plan 0485e5). Unlike PDF Upload there is no per-page queue: the text
663 - * extracts in one pass and routes through submit_content_to_db, whose chunker
664 - * takes over for long content. The uploaded file is read from the PHP temp
665 - * file and never persisted — only its extracted text enters the KB.
666 - *
667 - * Source identity matches PDF Upload's scheme: upload://<filename>, stable
668 - * across re-uploads so a re-import REPLACES (delete_chunks_for_url + upsert
669 - * per identity) instead of duplicating.
670 - */
671 -public function mxchat_handle_document_file_submission() {
672 - if (!isset($_POST['submit_document_file']) || !current_user_can('manage_options')) {
673 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
674 - }
675 -
676 - check_admin_referer('mxchat_submit_document_file_action', 'mxchat_submit_document_file_nonce');
677 -
678 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
679 -
680 - if (empty($_FILES['document_file']) || $_FILES['document_file']['error'] !== UPLOAD_ERR_OK) {
681 - $error_code = isset($_FILES['document_file']['error']) ? $_FILES['document_file']['error'] : UPLOAD_ERR_NO_FILE;
682 - $error_messages = array(
683 - UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
684 - UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
685 - UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
686 - UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a document.', 'mxchat'),
687 - UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
688 - UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
689 - );
690 - $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
691 - set_transient('mxchat_admin_notice_error', $error_msg, 30);
692 - wp_safe_redirect(esc_url($redirect_url));
693 - exit;
694 - }
695 -
696 - $file = $_FILES['document_file'];
697 - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
698 -
699 - $finfo = finfo_open(FILEINFO_MIME_TYPE);
700 - $mime_type = finfo_file($finfo, $file['tmp_name']);
701 - finfo_close($finfo);
702 -
703 - // Per-extension MIME expectations. finfo commonly reports .docx as
704 - // application/zip (it IS a Zip container) and .md as plain text.
705 - $mime_ok = false;
706 - if ($ext === 'docx') {
707 - $mime_ok = in_array($mime_type, array(
708 - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
709 - 'application/zip',
710 - ), true);
711 - } elseif ($ext === 'txt' || $ext === 'md') {
712 - $mime_ok = (strpos((string) $mime_type, 'text/') === 0);
713 - }
714 -
715 - if (!$mime_ok) {
716 - set_transient('mxchat_admin_notice_error',
717 - esc_html__('Invalid or unreadable document. Accepted types: .docx, .txt, .md.', 'mxchat'),
718 - 30
719 - );
720 - wp_safe_redirect(esc_url($redirect_url));
721 - exit;
722 - }
723 -
724 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
725 - $original_filename = sanitize_file_name($file['name']);
726 -
727 - // ---- Extract text (ONE extractor for .docx — the word handler's) ----
728 - if ($ext === 'docx') {
729 - $text = MXChat_Word_Handler::extract_docx_text($file['tmp_name']);
730 - if ($text === false) {
731 - set_transient('mxchat_admin_notice_error',
732 - esc_html__('The .docx file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
733 - 30
734 - );
735 - wp_safe_redirect(esc_url($redirect_url));
736 - exit;
737 - }
738 - } else {
739 - // .txt / .md read as-is. Markdown keeps its syntax on purpose —
740 - // headings are useful retrieval signal.
741 - $text = (string) file_get_contents($file['tmp_name']);
742 - $text = wp_check_invalid_utf8($text);
743 - $text = trim($text);
744 - }
745 -
746 - if ($text === '') {
747 - set_transient('mxchat_admin_notice_error',
748 - esc_html__('The uploaded document contains no readable text.', 'mxchat'),
749 - 30
750 - );
751 - wp_safe_redirect(esc_url($redirect_url));
752 - exit;
753 - }
754 -
755 - // Size cap — same pdf_max_pages setting the PDF/toolbar paths use, but
756 - // estimated by CHARACTERS (~2500/page): the .docx cleaner collapses all
757 - // newlines to spaces, so a paragraph count reads 1 for any Word file.
758 - // Processing is synchronous — an unbounded document risks a timeout.
759 - $options = get_option('mxchat_options', array());
760 - $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
761 - $estimated_pages = (int) ceil(strlen($text) / 2500);
762 - if ($estimated_pages > $max_pages) {
763 - set_transient('mxchat_admin_notice_error',
764 - sprintf(
765 - esc_html__('The document is too large (about %1$d pages; the limit is %2$d). Split it into smaller files, or raise the PDF max pages setting.', 'mxchat'),
766 - $estimated_pages,
767 - $max_pages
768 - ),
769 - 30
770 - );
771 - wp_safe_redirect(esc_url($redirect_url));
772 - exit;
773 - }
774 -
775 - // Embedding API key — bot-aware, same shape as the direct-content handler.
776 - $api_key = '';
777 - if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
778 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
779 - $api_key = $bot_options['api_key'] ?? '';
780 - }
781 - if (empty($api_key)) {
782 - $api_key = $options['api_key'] ?? '';
783 - }
784 -
785 - // Stable identity — PDF Upload's scheme. A re-upload of the same filename
786 - // replaces: clear old chunks first (covers a doc shrinking below the chunk
787 - // threshold, where the single-vector path would not clean them), then
788 - // submit — the chunked path re-deletes harmlessly.
789 - $source_label = 'upload://' . $original_filename;
790 - MxChat_Utils::delete_chunks_for_url($source_label, $bot_id);
791 - $result = MxChat_Utils::submit_content_to_db($text, $source_label, $api_key, null, $bot_id, 'document');
792 -
793 - if (is_wp_error($result)) {
794 - set_transient('mxchat_admin_notice_error',
795 - esc_html__('Failed to import the document: ', 'mxchat') . esc_html($result->get_error_message()),
796 - 30
797 - );
798 - } else {
799 - set_transient('mxchat_admin_notice_success',
800 - sprintf(
801 - esc_html__('Document "%s" imported into the knowledge base.', 'mxchat'),
802 - esc_html($original_filename)
803 - ),
804 - 30
805 - );
806 - }
807 -
808 - wp_safe_redirect(esc_url($redirect_url));
809 - exit;
810 -}
811 -
812 -/**
813 407 * Validate PDF and count pages with multiple parser attempts
814 408 */
815 409 private function mxchat_validate_and_count_pdf_pages($pdf_path) {
816 410 // Method 1: Try with Smalot PDF Parser (your current method)
@@ -1033,25 +627,8 @@
1033 627 /**
1034 628 * AJAX: Get full content for editing — reassembles chunks if needed.
1035 629 * Works for both WordPress DB and Pinecone entries.
1036 630 */
1037 -/**
1038 - * Sanitize a knowledge entry's source_url from an AJAX request WITHOUT destroying
1039 - * its identity. sanitize_text_field() strips percent-encoded octets (%20, %D7%A9…),
1040 - * so a percent-encoded URL — every non-ASCII permalink — would md5 to a DIFFERENT
1041 - * id than the one it was stored under: reads miss the entry and saves write an
1042 - * orphan copy while the original keeps its stale text. URLs get esc_url_raw
1043 - * (identity-preserving, matches what import stored); non-URL keys (mxchat://,
1044 - * _ungrouped_) keep the old sanitizer.
1045 - */
1046 -private function sanitize_entry_source_url( $raw ) {
1047 - $raw = trim( (string) $raw );
1048 - if ( preg_match( '#^https?://#i', $raw ) ) {
1049 - return esc_url_raw( $raw );
1050 - }
1051 - return sanitize_text_field( $raw );
1052 -}
1053 -
1054 631 public function ajax_mxchat_get_entry_content() {
1055 632 check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1056 633
1057 634 if ( ! current_user_can('manage_options') ) {
@@ -1057,19 +634,16 @@
1057 634 if ( ! current_user_can('manage_options') ) {
1058 635 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1059 636 }
1060 637
1061 - $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
638 + $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1062 639 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1063 640 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1064 641 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1065 642
1066 643 if ( $data_source === 'pinecone' ) {
1067 - // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1068 - // destroy them, so re-read the raw value for this branch only.
1069 - $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1070 644 // Pinecone: fetch vectors by source_url, reassemble chunks
1071 - $content = $this->get_pinecone_entry_content( $source_url, $vector_id, $bot_id );
645 + $content = $this->get_pinecone_entry_content( $source_url, $entry_id, $bot_id );
1072 646 } else {
1073 647 // WordPress DB
1074 648 $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
1075 649 }
@@ -1168,56 +742,44 @@
1168 742 if ( empty($host) || empty($api_key) ) {
1169 743 return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
1170 744 }
1171 745
1172 - // Manual entries carry no source_url (their vector id is a minted manual_* string,
1173 - // not md5 of anything the row can hand us) — fetch the exact vector instead.
1174 - // '_ungrouped_' is the table view's synthetic display key for such rows.
1175 - if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1176 - $vector_ids = array( $entry_id );
1177 - } else {
1178 - // List vectors with the source_url prefix
1179 - $base_id = md5( $source_url );
1180 - $vector_ids = array( $base_id );
746 + // List vectors with the source_url prefix
747 + $base_id = md5( $source_url );
748 + $vector_ids = array( $base_id );
1181 749
1182 - // Find chunk vectors
1183 - // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1184 - // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1185 - $list_url = "https://{$host}/vectors/list";
1186 - $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1187 - if ( ! empty($namespace) ) {
1188 - $list_params['namespace'] = $namespace;
1189 - }
750 + // Find chunk vectors
751 + $list_url = "https://{$host}/vectors/list";
752 + $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
753 + if ( ! empty($namespace) ) {
754 + $list_body['namespace'] = $namespace;
755 + }
1190 756
1191 - $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1192 - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1193 - 'timeout' => 15,
1194 - ) );
757 + $list_resp = wp_remote_post( $list_url, array(
758 + 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
759 + 'body' => wp_json_encode( $list_body ),
760 + 'timeout' => 15,
761 + ) );
1195 762
1196 - if ( ! is_wp_error($list_resp) ) {
1197 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1198 - if ( ! empty($list_data['vectors']) ) {
1199 - foreach ( $list_data['vectors'] as $v ) {
1200 - $vector_ids[] = $v['id'];
1201 - }
763 + if ( ! is_wp_error($list_resp) ) {
764 + $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
765 + if ( ! empty($list_data['vectors']) ) {
766 + foreach ( $list_data['vectors'] as $v ) {
767 + $vector_ids[] = $v['id'];
1202 768 }
1203 769 }
1204 770 }
1205 771
1206 772 // Fetch vectors with metadata
1207 - // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1208 - // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1209 - // the query string explicitly.
1210 - $fetch_query = array();
1211 - foreach ( $vector_ids as $fetch_vid ) {
1212 - $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1213 - }
773 + $fetch_url = "https://{$host}/vectors/fetch";
774 + $fetch_body = array( 'ids' => $vector_ids );
1214 775 if ( ! empty($namespace) ) {
1215 - $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
776 + $fetch_body['namespace'] = $namespace;
1216 777 }
1217 778
1218 - $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1219 - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
779 + $fetch_resp = wp_remote_post( $fetch_url, array(
780 + 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
781 + 'body' => wp_json_encode( $fetch_body ),
1220 782 'timeout' => 15,
1221 783 ) );
1222 784
1223 785 if ( is_wp_error($fetch_resp) ) {
@@ -1252,235 +814,8 @@
1252 814 );
1253 815 }
1254 816
1255 817 /**
1256 - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1257 - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1258 - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1259 - */
1260 -public function ajax_mxchat_inspect_entry() {
1261 - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1262 -
1263 - if ( ! current_user_can('manage_options') ) {
1264 - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1265 - }
1266 -
1267 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1268 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1269 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1270 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1271 -
1272 - if ( $data_source === 'pinecone' ) {
1273 - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
1274 - } else {
1275 - $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1276 - }
1277 -
1278 - if ( is_wp_error( $result ) ) {
1279 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1280 - }
1281 -
1282 - wp_send_json_success( $result );
1283 -}
1284 -
1285 -/**
1286 - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1287 - * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1288 - * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1289 - */
1290 -private function inspect_wordpress_entry( $source_url, $entry_id ) {
1291 - global $wpdb;
1292 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1293 -
1294 - $rows = array();
1295 -
1296 - // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1297 - // Direct Content entries (the spec's manual-entry case), which share one
1298 - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1299 - // display key (invented by the table view for rows with no source_url) is
1300 - // excluded; those fall through to the entry_id lookup below.
1301 - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1302 - $rows = $wpdb->get_results( $wpdb->prepare(
1303 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1304 - $source_url
1305 - ) );
1306 - }
1307 -
1308 - // Fallback / manual "Direct Content" entries: fetch the single row by id.
1309 - if ( empty( $rows ) && $entry_id > 0 ) {
1310 - $row = $wpdb->get_row( $wpdb->prepare(
1311 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1312 - $entry_id
1313 - ) );
1314 - if ( $row ) {
1315 - $rows = array( $row );
1316 - }
1317 - }
1318 -
1319 - if ( empty( $rows ) ) {
1320 - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1321 - }
1322 -
1323 - $chunks = array();
1324 - $content_type = '';
1325 - foreach ( $rows as $row ) {
1326 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1327 - $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1328 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1329 - $content_type = $row->content_type;
1330 - $chunks[] = array(
1331 - 'index' => $index,
1332 - 'text' => $text,
1333 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1334 - 'row_id' => intval( $row->id ),
1335 - );
1336 - }
1337 -
1338 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1339 -
1340 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1341 -
1342 - return array(
1343 - 'store' => 'wordpress',
1344 - 'source_url' => $source_url,
1345 - 'content_type' => $content_type,
1346 - 'is_chunked' => count( $chunks ) > 1,
1347 - 'chunk_count' => count( $chunks ),
1348 - 'assembled' => $assembled,
1349 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1350 - 'chunks' => array_values( $chunks ),
1351 - // WP-DB storage carries no separate vector metadata; surface that fact
1352 - // rather than letting the owner guess (the spec's taxonomy question).
1353 - 'metadata' => array(),
1354 - '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'),
1355 - );
1356 -}
1357 -
1358 -/**
1359 - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1360 - * but keeps each vector's text + metadata instead of imploding, so the owner can
1361 - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1362 - * are present per chunk. READ-ONLY.
1363 - */
1364 -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1365 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1366 - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1367 - }
1368 -
1369 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1370 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1371 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1372 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1373 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1374 - } else {
1375 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1376 - $api_key = $bot_config['api_key'] ?? '';
1377 - $host = $bot_config['host'] ?? '';
1378 - $namespace = $bot_config['namespace'] ?? '';
1379 - }
1380 -
1381 - if ( empty($host) || empty($api_key) ) {
1382 - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1383 - }
1384 -
1385 - $base_id = md5( $source_url );
1386 - $vector_ids = array( $base_id );
1387 -
1388 - // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1389 - // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1390 - $list_url = "https://{$host}/vectors/list";
1391 - $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1392 - if ( ! empty($namespace) ) {
1393 - $list_params['namespace'] = $namespace;
1394 - }
1395 -
1396 - $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1397 - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1398 - 'timeout' => 15,
1399 - ) );
1400 -
1401 - if ( ! is_wp_error($list_resp) ) {
1402 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1403 - if ( ! empty($list_data['vectors']) ) {
1404 - foreach ( $list_data['vectors'] as $v ) {
1405 - $vector_ids[] = $v['id'];
1406 - }
1407 - }
1408 - }
1409 -
1410 - // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1411 - // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1412 - // the query string explicitly.
1413 - $fetch_query = array();
1414 - foreach ( $vector_ids as $fetch_vid ) {
1415 - $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1416 - }
1417 - if ( ! empty($namespace) ) {
1418 - $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1419 - }
1420 -
1421 - $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1422 - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1423 - 'timeout' => 15,
1424 - ) );
1425 -
1426 - if ( is_wp_error($fetch_resp) ) {
1427 - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1428 - }
1429 -
1430 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1431 - $vectors = $fetch_data['vectors'] ?? array();
1432 -
1433 - if ( empty($vectors) ) {
1434 - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1435 - }
1436 -
1437 - // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1438 - // what is (and is NOT) stored per vector.
1439 - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1440 - $chunks = array();
1441 - $content_type = '';
1442 - foreach ( $vectors as $vid => $vector ) {
1443 - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1444 - $text = $meta['text'] ?? '';
1445 - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1446 - $content_type = $meta['type'] ?? $content_type;
1447 -
1448 - $clean_meta = array();
1449 - foreach ( $meta_fields as $field ) {
1450 - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1451 - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1452 - }
1453 - }
1454 -
1455 - $chunks[] = array(
1456 - 'index' => $index,
1457 - 'text' => $text,
1458 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1459 - 'vector_id' => (string) $vid,
1460 - 'metadata' => $clean_meta,
1461 - );
1462 - }
1463 -
1464 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1465 -
1466 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1467 -
1468 - return array(
1469 - 'store' => 'pinecone',
1470 - 'source_url' => $source_url,
1471 - 'content_type' => $content_type,
1472 - 'is_chunked' => count( $chunks ) > 1,
1473 - 'chunk_count' => count( $chunks ),
1474 - 'assembled' => $assembled,
1475 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1476 - 'chunks' => array_values( $chunks ),
1477 - 'metadata' => array(),
1478 - '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'),
1479 - );
1480 -}
1481 -
1482 -/**
1483 818 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1484 819 * Works for both WordPress DB and Pinecone entries.
1485 820 */
1486 821 public function ajax_mxchat_save_entry_content() {
@@ -1489,9 +824,9 @@
1489 824 if ( ! current_user_can('manage_options') ) {
1490 825 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1491 826 }
1492 827
1493 - $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
828 + $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1494 829 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1495 830 $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1496 831 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1497 832 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
@@ -1512,74 +847,38 @@
1512 847 if ( empty($api_key) ) {
1513 848 $api_key = $options['api_key'] ?? '';
1514 849 }
1515 850
1516 - if ( $data_source === 'pinecone' ) {
1517 - // Pinecone branch. The WP-DB manual-entry delete below must never run here:
1518 - // Pinecone ids are strings, and absint() on a digit-leading md5 hash would
1519 - // yield a real (unrelated) WP row id.
1520 - $raw_vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1521 - $is_manual_single = empty($source_url) || strpos($source_url, '_ungrouped_') === 0;
1522 - $is_manual_chunked = strpos($source_url, 'mxchat://') === 0;
851 + global $wpdb;
852 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1523 853
1524 - if ( $is_manual_single || $is_manual_chunked ) {
1525 - // Manual content: remove the old vectors first, then store as fresh manual
1526 - // content — submit_content_to_db mints a new unique identity (manual_* id
1527 - // for a single vector, an mxchat:// chunk prefix if it now chunks).
1528 - if ( $is_manual_chunked ) {
1529 - // Minted identity: base + chunk vectors share the md5(mxchat://...) prefix.
1530 - MxChat_Utils::delete_chunks_for_url( $source_url, $bot_id );
1531 - } elseif ( ! empty($raw_vector_id) && class_exists('MxChat_Pinecone_Manager') ) {
1532 - $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
1533 - $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options( $bot_id );
1534 - if ( ! empty($pinecone_options['mxchat_pinecone_api_key']) && ! empty($pinecone_options['mxchat_pinecone_host']) ) {
1535 - $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
1536 - $raw_vector_id,
1537 - $pinecone_options['mxchat_pinecone_api_key'],
1538 - $pinecone_options['mxchat_pinecone_host'],
1539 - $pinecone_options['mxchat_pinecone_namespace'] ?? ''
1540 - );
1541 - }
1542 - }
1543 - $result = MxChat_Utils::submit_content_to_db( $content, '', $api_key, null, $bot_id, $content_type );
1544 - } else {
1545 - // URL-sourced entry: identity is md5(source_url). submit_content_to_db
1546 - // handles delete-old-chunks → re-chunk → re-embed → store, and sweeps
1547 - // stale chunk vectors when the content now fits in a single vector.
1548 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, md5($source_url), $bot_id, $content_type );
854 + // If source_url is empty but we have an entry_id, look it up
855 + if ( empty($source_url) && $entry_id > 0 && $data_source === 'wordpress' ) {
856 + $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
857 + if ( $row && ! empty($row->source_url) ) {
858 + $source_url = $row->source_url;
1549 859 }
1550 - } else {
1551 - global $wpdb;
1552 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
860 + }
1553 861
1554 - // If source_url is empty but we have an entry_id, look it up
1555 - if ( empty($source_url) && $entry_id > 0 ) {
1556 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1557 - if ( $row && ! empty($row->source_url) ) {
1558 - $source_url = $row->source_url;
1559 - }
862 + // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
863 + // so submit_content_to_db creates a replacement instead of a duplicate
864 + // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
865 + $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
866 + if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
867 + $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
868 + // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
869 + // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
870 + if ( $is_legacy_manual ) {
871 + $source_url = '';
1560 872 }
873 + }
1561 874
1562 - // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1563 - // so submit_content_to_db creates a replacement instead of a duplicate
1564 - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1565 - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1566 - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1567 - $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1568 - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1569 - // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1570 - if ( $is_legacy_manual ) {
1571 - $source_url = '';
1572 - }
1573 - }
875 + // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
876 + $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1574 877
1575 - // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1576 - $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
878 + // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
879 + $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1577 880
1578 - // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1579 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1580 - }
1581 -
1582 881 if ( is_wp_error($result) ) {
1583 882 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1584 883 }
1585 884
@@ -1665,34 +964,42 @@
1665 964
1666 965 // Get bot_id from form submission
1667 966 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1668 967
1669 - // Get bot-specific options and validate the embedding decision —
1670 - // custom-provider-aware (plan cbd5fd).
968 + // Get bot-specific options and validate API key
1671 969 $bot_options = $this->get_bot_options($bot_id);
1672 970 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
1673 -
1674 - $preflight = MxChat_Utils::embedding_preflight($options);
1675 - if (!$preflight['ok']) {
1676 - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
971 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
972 +
973 + if (strpos($selected_model, 'voyage') === 0) {
974 + $api_key = $options['voyage_api_key'] ?? '';
975 + $provider_name = 'Voyage AI';
976 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
977 + $api_key = $options['gemini_api_key'] ?? '';
978 + $provider_name = 'Google Gemini';
979 + } else {
980 + $api_key = $options['api_key'] ?? '';
981 + $provider_name = 'OpenAI';
982 + }
983 +
984 + if (empty($api_key)) {
985 + $error_message = sprintf(
986 + esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
987 + $provider_name
988 + );
989 + set_transient('mxchat_admin_notice_error', $error_message, 30);
1677 990 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1678 991 exit;
1679 992 }
1680 - $api_key = $preflight['api_key'];
1681 993
1682 - // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1683 - // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1684 - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1685 - // from the site's own media library, which route through this same call).
1686 - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1687 - // the browser-only Accept-Language fingerprint is dropped so it stays
1688 - // coherent with a bot identity.
994 + // Fetch URL — use browser-like headers so servers with bot protection don't block us
1689 995 $response = wp_remote_get($submitted_url, array(
1690 996 'timeout' => 30,
1691 997 'sslverify' => false,
1692 - 'user-agent' => mxchat_ingest_user_agent(),
998 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
1693 999 'headers' => array(
1694 1000 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1001 + 'Accept-Language' => 'en-US,en;q=0.9',
1695 1002 ),
1696 1003 ));
1697 1004
1698 1005 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
@@ -1763,22 +1070,12 @@
1763 1070 esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1764 1071 30
1765 1072 );
1766 1073 } else {
1767 - // Surface the reason the handler already computed (embedding pre-flight,
1768 - // empty sitemap, queue failure). The old message pointed at the status
1769 - // area, which is empty on this path — nothing was ever queued.
1770 - if (is_string($result) && $result !== '') {
1771 - set_transient('mxchat_admin_notice_error',
1772 - esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
1773 - 30
1774 - );
1775 - } else {
1776 - set_transient('mxchat_admin_notice_error',
1777 - esc_html__('Failed to queue sitemap processing.', 'mxchat'),
1778 - 30
1779 - );
1780 - }
1074 + set_transient('mxchat_admin_notice_error',
1075 + esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
1076 + 30
1077 + );
1781 1078 }
1782 1079
1783 1080 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1784 1081 exit;
@@ -1921,140 +1218,8 @@
1921 1218 *
1922 1219 * @param string $content The content containing shortcodes
1923 1220 * @return string Content with shortcode tags removed but inner content preserved
1924 1221 */
1925 -/**
1926 - * Single-pass HTML entity decode for text entering the knowledge base.
1927 - * The corpus should hold what a human reads: a stored `&amp;` consumes
1928 - * extra tokens, distorts the vector away from the form a visitor's
1929 - * question uses, and can be quoted back verbatim in an answer.
1930 - * Deliberately NOT looped to a fixed point — a stored `&amp;amp;` is a
1931 - * legitimate literal `&amp;` and must not collapse further (data loss).
1932 - * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
1933 - * paths call this at their output points so the treatment cannot drift.
1934 - * (Plan d2c92e.)
1935 - */
1936 -private function mxchat_decode_entities_for_indexing($text) {
1937 - return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1938 -}
1939 -
1940 -/**
1941 - * Price lines for a product's indexed text, pinned to the store's BASE currency.
1942 - *
1943 - * The four product assembly paths each used to call get_woocommerce_currency_symbol()
1944 - * with no argument, which resolves the currency active on the CURRENT request.
1945 - * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
1946 - * request, so whichever currency the store happened to be serving when an import ran
1947 - * was frozen into every product it indexed. The amounts have the mirror problem: the
1948 - * woocommerce_product_get_* filters convert prices in the 'view' context but not in
1949 - * 'edit', so a converted amount could be paired with an unconverted symbol and produce
1950 - * a price that is not merely wrong but incoherent.
1951 - *
1952 - * Base currency option + 'edit' context makes both halves agree and makes the output
1953 - * independent of when the import ran. The currency CODE is emitted alongside the symbol
1954 - * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
1955 - * (Plan 7403ec.)
1956 - */
1957 -private function mxchat_product_price_lines($product) {
1958 - if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
1959 - return '';
1960 - }
1961 -
1962 - $currency = get_option('woocommerce_currency');
1963 - $currency = is_string($currency) ? trim($currency) : '';
1964 - $symbol = ($currency !== '')
1965 - ? get_woocommerce_currency_symbol($currency)
1966 - : get_woocommerce_currency_symbol();
1967 - $symbol = $this->mxchat_decode_entities_for_indexing($symbol);
1968 -
1969 - $regular_price = $product->get_regular_price('edit');
1970 - $sale_price = $product->get_sale_price('edit');
1971 - $price = $product->get_price('edit');
1972 -
1973 - $lines = '';
1974 -
1975 - if (!empty($regular_price)) {
1976 - $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
1977 - } elseif (!empty($price)) {
1978 - $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
1979 - }
1980 -
1981 - if (!empty($sale_price) && $sale_price !== $regular_price) {
1982 - $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
1983 - }
1984 -
1985 - if ($product->is_type('variable')) {
1986 - list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
1987 - if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
1988 - $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
1989 - . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
1990 - }
1991 - }
1992 -
1993 - return $lines;
1994 -}
1995 -
1996 -/**
1997 - * One indexed price amount, labelled with its currency code.
1998 - *
1999 - * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
2000 - * is kept so a quoted price still reads naturally. Falls back to the old symbol-only
2001 - * shape when WooCommerce has no base currency configured, and drops the parenthetical
2002 - * when the symbol is absent or IS the code (several currencies have no distinct glyph).
2003 - */
2004 -private function mxchat_format_indexed_price($amount, $currency, $symbol) {
2005 - $amount = (string) $amount;
2006 -
2007 - if ($currency === '') {
2008 - return $symbol . $amount;
2009 - }
2010 -
2011 - if ($symbol === '' || $symbol === $currency) {
2012 - return $currency . ' ' . $amount;
2013 - }
2014 -
2015 - return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
2016 -}
2017 -
2018 -/**
2019 - * Min/max variation price read from the variations themselves in 'edit' context.
2020 - *
2021 - * get_variation_price() reads WooCommerce's display price cache, which multi-currency
2022 - * plugins populate with converted values — the same defect the rest of this helper
2023 - * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
2024 - * the store's own price formatting, and (null, null) when no variation carries a price.
2025 - */
2026 -private function mxchat_variation_price_range($product) {
2027 - $min_raw = null;
2028 - $max_raw = null;
2029 - $min_val = null;
2030 - $max_val = null;
2031 -
2032 - $children = method_exists($product, 'get_children') ? $product->get_children() : array();
2033 -
2034 - foreach ($children as $child_id) {
2035 - $variation = wc_get_product($child_id);
2036 - if (!$variation) {
2037 - continue;
2038 - }
2039 - $raw = $variation->get_price('edit');
2040 - if ($raw === '' || $raw === null) {
2041 - continue;
2042 - }
2043 - $val = (float) $raw;
2044 - if ($min_val === null || $val < $min_val) {
2045 - $min_val = $val;
2046 - $min_raw = $raw;
2047 - }
2048 - if ($max_val === null || $val > $max_val) {
2049 - $max_val = $val;
2050 - $max_raw = $raw;
2051 - }
2052 - }
2053 -
2054 - return array($min_raw, $max_raw);
2055 -}
2056 -
2057 1222 private function strip_shortcode_tags_preserve_content($content) {
2058 1223 // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
2059 1224 // Content between tags is inherently preserved since only brackets are targeted
2060 1225 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
@@ -2098,37 +1263,24 @@
2098 1263
2099 1264 // Ensure valid UTF-8 encoding
2100 1265 $content = wp_check_invalid_utf8($content);
2101 1266
2102 - // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
2103 - // Counts CHARACTERS (/u), and never strips a run containing characters from a
2104 - // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
2105 - // where a normal paragraph is legitimately one unbroken run.
2106 - $content = preg_replace_callback('/\S{300,}/u', function ($m) {
2107 - return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
2108 - }, $content);
2109 -
2110 - // Remove emoji/symbol blocks only — not the whole supplementary plane, which
2111 - // also holds CJK Extension B ideographs used in real Chinese/Japanese names.
2112 - // A ZWJ (U+200D) BETWEEN stripped pictographs is consumed with them, so a
2113 - // family sequence like 👨‍👩‍👧 leaves no invisible zero-width residue behind
2114 - // (the joiner between NON-emoji characters — Hindi conjuncts — is untouched).
2115 - $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}](?:\x{200D}[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}])*/u', '', $content);
2116 -
2117 - // There is deliberately NO catch-all character allowlist here (plan 209e57;
2118 - // one existed until 3.2.20). Every genuinely dangerous byte is already gone:
2119 - // control characters, null bytes, invalid UTF-8 and the emoji blocks are all
2120 - // stripped above. The allowlist's only remaining effect was to damage scripts
2121 - // nobody thought to enumerate — Unicode Cf (Format) was missing, so it
2122 - // replaced the zero-width joiner/non-joiner with spaces and silently split
2123 - // Persian words (می‌روم → می روم) and broke Hindi conjuncts (क्‍ष → क् ष).
2124 - // Do not add one back; the failure mode of an allowlist is exactly this.
2125 -
2126 - // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
2127 - // but cut on a character boundary so a multibyte char is never split mid-sequence)
1267 + // Remove any extremely long strings without spaces (often garbage)
1268 + $content = preg_replace('/\S{300,}/', ' ', $content);
1269 +
1270 + // Replace problematic characters that often cause database issues
1271 + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
1272 +
1273 + // Replace any remaining potentially problematic characters with spaces
1274 + // BUT preserve newlines by temporarily replacing them
1275 + $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
1276 + $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
1277 + $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
1278 +
1279 + // Limit to reasonable length if needed
2128 1280 $max_length = 65000; // Just under MySQL TEXT field limit
2129 1281 if (strlen($content) > $max_length) {
2130 - $content = mb_strcut($content, 0, $max_length, 'UTF-8');
1282 + $content = substr($content, 0, $max_length);
2131 1283 }
2132 1284
2133 1285 //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
2134 1286 return $content;
@@ -2966,19 +2118,10 @@
2966 2118 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2967 2119 <?php endif; ?>
2968 2120 </td>
2969 2121 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2122 + <?php if ($data_source !== 'pinecone') : ?>
2970 2123 <button type="button"
2971 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
2972 - data-source-url="<?php echo esc_attr($source_url); ?>"
2973 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2974 - data-data-source="<?php echo esc_attr($data_source); ?>"
2975 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2976 - data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
2977 - title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
2978 - <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
2979 - </button>
2980 - <button type="button"
2981 2124 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2982 2125 data-source-url="<?php echo esc_attr($source_url); ?>"
2983 2126 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2984 2127 data-data-source="<?php echo esc_attr($data_source); ?>"
@@ -2986,8 +2129,9 @@
2986 2129 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2987 2130 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2988 2131 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2989 2132 </button>
2133 + <?php endif; ?>
2990 2134 <button type="button"
2991 2135 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2992 2136 data-source-url="<?php echo esc_attr($source_url); ?>"
2993 2137 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
@@ -3119,29 +2263,9 @@
3119 2263 <?php else : ?>
3120 2264 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
3121 2265 <?php endif; ?>
3122 2266 </td>
3123 - <td style="padding: 12px 16px; white-space: nowrap;">
3124 - <button type="button"
3125 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3126 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3127 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3128 - data-data-source="<?php echo esc_attr($data_source); ?>"
3129 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3130 - data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3131 - title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3132 - <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3133 - </button>
3134 - <button type="button"
3135 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3136 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3137 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3138 - data-data-source="<?php echo esc_attr($data_source); ?>"
3139 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3140 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3141 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3142 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3143 - </button>
2267 + <td style="padding: 12px 16px;">
3144 2268 <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);">
3145 2269 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
3146 2270 </button>
3147 2271 </td>
@@ -3714,9 +2838,9 @@
3714 2838 $response = wp_remote_head($url, array(
3715 2839 'timeout' => 10,
3716 2840 'sslverify' => false,
3717 2841 'redirection' => 1,
3718 - 'user-agent' => mxchat_ingest_user_agent(),
2842 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3719 2843 ));
3720 2844
3721 2845 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3722 2846 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3776,11 +2900,12 @@
3776 2900
3777 2901 $response = wp_remote_get($url, array(
3778 2902 'timeout' => 30,
3779 2903 'sslverify' => false,
3780 - 'user-agent' => mxchat_ingest_user_agent(),
2904 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3781 2905 'headers' => array(
3782 2906 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2907 + 'Accept-Language' => 'en-US,en;q=0.9',
3783 2908 ),
3784 2909 ));
3785 2910
3786 2911 if (is_wp_error($response)) {
@@ -3834,11 +2959,12 @@
3834 2959 private function get_sitemap_url_count($url) {
3835 2960 $response = wp_remote_get($url, array(
3836 2961 'timeout' => 30,
3837 2962 'sslverify' => false,
3838 - 'user-agent' => mxchat_ingest_user_agent(),
2963 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3839 2964 'headers' => array(
3840 2965 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2966 + 'Accept-Language' => 'en-US,en;q=0.9',
3841 2967 ),
3842 2968 ));
3843 2969
3844 2970 if (is_wp_error($response)) {
@@ -3864,9 +2990,9 @@
3864 2990
3865 2991 $response = wp_remote_get($robots_url, array(
3866 2992 'timeout' => 15,
3867 2993 'sslverify' => false,
3868 - 'user-agent' => mxchat_ingest_user_agent(),
2994 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3869 2995 ));
3870 2996
3871 2997 if (is_wp_error($response)) {
3872 2998 return $sitemaps;
@@ -4357,12 +3483,20 @@
4357 3483
4358 3484 // Get bot_id from request
4359 3485 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
4360 3486
4361 - // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
4362 - // plan 11720c). The import modal shows a passive status line pointing
4363 - // there; the old per-batch checkbox and its remembered default are gone.
4364 - $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
3487 + // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3488 + // don't re-check on every batch; the default is OFF for installs that haven't set it.
3489 + $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3490 + $mxchat_options = get_option('mxchat_options', array());
3491 + if (!is_array($mxchat_options)) {
3492 + $mxchat_options = array();
3493 + }
3494 + $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3495 + if ($prior_default !== $extract_acf_pdfs) {
3496 + $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3497 + update_option('mxchat_options', $mxchat_options);
3498 + }
4365 3499
4366 3500 // Process only ONE post at a time to avoid request size issues
4367 3501 $post_id = reset($post_ids);
4368 3502 $post = get_post($post_id);
@@ -4371,34 +3505,173 @@
4371 3505 wp_send_json_error('Post not found');
4372 3506 exit;
4373 3507 }
4374 3508
4375 - /**
4376 - * Allow developers to modify post data before processing into the knowledge base.
4377 - * Applied on BOTH content-preparation paths (this manual bulk import and the
4378 - * auto-sync path in mxchat_handle_post_update) with the same signature, so a
4379 - * callback registered once covers every indexing route. Purely additive —
4380 - * zero behaviour change when unhooked.
4381 - *
4382 - * @param WP_Post $post The post about to be indexed.
4383 - * @param string $bot_id Bot context for this import.
4384 - */
3509 + // Allow developers to modify post data before processing into knowledge base
4385 3510 $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
4386 - if (!($post instanceof WP_Post)) {
4387 - $post = get_post($post_id); // defend against a bad callback return
3511 +
3512 + // Get content including title, short description (for WooCommerce), and main content
3513 + $content = $post->post_title . "\n\n";
3514 +
3515 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3516 + if (!empty($post->post_excerpt)) {
3517 + // Remove shortcode tags but preserve content inside them
3518 + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
3519 + $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
4388 3520 }
4389 3521
4390 - // Assemble the indexable text via the shared post-kind assembler (a3d60c).
4391 - // Bulk import reads raw post fields, has always included product custom tabs,
4392 - // and passes the install-level ACF→PDF option.
4393 - $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
4394 - 'read_display' => false,
4395 - 'extract_acf_pdfs' => $extract_acf_pdfs,
4396 - 'include_product_tabs' => true,
4397 - ));
4398 - $content = $prepared['content'];
4399 - $pdf_extracted_count = $prepared['pdf_extracted_count'];
3522 + // Add main content - remove shortcode tags but preserve content inside them
3523 + $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
3524 + $content .= wp_strip_all_tags($clean_content);
4400 3525
3526 + // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
3527 + if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
3528 + $product = wc_get_product($post_id);
3529 +
3530 + if ($product) {
3531 + // Get pricing information
3532 + $regular_price = $product->get_regular_price();
3533 + $sale_price = $product->get_sale_price();
3534 + $price = $product->get_price();
3535 + $sku = $product->get_sku();
3536 +
3537 + // Get currency symbol
3538 + $currency_symbol = get_woocommerce_currency_symbol();
3539 +
3540 + // Add pricing information
3541 + $content .= "\n";
3542 + if (!empty($regular_price)) {
3543 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3544 + } elseif (!empty($price)) {
3545 + $content .= "Price: " . $currency_symbol . $price . "\n";
3546 + }
3547 +
3548 + if (!empty($sale_price) && $sale_price !== $regular_price) {
3549 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3550 + }
3551 +
3552 + // Handle variable products - show price range
3553 + if ($product->is_type('variable')) {
3554 + $min_price = $product->get_variation_price('min');
3555 + $max_price = $product->get_variation_price('max');
3556 + if ($min_price !== $max_price) {
3557 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3558 + }
3559 + }
3560 +
3561 + if (!empty($sku)) {
3562 + $content .= "SKU: " . $sku . "\n";
3563 + }
3564 +
3565 + // Get product categories
3566 + $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3567 + if (!empty($categories) && !is_wp_error($categories)) {
3568 + $content .= "Categories: " . implode(', ', $categories) . "\n";
3569 + }
3570 + }
3571 +
3572 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3573 + $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3574 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
3575 + foreach ($custom_tabs as $tab) {
3576 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3577 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
3578 +
3579 + if (!empty($tab_title) && !empty($tab_content)) {
3580 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3581 + }
3582 + }
3583 + }
3584 +
3585 + // Also check for reusable/saved tabs applied to this product
3586 + $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3587 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3588 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3589 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
3590 + foreach ($applied_saved_tabs as $saved_tab_id) {
3591 + if (isset($saved_tabs[$saved_tab_id])) {
3592 + $tab = $saved_tabs[$saved_tab_id];
3593 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3594 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
3595 +
3596 + if (!empty($tab_title) && !empty($tab_content)) {
3597 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3598 + }
3599 + }
3600 + }
3601 + }
3602 + }
3603 + }
3604 +
3605 + // ADD ACF FIELDS SUPPORT
3606 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3607 + $pdf_extracted_count = 0;
3608 + if (!empty($acf_fields)) {
3609 + $acf_content_parts = array();
3610 + $pdf_attachment_ids = array();
3611 +
3612 + foreach ($acf_fields as $field_name => $field_value) {
3613 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3614 +
3615 + if (!empty($formatted_value)) {
3616 + $field_label = ucwords(str_replace('_', ' ', $field_name));
3617 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
3618 + }
3619 +
3620 + // Walk this field's value tree for any PDF attachment references and queue them for extraction.
3621 + // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
3622 + // still lands in the KB but the heavier PDF parsing is skipped.
3623 + if ($extract_acf_pdfs) {
3624 + $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
3625 + }
3626 + }
3627 +
3628 + if (!empty($acf_content_parts)) {
3629 + $content .= "\n\n" . implode("\n", $acf_content_parts);
3630 + }
3631 +
3632 + // Extract text from each unique PDF found in ACF fields and append as a labeled section
3633 + if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
3634 + $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
3635 + $pdf_sections = array();
3636 + foreach ($pdf_attachment_ids as $att_id) {
3637 + $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
3638 + if (!empty($pdf_text)) {
3639 + $pdf_title = get_the_title($att_id);
3640 + $pdf_url = wp_get_attachment_url($att_id);
3641 + $header = 'PDF Attachment';
3642 + if (!empty($pdf_title)) {
3643 + $header .= ': ' . $pdf_title;
3644 + }
3645 + if (!empty($pdf_url)) {
3646 + $header .= ' (' . $pdf_url . ')';
3647 + }
3648 + $pdf_sections[] = $header . "\n" . $pdf_text;
3649 + $pdf_extracted_count++;
3650 + }
3651 + }
3652 + if (!empty($pdf_sections)) {
3653 + $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
3654 + }
3655 + }
3656 + }
3657 +
3658 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3659 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3660 + if (!empty($custom_meta)) {
3661 + $meta_content_parts = array();
3662 +
3663 + foreach ($custom_meta as $meta_key => $meta_value) {
3664 + // Convert meta key to readable label
3665 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3666 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
3667 + }
3668 +
3669 + if (!empty($meta_content_parts)) {
3670 + $content .= "\n\n" . implode("\n", $meta_content_parts);
3671 + }
3672 + }
3673 +
4401 3674 // Debug logging for WordPress Import content
4402 3675 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
4403 3676 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
4404 3677 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
@@ -4405,19 +3678,29 @@
4405 3678 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
4406 3679
4407 3680 // Note: Removed 10,000 char limit - chunking now handles large content properly
4408 3681
4409 - // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
3682 + // Get bot-specific API key
4410 3683 $bot_options = $this->get_bot_options($bot_id);
4411 3684 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4412 -
4413 - $preflight = MxChat_Utils::embedding_preflight($options);
4414 - if (!$preflight['ok']) {
4415 - MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4416 - wp_send_json_error($preflight['reason']);
3685 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3686 +
3687 + if (strpos($selected_model, 'voyage') === 0) {
3688 + $api_key = $options['voyage_api_key'] ?? '';
3689 + $provider_name = 'Voyage AI';
3690 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3691 + $api_key = $options['gemini_api_key'] ?? '';
3692 + $provider_name = 'Google Gemini';
3693 + } else {
3694 + $api_key = $options['api_key'] ?? '';
3695 + $provider_name = 'OpenAI';
3696 + }
3697 +
3698 + if (empty($api_key)) {
3699 + MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
3700 + wp_send_json_error($provider_name . ' API key not configured');
4417 3701 exit;
4418 3702 }
4419 - $api_key = $preflight['api_key'];
4420 3703
4421 3704 $source_url = get_permalink($post_id);
4422 3705 $vector_id = md5($source_url); // Vector ID for Pinecone
4423 3706
@@ -4488,11 +3771,11 @@
4488 3771 // Automatically apply role restriction based on tags
4489 3772 $this->apply_role_restriction_to_post($post_id, $source_url);
4490 3773
4491 3774 $operation_type = $is_update ? 'update' : 'new';
4492 -
3775 +
4493 3776 // Count ACF fields for debugging
4494 - $acf_field_count = $prepared['acf_fields_found'];
3777 + $acf_field_count = count($acf_fields);
4495 3778
4496 3779 // Success response with minimal data
4497 3780 wp_send_json_success(array(
4498 3781 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
@@ -4572,9 +3855,9 @@
4572 3855 );
4573 3856 } else {
4574 3857 // Update WordPress DB
4575 3858 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4576 -
3859 +
4577 3860 $wpdb->update(
4578 3861 $table_name,
4579 3862 array('role_restriction' => $highest_role),
4580 3863 array('source_url' => $source_url),
@@ -4581,15 +3864,8 @@
4581 3864 array('%s'),
4582 3865 array('%s')
4583 3866 );
4584 3867 }
4585 -
4586 - // The entry's restriction just changed — keep the OpenAI Vector Store
4587 - // mirror consistent: non-public pulls the file (file_search has no
4588 - // per-role filtering), public re-mirrors it (plan 15b5c6).
4589 - if (class_exists('MxChat_Vectorstore_Manager')) {
4590 - MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
4591 - }
4592 3868 }
4593 3869
4594 3870 public function mxchat_get_public_post_types() {
4595 3871 // Get all public post types
@@ -4660,63 +3936,64 @@
4660 3936
4661 3937 return $pinecone_data;
4662 3938 }
4663 3939 public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
3940 + //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
3941 +
4664 3942 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4665 3943 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4666 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4667 3944
4668 3945 if (empty($api_key) || empty($host) || empty($vector_ids)) {
3946 + //error_log('DEBUG: Missing parameters for fetch by IDs');
4669 3947 return array();
4670 3948 }
4671 3949
4672 3950 try {
4673 - // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
4674 - // repeated in the query string (ids=a&ids=b — http_build_query would
4675 - // emit ids[0]=a); the old POST here was answered 200-with-an-empty-body,
4676 - // which read as "nothing indexed". Chunked at 100 ids to stay well
4677 - // under the measured HTTP 414 URL-length boundary.
4678 - $vectors = array();
4679 - foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
4680 - $fetch_query = array();
4681 - foreach ($chunk as $fetch_vid) {
4682 - $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
4683 - }
4684 - if (!empty($namespace)) {
4685 - $fetch_query[] = 'namespace=' . rawurlencode($namespace);
4686 - }
3951 + $fetch_url = "https://{$host}/vectors/fetch";
3952 + //error_log('DEBUG: Fetch URL: ' . $fetch_url);
3953 + //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
4687 3954
4688 - $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
4689 - 'headers' => array(
4690 - 'Api-Key' => $api_key,
4691 - 'accept' => 'application/json'
4692 - ),
4693 - 'timeout' => 30
4694 - ));
3955 + // Pinecone fetch API allows fetching specific vectors by ID
3956 + $fetch_data = array(
3957 + 'ids' => array_values($vector_ids)
3958 + );
4695 3959
4696 - if (is_wp_error($response)) {
4697 - error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
4698 - continue;
4699 - }
3960 + $response = wp_remote_post($fetch_url, array(
3961 + 'headers' => array(
3962 + 'Api-Key' => $api_key,
3963 + 'Content-Type' => 'application/json'
3964 + ),
3965 + 'body' => json_encode($fetch_data),
3966 + 'timeout' => 30
3967 + ));
4700 3968
4701 - if (wp_remote_retrieve_response_code($response) !== 200) {
4702 - error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
4703 - continue;
4704 - }
3969 + if (is_wp_error($response)) {
3970 + //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
3971 + return array();
3972 + }
4705 3973
4706 - $data = json_decode(wp_remote_retrieve_body($response), true);
4707 - if (isset($data['vectors']) && is_array($data['vectors'])) {
4708 - $vectors += $data['vectors'];
4709 - }
3974 + $response_code = wp_remote_retrieve_response_code($response);
3975 + //error_log('DEBUG: Fetch response code: ' . $response_code);
3976 +
3977 + if ($response_code !== 200) {
3978 + $error_body = wp_remote_retrieve_body($response);
3979 + //error_log('DEBUG: Fetch failed with body: ' . $error_body);
3980 + return array();
4710 3981 }
4711 3982
4712 - if (empty($vectors)) {
3983 + $body = wp_remote_retrieve_body($response);
3984 + $data = json_decode($body, true);
3985 +
3986 + //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
3987 +
3988 + if (!isset($data['vectors'])) {
3989 + //error_log('DEBUG: No vectors key in response');
4713 3990 return array();
4714 3991 }
4715 3992
4716 3993 $processed_data = array();
4717 3994
4718 - foreach ($vectors as $vector_id => $vector_data) {
3995 + foreach ($data['vectors'] as $vector_id => $vector_data) {
4719 3996 $metadata = $vector_data['metadata'] ?? array();
4720 3997 $source_url = $metadata['source_url'] ?? '';
4721 3998
4722 3999 if (!empty($source_url)) {
@@ -4787,11 +4064,8 @@
4787 4064 */
4788 4065 public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4789 4066 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4790 4067 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4791 - // plan 793b82: this scan was namespace-blind — on a namespaced setup it
4792 - // surveyed the default namespace and reported the wrong content as indexed.
4793 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4794 4068
4795 4069 if (empty($api_key) || empty($host)) {
4796 4070 return array();
4797 4071 }
@@ -4826,12 +4100,8 @@
4826 4100 'topK' => 10000,
4827 4101 'vector' => $random_vector
4828 4102 );
4829 4103
4830 - if (!empty($namespace)) {
4831 - $query_data['namespace'] = $namespace;
4832 - }
4833 -
4834 4104 $response = wp_remote_post($query_url, array(
4835 4105 'headers' => array(
4836 4106 'Api-Key' => $api_key,
4837 4107 'Content-Type' => 'application/json'
@@ -4844,9 +4114,9 @@
4844 4114 continue;
4845 4115 }
4846 4116
4847 4117 $response_code = wp_remote_retrieve_response_code($response);
4848 -
4118 +
4849 4119 if ($response_code !== 200) {
4850 4120 continue;
4851 4121 }
4852 4122
@@ -5055,23 +4325,13 @@
5055 4325 $error_message = $error_json['error']['message'] ?? 'No message';
5056 4326 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
5057 4327 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
5058 4328
5059 - // Keep the provider's own diagnostic — a restricted-key 401 names the
5060 - // exact missing scope, and replacing it with "check your API key" sent
5061 - // a customer to regenerate two keys (plan 46b596). Same shape as
5062 - // MxChat_Utils::embedding_failure_error() so both ingestion paths read
5063 - // identically. Key never appears in provider messages, but scrub anyway.
5064 - if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
5065 - if (is_string($api_key) && $api_key !== '') {
5066 - $error_message = str_replace($api_key, '[redacted]', $error_message);
5067 - }
5068 - $error_message = sprintf(
5069 - 'Embedding failed (%s, HTTP %d): %s',
5070 - $selected_model,
5071 - $http_code,
5072 - substr($error_message, 0, 300)
5073 - );
4329 + // Customize error message for common API errors
4330 + if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
4331 + $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
4332 + } elseif ($error_type === 'authentication_error') {
4333 + $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
5074 4334 }
5075 4335
5076 4336 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
5077 4337 return $error_message;
@@ -5583,38 +4843,25 @@
5583 4843 /**
5584 4844 * Get all ACF fields for a specific post, excluding any fields the user has disabled
5585 4845 */
5586 4846 public function mxchat_get_acf_fields_for_post($post_id) {
5587 - if (!function_exists('get_field_objects')) {
4847 + if (!function_exists('get_fields')) {
5588 4848 return array();
5589 4849 }
5590 4850
5591 - // Field OBJECTS, not get_fields(): exclusion matches on the field KEY
5592 - // (unique per field) rather than the name (shared across groups — plan
5593 - // 30e81f). ACF's own get_fields() is implemented as get_field_objects()
5594 - // reduced to name => value, so the un-excluded reduction below is the
5595 - // identical shape and order the previous get_fields() call produced.
5596 - $field_objects = get_field_objects($post_id);
5597 - if (!$field_objects || !is_array($field_objects)) {
4851 + $fields = get_fields($post_id);
4852 + if (!$fields || !is_array($fields)) {
5598 4853 return array();
5599 4854 }
5600 4855
4856 + // Get excluded fields from settings
5601 4857 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
5602 - if (!is_array($excluded_fields)) {
5603 - $excluded_fields = array();
5604 - }
5605 -
5606 - $fields = array();
5607 - foreach ($field_objects as $field_name => $field_object) {
5608 - if (!empty($excluded_fields)) {
5609 - $field_key = isset($field_object['key']) ? $field_object['key'] : '';
5610 - // Legacy name entries stay honored: a stored name whose group was
5611 - // inactive at migration time still excludes every field wearing it.
5612 - if (in_array($field_key, $excluded_fields, true) || in_array($field_name, $excluded_fields, true)) {
5613 - continue;
4858 + if (!empty($excluded_fields) && is_array($excluded_fields)) {
4859 + foreach ($excluded_fields as $excluded_field) {
4860 + if (isset($fields[$excluded_field])) {
4861 + unset($fields[$excluded_field]);
5614 4862 }
5615 4863 }
5616 - $fields[$field_name] = isset($field_object['value']) ? $field_object['value'] : null;
5617 4864 }
5618 4865
5619 4866 return $fields;
5620 4867 }
@@ -5626,11 +4873,8 @@
5626 4873 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
5627 4874 return array();
5628 4875 }
5629 4876
5630 - // Keyed by GROUP KEY, not title (titles are not unique), and each field
5631 - // entry carries its ACF field key — the unique identifier every toggle,
5632 - // save, and index-time exclusion now runs on (plans 30e81f / bf57e0).
5633 4877 $all_fields = array();
5634 4878 $field_groups = acf_get_field_groups();
5635 4879
5636 4880 if (!empty($field_groups)) {
@@ -5636,21 +4880,16 @@
5636 4880 if (!empty($field_groups)) {
5637 4881 foreach ($field_groups as $group) {
5638 4882 $group_fields = acf_get_fields($group['key']);
5639 4883 if (!empty($group_fields)) {
5640 - $entry = array(
5641 - 'title' => $group['title'],
5642 - 'fields' => array(),
5643 - );
4884 + $all_fields[$group['title']] = array();
5644 4885 foreach ($group_fields as $field) {
5645 - $entry['fields'][] = array(
5646 - 'key' => $field['key'],
5647 - 'name' => $field['name'],
4886 + $all_fields[$group['title']][] = array(
4887 + 'name' => $field['name'],
5648 4888 'label' => $field['label'],
5649 - 'type' => $field['type']
4889 + 'type' => $field['type']
5650 4890 );
5651 4891 }
5652 - $all_fields[$group['key']] = $entry;
5653 4892 }
5654 4893 }
5655 4894 }
5656 4895
@@ -6045,11 +5284,9 @@
6045 5284 $parser = new \Smalot\PdfParser\Parser();
6046 5285 $pdf = $parser->parseFile($pdf_path);
6047 5286 $pages = $pdf->getPages();
6048 5287 $page_texts = array();
6049 - $acf_page_num = 0;
6050 5288 foreach ($pages as $page) {
6051 - $acf_page_num++;
6052 5289 $page_text = '';
6053 5290 try {
6054 5291 $page_text = $page->getText();
6055 5292 } catch (\Exception $e) {
@@ -6055,9 +5292,8 @@
6055 5292 } catch (\Exception $e) {
6056 5293 $page_text = '';
6057 5294 }
6058 5295 if (!empty($page_text)) {
6059 - $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num);
6060 5296 $page_texts[] = $page_text;
6061 5297 }
6062 5298 }
6063 5299 $text = trim(implode("\n\n", $page_texts));
@@ -6155,13 +5391,8 @@
6155 5391 $this->mxchat_handle_post_update($post_id, $post, true);
6156 5392 }
6157 5393
6158 5394 public function mxchat_handle_post_update($post_id, $post, $update) {
6159 - // The in-flight-update marker has done its job the moment post_updated runs; drop it
6160 - // before any early return so it can never outlive its own save (a failed $wpdb->update
6161 - // inside wp_insert_post returns after pre_post_update but before the transition).
6162 - unset($this->pending_post_update[$post_id]);
6163 -
6164 5395 // Basic validation checks
6165 5396 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
6166 5397 return;
6167 5398 }
@@ -6199,11 +5430,9 @@
6199 5430 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
6200 5431 // Use the stored URL from when it was published, or fall back to current permalink
6201 5432 $source_url = $previous_url ?: get_permalink($post_id);
6202 5433
6203 - // mxchat_handle_status_transition already deleted for this post earlier in this
6204 - // request (it fires first inside wp_insert_post); skip the redundant round-trip.
6205 - if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
5434 + if ($source_url) {
6206 5435 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6207 5436 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
6208 5437 }
6209 5438
@@ -6232,402 +5461,209 @@
6232 5461 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
6233 5462 }
6234 5463 }
6235 5464
6236 - // Only process currently published content for adding/updating.
6237 - // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
6238 - // already indexed this post earlier in this request (editor publishes fire
6239 - // transition_post_status first, then post_updated) — skip the duplicate embed.
6240 - // Consume-once: the flag is cleared when honoured, so a LATER save of the same
6241 - // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
5465 + // Only process currently published content for adding/updating
6242 5466 if ($post->post_status === 'publish') {
6243 - if (!empty($this->transition_indexed_posts[$post_id])) {
6244 - unset($this->transition_indexed_posts[$post_id]);
6245 - } else {
6246 - $this->mxchat_index_published_post($post_id, $post);
6247 - }
6248 - }
6249 -
6250 - // Clean up the stored previous status if not used above
6251 - if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6252 - delete_transient($previous_status_key);
6253 - delete_transient($previous_url_key);
6254 - }
6255 -}
6256 -
6257 -/**
6258 - * Index a published post into the knowledge base: preprocessing filter, content
6259 - * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
6260 - * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
6261 - * upsert, then tag-based role restriction.
6262 - *
6263 - * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
6264 - * transition_post_status arrival edge (mxchat_handle_status_transition), so
6265 - * scheduled publishes (wp_publish_post) and direct status=publish inserts index
6266 - * identically to editor saves (plan 3055e1). Pure extraction of the former
6267 - * publish branch — body indentation retained to keep the diff reviewable.
6268 - */
6269 -private function mxchat_index_published_post($post_id, $post) {
6270 - $post_type = $post->post_type;
6271 -
6272 - // WooCommerce products are owned by the WC-object assembler (plan a3d60c):
6273 - // whenever WooCommerce is active AND the integration is enabled, every
6274 - // product save also fires save_post_product, which queues
6275 - // mxchat_store_product_embedding on shutdown — and that writer runs LAST,
6276 - // overwriting the same md5(permalink) row this path would write. Assembling
6277 - // and embedding the product here was pure duplicate spend (measured: two
6278 - // embedding calls per product save, second one wins). Skip ONLY under the
6279 - // exact conditions the shutdown writer runs — same option read as its own
6280 - // gate — because with the integration off (or WooCommerce inactive) this
6281 - // path is the sole product indexer and must keep working.
6282 - if ($post_type === 'product'
6283 - && class_exists('WooCommerce')
6284 - && isset($this->options['enable_woocommerce_integration'])
6285 - && in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6286 - return;
6287 - }
6288 -
6289 5467 // Get the source URL
6290 5468 $source_url = get_permalink($post_id);
5469 +
5470 + // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
5471 + $title = get_the_title($post_id);
5472 + $content = get_post_field('post_content', $post_id);
5473 + $excerpt = get_post_field('post_excerpt', $post_id);
6291 5474
6292 - // A draft published programmatically (wp_publish_post) can reach this
6293 - // point with an EMPTY post_name — wp_insert_post skips slug generation
6294 - // for draft/pending — and get_permalink() then resolves to the bare
6295 - // site root. A knowledge row keyed to the homepage cites the wrong URL
6296 - // and answers homepage questions with this post's body, so refuse to
6297 - // write it; the post indexes correctly on its next save, once the slug
6298 - // exists. The empty-post_name test is what keeps a legitimate static
6299 - // front page (which has a slug but a root permalink) indexable.
6300 - // (Plan d138c4.)
6301 - if ('' === $post->post_name
6302 - && untrailingslashit($source_url) === untrailingslashit(home_url())) {
6303 - return;
6304 - }
5475 + // Remove shortcode tags but preserve content inside them
5476 + $content = $this->strip_shortcode_tags_preserve_content($content);
5477 + $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
6305 5478
6306 - /**
6307 - * Allow developers to modify post data before processing into the knowledge base.
6308 - * Same filter and signature as the manual bulk-import path
6309 - * (ajax_mxchat_process_selected_content), so a callback registered once covers
6310 - * every indexing route. Purely additive — zero behaviour change when unhooked.
6311 - * Auto-sync runs under the 'default' bot context, matching the rest of this
6312 - * function.
6313 - *
6314 - * @param WP_Post $post The post about to be indexed.
6315 - * @param string $bot_id Bot context ('default' on auto-sync).
6316 - */
6317 - $post = apply_filters('mxchat_before_process_post', $post, 'default');
6318 - if (!($post instanceof WP_Post)) {
6319 - $post = get_post($post_id); // defend against a bad callback return
6320 - }
5479 + // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
5480 + $content = wp_strip_all_tags($content);
6321 5481
6322 - // Assemble the indexable text via the shared post-kind assembler (a3d60c),
6323 - // reading from the FILTERED post object — not re-fetched by ID, which would
6324 - // discard it. Auto-sync reads content/excerpt in its historical
6325 - // get_post_field() display context, never appended product custom tabs
6326 - // (its product branch is reachable only with the WooCommerce integration
6327 - // off), and gates ACF→PDF extraction behind its own opt-in option —
6328 - // default OFF, because re-parsing every ACF PDF on every editor save is
6329 - // expensive and most sites don't want it (the 25 MB size cap lives in the
6330 - // shared extractor either way).
6331 - $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
6332 - 'read_display' => true,
6333 - 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
6334 - 'include_product_tabs' => false,
6335 - ));
6336 - $final_content = $prepared['content'];
5482 + // Combine title, short description (if exists), and content
5483 + $final_content = $title . "\n\n";
6337 5484
6338 - // Embedding decision — custom-provider-aware. Gating on a cloud API key
6339 - // here silently killed auto-sync on keyless custom-embeddings sites,
6340 - // because generate_embedding() routes custom FIRST and never needs the
6341 - // key (plan cbd5fd). Silent-return shape preserved.
6342 - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6343 - if (!$preflight['ok']) {
6344 - return;
5485 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
5486 + if (!empty($excerpt)) {
5487 + $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
6345 5488 }
6346 - $api_key = $preflight['api_key'];
6347 5489
6348 - // Use the centralized utility function for storage
6349 - $result = MxChat_Utils::submit_content_to_db(
6350 - $final_content,
6351 - $source_url,
6352 - $api_key,
6353 - md5($source_url) // Vector ID for Pinecone
6354 - );
6355 -
6356 - // After successful storage, apply role restriction based on tags
6357 - if (!is_wp_error($result)) {
6358 - $this->apply_role_restriction_to_post($post_id, $source_url);
6359 - }
6360 -}
5490 + $final_content .= $content;
6361 5491
6362 -/**
6363 - * Shared post-fields content assembler (plan a3d60c) — the ONE body behind both
6364 - * post-kind ingestion paths: manual bulk import (ajax_mxchat_process_selected_content)
6365 - * and auto-sync (mxchat_index_published_post). Behavior-preserving extraction; the
6366 - * measured per-caller differences ride $args instead of living as drifting copies:
6367 - *
6368 - * 'read_display' bool Auto-sync historically reads content/excerpt via
6369 - * get_post_field() in its default 'display' context
6370 - * (the post_content / post_excerpt display filters
6371 - * fire); bulk import reads the raw properties. Inert
6372 - * on a stock install — preserved per-path, not converged.
6373 - * 'extract_acf_pdfs' bool Each caller passes its OWN option (bulk:
6374 - * mxchat_acf_pdf_extraction; auto-sync:
6375 - * mxchat_auto_sync_acf_pdfs) — the two-option design
6376 - * is deliberate (plan 11720c). Gates BOTH the PDF-id
6377 - * collection walk and the extraction loop; the ids are
6378 - * only ever read inside the extraction branch, so
6379 - * gating collection is output-identical on every install.
6380 - * 'include_product_tabs' bool The bulk path has always appended yikes_woo custom
6381 - * tabs to product content; the auto-sync product branch
6382 - * (reachable only with the WooCommerce integration off)
6383 - * never did. Preserved per-path — converging it would be
6384 - * a behavior change, recorded on the plan instead.
6385 - *
6386 - * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
6387 - * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
6388 - */
6389 -private function mxchat_prepare_post_content_for_indexing($post_id, $post, $args) {
6390 - $read_display = !empty($args['read_display']);
6391 - $extract_acf_pdfs = !empty($args['extract_acf_pdfs']);
6392 - $include_product_tabs = !empty($args['include_product_tabs']);
5492 + // For WooCommerce products, include pricing and product details
5493 + if ($post_type === 'product' && class_exists('WooCommerce')) {
5494 + $product = wc_get_product($post_id);
6393 5495
6394 - // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
6395 - // convert_chars and prepends the "Protected:" / "Private:" display chrome.
6396 - // The knowledge base stores facts, not display strings. Entity decode at
6397 - // output time (single-pass, shared helper) — a stored `&amp;` embeds worse
6398 - // than `&` and gets quoted back to visitors (d2c92e).
6399 - $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n";
5496 + if ($product) {
5497 + // Get pricing information
5498 + $regular_price = $product->get_regular_price();
5499 + $sale_price = $product->get_sale_price();
5500 + $price = $product->get_price();
5501 + $sku = $product->get_sku();
6400 5502
6401 - $raw_excerpt = $read_display ? get_post_field('post_excerpt', $post) : $post->post_excerpt;
6402 - $raw_content = $read_display ? get_post_field('post_content', $post) : $post->post_content;
5503 + // Get currency symbol
5504 + $currency_symbol = get_woocommerce_currency_symbol();
6403 5505
6404 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
6405 - // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
6406 - // empty, and testing the raw value emitted a bare "Short Description: " label
6407 - // with no value after it. trim() only in the TEST — the emitted value is
6408 - // untouched, so a populated excerpt is byte-identical to before. A
6409 - // whitespace-only excerpt is an empty excerpt and must not produce a labelled
6410 - // line with nothing after it.
6411 - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($raw_excerpt);
6412 - if (trim($clean_excerpt) !== '') {
6413 - $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
6414 - }
5506 + // Add pricing information
5507 + $final_content .= "\n";
5508 + if (!empty($regular_price)) {
5509 + $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
5510 + } elseif (!empty($price)) {
5511 + $final_content .= "Price: " . $currency_symbol . $price . "\n";
5512 + }
6415 5513
6416 - // Main content — remove shortcode tags but preserve content inside them, then
6417 - // strip tags (don't use 'the_content' filter as it may re-add shortcodes).
6418 - $clean_content = $this->strip_shortcode_tags_preserve_content($raw_content);
6419 - $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
5514 + if (!empty($sale_price) && $sale_price !== $regular_price) {
5515 + $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5516 + }
6420 5517
6421 - // WooCommerce product enrichment (post-fields kind). The WC-object assembler
6422 - // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
6423 - // integration is on; this branch serves the bulk import (all configurations)
6424 - // and auto-sync with the integration off.
6425 - if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
6426 - $product = wc_get_product($post_id);
5518 + // Handle variable products - show price range
5519 + if ($product->is_type('variable')) {
5520 + $min_price = $product->get_variation_price('min');
5521 + $max_price = $product->get_variation_price('max');
5522 + if ($min_price !== $max_price) {
5523 + $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5524 + }
5525 + }
6427 5526
6428 - if ($product) {
6429 - $content .= "\n";
6430 - $content .= $this->mxchat_woo_product_summary_lines($product);
6431 - }
5527 + if (!empty($sku)) {
5528 + $final_content .= "SKU: " . $sku . "\n";
5529 + }
6432 5530
6433 - if ($include_product_tabs) {
6434 - $content .= $this->mxchat_woo_custom_tabs_text($post_id);
5531 + // Get product categories
5532 + $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
5533 + if (!empty($categories) && !is_wp_error($categories)) {
5534 + $final_content .= "Categories: " . implode(', ', $categories) . "\n";
5535 + }
5536 + }
6435 5537 }
6436 - }
6437 5538
6438 - // For custom post types like job_listing, include additional fields
6439 - if (get_post_type($post_id) === 'job_listing') {
6440 - // Add job-specific meta if available
6441 - $job_location = get_post_meta($post_id, '_job_location', true);
6442 - if (!empty($job_location)) {
6443 - $content .= "\n\nLocation: " . $job_location;
6444 - }
5539 + // For custom post types like job_listing, include additional fields
5540 + if ($post_type === 'job_listing') {
5541 + // Add job-specific meta if available
5542 + $job_location = get_post_meta($post_id, '_job_location', true);
5543 + if (!empty($job_location)) {
5544 + $final_content .= "\n\nLocation: " . $job_location;
5545 + }
6445 5546
6446 - // Get job type terms
6447 - $job_types = get_the_terms($post_id, 'job_listing_type');
6448 - if (!empty($job_types) && !is_wp_error($job_types)) {
6449 - $types = array();
6450 - foreach ($job_types as $type) {
6451 - $types[] = $type->name;
5547 + // Get job type terms
5548 + $job_types = get_the_terms($post_id, 'job_listing_type');
5549 + if (!empty($job_types) && !is_wp_error($job_types)) {
5550 + $types = array();
5551 + foreach ($job_types as $type) {
5552 + $types[] = $type->name;
5553 + }
5554 + $final_content .= "\n\nJob Type: " . implode(', ', $types);
6452 5555 }
6453 - $content .= "\n\nJob Type: " . implode(', ', $types);
6454 - }
6455 5556
6456 - // Get company name if available
6457 - $company_name = get_post_meta($post_id, '_company_name', true);
6458 - if (!empty($company_name)) {
6459 - $content .= "\n\nCompany: " . $company_name;
5557 + // Get company name if available
5558 + $company_name = get_post_meta($post_id, '_company_name', true);
5559 + if (!empty($company_name)) {
5560 + $final_content .= "\n\nCompany: " . $company_name;
5561 + }
6460 5562 }
6461 - }
6462 5563
6463 - // ADD ACF FIELDS SUPPORT
6464 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6465 - $pdf_extracted_count = 0;
6466 - if (!empty($acf_fields)) {
6467 - $acf_content_parts = array();
6468 - $pdf_attachment_ids = array();
5564 + // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5565 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5566 + if (!empty($acf_fields)) {
5567 + $acf_content_parts = array();
5568 + $pdf_attachment_ids = array();
6469 5569
6470 - foreach ($acf_fields as $field_name => $field_value) {
6471 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5570 + foreach ($acf_fields as $field_name => $field_value) {
5571 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5572 + if (!empty($formatted_value)) {
5573 + // Convert field name to readable label
5574 + $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5575 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
5576 + }
6472 5577
6473 - if (!empty($formatted_value)) {
6474 - // Both separators: a hyphenated ACF name should read as words.
6475 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
6476 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
5578 + $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
6477 5579 }
6478 5580
6479 - // Walk this field's value tree for any PDF attachment references and
6480 - // queue them for extraction — only when this caller's PDF option is on.
6481 - if ($extract_acf_pdfs) {
6482 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5581 + if (!empty($acf_content_parts)) {
5582 + $final_content .= "\n\n" . implode("\n", $acf_content_parts);
6483 5583 }
6484 - }
6485 5584
6486 - if (!empty($acf_content_parts)) {
6487 - $content .= "\n\n" . implode("\n", $acf_content_parts);
6488 - }
6489 -
6490 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
6491 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
6492 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
6493 - $pdf_sections = array();
6494 - foreach ($pdf_attachment_ids as $att_id) {
6495 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
6496 - if (!empty($pdf_text)) {
6497 - $pdf_title = get_the_title($att_id);
6498 - $pdf_url = wp_get_attachment_url($att_id);
6499 - $header = 'PDF Attachment';
6500 - if (!empty($pdf_title)) {
6501 - $header .= ': ' . $pdf_title;
5585 + // Gate the auto-sync PDF-extraction loop behind an opt-in option.
5586 + // Mirrors the per-batch checkbox the manual content selector has; the
5587 + // 25 MB size cap lives in the shared extractor so it applies in both
5588 + // paths regardless. Default OFF — re-parsing every ACF PDF on every
5589 + // editor save is expensive and most sites don't want it.
5590 + $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
5591 + if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
5592 + $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
5593 + $pdf_sections = array();
5594 + foreach ($pdf_attachment_ids as $att_id) {
5595 + $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
5596 + if (!empty($pdf_text)) {
5597 + $pdf_title = get_the_title($att_id);
5598 + $pdf_url = wp_get_attachment_url($att_id);
5599 + $header = 'PDF Attachment';
5600 + if (!empty($pdf_title)) {
5601 + $header .= ': ' . $pdf_title;
5602 + }
5603 + if (!empty($pdf_url)) {
5604 + $header .= ' (' . $pdf_url . ')';
5605 + }
5606 + $pdf_sections[] = $header . "\n" . $pdf_text;
6502 5607 }
6503 - if (!empty($pdf_url)) {
6504 - $header .= ' (' . $pdf_url . ')';
6505 - }
6506 - $pdf_sections[] = $header . "\n" . $pdf_text;
6507 - $pdf_extracted_count++;
6508 5608 }
5609 + if (!empty($pdf_sections)) {
5610 + $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
5611 + }
6509 5612 }
6510 - if (!empty($pdf_sections)) {
6511 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
6512 - }
6513 5613 }
6514 - }
6515 5614
6516 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
6517 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
6518 - if (!empty($custom_meta)) {
6519 - $meta_content_parts = array();
5615 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5616 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
5617 + if (!empty($custom_meta)) {
5618 + $meta_content_parts = array();
6520 5619
6521 - foreach ($custom_meta as $meta_key => $meta_value) {
6522 - // Convert meta key to readable label
6523 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
6524 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
5620 + foreach ($custom_meta as $meta_key => $meta_value) {
5621 + // Convert meta key to readable label
5622 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
5623 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
5624 + }
5625 +
5626 + if (!empty($meta_content_parts)) {
5627 + $final_content .= "\n\n" . implode("\n", $meta_content_parts);
5628 + }
6525 5629 }
6526 5630
6527 - if (!empty($meta_content_parts)) {
6528 - $content .= "\n\n" . implode("\n", $meta_content_parts);
5631 + // Get API key with proper model detection
5632 + $options = get_option('mxchat_options');
5633 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5634 +
5635 + if (strpos($selected_model, 'voyage') === 0) {
5636 + $api_key = $options['voyage_api_key'] ?? '';
5637 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5638 + $api_key = $options['gemini_api_key'] ?? '';
5639 + } else {
5640 + $api_key = $options['api_key'] ?? '';
6529 5641 }
6530 - }
6531 -
6532 - return array(
6533 - 'content' => $content,
6534 - 'acf_fields_found' => count($acf_fields),
6535 - 'pdf_extracted_count' => $pdf_extracted_count,
6536 - );
6537 -}
6538 -
6539 -/**
6540 - * Shared WC-object product assembler (plan a3d60c) — the ONE body behind the two
6541 - * WooCommerce-object ingestion paths: the auto-sync product writer
6542 - * (mxchat_store_product_embedding) and the URL/sitemap product import
6543 - * (mxchat_extract_woocommerce_product_content). Assembles from the WC_Product,
6544 - * the authoritative source for product rows (scope decision on the plan).
6545 - */
6546 -private function mxchat_prepare_product_content_for_indexing($product) {
6547 - $title = $product->get_name();
6548 - $description = $product->get_description();
6549 - $short_description = $product->get_short_description();
6550 -
6551 - // Format content consistently
6552 - $content = $title . "\n\n";
6553 -
6554 - if (!empty($short_description)) {
6555 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6556 - }
6557 -
6558 - if (!empty($description)) {
6559 - $content .= wp_strip_all_tags($description) . "\n\n";
6560 - }
6561 -
6562 - $content .= $this->mxchat_woo_product_summary_lines($product);
6563 - $content .= $this->mxchat_woo_custom_tabs_text($product->get_id());
6564 -
6565 - return $content;
6566 -}
6567 -
6568 -/**
6569 - * Pricing + SKU + categories lines for a product — shared by both assembler kinds
6570 - * (the post-fields product enrichment and the WC-object assembler).
6571 - */
6572 -private function mxchat_woo_product_summary_lines($product) {
6573 - // Add pricing information (base-currency pinned — see mxchat_product_price_lines)
6574 - $lines = $this->mxchat_product_price_lines($product);
6575 -
6576 - $sku = $product->get_sku();
6577 - if (!empty($sku)) {
6578 - $lines .= "SKU: " . $sku . "\n";
6579 - }
6580 -
6581 - // Get product categories
6582 - $categories = wp_get_post_terms($product->get_id(), 'product_cat', array('fields' => 'names'));
6583 - if (!empty($categories) && !is_wp_error($categories)) {
6584 - $lines .= "Categories: " . implode(', ', $categories) . "\n";
6585 - }
6586 -
6587 - return $lines;
6588 -}
6589 -
6590 -/**
6591 - * Custom Product Tabs text (supports "Custom Product Tabs for WooCommerce" by
6592 - * Code Parrots) — direct tabs plus applied reusable/saved tabs. The ONE copy of
6593 - * the yikes_woo logic; three sites carried byte-identical clones before a3d60c.
6594 - */
6595 -private function mxchat_woo_custom_tabs_text($product_id) {
6596 - $text = '';
6597 -
6598 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6599 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
6600 - foreach ($custom_tabs as $tab) {
6601 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6602 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6603 -
6604 - if (!empty($tab_title) && !empty($tab_content)) {
6605 - $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6606 - }
5642 +
5643 + if (empty($api_key)) {
5644 + return;
6607 5645 }
5646 +
5647 + // Use the centralized utility function for storage
5648 + $result = MxChat_Utils::submit_content_to_db(
5649 + $final_content,
5650 + $source_url,
5651 + $api_key,
5652 + md5($source_url) // Vector ID for Pinecone
5653 + );
5654 +
5655 + // After successful storage, apply role restriction based on tags
5656 + if (!is_wp_error($result)) {
5657 + $this->apply_role_restriction_to_post($post_id, $source_url);
5658 + }
6608 5659 }
6609 -
6610 - // Also check for reusable/saved tabs applied to this product
6611 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6612 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6613 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6614 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
6615 - foreach ($applied_saved_tabs as $saved_tab_id) {
6616 - if (isset($saved_tabs[$saved_tab_id])) {
6617 - $tab = $saved_tabs[$saved_tab_id];
6618 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6619 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
6620 -
6621 - if (!empty($tab_title) && !empty($tab_content)) {
6622 - $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6623 - }
6624 - }
6625 - }
6626 - }
5660 +
5661 + // Clean up the stored previous status if not used above
5662 + if ($previous_status !== 'publish' || $post->post_status === 'publish') {
5663 + delete_transient($previous_status_key);
5664 + delete_transient($previous_url_key);
6627 5665 }
6628 -
6629 - return $text;
6630 5666 }
6631 5667
6632 5668 /**
6633 5669 * Store the post status and URL before update to detect status transitions
@@ -6633,12 +5669,8 @@
6633 5669 * Store the post status and URL before update to detect status transitions
6634 5670 * This runs before the post is actually updated in the database
6635 5671 */
6636 5672 public function mxchat_store_pre_update_status($post_id, $data) {
6637 - // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
6638 - // this request and can consume the arrival-edge guard (plan a664f3).
6639 - $this->pending_post_update[$post_id] = true;
6640 -
6641 5673 // Get the current post from database (before update)
6642 5674 $current_post = get_post($post_id);
6643 5675
6644 5676 if ($current_post) {
@@ -6654,407 +5686,8 @@
6654 5686 }
6655 5687 }
6656 5688 }
6657 5689
6658 -/**
6659 - * Whether auto-sync is enabled for a post type (mirrors the checks used by the
6660 - * update/delete handlers; kept as one helper so new call sites cannot drift).
6661 - */
6662 -private function mxchat_is_auto_sync_enabled($post_type) {
6663 - if ($post_type === 'post') {
6664 - return get_option('mxchat_auto_sync_posts') === '1';
6665 - }
6666 - if ($post_type === 'page') {
6667 - return get_option('mxchat_auto_sync_pages') === '1';
6668 - }
6669 - return get_option('mxchat_auto_sync_' . $post_type) === '1';
6670 -}
6671 -
6672 -/**
6673 - * Remove a post's vectors the moment it leaves 'publish', using the authoritative
6674 - * old status core passes to transition_post_status — no transient involved (plan 816fb1).
6675 - *
6676 - * Covers status changes that never route through wp_update_post (scheduled-expiry
6677 - * plugins and others that flip post_status directly and call wp_transition_post_status),
6678 - * where neither pre_post_update nor post_updated fires and the old detection missed.
6679 - */
6680 -public function mxchat_handle_status_transition($new_status, $old_status, $post) {
6681 - if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
6682 - return;
6683 - }
6684 -
6685 - // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
6686 - // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
6687 - // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
6688 - // auto-sync ADD path alone misses them. Editor publishes also pass through here;
6689 - // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
6690 - // a second time in the same request.
6691 - if ($new_status === 'publish' && $old_status !== 'publish') {
6692 - if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
6693 - $this->mxchat_index_published_post($post->ID, $post);
6694 -
6695 - // Arm the double-fire guard ONLY when a post_updated is actually coming to
6696 - // consume it (plan a664f3). Two publish paths never fire post_updated at all:
6697 - // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
6698 - // call check_and_publish_future_post() makes for scheduled posts. Arming the
6699 - // guard unconditionally left it set with nothing to consume it, so the NEXT
6700 - // update of that post was swallowed entirely: zero embed calls, no knowledge
6701 - // -base row, silently. Consume-once on this side too, so a guard can never
6702 - // outlive the single save it was armed for.
6703 - if (!empty($this->pending_post_update[$post->ID])) {
6704 - unset($this->pending_post_update[$post->ID]);
6705 - $this->transition_indexed_posts[$post->ID] = true;
6706 - }
6707 - }
6708 - return;
6709 - }
6710 -
6711 - // Only the publish -> not-publish edge matters here.
6712 - if ($old_status !== 'publish' || $new_status === 'publish') {
6713 - return;
6714 - }
6715 - // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
6716 - // resolution; skip to avoid a second network round-trip per trash.
6717 - if ($new_status === 'trash') {
6718 - return;
6719 - }
6720 - if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
6721 - return;
6722 - }
6723 -
6724 - $urls = array();
6725 -
6726 - // The DB may already hold the new status when this fires, so get_permalink() on the
6727 - // live post could build a draft-style URL whose md5 misses the stored vector IDs.
6728 - // Reconstruct the published permalink from a clone instead.
6729 - $published_clone = clone $post;
6730 - $published_clone->post_status = 'publish';
6731 - $published_url = get_permalink($published_clone);
6732 - if ($published_url) {
6733 - $urls[] = $published_url;
6734 - }
6735 -
6736 - // Honour the pre-update capture when present (covers a slug change in the same save).
6737 - $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
6738 - if (!empty($previous_url)) {
6739 - $urls[] = $previous_url;
6740 - }
6741 -
6742 - foreach (array_unique($urls) as $url) {
6743 - MxChat_Utils::delete_chunks_for_url($url, 'default');
6744 - }
6745 -
6746 - if (!empty($urls)) {
6747 - $this->transition_deleted_posts[$post->ID] = true;
6748 - }
6749 -}
6750 -
6751 -/**
6752 - * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
6753 - * trashed, or made private before the transition_post_status handler existed.
6754 - *
6755 - * Walks every auto-synced post type's non-published posts, reconstructs each one's
6756 - * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
6757 - * Deletion is idempotent, so never-indexed posts are a cheap no-op.
6758 - *
6759 - * ## OPTIONS
6760 - *
6761 - * [--dry-run]
6762 - * : Report what would be removed without deleting anything.
6763 - *
6764 - * ## EXAMPLES
6765 - *
6766 - * wp mxchat prune-unpublished --dry-run
6767 - * wp mxchat prune-unpublished
6768 - */
6769 -public function cli_prune_unpublished($args, $assoc_args) {
6770 - global $wpdb;
6771 - $dry_run = !empty($assoc_args['dry-run']);
6772 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6773 -
6774 - $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
6775 - $synced_types = array();
6776 - foreach ($candidate_types as $type) {
6777 - if ($this->mxchat_is_auto_sync_enabled($type)) {
6778 - $synced_types[] = $type;
6779 - }
6780 - }
6781 - if (empty($synced_types)) {
6782 - WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
6783 - return;
6784 - }
6785 -
6786 - $scanned = 0;
6787 - $pruned = 0;
6788 - $paged = 1;
6789 - do {
6790 - $query = new WP_Query(array(
6791 - 'post_type' => $synced_types,
6792 - 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
6793 - 'posts_per_page' => 100,
6794 - 'paged' => $paged,
6795 - 'fields' => 'ids',
6796 - ));
6797 - foreach ($query->posts as $post_id) {
6798 - $post = get_post($post_id);
6799 - if (!$post) {
6800 - continue;
6801 - }
6802 - $scanned++;
6803 -
6804 - // Rebuild the permalink the post had while published: publish-status clone,
6805 - // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
6806 - $clone = clone $post;
6807 - $clone->post_status = 'publish';
6808 - if (substr($clone->post_name, -9) === '__trashed') {
6809 - $clone->post_name = substr($clone->post_name, 0, -9);
6810 - }
6811 - $url = get_permalink($clone);
6812 - if (!$url) {
6813 - continue;
6814 - }
6815 -
6816 - // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
6817 - // reads 0 but the delete below still routes to Pinecone and is idempotent.
6818 - $local_rows = (int) $wpdb->get_var($wpdb->prepare(
6819 - "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
6820 - ));
6821 -
6822 - if ($dry_run) {
6823 - if ($local_rows > 0) {
6824 - WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
6825 - $pruned += $local_rows;
6826 - }
6827 - continue;
6828 - }
6829 -
6830 - MxChat_Utils::delete_chunks_for_url($url, 'default');
6831 - if ($local_rows > 0) {
6832 - WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
6833 - $pruned += $local_rows;
6834 - }
6835 - }
6836 - $more = $paged < $query->max_num_pages;
6837 - $paged++;
6838 - } while ($more);
6839 -
6840 - WP_CLI::success(sprintf(
6841 - '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
6842 - $dry_run ? 'Would remove' : 'Removed',
6843 - $pruned,
6844 - $scanned,
6845 - ' (Pinecone-mode deletions are not counted locally.)'
6846 - ));
6847 -}
6848 -
6849 -/**
6850 - * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual
6851 - * (reversed) order before the RTL normalizer existed. 32bf9e fixed new
6852 - * imports only; this fixes rows already in the table without the customer
6853 - * having to re-source and re-upload the original PDFs (plan d1e6f7).
6854 - *
6855 - * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a
6856 - * row is a candidate exactly when the normalizer would change it, so the
6857 - * import-time heuristic and the repair heuristic can never disagree.
6858 - * Repaired rows are RE-EMBEDDED — the stored vector was computed over
6859 - * reversed text and is as broken as the text — so a wet run calls the
6860 - * embedding provider once per repaired row on the site's API key. Runs
6861 - * beyond 25 rows therefore require --yes.
6862 - *
6863 - * Scope notes:
6864 - * - Scans the WordPress knowledge table. Pinecone-mode entries live in
6865 - * Pinecone, not this table, and are not scanned; if a scanned row's bot
6866 - * ALSO has Pinecone enabled (hybrid drift), the repaired entry is
6867 - * re-submitted through the normal import path so the md5-keyed Pinecone
6868 - * vector is replaced too.
6869 - * - Knowledge rows do not carry a bot id; --bot only selects whose
6870 - * embedding configuration (model + key) is used for re-embedding.
6871 - * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled
6872 - * normalization gets detections of zero, not surprise rewrites.
6873 - * - The metadata header the PDF importer stores before the text separator
6874 - * is preserved byte-identical; only the text segment is repaired.
6875 - *
6876 - * ## OPTIONS
6877 - *
6878 - * [--dry-run]
6879 - * : List the rows that would be repaired without changing anything.
6880 - *
6881 - * [--bot=<id>]
6882 - * : Embedding configuration to use for re-embedding. Default: default.
6883 - *
6884 - * [--all-content]
6885 - * : Scan every row containing right-to-left text, not just rows with PDF
6886 - * provenance (a page anchor in the source URL, or pdf content type).
6887 - *
6888 - * [--yes]
6889 - * : Proceed even when more than 25 rows need re-embedding (API cost gate).
6890 - *
6891 - * ## EXAMPLES
6892 - *
6893 - * wp mxchat rtl-repair --dry-run
6894 - * wp mxchat rtl-repair
6895 - * wp mxchat rtl-repair --all-content --yes
6896 - */
6897 -public function cli_rtl_repair($args, $assoc_args) {
6898 - global $wpdb;
6899 - $dry_run = !empty($assoc_args['dry-run']);
6900 - $all = !empty($assoc_args['all-content']);
6901 - $yes = !empty($assoc_args['yes']);
6902 - $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default';
6903 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6904 -
6905 - // Detection pass — no API calls. Walk the table in id batches so a large
6906 - // knowledge base never loads at once.
6907 - $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';
6908 - $candidates = array();
6909 - $scanned = 0;
6910 - $last_id = 0;
6911 - do {
6912 - if ($all) {
6913 - $rows = $wpdb->get_results($wpdb->prepare(
6914 - "SELECT id, article_content, source_url, content_type FROM {$table}
6915 - WHERE id > %d ORDER BY id ASC LIMIT 200",
6916 - $last_id
6917 - ));
6918 - } else {
6919 - $rows = $wpdb->get_results($wpdb->prepare(
6920 - "SELECT id, article_content, source_url, content_type FROM {$table}
6921 - WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf')
6922 - ORDER BY id ASC LIMIT 200",
6923 - $last_id,
6924 - '%' . $wpdb->esc_like('#page=') . '%'
6925 - ));
6926 - }
6927 - foreach ($rows as $row) {
6928 - $last_id = (int) $row->id;
6929 - $scanned++;
6930 - $content = (string) $row->article_content;
6931 - if (!preg_match($rtl_re, $content)) {
6932 - continue;
6933 - }
6934 - list($header, $text) = $this->mxchat_rtl_repair_split($content);
6935 - $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id);
6936 - if (is_string($normalized) && $normalized !== $text) {
6937 - $candidates[] = array(
6938 - 'id' => (int) $row->id,
6939 - 'source_url' => (string) $row->source_url,
6940 - 'content_type' => (string) $row->content_type,
6941 - 'new_content' => $header . $normalized,
6942 - );
6943 - }
6944 - }
6945 - } while (count($rows) === 200);
6946 -
6947 - WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates)));
6948 - if (empty($candidates)) {
6949 - WP_CLI::success('No reversed RTL rows found — nothing to repair.');
6950 - return;
6951 - }
6952 -
6953 - foreach ($candidates as $c) {
6954 - WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url']));
6955 - }
6956 - if ($dry_run) {
6957 - WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates)));
6958 - return;
6959 - }
6960 -
6961 - // Cost gate: re-embedding spends the customer's API budget.
6962 - WP_CLI::log(sprintf('Re-embedding will call the embedding provider once per row — %d call(s) on this site\'s API key.', count($candidates)));
6963 - if (count($candidates) > 25 && !$yes) {
6964 - WP_CLI::error(sprintf('%d rows need re-embedding (more than 25). Re-run with --yes to confirm the API cost. No rows were changed.', count($candidates)));
6965 - }
6966 -
6967 - $bot_options = $this->get_bot_options($bot_id);
6968 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6969 - $preflight = MxChat_Utils::embedding_preflight($options);
6970 - if (!$preflight['ok']) {
6971 - WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']);
6972 - }
6973 - $api_key = $preflight['api_key'];
6974 -
6975 - $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id);
6976 - $repaired = 0;
6977 - $failed = 0;
6978 - foreach ($candidates as $c) {
6979 - $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id);
6980 - if (!is_array($vector)) {
6981 - $failed++;
6982 - $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed';
6983 - // Text and vector must stay consistent: never write repaired text
6984 - // beside the stale reversed-text vector.
6985 - WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason));
6986 - continue;
6987 - }
6988 - $wpdb->update(
6989 - $table,
6990 - array(
6991 - 'article_content' => $c['new_content'],
6992 - 'embedding_vector' => maybe_serialize($vector),
6993 - ),
6994 - array('id' => $c['id']),
6995 - array('%s', '%s'),
6996 - array('%d')
6997 - );
6998 - $repaired++;
6999 - if (class_exists('MxChat_Admin')) {
7000 - MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array(
7001 - 'row_id' => $c['id'],
7002 - 'source_url' => $c['source_url'],
7003 - 'bot' => $bot_id,
7004 - ));
7005 - }
7006 - // Hybrid drift: the bot indexes into Pinecone but this row sat in the
7007 - // WP table — push the repaired entry through the normal import path so
7008 - // the md5(source_url)-keyed Pinecone vector is replaced as well.
7009 - if ($pinecone_hybrid) {
7010 - MxChat_Utils::submit_content_to_db(
7011 - $c['new_content'],
7012 - $c['source_url'],
7013 - $api_key,
7014 - null,
7015 - $bot_id,
7016 - $c['content_type'] !== '' ? $c['content_type'] : 'pdf'
7017 - );
7018 - }
7019 - }
7020 -
7021 - WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned));
7022 -}
7023 -
7024 -/**
7025 - * Split a stored KB row into (metadata header incl. separator, text segment).
7026 - * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text —
7027 - * repair must touch only the text and keep the header byte-identical.
7028 - */
7029 -private function mxchat_rtl_repair_split($content) {
7030 - $sep = "\n---\n";
7031 - $pos = strpos($content, $sep);
7032 - if ($pos !== false && $pos > 0 && $content[0] === '{') {
7033 - $maybe_json = substr($content, 0, $pos);
7034 - if (json_decode($maybe_json) !== null) {
7035 - return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep)));
7036 - }
7037 - }
7038 - return array('', $content);
7039 -}
7040 -
7041 -/**
7042 - * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for
7043 - * the repair CLI's hybrid-drift check.
7044 - */
7045 -private function mxchat_rtl_repair_pinecone_enabled($bot_id) {
7046 - if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
7047 - $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
7048 - if (!empty($cfg)) {
7049 - return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']);
7050 - }
7051 - }
7052 - $po = get_option('mxchat_pinecone_addon_options');
7053 - return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0'
7054 - && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']);
7055 -}
7056 -
7057 5690 public function mxchat_handle_post_delete($post_id) {
7058 5691 // Get post data before it's deleted
7059 5692 $post = get_post($post_id);
7060 5693
@@ -7154,20 +5787,110 @@
7154 5787
7155 5788 $source_url = get_permalink($product->get_id());
7156 5789 $product_id = $product->get_id();
7157 5790
7158 - // Build product content via the shared WC-object assembler (a3d60c) — this
7159 - // writer owns product rows whenever the integration is on.
7160 - $content = $this->mxchat_prepare_product_content_for_indexing($product);
5791 + // Build product content
5792 + $title = $product->get_name();
5793 + $description = $product->get_description();
5794 + $short_description = $product->get_short_description();
5795 + $regular_price = $product->get_regular_price();
5796 + $sale_price = $product->get_sale_price();
5797 + $price = $product->get_price();
5798 + $sku = $product->get_sku();
7161 5799
7162 - // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
7163 - // shape preserved.
7164 - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
7165 - if (!$preflight['ok']) {
7166 - //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
5800 + // Get currency symbol
5801 + $currency_symbol = get_woocommerce_currency_symbol();
5802 +
5803 + // Format content consistently
5804 + $content = $title . "\n\n";
5805 +
5806 + if (!empty($short_description)) {
5807 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
5808 + }
5809 +
5810 + if (!empty($description)) {
5811 + $content .= wp_strip_all_tags($description) . "\n\n";
5812 + }
5813 +
5814 + // Add pricing information
5815 + if (!empty($regular_price)) {
5816 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
5817 + } elseif (!empty($price)) {
5818 + $content .= "Price: " . $currency_symbol . $price . "\n";
5819 + }
5820 +
5821 + if (!empty($sale_price) && $sale_price !== $regular_price) {
5822 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5823 + }
5824 +
5825 + // Handle variable products - show price range
5826 + if ($product->is_type('variable')) {
5827 + $min_price = $product->get_variation_price('min');
5828 + $max_price = $product->get_variation_price('max');
5829 + if ($min_price !== $max_price) {
5830 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5831 + }
5832 + }
5833 +
5834 + if (!empty($sku)) {
5835 + $content .= "SKU: " . $sku . "\n";
5836 + }
5837 +
5838 + // Get product categories
5839 + $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
5840 + if (!empty($categories) && !is_wp_error($categories)) {
5841 + $content .= "Categories: " . implode(', ', $categories) . "\n";
5842 + }
5843 +
5844 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
5845 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
5846 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
5847 + foreach ($custom_tabs as $tab) {
5848 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5849 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
5850 +
5851 + if (!empty($tab_title) && !empty($tab_content)) {
5852 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5853 + }
5854 + }
5855 + }
5856 +
5857 + // Also check for reusable/saved tabs applied to this product
5858 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
5859 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
5860 + // Get the saved tabs option
5861 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
5862 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
5863 + foreach ($applied_saved_tabs as $saved_tab_id) {
5864 + if (isset($saved_tabs[$saved_tab_id])) {
5865 + $tab = $saved_tabs[$saved_tab_id];
5866 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5867 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
5868 +
5869 + if (!empty($tab_title) && !empty($tab_content)) {
5870 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5871 + }
5872 + }
5873 + }
5874 + }
5875 + }
5876 +
5877 + // Get API key with proper model detection
5878 + $options = get_option('mxchat_options');
5879 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5880 +
5881 + if (strpos($selected_model, 'voyage') === 0) {
5882 + $api_key = $options['voyage_api_key'] ?? '';
5883 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5884 + $api_key = $options['gemini_api_key'] ?? '';
5885 + } else {
5886 + $api_key = $options['api_key'] ?? '';
5887 + }
5888 +
5889 + if (empty($api_key)) {
5890 + //error_log('MxChat Auto-sync: No API key configured for embedding model');
7167 5891 return;
7168 5892 }
7169 - $api_key = $preflight['api_key'];
7170 5893
7171 5894 // Use the centralized utility function for storage
7172 5895 $result = MxChat_Utils::submit_content_to_db(
7173 5896 $content,
@@ -7243,23 +5966,17 @@
7243 5966
7244 5967 // Delete from Pinecone
7245 5968 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
7246 5969 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
7247 - $vector_id,
7248 - $pinecone_options['mxchat_pinecone_api_key'],
7249 - $pinecone_options['mxchat_pinecone_host'],
7250 - $pinecone_options['mxchat_pinecone_namespace'] ?? ''
5970 + $vector_id,
5971 + $pinecone_options['mxchat_pinecone_api_key'],
5972 + $pinecone_options['mxchat_pinecone_host']
7251 5973 );
7252 5974
7253 5975 if ($result['success']) {
7254 - // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6);
7255 - // a chunk vector id reduces to its base entry there.
7256 - if (class_exists('MxChat_Vectorstore_Manager')) {
7257 - MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, 'default');
7258 - }
7259 5976 // No cache clearing needed since we removed caching
7260 - set_transient('mxchat_admin_notice_success',
7261 - esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
5977 + set_transient('mxchat_admin_notice_success',
5978 + esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
7262 5979 30
7263 5980 );
7264 5981 } else {
7265 5982 set_transient('mxchat_admin_notice_error',
@@ -7304,21 +6021,16 @@
7304 6021 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
7305 6022 exit;
7306 6023 }
7307 6024
7308 - // Delete from the correct Pinecone index and namespace
6025 + // Delete from the correct Pinecone index
7309 6026 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
7310 - $vector_id,
7311 - $pinecone_options['mxchat_pinecone_api_key'],
7312 - $pinecone_options['mxchat_pinecone_host'],
7313 - $pinecone_options['mxchat_pinecone_namespace'] ?? ''
6027 + $vector_id,
6028 + $pinecone_options['mxchat_pinecone_api_key'],
6029 + $pinecone_options['mxchat_pinecone_host']
7314 6030 );
7315 6031
7316 6032 if ($result['success']) {
7317 - // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6)
7318 - if (class_exists('MxChat_Vectorstore_Manager')) {
7319 - MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, $bot_id);
7320 - }
7321 6033 // No cache clearing needed since we removed caching
7322 6034 wp_send_json_success(array(
7323 6035 'message' => 'Entry deleted successfully from Pinecone',
7324 6036 'vector_id' => $vector_id,
@@ -7417,13 +6129,8 @@
7417 6129 }
7418 6130 }
7419 6131
7420 6132 if (empty($vectors_to_delete)) {
7421 - // Entry already gone from Pinecone — still clear any mirrored
7422 - // Vector Store file so it can't outlive the entry (plan 15b5c6).
7423 - if (class_exists('MxChat_Vectorstore_Manager')) {
7424 - MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
7425 - }
7426 6133 wp_send_json_success(array(
7427 6134 'message' => 'No vectors found to delete',
7428 6135 'source_url' => $source_url
7429 6136 ));
@@ -7464,13 +6171,8 @@
7464 6171 wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
7465 6172 exit;
7466 6173 }
7467 6174
7468 - // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
7469 - if (class_exists('MxChat_Vectorstore_Manager')) {
7470 - MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
7471 - }
7472 -
7473 6175 wp_send_json_success(array(
7474 6176 'message' => 'All chunks deleted successfully from Pinecone',
7475 6177 'source_url' => $source_url,
7476 6178 'deleted_count' => count($vectors_to_delete)
@@ -7492,13 +6194,8 @@
7492 6194 wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
7493 6195 exit;
7494 6196 }
7495 6197
7496 - // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
7497 - if (class_exists('MxChat_Vectorstore_Manager')) {
7498 - MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
7499 - }
7500 -
7501 6198 wp_send_json_success(array(
7502 6199 'message' => 'All chunks deleted successfully from database',
7503 6200 'source_url' => $source_url,
7504 6201 'deleted_count' => $result
@@ -7533,15 +6230,8 @@
7533 6230
7534 6231 global $wpdb;
7535 6232 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
7536 6233
7537 - // Capture the identity BEFORE the row disappears — needed to mirror the
7538 - // change into the Vector Store (plan 15b5c6).
7539 - $source_url = $wpdb->get_var($wpdb->prepare(
7540 - "SELECT source_url FROM {$table_name} WHERE id = %d",
7541 - $entry_id
7542 - ));
7543 -
7544 6234 // Clear cache for this entry
7545 6235 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
7546 6236
7547 6237 // Delete from database
@@ -7551,23 +6241,8 @@
7551 6241 array('%d')
7552 6242 );
7553 6243
7554 6244 if ($result !== false) {
7555 - // Mirror to the Vector Store: if sibling rows remain (this was one
7556 - // chunk of a larger entry) the entry's file is REFRESHED from what's
7557 - // left; if none remain, the file is removed.
7558 - if (!empty($source_url) && class_exists('MxChat_Vectorstore_Manager')) {
7559 - $remaining = (int) $wpdb->get_var($wpdb->prepare(
7560 - "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
7561 - $source_url
7562 - ));
7563 - if ($remaining > 0) {
7564 - MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, '', 'default');
7565 - } else {
7566 - MxChat_Vectorstore_Manager::sync_delete_entry($source_url, 'default');
7567 - }
7568 - }
7569 -
7570 6245 wp_send_json_success(array(
7571 6246 'message' => 'Entry deleted successfully',
7572 6247 'entry_id' => $entry_id
7573 6248 ));
@@ -7620,9 +6295,8 @@
7620 6295 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
7621 6296
7622 6297 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
7623 6298 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
7624 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
7625 6299
7626 6300 // =============================================
7627 6301 // PHASE 1: Collect all Pinecone vector IDs
7628 6302 // and separate WordPress entries
@@ -7629,10 +6303,8 @@
7629 6303 // =============================================
7630 6304 $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
7631 6305 $wordpress_entries = array(); // entries for WordPress DB deletion
7632 6306 $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
7633 - $vs_mirror_urls = array(); // Vector Store mirror: URLs to delete (plan 15b5c6)
7634 - $vs_mirror_keys = array(); // Vector Store mirror: bare vector ids to delete
7635 6307
7636 6308 foreach ($entries as $entry) {
7637 6309 $entry_id = sanitize_text_field($entry['id'] ?? '');
7638 6310 $source = sanitize_text_field($entry['source'] ?? 'wordpress');
@@ -7657,11 +6329,8 @@
7657 6329 $base_vector_id = md5($source_url);
7658 6330 $all_vector_ids[] = $base_vector_id;
7659 6331
7660 6332 $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
7661 - if (!empty($namespace)) {
7662 - $list_url .= '&namespace=' . rawurlencode($namespace);
7663 - }
7664 6333 $list_response = wp_remote_get($list_url, array(
7665 6334 'headers' => array(
7666 6335 'Api-Key' => $api_key,
7667 6336 'accept' => 'application/json'
@@ -7682,14 +6351,8 @@
7682 6351 } else {
7683 6352 // Single entry: the entry_id IS the vector ID
7684 6353 $all_vector_ids[] = $entry_id;
7685 6354 }
7686 -
7687 - if (!empty($source_url)) {
7688 - $vs_mirror_urls[] = $source_url;
7689 - } else {
7690 - $vs_mirror_keys[] = $entry_id;
7691 - }
7692 6355 } else {
7693 6356 $wordpress_entries[] = $entry;
7694 6357 }
7695 6358 }
@@ -7702,12 +6365,8 @@
7702 6365 $pinecone_success = true;
7703 6366 $batches = array_chunk($all_vector_ids, 100);
7704 6367
7705 6368 foreach ($batches as $batch) {
7706 - $delete_body = array('ids' => $batch);
7707 - if (!empty($namespace)) {
7708 - $delete_body['namespace'] = $namespace;
7709 - }
7710 6369 $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
7711 6370 'headers' => array(
7712 6371 'Api-Key' => $api_key,
7713 6372 'accept' => 'application/json',
@@ -7712,9 +6371,9 @@
7712 6371 'Api-Key' => $api_key,
7713 6372 'accept' => 'application/json',
7714 6373 'content-type' => 'application/json'
7715 6374 ),
7716 - 'body' => wp_json_encode($delete_body),
6375 + 'body' => wp_json_encode(array('ids' => $batch)),
7717 6376 'timeout' => 60
7718 6377 ));
7719 6378
7720 6379 if (is_wp_error($delete_response)) {
@@ -7739,18 +6398,8 @@
7739 6398 } else {
7740 6399 $failed_ids[] = $eid;
7741 6400 }
7742 6401 }
7743 -
7744 - // Mirror the removals to the OpenAI Vector Store (plan 15b5c6)
7745 - if ($pinecone_success && class_exists('MxChat_Vectorstore_Manager')) {
7746 - foreach (array_unique($vs_mirror_urls) as $vs_url) {
7747 - MxChat_Vectorstore_Manager::sync_delete_entry($vs_url, $bot_id);
7748 - }
7749 - foreach (array_unique($vs_mirror_keys) as $vs_key) {
7750 - MxChat_Vectorstore_Manager::sync_delete_by_key($vs_key, $bot_id);
7751 - }
7752 - }
7753 6402 }
7754 6403
7755 6404 // =============================================
7756 6405 // PHASE 3: WordPress database deletions
@@ -7770,15 +6419,9 @@
7770 6419 $table_name,
7771 6420 array('source_url' => $source_url),
7772 6421 array('%s')
7773 6422 );
7774 - $row_url = $source_url;
7775 6423 } else {
7776 - // Identity captured pre-delete for the Vector Store mirror
7777 - $row_url = $wpdb->get_var($wpdb->prepare(
7778 - "SELECT source_url FROM {$table_name} WHERE id = %d",
7779 - intval($entry_id)
7780 - ));
7781 6424 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
7782 6425 $result = $wpdb->delete(
7783 6426 $table_name,
7784 6427 array('id' => intval($entry_id)),
@@ -7787,21 +6430,8 @@
7787 6430 }
7788 6431
7789 6432 if ($result !== false) {
7790 6433 $success_ids[] = $entry_id;
7791 - // Mirror to the Vector Store: refresh the entry's file when
7792 - // sibling chunk rows survive, remove it when none do.
7793 - if (!empty($row_url) && class_exists('MxChat_Vectorstore_Manager')) {
7794 - $remaining = (int) $wpdb->get_var($wpdb->prepare(
7795 - "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
7796 - $row_url
7797 - ));
7798 - if ($remaining > 0) {
7799 - MxChat_Vectorstore_Manager::sync_upsert_entry($row_url, '', $bot_id);
7800 - } else {
7801 - MxChat_Vectorstore_Manager::sync_delete_entry($row_url, $bot_id);
7802 - }
7803 - }
7804 6434 } else {
7805 6435 $failed_ids[] = $entry_id;
7806 6436 $errors[] = "Database error for entry: $entry_id";
7807 6437 }
@@ -7953,29 +6583,9 @@
7953 6583 if ($result === false) {
7954 6584 wp_send_json_error('Database update failed: ' . $wpdb->last_error);
7955 6585 exit;
7956 6586 }
7957 -
7958 - // Keep the OpenAI Vector Store mirror consistent with the new restriction
7959 - // (plan 15b5c6): non-public pulls the mirrored file, public re-mirrors.
7960 - if (class_exists('MxChat_Vectorstore_Manager')) {
7961 - if ($data_source === 'pinecone') {
7962 - if ($role_restriction !== 'public') {
7963 - MxChat_Vectorstore_Manager::sync_delete_by_key($entry_id, 'default');
7964 - }
7965 - // Public again: Pinecone-mode content isn't held locally, so the
7966 - // entry re-mirrors on its next save/import rather than here.
7967 - } else {
7968 - $row_url = $wpdb->get_var($wpdb->prepare(
7969 - "SELECT source_url FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
7970 - absint($entry_id)
7971 - ));
7972 - if (!empty($row_url)) {
7973 - MxChat_Vectorstore_Manager::handle_role_change($row_url, 'default', $role_restriction);
7974 - }
7975 - }
7976 - }
7977 -
6587 +
7978 6588 wp_send_json_success(array(
7979 6589 'message' => 'Role restriction updated successfully',
7980 6590 'role_restriction' => $role_restriction,
7981 6591 'data_source' => $data_source,
@@ -8018,16 +6628,16 @@
8018 6628 wp_send_json_error('Unauthorized access');
8019 6629 exit;
8020 6630 }
8021 6631
8022 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6632 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
8023 6633 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
8024 -
8025 - if (empty($tag_input)) {
8026 - wp_send_json_error('Please enter a tag name or slug');
6634 +
6635 + if (empty($tag_slug)) {
6636 + wp_send_json_error('Tag slug is required');
8027 6637 exit;
8028 6638 }
8029 -
6639 +
8030 6640 // Validate role restriction
8031 6641 $valid_roles = array_keys($this->mxchat_get_role_options());
8032 6642 if (!in_array($role_restriction, $valid_roles)) {
8033 6643 wp_send_json_error('Invalid role restriction');
@@ -8032,27 +6642,16 @@
8032 6642 if (!in_array($role_restriction, $valid_roles)) {
8033 6643 wp_send_json_error('Invalid role restriction');
8034 6644 exit;
8035 6645 }
8036 -
8037 - // Resolve the tag by slug first, then fall back to its display name, so users can
8038 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
8039 - // labeled by name but previously validated by slug only, producing the confusing
8040 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
8041 - $term = get_term_by('slug', $tag_input, 'post_tag');
6646 +
6647 + // Check if tag exists in WordPress
6648 + $term = get_term_by('slug', $tag_slug, 'post_tag');
8042 6649 if (!$term) {
8043 - $term = get_term_by('name', $tag_input, 'post_tag');
8044 - }
8045 - if (!$term) {
8046 - 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.');
6650 + wp_send_json_error('Tag does not exist in WordPress');
8047 6651 exit;
8048 6652 }
8049 -
8050 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
8051 - // compares against each post's tag slugs, so the stored key must be a slug,
8052 - // never the raw (possibly display-name) input.
8053 - $tag_slug = $term->slug;
8054 -
6653 +
8055 6654 // Get existing mappings
8056 6655 $mappings = get_option('mxchat_tag_role_mappings', array());
8057 6656
8058 6657 // Check if mapping already exists
@@ -8327,9 +6926,9 @@
8327 6926 );
8328 6927 } else {
8329 6928 // Update WordPress DB
8330 6929 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8331 -
6930 +
8332 6931 $wpdb->update(
8333 6932 $table_name,
8334 6933 array('role_restriction' => $highest_role),
8335 6934 array('source_url' => $source_url),
@@ -8336,15 +6935,8 @@
8336 6935 array('%s'),
8337 6936 array('%s')
8338 6937 );
8339 6938 }
8340 -
8341 - // The entry's restriction just changed — keep the OpenAI Vector Store
8342 - // mirror consistent: non-public pulls the file (file_search has no
8343 - // per-role filtering), public re-mirrors it (plan 15b5c6).
8344 - if (class_exists('MxChat_Vectorstore_Manager')) {
8345 - MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
8346 - }
8347 6939 }
8348 6940
8349 6941 /**
8350 6942 * Apply role restriction after content is stored (for auto-sync)
@@ -8413,9 +7005,9 @@
8413 7005 );
8414 7006 } else {
8415 7007 // Update WordPress DB
8416 7008 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
8417 -
7009 +
8418 7010 $wpdb->update(
8419 7011 $table_name,
8420 7012 array('role_restriction' => $highest_role),
8421 7013 array('source_url' => $source_url),
@@ -8422,15 +7014,8 @@
8422 7014 array('%s'),
8423 7015 array('%s')
8424 7016 );
8425 7017 }
8426 -
8427 - // The entry's restriction just changed — keep the OpenAI Vector Store
8428 - // mirror consistent: non-public pulls the file (file_search has no
8429 - // per-role filtering), public re-mirrors it (plan 15b5c6).
8430 - if (class_exists('MxChat_Vectorstore_Manager')) {
8431 - MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
8432 - }
8433 7018 }
8434 7019
8435 7020
8436 7021 // ========================================
@@ -8891,19 +7476,25 @@
8891 7476 if (empty($url)) {
8892 7477 return new WP_Error('invalid_url', 'URL is empty');
8893 7478 }
8894 7479
8895 - // Get bot-specific embedding decision early (needed for both paths) —
8896 - // custom-provider-aware (plan cbd5fd). Error code preserved.
7480 + // Get bot-specific API key early (needed for both paths)
8897 7481 $bot_options = $this->get_bot_options($bot_id);
8898 7482 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7483 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
8899 7484
8900 - $preflight = MxChat_Utils::embedding_preflight($options);
8901 - if (!$preflight['ok']) {
8902 - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
7485 + if (strpos($selected_model, 'voyage') === 0) {
7486 + $api_key = $options['voyage_api_key'] ?? '';
7487 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7488 + $api_key = $options['gemini_api_key'] ?? '';
7489 + } else {
7490 + $api_key = $options['api_key'] ?? '';
8903 7491 }
8904 - $api_key = $preflight['api_key'];
8905 7492
7493 + if (empty($api_key)) {
7494 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7495 + }
7496 +
8906 7497 // Check if this is a WooCommerce product URL and WooCommerce is active
8907 7498 $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
8908 7499 $content_type = $is_product_url ? 'product' : 'url';
8909 7500
@@ -8930,9 +7521,9 @@
8930 7521 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
8931 7522 $response = wp_remote_get($url, array(
8932 7523 'timeout' => $is_likely_pdf ? 120 : 30,
8933 7524 'redirection' => 5,
8934 - 'user-agent' => mxchat_ingest_user_agent(),
7525 + 'user-agent' => 'MxChat/1.0'
8935 7526 ));
8936 7527
8937 7528 if (is_wp_error($response)) {
8938 7529 return $response;
@@ -9097,9 +7688,8 @@
9097 7688
9098 7689 for ($i = 0; $i < $total_pages; $i++) {
9099 7690 $page_num = $i + 1;
9100 7691 $text = $pages[$i]->getText();
9101 - $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num);
9102 7692 if (empty($text)) {
9103 7693 $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
9104 7694 continue;
9105 7695 }
@@ -9186,12 +7776,95 @@
9186 7776 if (!$product) {
9187 7777 return false;
9188 7778 }
9189 7779
9190 - // Build product content via the shared WC-object assembler (a3d60c) — same
9191 - // body as the auto-sync product writer, so the two paths can never drift.
9192 - $content = $this->mxchat_prepare_product_content_for_indexing($product);
7780 + // Build product content with pricing (similar to mxchat_store_product_embedding)
7781 + $title = $product->get_name();
7782 + $description = $product->get_description();
7783 + $short_description = $product->get_short_description();
7784 + $sku = $product->get_sku();
9193 7785
7786 + // Get pricing information
7787 + $regular_price = $product->get_regular_price();
7788 + $sale_price = $product->get_sale_price();
7789 + $price = $product->get_price(); // Current active price
7790 +
7791 + // Get currency symbol
7792 + $currency_symbol = get_woocommerce_currency_symbol();
7793 +
7794 + // Format content
7795 + $content = $title . "\n\n";
7796 +
7797 + if (!empty($short_description)) {
7798 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7799 + }
7800 +
7801 + if (!empty($description)) {
7802 + $content .= wp_strip_all_tags($description) . "\n\n";
7803 + }
7804 +
7805 + // Add pricing information
7806 + if (!empty($regular_price)) {
7807 + $content .= "Price: " . $currency_symbol . $regular_price . "\n";
7808 + } elseif (!empty($price)) {
7809 + $content .= "Price: " . $currency_symbol . $price . "\n";
7810 + }
7811 +
7812 + if (!empty($sale_price) && $sale_price !== $regular_price) {
7813 + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
7814 + }
7815 +
7816 + // Handle variable products - show price range
7817 + if ($product->is_type('variable')) {
7818 + $min_price = $product->get_variation_price('min');
7819 + $max_price = $product->get_variation_price('max');
7820 + if ($min_price !== $max_price) {
7821 + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
7822 + }
7823 + }
7824 +
7825 + if (!empty($sku)) {
7826 + $content .= "SKU: " . $sku . "\n";
7827 + }
7828 +
7829 + // Get product categories
7830 + $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
7831 + if (!empty($categories) && !is_wp_error($categories)) {
7832 + $content .= "Categories: " . implode(', ', $categories) . "\n";
7833 + }
7834 +
7835 + // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
7836 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7837 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
7838 + foreach ($custom_tabs as $tab) {
7839 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7840 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
7841 +
7842 + if (!empty($tab_title) && !empty($tab_content)) {
7843 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7844 + }
7845 + }
7846 + }
7847 +
7848 + // Also check for reusable/saved tabs applied to this product
7849 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7850 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7851 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7852 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
7853 + foreach ($applied_saved_tabs as $saved_tab_id) {
7854 + if (isset($saved_tabs[$saved_tab_id])) {
7855 + $tab = $saved_tabs[$saved_tab_id];
7856 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7857 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
7858 +
7859 + if (!empty($tab_title) && !empty($tab_content)) {
7860 + $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7861 + }
7862 + }
7863 + }
7864 + }
7865 + }
7866 +
9194 7867 return $this->mxchat_sanitize_content_for_api($content);
9195 7868 }
9196 7869
9197 7870 /**
@@ -9221,9 +7894,8 @@
9221 7894 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
9222 7895 }
9223 7896
9224 7897 $text = $pages[$page_number - 1]->getText();
9225 - $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number);
9226 7898
9227 7899 if (empty($text)) {
9228 7900 return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
9229 7901 }
@@ -9244,18 +7916,24 @@
9244 7916
9245 7917 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
9246 7918 $page_url = esc_url($pdf_url . "#page=" . $page_number);
9247 7919
9248 - // Get bot-specific embedding decision — custom-provider-aware
9249 - // (plan cbd5fd). Error code preserved.
7920 + // Get bot-specific API key
9250 7921 $bot_options = $this->get_bot_options($bot_id);
9251 7922 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
9252 -
9253 - $preflight = MxChat_Utils::embedding_preflight($options);
9254 - if (!$preflight['ok']) {
9255 - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
7923 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7924 +
7925 + if (strpos($selected_model, 'voyage') === 0) {
7926 + $api_key = $options['voyage_api_key'] ?? '';
7927 + } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7928 + $api_key = $options['gemini_api_key'] ?? '';
7929 + } else {
7930 + $api_key = $options['api_key'] ?? '';
9256 7931 }
9257 - $api_key = $preflight['api_key'];
7932 +
7933 + if (empty($api_key)) {
7934 + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7935 + }
9258 7936
9259 7937 // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
9260 7938 $result = MxChat_Utils::submit_content_to_db(
9261 7939 $content_with_metadata,