PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.8
MxChat – AI Chatbot & Content Generation for WordPress v3.1.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 +113 -838 3.2.143.1.8 View file →
@@ -31,9 +31,8 @@
31 31 // Admin post handlers for form submissions
32 32 add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 33 add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 34 add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
35 - add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
36 35 add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
37 36
38 37 // AJAX handlers for real-time processing and status updates
39 38 add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
@@ -59,10 +58,12 @@
59 58 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
60 59 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
61 60 add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
62 61 add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
63 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
64 62
63 + // Hook for content deletion
64 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
65 +
65 66 // WordPress post management hooks
66 67 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
67 68 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
68 69 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
@@ -158,253 +159,8 @@
158 159 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
159 160 exit;
160 161 }
161 162
162 -/**
163 - * Handle the "YouTube" KB import source (admin-post form submission).
164 - *
165 - * Per-video description mode:
166 - * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
167 - * If no usable transcript, index the metadata anyway, tell the admin,
168 - * and bounce back with the manual box pre-filled (never fail silently).
169 - * - manual: the admin's own description is what gets indexed; metadata rides along.
170 - *
171 - * The row is stored with content_type 'youtube' and source_url = the canonical
172 - * watch URL, so re-importing the same video UPDATES the entry (source_url
173 - * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
174 - * "augment a metadata-only entry" path.
175 - */
176 -public function mxchat_handle_youtube_submission() {
177 - if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
178 - wp_die(esc_html__('Unauthorized access', 'mxchat'));
179 - }
180 -
181 - check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
182 -
183 - $redirect_url = admin_url('admin.php?page=mxchat-prompts');
184 -
185 - $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
186 - $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
187 -
188 - if (empty($video_id)) {
189 - set_transient('mxchat_admin_notice_error',
190 - esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
191 - 30
192 - );
193 - wp_safe_redirect(esc_url($redirect_url));
194 - exit;
195 - }
196 -
197 - $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
198 -
199 - $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
200 - $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
201 -
202 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
203 -
204 - // Resolve the embedding API key exactly like the sibling handlers.
205 - $bot_options = $this->get_bot_options($bot_id);
206 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
207 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
208 -
209 - if (strpos($selected_model, 'voyage') === 0) {
210 - $api_key = $options['voyage_api_key'] ?? '';
211 - $provider_name = 'Voyage AI';
212 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
213 - $api_key = $options['gemini_api_key'] ?? '';
214 - $provider_name = 'Google Gemini';
215 - } else {
216 - $api_key = $options['api_key'] ?? '';
217 - $provider_name = 'OpenAI';
218 - }
219 -
220 - if (empty($api_key)) {
221 - set_transient('mxchat_admin_notice_error',
222 - sprintf(
223 - esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
224 - $provider_name
225 - ),
226 - 30
227 - );
228 - wp_safe_redirect(esc_url($redirect_url));
229 - exit;
230 - }
231 -
232 - // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
233 - // manual mode it enriches the indexed text with the real title/channel.
234 - $meta = $this->mxchat_fetch_youtube_oembed($video_id);
235 - $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
236 - $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
237 -
238 - $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
239 - if ($video_channel !== '') {
240 - $header_lines .= 'Channel: ' . $video_channel . "\n";
241 - }
242 - $header_lines .= 'URL: ' . $canonical_url . "\n\n";
243 -
244 - $transcript_missing = false;
245 -
246 - if ($description_mode === 'manual') {
247 - if ($manual_description === '') {
248 - set_transient('mxchat_admin_notice_error',
249 - esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
250 - 30
251 - );
252 - wp_safe_redirect(esc_url($redirect_url));
253 - exit;
254 - }
255 - $indexed_text = $header_lines . $manual_description;
256 - } else {
257 - $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
258 -
259 - if (strlen($transcript) >= 200) {
260 - $indexed_text = $header_lines . $transcript;
261 - } else {
262 - // Graceful fallback: captions disabled / blocked / no speech. Auto
263 - // reliably gets metadata; it does NOT guarantee a transcript.
264 - $transcript_missing = true;
265 -
266 - if ($video_title === '' && $video_channel === '') {
267 - // Both halves failed — nothing meaningful to index.
268 - set_transient('mxchat_admin_notice_error',
269 - 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'),
270 - 30
271 - );
272 - wp_safe_redirect(esc_url($redirect_url));
273 - exit;
274 - }
275 -
276 - $indexed_text = $header_lines . sprintf(
277 - /* translators: 1: video title, 2: channel name */
278 - __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
279 - $video_title !== '' ? $video_title : $canonical_url,
280 - $video_channel !== '' ? $video_channel : 'YouTube'
281 - );
282 - }
283 - }
284 -
285 - $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
286 -
287 - if (is_wp_error($result)) {
288 - set_transient('mxchat_admin_notice_error',
289 - esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
290 - 30
291 - );
292 - wp_safe_redirect(esc_url($redirect_url));
293 - exit;
294 - }
295 -
296 - if ($transcript_missing) {
297 - set_transient('mxchat_admin_notice_success',
298 - 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'),
299 - 30
300 - );
301 - // Bounce back with prefill args so the page reopens the YouTube form in
302 - // manual mode with the URL + fetched title ready to augment.
303 - $redirect_url = add_query_arg(array(
304 - 'mxchat_yt_prefill' => '1',
305 - 'yt_url' => rawurlencode($canonical_url),
306 - 'yt_title' => rawurlencode($video_title),
307 - ), $redirect_url);
308 - } else {
309 - set_transient('mxchat_admin_notice_success',
310 - esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
311 - 30
312 - );
313 - }
314 -
315 - wp_safe_redirect(esc_url_raw($redirect_url));
316 - exit;
317 -}
318 -
319 -/**
320 - * Fetch YouTube oEmbed metadata for a video (no API key required).
321 - * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
322 - */
323 -private function mxchat_fetch_youtube_oembed($video_id) {
324 - $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
325 - $response = wp_remote_get($oembed_url, array('timeout' => 15));
326 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
327 - return array();
328 - }
329 - $data = json_decode(wp_remote_retrieve_body($response), true);
330 - return is_array($data) ? $data : array();
331 -}
332 -
333 -/**
334 - * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
335 - * YouTube's unofficial timedtext route (the caption track list embedded in the
336 - * watch page), which YouTube has broken before and will break again. Every
337 - * failure mode returns '' so a break degrades to the metadata-only import path
338 - * instead of erroring the whole submission. Do not let anything in here throw.
339 - */
340 -private function mxchat_fetch_youtube_transcript($video_id) {
341 - $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
342 -
343 - // First try the honest ingest UA; some responses omit the player config for
344 - // bot UAs, so retry once with a browser UA before giving up.
345 - $user_agents = array(
346 - mxchat_ingest_user_agent(),
347 - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
348 - );
349 -
350 - $tracks = array();
351 - foreach ($user_agents as $ua) {
352 - $response = wp_remote_get($watch_url, array(
353 - 'timeout' => 20,
354 - 'user-agent' => $ua,
355 - ));
356 - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
357 - continue;
358 - }
359 - $body = wp_remote_retrieve_body($response);
360 - if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
361 - continue;
362 - }
363 - $decoded = json_decode($m[1], true);
364 - if (is_array($decoded) && !empty($decoded)) {
365 - $tracks = $decoded;
366 - break;
367 - }
368 - }
369 -
370 - if (empty($tracks)) {
371 - return '';
372 - }
373 -
374 - // Prefer an English track, else take the first offered.
375 - $chosen = null;
376 - foreach ($tracks as $track) {
377 - if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
378 - $chosen = $track;
379 - break;
380 - }
381 - }
382 - if ($chosen === null) {
383 - $chosen = $tracks[0];
384 - }
385 - if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
386 - return '';
387 - }
388 -
389 - $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
390 - if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
391 - return '';
392 - }
393 - $xml = wp_remote_retrieve_body($timedtext);
394 - if (!is_string($xml) || strpos($xml, '<text') === false) {
395 - return '';
396 - }
397 -
398 - // <text start=".." dur="..">caption</text> — strip tags, decode the
399 - // double-encoded entities timedtext ships, collapse whitespace.
400 - $text = preg_replace('/<[^>]+>/', ' ', $xml);
401 - $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
402 - $text = trim(preg_replace('/\s+/u', ' ', $text));
403 -
404 - return $text;
405 -}
406 -
407 163 public function mxchat_is_pdf_url($url, $response) {
408 164 $content_type = wp_remote_retrieve_header($response, 'content-type');
409 165 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
410 166
@@ -1061,230 +817,8 @@
1061 817 );
1062 818 }
1063 819
1064 820 /**
1065 - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1066 - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1067 - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1068 - */
1069 -public function ajax_mxchat_inspect_entry() {
1070 - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1071 -
1072 - if ( ! current_user_can('manage_options') ) {
1073 - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1074 - }
1075 -
1076 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1077 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1078 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1079 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1080 -
1081 - if ( $data_source === 'pinecone' ) {
1082 - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
1083 - } else {
1084 - $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1085 - }
1086 -
1087 - if ( is_wp_error( $result ) ) {
1088 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1089 - }
1090 -
1091 - wp_send_json_success( $result );
1092 -}
1093 -
1094 -/**
1095 - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1096 - * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1097 - * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1098 - */
1099 -private function inspect_wordpress_entry( $source_url, $entry_id ) {
1100 - global $wpdb;
1101 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1102 -
1103 - $rows = array();
1104 -
1105 - // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1106 - // Direct Content entries (the spec's manual-entry case), which share one
1107 - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1108 - // display key (invented by the table view for rows with no source_url) is
1109 - // excluded; those fall through to the entry_id lookup below.
1110 - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1111 - $rows = $wpdb->get_results( $wpdb->prepare(
1112 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1113 - $source_url
1114 - ) );
1115 - }
1116 -
1117 - // Fallback / manual "Direct Content" entries: fetch the single row by id.
1118 - if ( empty( $rows ) && $entry_id > 0 ) {
1119 - $row = $wpdb->get_row( $wpdb->prepare(
1120 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1121 - $entry_id
1122 - ) );
1123 - if ( $row ) {
1124 - $rows = array( $row );
1125 - }
1126 - }
1127 -
1128 - if ( empty( $rows ) ) {
1129 - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1130 - }
1131 -
1132 - $chunks = array();
1133 - $content_type = '';
1134 - foreach ( $rows as $row ) {
1135 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1136 - $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1137 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1138 - $content_type = $row->content_type;
1139 - $chunks[] = array(
1140 - 'index' => $index,
1141 - 'text' => $text,
1142 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1143 - 'row_id' => intval( $row->id ),
1144 - );
1145 - }
1146 -
1147 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1148 -
1149 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1150 -
1151 - return array(
1152 - 'store' => 'wordpress',
1153 - 'source_url' => $source_url,
1154 - 'content_type' => $content_type,
1155 - 'is_chunked' => count( $chunks ) > 1,
1156 - 'chunk_count' => count( $chunks ),
1157 - 'assembled' => $assembled,
1158 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1159 - 'chunks' => array_values( $chunks ),
1160 - // WP-DB storage carries no separate vector metadata; surface that fact
1161 - // rather than letting the owner guess (the spec's taxonomy question).
1162 - 'metadata' => array(),
1163 - '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'),
1164 - );
1165 -}
1166 -
1167 -/**
1168 - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1169 - * but keeps each vector's text + metadata instead of imploding, so the owner can
1170 - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1171 - * are present per chunk. READ-ONLY.
1172 - */
1173 -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1174 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1175 - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1176 - }
1177 -
1178 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1179 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1180 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1181 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1182 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1183 - } else {
1184 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1185 - $api_key = $bot_config['api_key'] ?? '';
1186 - $host = $bot_config['host'] ?? '';
1187 - $namespace = $bot_config['namespace'] ?? '';
1188 - }
1189 -
1190 - if ( empty($host) || empty($api_key) ) {
1191 - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1192 - }
1193 -
1194 - $base_id = md5( $source_url );
1195 - $vector_ids = array( $base_id );
1196 -
1197 - $list_url = "https://{$host}/vectors/list";
1198 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1199 - if ( ! empty($namespace) ) {
1200 - $list_body['namespace'] = $namespace;
1201 - }
1202 -
1203 - $list_resp = wp_remote_post( $list_url, array(
1204 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1205 - 'body' => wp_json_encode( $list_body ),
1206 - 'timeout' => 15,
1207 - ) );
1208 -
1209 - if ( ! is_wp_error($list_resp) ) {
1210 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1211 - if ( ! empty($list_data['vectors']) ) {
1212 - foreach ( $list_data['vectors'] as $v ) {
1213 - $vector_ids[] = $v['id'];
1214 - }
1215 - }
1216 - }
1217 -
1218 - $fetch_url = "https://{$host}/vectors/fetch";
1219 - $fetch_body = array( 'ids' => $vector_ids );
1220 - if ( ! empty($namespace) ) {
1221 - $fetch_body['namespace'] = $namespace;
1222 - }
1223 -
1224 - $fetch_resp = wp_remote_post( $fetch_url, array(
1225 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
1226 - 'body' => wp_json_encode( $fetch_body ),
1227 - 'timeout' => 15,
1228 - ) );
1229 -
1230 - if ( is_wp_error($fetch_resp) ) {
1231 - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1232 - }
1233 -
1234 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1235 - $vectors = $fetch_data['vectors'] ?? array();
1236 -
1237 - if ( empty($vectors) ) {
1238 - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1239 - }
1240 -
1241 - // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1242 - // what is (and is NOT) stored per vector.
1243 - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1244 - $chunks = array();
1245 - $content_type = '';
1246 - foreach ( $vectors as $vid => $vector ) {
1247 - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1248 - $text = $meta['text'] ?? '';
1249 - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1250 - $content_type = $meta['type'] ?? $content_type;
1251 -
1252 - $clean_meta = array();
1253 - foreach ( $meta_fields as $field ) {
1254 - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1255 - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1256 - }
1257 - }
1258 -
1259 - $chunks[] = array(
1260 - 'index' => $index,
1261 - 'text' => $text,
1262 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1263 - 'vector_id' => (string) $vid,
1264 - 'metadata' => $clean_meta,
1265 - );
1266 - }
1267 -
1268 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1269 -
1270 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1271 -
1272 - return array(
1273 - 'store' => 'pinecone',
1274 - 'source_url' => $source_url,
1275 - 'content_type' => $content_type,
1276 - 'is_chunked' => count( $chunks ) > 1,
1277 - 'chunk_count' => count( $chunks ),
1278 - 'assembled' => $assembled,
1279 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1280 - 'chunks' => array_values( $chunks ),
1281 - 'metadata' => array(),
1282 - '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'),
1283 - );
1284 -}
1285 -
1286 -/**
1287 821 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1288 822 * Works for both WordPress DB and Pinecone entries.
1289 823 */
1290 824 public function ajax_mxchat_save_entry_content() {
@@ -1459,21 +993,16 @@
1459 993 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1460 994 exit;
1461 995 }
1462 996
1463 - // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1464 - // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1465 - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1466 - // from the site's own media library, which route through this same call).
1467 - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1468 - // the browser-only Accept-Language fingerprint is dropped so it stays
1469 - // coherent with a bot identity.
997 + // Fetch URL — use browser-like headers so servers with bot protection don't block us
1470 998 $response = wp_remote_get($submitted_url, array(
1471 999 'timeout' => 30,
1472 1000 'sslverify' => false,
1473 - 'user-agent' => mxchat_ingest_user_agent(),
1001 + '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',
1474 1002 'headers' => array(
1475 1003 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1004 + 'Accept-Language' => 'en-US,en;q=0.9',
1476 1005 ),
1477 1006 ));
1478 1007
1479 1008 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
@@ -3312,9 +2841,9 @@
3312 2841 $response = wp_remote_head($url, array(
3313 2842 'timeout' => 10,
3314 2843 'sslverify' => false,
3315 2844 'redirection' => 1,
3316 - 'user-agent' => mxchat_ingest_user_agent(),
2845 + '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',
3317 2846 ));
3318 2847
3319 2848 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3320 2849 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3374,11 +2903,12 @@
3374 2903
3375 2904 $response = wp_remote_get($url, array(
3376 2905 'timeout' => 30,
3377 2906 'sslverify' => false,
3378 - 'user-agent' => mxchat_ingest_user_agent(),
2907 + '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',
3379 2908 'headers' => array(
3380 2909 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2910 + 'Accept-Language' => 'en-US,en;q=0.9',
3381 2911 ),
3382 2912 ));
3383 2913
3384 2914 if (is_wp_error($response)) {
@@ -3432,11 +2962,12 @@
3432 2962 private function get_sitemap_url_count($url) {
3433 2963 $response = wp_remote_get($url, array(
3434 2964 'timeout' => 30,
3435 2965 'sslverify' => false,
3436 - 'user-agent' => mxchat_ingest_user_agent(),
2966 + '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',
3437 2967 'headers' => array(
3438 2968 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2969 + 'Accept-Language' => 'en-US,en;q=0.9',
3439 2970 ),
3440 2971 ));
3441 2972
3442 2973 if (is_wp_error($response)) {
@@ -3462,9 +2993,9 @@
3462 2993
3463 2994 $response = wp_remote_get($robots_url, array(
3464 2995 'timeout' => 15,
3465 2996 'sslverify' => false,
3466 - 'user-agent' => mxchat_ingest_user_agent(),
2997 + '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',
3467 2998 ));
3468 2999
3469 3000 if (is_wp_error($response)) {
3470 3001 return $sitemaps;
@@ -3954,22 +3485,9 @@
3954 3485 }
3955 3486
3956 3487 // Get bot_id from request
3957 3488 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3958 -
3959 - // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3960 - // don't re-check on every batch; the default is OFF for installs that haven't set it.
3961 - $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3962 - $mxchat_options = get_option('mxchat_options', array());
3963 - if (!is_array($mxchat_options)) {
3964 - $mxchat_options = array();
3965 - }
3966 - $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3967 - if ($prior_default !== $extract_acf_pdfs) {
3968 - $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3969 - update_option('mxchat_options', $mxchat_options);
3970 - }
3971 -
3489 +
3972 3490 // Process only ONE post at a time to avoid request size issues
3973 3491 $post_id = reset($post_ids);
3974 3492 $post = get_post($post_id);
3975 3493
@@ -4075,57 +3593,23 @@
4075 3593 }
4076 3594
4077 3595 // ADD ACF FIELDS SUPPORT
4078 3596 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
4079 - $pdf_extracted_count = 0;
4080 3597 if (!empty($acf_fields)) {
4081 3598 $acf_content_parts = array();
4082 - $pdf_attachment_ids = array();
4083 -
3599 +
4084 3600 foreach ($acf_fields as $field_name => $field_value) {
4085 3601 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
4086 -
3602 +
4087 3603 if (!empty($formatted_value)) {
4088 3604 $field_label = ucwords(str_replace('_', ' ', $field_name));
4089 3605 $acf_content_parts[] = $field_label . ": " . $formatted_value;
4090 3606 }
4091 -
4092 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
4093 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
4094 - // still lands in the KB but the heavier PDF parsing is skipped.
4095 - if ($extract_acf_pdfs) {
4096 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
4097 - }
4098 3607 }
4099 -
3608 +
4100 3609 if (!empty($acf_content_parts)) {
4101 3610 $content .= "\n\n" . implode("\n", $acf_content_parts);
4102 3611 }
4103 -
4104 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
4105 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
4106 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
4107 - $pdf_sections = array();
4108 - foreach ($pdf_attachment_ids as $att_id) {
4109 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
4110 - if (!empty($pdf_text)) {
4111 - $pdf_title = get_the_title($att_id);
4112 - $pdf_url = wp_get_attachment_url($att_id);
4113 - $header = 'PDF Attachment';
4114 - if (!empty($pdf_title)) {
4115 - $header .= ': ' . $pdf_title;
4116 - }
4117 - if (!empty($pdf_url)) {
4118 - $header .= ' (' . $pdf_url . ')';
4119 - }
4120 - $pdf_sections[] = $header . "\n" . $pdf_text;
4121 - $pdf_extracted_count++;
4122 - }
4123 - }
4124 - if (!empty($pdf_sections)) {
4125 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
4126 - }
4127 - }
4128 3612 }
4129 3613
4130 3614 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
4131 3615 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4255,9 +3739,8 @@
4255 3739 'title' => $post->post_title,
4256 3740 'operation_type' => $operation_type,
4257 3741 'vector_id' => $vector_id,
4258 3742 'acf_fields_found' => $acf_field_count,
4259 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4260 3743 'content_preview' => substr($content, 0, 100) . '...',
4261 3744 'bot_id' => $bot_id
4262 3745 ));
4263 3746 exit;
@@ -4672,20 +4155,9 @@
4672 4155
4673 4156 // Get bot-specific options
4674 4157 $bot_options = $this->get_bot_options($bot_id);
4675 4158 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4676 -
4677 - // Opt-in: when the custom provider is selected for embeddings, index through
4678 - // the same custom endpoint the query path uses so stored vectors and query
4679 - // vectors share a model. Returns the vector array on success, or an error
4680 - // string on failure (this function's existing failure contract).
4681 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4682 - if (!class_exists('MxChat_Utils')) {
4683 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4684 - }
4685 - return MxChat_Utils::generate_embedding_custom($text, $options);
4686 - }
4687 -
4159 +
4688 4160 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4689 4161 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4690 4162
4691 4163 // Determine provider and endpoint
@@ -5603,197 +5075,8 @@
5603 5075 return implode(', ', array_filter($text_parts));
5604 5076 }
5605 5077
5606 5078 /**
5607 - * Walk an ACF field value tree and collect attachment IDs for any value that
5608 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5609 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5610 - * plain URL string), and recurses through repeater/group/flexible content.
5611 - *
5612 - * @param mixed $value The ACF field value (any depth)
5613 - * @param array $out Accumulator (passed by reference) for attachment IDs
5614 - * @param int $depth Recursion guard
5615 - */
5616 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5617 - if ($depth > 6) {
5618 - return; // prevent runaway recursion on circular/very-deep structures
5619 - }
5620 -
5621 - if (empty($value)) {
5622 - return;
5623 - }
5624 -
5625 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5626 - if (is_array($value)) {
5627 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5628 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5629 - if ($looks_like_attachment) {
5630 - $att_id = 0;
5631 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5632 - $att_id = (int) $value['ID'];
5633 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5634 - $att_id = (int) $value['id'];
5635 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5636 - $att_id = (int) attachment_url_to_postid($value['url']);
5637 - }
5638 -
5639 - $is_pdf = false;
5640 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5641 - $is_pdf = true;
5642 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5643 - $is_pdf = true;
5644 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5645 - $is_pdf = true;
5646 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5647 - $is_pdf = true;
5648 - }
5649 -
5650 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5651 - $out[] = $att_id;
5652 - }
5653 - // An array node that represents one attachment doesn't contain other
5654 - // attachments inside it — done with this branch.
5655 - return;
5656 - }
5657 -
5658 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5659 - foreach ($value as $sub) {
5660 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5661 - }
5662 - return;
5663 - }
5664 -
5665 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5666 - if (is_numeric($value)) {
5667 - $att_id = (int) $value;
5668 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5669 - $out[] = $att_id;
5670 - }
5671 - return;
5672 - }
5673 -
5674 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5675 - if (is_string($value)) {
5676 - $trimmed = trim($value);
5677 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5678 - $att_id = (int) attachment_url_to_postid($trimmed);
5679 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5680 - $out[] = $att_id;
5681 - }
5682 - }
5683 - return;
5684 - }
5685 -}
5686 -
5687 -/**
5688 - * Heuristic: does this URL/string look like a PDF reference?
5689 - * Tolerates query strings and fragments (#page=2).
5690 - */
5691 -private function mxchat_url_looks_like_pdf($url) {
5692 - if (!is_string($url) || $url === '') {
5693 - return false;
5694 - }
5695 - // Strip query + fragment before checking extension
5696 - $path = preg_replace('/[?#].*$/', '', $url);
5697 - return (bool) preg_match('/\.pdf$/i', $path);
5698 -}
5699 -
5700 -/**
5701 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5702 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5703 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5704 - * only parse the same PDF once unless the file changes on disk.
5705 - *
5706 - * @param int $attachment_id
5707 - * @return string Extracted plain text, or '' on failure.
5708 - */
5709 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5710 - $attachment_id = (int) $attachment_id;
5711 - if ($attachment_id <= 0) {
5712 - return '';
5713 - }
5714 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5715 - return '';
5716 - }
5717 -
5718 - $pdf_path = get_attached_file($attachment_id);
5719 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5720 - return '';
5721 - }
5722 -
5723 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5724 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5725 - $default_max_bytes = 25 * 1024 * 1024;
5726 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5727 - if ($max_bytes > 0) {
5728 - $file_size = @filesize($pdf_path);
5729 - if ($file_size !== false && $file_size > $max_bytes) {
5730 - error_log(sprintf(
5731 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5732 - $attachment_id,
5733 - basename($pdf_path),
5734 - $file_size,
5735 - $max_bytes
5736 - ));
5737 - return '';
5738 - }
5739 - }
5740 -
5741 - $mtime = @filemtime($pdf_path);
5742 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5743 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5744 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5745 - return (string) $cached['text'];
5746 - }
5747 -
5748 - $text = '';
5749 - try {
5750 - if (function_exists('mxchat_load_pdf_parser')) {
5751 - mxchat_load_pdf_parser();
5752 - }
5753 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5754 - return '';
5755 - }
5756 - $parser = new \Smalot\PdfParser\Parser();
5757 - $pdf = $parser->parseFile($pdf_path);
5758 - $pages = $pdf->getPages();
5759 - $page_texts = array();
5760 - foreach ($pages as $page) {
5761 - $page_text = '';
5762 - try {
5763 - $page_text = $page->getText();
5764 - } catch (\Exception $e) {
5765 - $page_text = '';
5766 - }
5767 - if (!empty($page_text)) {
5768 - $page_texts[] = $page_text;
5769 - }
5770 - }
5771 - $text = trim(implode("\n\n", $page_texts));
5772 - } catch (\Exception $e) {
5773 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5774 - return '';
5775 - } catch (\Throwable $e) {
5776 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5777 - return '';
5778 - }
5779 -
5780 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5781 - // The chunker downstream will still split this into multiple vectors.
5782 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5783 - if ($max_len > 0 && strlen($text) > $max_len) {
5784 - $text = substr($text, 0, $max_len);
5785 - }
5786 -
5787 - update_post_meta($attachment_id, $cache_meta_key, array(
5788 - 'mtime' => (int) $mtime,
5789 - 'text' => $text,
5790 - ));
5791 -
5792 - return $text;
5793 -}
5794 -
5795 -/**
5796 5079 * Handle ACF save - fires after ACF fields are saved
5797 5080 * This ensures ACF field data is available when syncing to knowledge base
5798 5081 */
5799 5082 public function mxchat_handle_acf_save($post_id) {
@@ -5901,33 +5184,40 @@
5901 5184 // If the post was previously published but is now not published, remove from knowledge base
5902 5185 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5903 5186 // Use the stored URL from when it was published, or fall back to current permalink
5904 5187 $source_url = $previous_url ?: get_permalink($post_id);
5188 +
5189 + if ($source_url) {
5190 + // Check if Pinecone is enabled
5191 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5192 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5905 5193
5906 - if ($source_url) {
5907 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5908 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5194 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5195 + // Delete from Pinecone
5196 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5197 + } else {
5198 + // Delete from WordPress DB
5199 + global $wpdb;
5200 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5201 +
5202 + $result = $wpdb->delete(
5203 + $table_name,
5204 + array('source_url' => $source_url),
5205 + array('%s')
5206 + );
5207 + }
5909 5208 }
5910 -
5209 +
5911 5210 // Clean up the transients and exit early
5912 5211 delete_transient($previous_status_key);
5913 5212 delete_transient($previous_url_key);
5914 5213 return;
5915 5214 }
5916 -
5917 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5918 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5919 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5920 - $current_url = get_permalink($post_id);
5921 - if ($current_url && $current_url !== $previous_url) {
5922 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5923 - }
5924 - }
5925 -
5215 +
5926 5216 // Store the current status for next time (if this is an update)
5927 5217 if ($update) {
5928 5218 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5929 -
5219 +
5930 5220 // If the post is currently published, also store its URL
5931 5221 if ($post->post_status === 'publish') {
5932 5222 $current_url = get_permalink($post_id);
5933 5223 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
@@ -6036,9 +5326,8 @@
6036 5326 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
6037 5327 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6038 5328 if (!empty($acf_fields)) {
6039 5329 $acf_content_parts = array();
6040 - $pdf_attachment_ids = array();
6041 5330
6042 5331 foreach ($acf_fields as $field_name => $field_value) {
6043 5332 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
6044 5333 if (!empty($formatted_value)) {
@@ -6045,44 +5334,13 @@
6045 5334 // Convert field name to readable label
6046 5335 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
6047 5336 $acf_content_parts[] = $field_label . ": " . $formatted_value;
6048 5337 }
6049 -
6050 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
6051 5338 }
6052 5339
6053 5340 if (!empty($acf_content_parts)) {
6054 5341 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
6055 5342 }
6056 -
6057 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
6058 - // Mirrors the per-batch checkbox the manual content selector has; the
6059 - // 25 MB size cap lives in the shared extractor so it applies in both
6060 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
6061 - // editor save is expensive and most sites don't want it.
6062 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
6063 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
6064 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
6065 - $pdf_sections = array();
6066 - foreach ($pdf_attachment_ids as $att_id) {
6067 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
6068 - if (!empty($pdf_text)) {
6069 - $pdf_title = get_the_title($att_id);
6070 - $pdf_url = wp_get_attachment_url($att_id);
6071 - $header = 'PDF Attachment';
6072 - if (!empty($pdf_title)) {
6073 - $header .= ': ' . $pdf_title;
6074 - }
6075 - if (!empty($pdf_url)) {
6076 - $header .= ' (' . $pdf_url . ')';
6077 - }
6078 - $pdf_sections[] = $header . "\n" . $pdf_text;
6079 - }
6080 - }
6081 - if (!empty($pdf_sections)) {
6082 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
6083 - }
6084 - }
6085 5343 }
6086 5344
6087 5345 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
6088 5346 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -6189,14 +5447,12 @@
6189 5447 if (!$should_sync) {
6190 5448 return;
6191 5449 }
6192 5450
6193 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
6194 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
6195 - // real vector IDs stored under the original URL.
6196 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
5451 + // Get the URL before post is deleted
5452 + $source_url = get_permalink($post_id);
6197 5453 if (!$source_url) {
6198 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5454 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
6199 5455 return;
6200 5456 }
6201 5457
6202 5458 // Use chunk-aware deletion (handles both chunked and non-chunked content)
@@ -6204,36 +5460,56 @@
6204 5460
6205 5461 if (is_wp_error($delete_result)) {
6206 5462 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
6207 5463 }
5464 +}
6208 5465
6209 - delete_transient('mxchat_prev_url_' . $post_id);
6210 - delete_transient('mxchat_prev_status_' . $post_id);
6211 -}
6212 5466
6213 -/**
6214 - * Resolve the source URL for a post being trashed/deleted.
6215 - *
6216 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
6217 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
6218 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
6219 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
6220 - */
6221 -private function mxchat_resolve_pre_trash_url($post_id) {
6222 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
6223 - if (!empty($previous_url)) {
6224 - return $previous_url;
6225 - }
5467 + /**
5468 + * Deletes data from Pinecone using a source URL
5469 + */
5470 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
5471 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5472 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
6226 5473
6227 - $current = get_permalink($post_id);
6228 - if (!$current) {
6229 - return '';
6230 - }
6231 - return preg_replace('#__trashed(/?)$#', '$1', $current);
6232 -}
5474 + if (empty($host) || empty($api_key)) {
5475 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
5476 + return false;
5477 + }
6233 5478
5479 + $api_endpoint = "https://{$host}/vectors/delete";
5480 + $vector_id = md5($source_url);
6234 5481
5482 + $request_body = array(
5483 + 'ids' => array($vector_id)
5484 + );
6235 5485
5486 + $response = wp_remote_post($api_endpoint, array(
5487 + 'headers' => array(
5488 + 'Api-Key' => $api_key,
5489 + 'accept' => 'application/json',
5490 + 'content-type' => 'application/json'
5491 + ),
5492 + 'body' => wp_json_encode($request_body),
5493 + 'timeout' => 30
5494 + ));
5495 +
5496 + if (is_wp_error($response)) {
5497 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
5498 + return false;
5499 + }
5500 +
5501 + $response_code = wp_remote_retrieve_response_code($response);
5502 + if ($response_code !== 200) {
5503 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
5504 + return false;
5505 + }
5506 +
5507 + return true;
5508 + }
5509 +
5510 +
5511 +
6236 5512 public function mxchat_handle_product_change($post_id, $post, $update) {
6237 5513 if ($post->post_type !== 'product') {
6238 5514 return;
6239 5515 }
@@ -6385,18 +5661,28 @@
6385 5661 if (get_post_type($post_id) !== 'product') {
6386 5662 return;
6387 5663 }
6388 5664
6389 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6390 - if (!$source_url) {
6391 - return;
6392 - }
5665 + $source_url = get_permalink($post_id);
6393 5666
6394 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6395 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5667 + // Check if Pinecone is enabled
5668 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5669 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6396 5670
6397 - delete_transient('mxchat_prev_url_' . $post_id);
6398 - delete_transient('mxchat_prev_status_' . $post_id);
5671 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5672 + // Delete from Pinecone
5673 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5674 + } else {
5675 + // Delete from WordPress DB
5676 + global $wpdb;
5677 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5678 +
5679 + $wpdb->delete(
5680 + $table_name,
5681 + array('source_url' => $source_url),
5682 + array('%s')
5683 + );
5684 + }
6399 5685 }
6400 5686
6401 5687 /**
6402 5688 * Handle individual Pinecone content deletion
@@ -7100,16 +6386,16 @@
7100 6386 wp_send_json_error('Unauthorized access');
7101 6387 exit;
7102 6388 }
7103 6389
7104 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6390 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
7105 6391 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
7106 -
7107 - if (empty($tag_input)) {
7108 - wp_send_json_error('Please enter a tag name or slug');
6392 +
6393 + if (empty($tag_slug)) {
6394 + wp_send_json_error('Tag slug is required');
7109 6395 exit;
7110 6396 }
7111 -
6397 +
7112 6398 // Validate role restriction
7113 6399 $valid_roles = array_keys($this->mxchat_get_role_options());
7114 6400 if (!in_array($role_restriction, $valid_roles)) {
7115 6401 wp_send_json_error('Invalid role restriction');
@@ -7114,27 +6400,16 @@
7114 6400 if (!in_array($role_restriction, $valid_roles)) {
7115 6401 wp_send_json_error('Invalid role restriction');
7116 6402 exit;
7117 6403 }
7118 -
7119 - // Resolve the tag by slug first, then fall back to its display name, so users can
7120 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
7121 - // labeled by name but previously validated by slug only, producing the confusing
7122 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
7123 - $term = get_term_by('slug', $tag_input, 'post_tag');
6404 +
6405 + // Check if tag exists in WordPress
6406 + $term = get_term_by('slug', $tag_slug, 'post_tag');
7124 6407 if (!$term) {
7125 - $term = get_term_by('name', $tag_input, 'post_tag');
7126 - }
7127 - if (!$term) {
7128 - 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.');
6408 + wp_send_json_error('Tag does not exist in WordPress');
7129 6409 exit;
7130 6410 }
7131 -
7132 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
7133 - // compares against each post's tag slugs, so the stored key must be a slug,
7134 - // never the raw (possibly display-name) input.
7135 - $tag_slug = $term->slug;
7136 -
6411 +
7137 6412 // Get existing mappings
7138 6413 $mappings = get_option('mxchat_tag_role_mappings', array());
7139 6414
7140 6415 // Check if mapping already exists
@@ -8004,9 +7279,9 @@
8004 7279 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
8005 7280 $response = wp_remote_get($url, array(
8006 7281 'timeout' => $is_likely_pdf ? 120 : 30,
8007 7282 'redirection' => 5,
8008 - 'user-agent' => mxchat_ingest_user_agent(),
7283 + 'user-agent' => 'MxChat/1.0'
8009 7284 ));
8010 7285
8011 7286 if (is_wp_error($response)) {
8012 7287 return $response;