| @@ -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,262 +159,13 @@ | ||
| 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 | |
| 411 | - // Check Content-Disposition header for .pdf filename (Google Drive sends this) | |
| 412 | - $disposition = wp_remote_retrieve_header($response, 'content-disposition'); | |
| 413 | - $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false; | |
| 414 | - | |
| 415 | - return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition; | |
| 167 | + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf'; | |
| 416 | 168 | } |
| 417 | 169 | |
| 418 | 170 | |
| 419 | 171 | public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') { |
| @@ -1061,230 +813,8 @@ | ||
| 1061 | 813 | ); |
| 1062 | 814 | } |
| 1063 | 815 | |
| 1064 | 816 | /** |
| 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 | 817 | * AJAX: Save edited content — re-chunks and re-embeds as needed. |
| 1288 | 818 | * Works for both WordPress DB and Pinecone entries. |
| 1289 | 819 | */ |
| 1290 | 820 | public function ajax_mxchat_save_entry_content() { |
| @@ -1329,17 +859,10 @@ | ||
| 1329 | 859 | } |
| 1330 | 860 | |
| 1331 | 861 | // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first |
| 1332 | 862 | // so submit_content_to_db creates a replacement instead of a duplicate |
| 1333 | - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries | |
| 1334 | - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0; | |
| 1335 | - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) { | |
| 863 | + if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0) ) { | |
| 1336 | 864 | $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) ); |
| 1337 | - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier | |
| 1338 | - // instead of reusing the shared URL (which would mass-delete other entries with the same URL) | |
| 1339 | - if ( $is_legacy_manual ) { | |
| 1340 | - $source_url = ''; | |
| 1341 | - } | |
| 1342 | 865 | } |
| 1343 | 866 | |
| 1344 | 867 | // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB |
| 1345 | 868 | $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id); |
| @@ -1416,22 +939,9 @@ | ||
| 1416 | 939 | exit; |
| 1417 | 940 | } |
| 1418 | 941 | |
| 1419 | 942 | $submitted_url = esc_url_raw($_POST['sitemap_url']); |
| 1420 | - | |
| 1421 | - // Convert Google Drive sharing URLs to direct download URLs | |
| 1422 | - if ( strpos($submitted_url, 'drive.google.com') !== false ) { | |
| 1423 | - $file_id = ''; | |
| 1424 | - if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) { | |
| 1425 | - $file_id = $m[1]; | |
| 1426 | - } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) { | |
| 1427 | - $file_id = $m[1]; | |
| 1428 | - } | |
| 1429 | - if ( ! empty($file_id) ) { | |
| 1430 | - $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id; | |
| 1431 | - } | |
| 1432 | - } | |
| 1433 | - | |
| 943 | + | |
| 1434 | 944 | // Get bot_id from form submission |
| 1435 | 945 | $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; |
| 1436 | 946 | |
| 1437 | 947 | // Get bot-specific options and validate API key |
| @@ -1459,23 +969,10 @@ | ||
| 1459 | 969 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 1460 | 970 | exit; |
| 1461 | 971 | } |
| 1462 | 972 | |
| 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. | |
| 1470 | - $response = wp_remote_get($submitted_url, array( | |
| 1471 | - 'timeout' => 30, | |
| 1472 | - 'sslverify' => false, | |
| 1473 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 1474 | - 'headers' => array( | |
| 1475 | - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 1476 | - ), | |
| 1477 | - )); | |
| 973 | + // Fetch URL | |
| 974 | + $response = wp_remote_get($submitted_url, array('timeout' => 30)); | |
| 1478 | 975 | |
| 1479 | 976 | if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { |
| 1480 | 977 | $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response); |
| 1481 | 978 | set_transient('mxchat_admin_notice_error', |
| @@ -3309,12 +2806,11 @@ | ||
| 3309 | 2806 | foreach ($primary_indexes as $path => $source) { |
| 3310 | 2807 | $url = trailingslashit($site_url) . $path; |
| 3311 | 2808 | |
| 3312 | 2809 | $response = wp_remote_head($url, array( |
| 3313 | - 'timeout' => 10, | |
| 2810 | + 'timeout' => 3, // Short timeout | |
| 3314 | 2811 | 'sslverify' => false, |
| 3315 | - 'redirection' => 1, | |
| 3316 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2812 | + 'redirection' => 1 | |
| 3317 | 2813 | )); |
| 3318 | 2814 | |
| 3319 | 2815 | if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { |
| 3320 | 2816 | // Found a sitemap index - parse it to get sub-sitemaps |
| @@ -3372,14 +2868,10 @@ | ||
| 3372 | 2868 | private function parse_sitemap_index($url) { |
| 3373 | 2869 | $sub_sitemaps = array(); |
| 3374 | 2870 | |
| 3375 | 2871 | $response = wp_remote_get($url, array( |
| 3376 | - 'timeout' => 30, | |
| 3377 | - 'sslverify' => false, | |
| 3378 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 3379 | - 'headers' => array( | |
| 3380 | - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 3381 | - ), | |
| 2872 | + 'timeout' => 5, | |
| 2873 | + 'sslverify' => false | |
| 3382 | 2874 | )); |
| 3383 | 2875 | |
| 3384 | 2876 | if (is_wp_error($response)) { |
| 3385 | 2877 | return $sub_sitemaps; |
| @@ -3430,14 +2922,10 @@ | ||
| 3430 | 2922 | * Get URL count from a sitemap |
| 3431 | 2923 | */ |
| 3432 | 2924 | private function get_sitemap_url_count($url) { |
| 3433 | 2925 | $response = wp_remote_get($url, array( |
| 3434 | - 'timeout' => 30, | |
| 3435 | - 'sslverify' => false, | |
| 3436 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 3437 | - 'headers' => array( | |
| 3438 | - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 3439 | - ), | |
| 2926 | + 'timeout' => 10, | |
| 2927 | + 'sslverify' => false | |
| 3440 | 2928 | )); |
| 3441 | 2929 | |
| 3442 | 2930 | if (is_wp_error($response)) { |
| 3443 | 2931 | return 0; |
| @@ -3460,11 +2948,10 @@ | ||
| 3460 | 2948 | $sitemaps = array(); |
| 3461 | 2949 | $robots_url = trailingslashit($site_url) . 'robots.txt'; |
| 3462 | 2950 | |
| 3463 | 2951 | $response = wp_remote_get($robots_url, array( |
| 3464 | - 'timeout' => 15, | |
| 3465 | - 'sslverify' => false, | |
| 3466 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2952 | + 'timeout' => 5, | |
| 2953 | + 'sslverify' => false | |
| 3467 | 2954 | )); |
| 3468 | 2955 | |
| 3469 | 2956 | if (is_wp_error($response)) { |
| 3470 | 2957 | return $sitemaps; |
| @@ -3954,22 +3441,9 @@ | ||
| 3954 | 3441 | } |
| 3955 | 3442 | |
| 3956 | 3443 | // Get bot_id from request |
| 3957 | 3444 | $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 | - | |
| 3445 | + | |
| 3972 | 3446 | // Process only ONE post at a time to avoid request size issues |
| 3973 | 3447 | $post_id = reset($post_ids); |
| 3974 | 3448 | $post = get_post($post_id); |
| 3975 | 3449 | |
| @@ -4075,57 +3549,23 @@ | ||
| 4075 | 3549 | } |
| 4076 | 3550 | |
| 4077 | 3551 | // ADD ACF FIELDS SUPPORT |
| 4078 | 3552 | $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id); |
| 4079 | - $pdf_extracted_count = 0; | |
| 4080 | 3553 | if (!empty($acf_fields)) { |
| 4081 | 3554 | $acf_content_parts = array(); |
| 4082 | - $pdf_attachment_ids = array(); | |
| 4083 | - | |
| 3555 | + | |
| 4084 | 3556 | foreach ($acf_fields as $field_name => $field_value) { |
| 4085 | 3557 | $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id); |
| 4086 | - | |
| 3558 | + | |
| 4087 | 3559 | if (!empty($formatted_value)) { |
| 4088 | 3560 | $field_label = ucwords(str_replace('_', ' ', $field_name)); |
| 4089 | 3561 | $acf_content_parts[] = $field_label . ": " . $formatted_value; |
| 4090 | 3562 | } |
| 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 | 3563 | } |
| 4099 | - | |
| 3564 | + | |
| 4100 | 3565 | if (!empty($acf_content_parts)) { |
| 4101 | 3566 | $content .= "\n\n" . implode("\n", $acf_content_parts); |
| 4102 | 3567 | } |
| 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 | 3568 | } |
| 4129 | 3569 | |
| 4130 | 3570 | // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields) |
| 4131 | 3571 | $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id); |
| @@ -4255,9 +3695,8 @@ | ||
| 4255 | 3695 | 'title' => $post->post_title, |
| 4256 | 3696 | 'operation_type' => $operation_type, |
| 4257 | 3697 | 'vector_id' => $vector_id, |
| 4258 | 3698 | 'acf_fields_found' => $acf_field_count, |
| 4259 | - 'pdf_extracted_count' => (int) $pdf_extracted_count, | |
| 4260 | 3699 | 'content_preview' => substr($content, 0, 100) . '...', |
| 4261 | 3700 | 'bot_id' => $bot_id |
| 4262 | 3701 | )); |
| 4263 | 3702 | exit; |
| @@ -4672,20 +4111,9 @@ | ||
| 4672 | 4111 | |
| 4673 | 4112 | // Get bot-specific options |
| 4674 | 4113 | $bot_options = $this->get_bot_options($bot_id); |
| 4675 | 4114 | $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 | - | |
| 4115 | + | |
| 4688 | 4116 | $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; |
| 4689 | 4117 | //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model); |
| 4690 | 4118 | |
| 4691 | 4119 | // Determine provider and endpoint |
| @@ -5603,197 +5031,8 @@ | ||
| 5603 | 5031 | return implode(', ', array_filter($text_parts)); |
| 5604 | 5032 | } |
| 5605 | 5033 | |
| 5606 | 5034 | /** |
| 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 | 5035 | * Handle ACF save - fires after ACF fields are saved |
| 5797 | 5036 | * This ensures ACF field data is available when syncing to knowledge base |
| 5798 | 5037 | */ |
| 5799 | 5038 | public function mxchat_handle_acf_save($post_id) { |
| @@ -5901,33 +5140,40 @@ | ||
| 5901 | 5140 | // If the post was previously published but is now not published, remove from knowledge base |
| 5902 | 5141 | if ($previous_status === 'publish' && $post->post_status !== 'publish') { |
| 5903 | 5142 | // Use the stored URL from when it was published, or fall back to current permalink |
| 5904 | 5143 | $source_url = $previous_url ?: get_permalink($post_id); |
| 5144 | + | |
| 5145 | + if ($source_url) { | |
| 5146 | + // Check if Pinecone is enabled | |
| 5147 | + $pinecone_options = get_option('mxchat_pinecone_addon_options', array()); | |
| 5148 | + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; | |
| 5905 | 5149 | |
| 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'); | |
| 5150 | + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) { | |
| 5151 | + // Delete from Pinecone | |
| 5152 | + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options); | |
| 5153 | + } else { | |
| 5154 | + // Delete from WordPress DB | |
| 5155 | + global $wpdb; | |
| 5156 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 5157 | + | |
| 5158 | + $result = $wpdb->delete( | |
| 5159 | + $table_name, | |
| 5160 | + array('source_url' => $source_url), | |
| 5161 | + array('%s') | |
| 5162 | + ); | |
| 5163 | + } | |
| 5909 | 5164 | } |
| 5910 | - | |
| 5165 | + | |
| 5911 | 5166 | // Clean up the transients and exit early |
| 5912 | 5167 | delete_transient($previous_status_key); |
| 5913 | 5168 | delete_transient($previous_url_key); |
| 5914 | 5169 | return; |
| 5915 | 5170 | } |
| 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 | - | |
| 5171 | + | |
| 5926 | 5172 | // Store the current status for next time (if this is an update) |
| 5927 | 5173 | if ($update) { |
| 5928 | 5174 | set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS); |
| 5929 | - | |
| 5175 | + | |
| 5930 | 5176 | // If the post is currently published, also store its URL |
| 5931 | 5177 | if ($post->post_status === 'publish') { |
| 5932 | 5178 | $current_url = get_permalink($post_id); |
| 5933 | 5179 | set_transient($previous_url_key, $current_url, DAY_IN_SECONDS); |
| @@ -6036,9 +5282,8 @@ | ||
| 6036 | 5282 | // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior) |
| 6037 | 5283 | $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id); |
| 6038 | 5284 | if (!empty($acf_fields)) { |
| 6039 | 5285 | $acf_content_parts = array(); |
| 6040 | - $pdf_attachment_ids = array(); | |
| 6041 | 5286 | |
| 6042 | 5287 | foreach ($acf_fields as $field_name => $field_value) { |
| 6043 | 5288 | $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id); |
| 6044 | 5289 | if (!empty($formatted_value)) { |
| @@ -6045,44 +5290,13 @@ | ||
| 6045 | 5290 | // Convert field name to readable label |
| 6046 | 5291 | $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name)); |
| 6047 | 5292 | $acf_content_parts[] = $field_label . ": " . $formatted_value; |
| 6048 | 5293 | } |
| 6049 | - | |
| 6050 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids); | |
| 6051 | 5294 | } |
| 6052 | 5295 | |
| 6053 | 5296 | if (!empty($acf_content_parts)) { |
| 6054 | 5297 | $final_content .= "\n\n" . implode("\n", $acf_content_parts); |
| 6055 | 5298 | } |
| 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 | 5299 | } |
| 6086 | 5300 | |
| 6087 | 5301 | // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields) |
| 6088 | 5302 | $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id); |
| @@ -6189,14 +5403,12 @@ | ||
| 6189 | 5403 | if (!$should_sync) { |
| 6190 | 5404 | return; |
| 6191 | 5405 | } |
| 6192 | 5406 | |
| 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); | |
| 5407 | + // Get the URL before post is deleted | |
| 5408 | + $source_url = get_permalink($post_id); | |
| 6197 | 5409 | if (!$source_url) { |
| 6198 | - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id); | |
| 5410 | + //error_log('MXChat: Failed to get permalink for post ' . $post_id); | |
| 6199 | 5411 | return; |
| 6200 | 5412 | } |
| 6201 | 5413 | |
| 6202 | 5414 | // Use chunk-aware deletion (handles both chunked and non-chunked content) |
| @@ -6204,36 +5416,56 @@ | ||
| 6204 | 5416 | |
| 6205 | 5417 | if (is_wp_error($delete_result)) { |
| 6206 | 5418 | //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message()); |
| 6207 | 5419 | } |
| 5420 | +} | |
| 6208 | 5421 | |
| 6209 | - delete_transient('mxchat_prev_url_' . $post_id); | |
| 6210 | - delete_transient('mxchat_prev_status_' . $post_id); | |
| 6211 | -} | |
| 6212 | 5422 | |
| 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 | - } | |
| 5423 | + /** | |
| 5424 | + * Deletes data from Pinecone using a source URL | |
| 5425 | + */ | |
| 5426 | + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) { | |
| 5427 | + $host = $pinecone_options['mxchat_pinecone_host'] ?? ''; | |
| 5428 | + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? ''; | |
| 6226 | 5429 | |
| 6227 | - $current = get_permalink($post_id); | |
| 6228 | - if (!$current) { | |
| 6229 | - return ''; | |
| 6230 | - } | |
| 6231 | - return preg_replace('#__trashed(/?)$#', '$1', $current); | |
| 6232 | -} | |
| 5430 | + if (empty($host) || empty($api_key)) { | |
| 5431 | + //error_log('MXChat: Pinecone deletion failed - missing configuration'); | |
| 5432 | + return false; | |
| 5433 | + } | |
| 6233 | 5434 | |
| 5435 | + $api_endpoint = "https://{$host}/vectors/delete"; | |
| 5436 | + $vector_id = md5($source_url); | |
| 6234 | 5437 | |
| 5438 | + $request_body = array( | |
| 5439 | + 'ids' => array($vector_id) | |
| 5440 | + ); | |
| 6235 | 5441 | |
| 5442 | + $response = wp_remote_post($api_endpoint, array( | |
| 5443 | + 'headers' => array( | |
| 5444 | + 'Api-Key' => $api_key, | |
| 5445 | + 'accept' => 'application/json', | |
| 5446 | + 'content-type' => 'application/json' | |
| 5447 | + ), | |
| 5448 | + 'body' => wp_json_encode($request_body), | |
| 5449 | + 'timeout' => 30 | |
| 5450 | + )); | |
| 5451 | + | |
| 5452 | + if (is_wp_error($response)) { | |
| 5453 | + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message()); | |
| 5454 | + return false; | |
| 5455 | + } | |
| 5456 | + | |
| 5457 | + $response_code = wp_remote_retrieve_response_code($response); | |
| 5458 | + if ($response_code !== 200) { | |
| 5459 | + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code); | |
| 5460 | + return false; | |
| 5461 | + } | |
| 5462 | + | |
| 5463 | + return true; | |
| 5464 | + } | |
| 5465 | + | |
| 5466 | + | |
| 5467 | + | |
| 6236 | 5468 | public function mxchat_handle_product_change($post_id, $post, $update) { |
| 6237 | 5469 | if ($post->post_type !== 'product') { |
| 6238 | 5470 | return; |
| 6239 | 5471 | } |
| @@ -6385,18 +5617,28 @@ | ||
| 6385 | 5617 | if (get_post_type($post_id) !== 'product') { |
| 6386 | 5618 | return; |
| 6387 | 5619 | } |
| 6388 | 5620 | |
| 6389 | - $source_url = $this->mxchat_resolve_pre_trash_url($post_id); | |
| 6390 | - if (!$source_url) { | |
| 6391 | - return; | |
| 6392 | - } | |
| 5621 | + $source_url = get_permalink($post_id); | |
| 6393 | 5622 | |
| 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'); | |
| 5623 | + // Check if Pinecone is enabled | |
| 5624 | + $pinecone_options = get_option('mxchat_pinecone_addon_options', array()); | |
| 5625 | + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; | |
| 6396 | 5626 | |
| 6397 | - delete_transient('mxchat_prev_url_' . $post_id); | |
| 6398 | - delete_transient('mxchat_prev_status_' . $post_id); | |
| 5627 | + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) { | |
| 5628 | + // Delete from Pinecone | |
| 5629 | + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options); | |
| 5630 | + } else { | |
| 5631 | + // Delete from WordPress DB | |
| 5632 | + global $wpdb; | |
| 5633 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 5634 | + | |
| 5635 | + $wpdb->delete( | |
| 5636 | + $table_name, | |
| 5637 | + array('source_url' => $source_url), | |
| 5638 | + array('%s') | |
| 5639 | + ); | |
| 5640 | + } | |
| 6399 | 5641 | } |
| 6400 | 5642 | |
| 6401 | 5643 | /** |
| 6402 | 5644 | * Handle individual Pinecone content deletion |
| @@ -7100,16 +6342,16 @@ | ||
| 7100 | 6342 | wp_send_json_error('Unauthorized access'); |
| 7101 | 6343 | exit; |
| 7102 | 6344 | } |
| 7103 | 6345 | |
| 7104 | - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : ''; | |
| 6346 | + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : ''; | |
| 7105 | 6347 | $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'); | |
| 6348 | + | |
| 6349 | + if (empty($tag_slug)) { | |
| 6350 | + wp_send_json_error('Tag slug is required'); | |
| 7109 | 6351 | exit; |
| 7110 | 6352 | } |
| 7111 | - | |
| 6353 | + | |
| 7112 | 6354 | // Validate role restriction |
| 7113 | 6355 | $valid_roles = array_keys($this->mxchat_get_role_options()); |
| 7114 | 6356 | if (!in_array($role_restriction, $valid_roles)) { |
| 7115 | 6357 | wp_send_json_error('Invalid role restriction'); |
| @@ -7114,27 +6356,16 @@ | ||
| 7114 | 6356 | if (!in_array($role_restriction, $valid_roles)) { |
| 7115 | 6357 | wp_send_json_error('Invalid role restriction'); |
| 7116 | 6358 | exit; |
| 7117 | 6359 | } |
| 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'); | |
| 6360 | + | |
| 6361 | + // Check if tag exists in WordPress | |
| 6362 | + $term = get_term_by('slug', $tag_slug, 'post_tag'); | |
| 7124 | 6363 | 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.'); | |
| 6364 | + wp_send_json_error('Tag does not exist in WordPress'); | |
| 7129 | 6365 | exit; |
| 7130 | 6366 | } |
| 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 | - | |
| 6367 | + | |
| 7137 | 6368 | // Get existing mappings |
| 7138 | 6369 | $mappings = get_option('mxchat_tag_role_mappings', array()); |
| 7139 | 6370 | |
| 7140 | 6371 | // Check if mapping already exists |
| @@ -7818,27 +7049,13 @@ | ||
| 7818 | 7049 | |
| 7819 | 7050 | $result = false; |
| 7820 | 7051 | $error_message = ''; |
| 7821 | 7052 | |
| 7822 | - // Read item directly from DB to get queue_id and preserve special chars in item_data | |
| 7823 | - // (POST round-trip through JS mangles characters like apostrophes in URLs) | |
| 7824 | - $db_item = $wpdb->get_row($wpdb->prepare( | |
| 7825 | - "SELECT queue_id, item_data FROM $table_name WHERE id = %d", | |
| 7826 | - $item_id | |
| 7827 | - )); | |
| 7828 | - $item_queue_id = $db_item ? $db_item->queue_id : ''; | |
| 7829 | - if ($db_item && !empty($db_item->item_data)) { | |
| 7830 | - $db_data = json_decode($db_item->item_data, true); | |
| 7831 | - if (is_array($db_data)) { | |
| 7832 | - $item_data = $db_data; | |
| 7833 | - } | |
| 7834 | - } | |
| 7835 | - | |
| 7836 | 7053 | switch ($item_type) { |
| 7837 | 7054 | case 'url': |
| 7838 | - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id); | |
| 7055 | + $result = $this->mxchat_process_queue_url($item_data, $bot_id); | |
| 7839 | 7056 | break; |
| 7840 | - | |
| 7057 | + | |
| 7841 | 7058 | case 'pdf_page': |
| 7842 | 7059 | $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id); |
| 7843 | 7060 | break; |
| 7844 | 7061 | |
| @@ -7846,37 +7063,11 @@ | ||
| 7846 | 7063 | throw new Exception('Unknown item type: ' . $item_type); |
| 7847 | 7064 | } |
| 7848 | 7065 | |
| 7849 | 7066 | if (is_wp_error($result)) { |
| 7850 | - $error_code = $result->get_error_code(); | |
| 7851 | - // Content errors (empty page, sanitization) are permanent — retrying won't help | |
| 7852 | - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found'); | |
| 7853 | - if (in_array($error_code, $permanent_codes)) { | |
| 7854 | - // Mark as permanently failed — set attempts = max_attempts so it won't be retried | |
| 7855 | - $current_item = $wpdb->get_row($wpdb->prepare( | |
| 7856 | - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id | |
| 7857 | - )); | |
| 7858 | - $wpdb->update( | |
| 7859 | - $table_name, | |
| 7860 | - array( | |
| 7861 | - 'status' => 'failed', | |
| 7862 | - 'error_message' => $result->get_error_message(), | |
| 7863 | - 'attempts' => $current_item ? $current_item->max_attempts : 3 | |
| 7864 | - ), | |
| 7865 | - array('id' => $item_id), | |
| 7866 | - array('%s', '%s', '%d'), | |
| 7867 | - array('%d') | |
| 7868 | - ); | |
| 7869 | - wp_send_json_error(array( | |
| 7870 | - 'message' => $result->get_error_message(), | |
| 7871 | - 'permanent_failure' => true, | |
| 7872 | - 'item_id' => $item_id | |
| 7873 | - )); | |
| 7874 | - return; | |
| 7875 | - } | |
| 7876 | 7067 | throw new Exception($result->get_error_message()); |
| 7877 | 7068 | } |
| 7878 | - | |
| 7069 | + | |
| 7879 | 7070 | if ($result === false) { |
| 7880 | 7071 | throw new Exception('Processing returned false - item may be empty or invalid'); |
| 7881 | 7072 | } |
| 7882 | 7073 | |
| @@ -7952,9 +7143,9 @@ | ||
| 7952 | 7143 | |
| 7953 | 7144 | /** |
| 7954 | 7145 | * Process a URL from the queue |
| 7955 | 7146 | */ |
| 7956 | -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') { | |
| 7147 | +private function mxchat_process_queue_url($item_data, $bot_id = 'default') { | |
| 7957 | 7148 | $url = isset($item_data['url']) ? $item_data['url'] : ''; |
| 7958 | 7149 | |
| 7959 | 7150 | if (empty($url)) { |
| 7960 | 7151 | return new WP_Error('invalid_url', 'URL is empty'); |
| @@ -8002,11 +7193,11 @@ | ||
| 8002 | 7193 | |
| 8003 | 7194 | // Fetch URL content (fallback for non-products or when WooCommerce extraction fails) |
| 8004 | 7195 | $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf'); |
| 8005 | 7196 | $response = wp_remote_get($url, array( |
| 8006 | - 'timeout' => $is_likely_pdf ? 120 : 30, | |
| 7197 | + 'timeout' => $is_likely_pdf ? 60 : 30, | |
| 8007 | 7198 | 'redirection' => 5, |
| 8008 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 7199 | + 'user-agent' => 'MxChat/1.0' | |
| 8009 | 7200 | )); |
| 8010 | 7201 | |
| 8011 | 7202 | if (is_wp_error($response)) { |
| 8012 | 7203 | return $response; |
| @@ -8013,14 +7204,14 @@ | ||
| 8013 | 7204 | } |
| 8014 | 7205 | |
| 8015 | 7206 | $response_code = wp_remote_retrieve_response_code($response); |
| 8016 | 7207 | if ($response_code !== 200) { |
| 8017 | - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url); | |
| 7208 | + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error'); | |
| 8018 | 7209 | } |
| 8019 | 7210 | |
| 8020 | - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline | |
| 7211 | + // Check if URL is a PDF — process through PDF pipeline instead of HTML | |
| 8021 | 7212 | if ($this->mxchat_is_pdf_url($url, $response)) { |
| 8022 | - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id); | |
| 7213 | + return $this->mxchat_process_pdf_url_inline($url, $response, $api_key, $bot_id); | |
| 8023 | 7214 | } |
| 8024 | 7215 | |
| 8025 | 7216 | $html = wp_remote_retrieve_body($response); |
| 8026 | 7217 | |
| @@ -8050,89 +7241,11 @@ | ||
| 8050 | 7241 | return $result; |
| 8051 | 7242 | } |
| 8052 | 7243 | |
| 8053 | 7244 | /** |
| 8054 | - * Expand a PDF URL into per-page queue items using the standard PDF pipeline. | |
| 8055 | - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count, | |
| 8056 | - * and adds pdf_page items to the same queue so they process with full progress tracking. | |
| 7245 | + * Process a PDF URL inline during sitemap queue processing. | |
| 7246 | + * Downloads the PDF, extracts all pages, and submits each to the DB. | |
| 8057 | 7247 | */ |
| 8058 | -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') { | |
| 8059 | - set_time_limit(120); // PDFs need extra time for download + parsing | |
| 8060 | - | |
| 8061 | - $upload_dir = wp_upload_dir(); | |
| 8062 | - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf'); | |
| 8063 | - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename; | |
| 8064 | - | |
| 8065 | - $response_body = wp_remote_retrieve_body($response); | |
| 8066 | - if (empty($response_body)) { | |
| 8067 | - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url); | |
| 8068 | - } | |
| 8069 | - | |
| 8070 | - if (!wp_mkdir_p(dirname($pdf_path))) { | |
| 8071 | - return new WP_Error('dir_error', 'Failed to create upload directory'); | |
| 8072 | - } | |
| 8073 | - | |
| 8074 | - file_put_contents($pdf_path, $response_body); | |
| 8075 | - | |
| 8076 | - if (!file_exists($pdf_path)) { | |
| 8077 | - return new WP_Error('save_error', 'Failed to save PDF file'); | |
| 8078 | - } | |
| 8079 | - | |
| 8080 | - try { | |
| 8081 | - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path); | |
| 8082 | - | |
| 8083 | - if ($total_pages === false || $total_pages < 1) { | |
| 8084 | - wp_delete_file($pdf_path); | |
| 8085 | - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url); | |
| 8086 | - } | |
| 8087 | - | |
| 8088 | - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base | |
| 8089 | - $pages = array(); | |
| 8090 | - for ($i = 1; $i <= $total_pages; $i++) { | |
| 8091 | - $pages[] = array( | |
| 8092 | - 'pdf_path' => $pdf_path, | |
| 8093 | - 'pdf_url' => $pdf_url, | |
| 8094 | - 'page_number' => $i, | |
| 8095 | - 'total_pages' => $total_pages | |
| 8096 | - ); | |
| 8097 | - } | |
| 8098 | - | |
| 8099 | - // Add pdf_page items to the SAME queue so the JS picks them up automatically | |
| 8100 | - if (!empty($queue_id)) { | |
| 8101 | - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id); | |
| 8102 | - } else { | |
| 8103 | - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow) | |
| 8104 | - $new_queue_id = 'pdf_' . md5($pdf_url . time()); | |
| 8105 | - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id); | |
| 8106 | - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url); | |
| 8107 | - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf'); | |
| 8108 | - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages); | |
| 8109 | - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id); | |
| 8110 | - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path); | |
| 8111 | - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql')); | |
| 8112 | - } | |
| 8113 | - | |
| 8114 | - if ($queued_count === 0) { | |
| 8115 | - wp_delete_file($pdf_path); | |
| 8116 | - return new WP_Error('queue_error', 'Failed to add PDF pages to queue'); | |
| 8117 | - } | |
| 8118 | - | |
| 8119 | - // Return true so the original URL item is marked complete | |
| 8120 | - // The new pdf_page items will be processed in subsequent batches | |
| 8121 | - return true; | |
| 8122 | - | |
| 8123 | - } catch (Exception $e) { | |
| 8124 | - if (file_exists($pdf_path)) { | |
| 8125 | - wp_delete_file($pdf_path); | |
| 8126 | - } | |
| 8127 | - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage()); | |
| 8128 | - } | |
| 8129 | -} | |
| 8130 | - | |
| 8131 | -/** | |
| 8132 | - * Legacy: Process a PDF URL inline during sitemap queue processing. | |
| 8133 | - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only. | |
| 8134 | - */ | |
| 8135 | 7248 | private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') { |
| 8136 | 7249 | set_time_limit(120); // PDFs need more time — downloading + parsing all pages |
| 8137 | 7250 | |
| 8138 | 7251 | $upload_dir = wp_upload_dir(); |
| @@ -8166,24 +7279,21 @@ | ||
| 8166 | 7279 | return new WP_Error('no_pages', 'PDF has no pages'); |
| 8167 | 7280 | } |
| 8168 | 7281 | |
| 8169 | 7282 | $processed = 0; |
| 8170 | - $skipped_pages = array(); | |
| 8171 | 7283 | |
| 8172 | 7284 | for ($i = 0; $i < $total_pages; $i++) { |
| 8173 | - $page_num = $i + 1; | |
| 8174 | 7285 | $text = $pages[$i]->getText(); |
| 8175 | 7286 | if (empty($text)) { |
| 8176 | - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding'; | |
| 8177 | 7287 | continue; |
| 8178 | 7288 | } |
| 8179 | 7289 | |
| 8180 | 7290 | $sanitized = $this->mxchat_sanitize_content_for_api($text); |
| 8181 | 7291 | if (empty($sanitized)) { |
| 8182 | - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content'; | |
| 8183 | 7292 | continue; |
| 8184 | 7293 | } |
| 8185 | 7294 | |
| 7295 | + $page_num = $i + 1; | |
| 8186 | 7296 | $metadata = array( |
| 8187 | 7297 | 'document_type' => 'pdf', |
| 8188 | 7298 | 'total_pages' => $total_pages, |
| 8189 | 7299 | 'current_page' => $page_num, |
| @@ -8207,12 +7317,8 @@ | ||
| 8207 | 7317 | |
| 8208 | 7318 | // Clean up the temp PDF file |
| 8209 | 7319 | wp_delete_file($pdf_path); |
| 8210 | 7320 | |
| 8211 | - if (!empty($skipped_pages)) { | |
| 8212 | - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages)); | |
| 8213 | - } | |
| 8214 | - | |
| 8215 | 7321 | return $processed > 0 ? true : false; |
| 8216 | 7322 | |
| 8217 | 7323 | } catch (Exception $e) { |
| 8218 | 7324 | if (file_exists($pdf_path)) { |
| @@ -8377,17 +7483,18 @@ | ||
| 8377 | 7483 | return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF'); |
| 8378 | 7484 | } |
| 8379 | 7485 | |
| 8380 | 7486 | $text = $pages[$page_number - 1]->getText(); |
| 8381 | - | |
| 7487 | + | |
| 8382 | 7488 | if (empty($text)) { |
| 8383 | - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding'); | |
| 7489 | + // Not an error - just an empty page | |
| 7490 | + return false; | |
| 8384 | 7491 | } |
| 8385 | - | |
| 7492 | + | |
| 8386 | 7493 | $sanitized = $this->mxchat_sanitize_content_for_api($text); |
| 8387 | - | |
| 7494 | + | |
| 8388 | 7495 | if (empty($sanitized)) { |
| 8389 | - return new WP_Error('empty_after_sanitization', 'Page ' . $page_number . ': Text was extracted but contained only special characters, control codes, or unsupported content that was removed during cleanup'); | |
| 7496 | + return false; | |
| 8390 | 7497 | } |
| 8391 | 7498 | |
| 8392 | 7499 | // Create metadata |
| 8393 | 7500 | $metadata = array( |
| @@ -8491,16 +7598,17 @@ | ||
| 8491 | 7598 | |
| 8492 | 7599 | // Calculate percentage |
| 8493 | 7600 | $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0; |
| 8494 | 7601 | |
| 8495 | - // Get failed items details (include all failed items, not just those that exhausted retries) | |
| 7602 | + // Get failed items details | |
| 8496 | 7603 | $failed_items = array(); |
| 8497 | 7604 | if ($failed > 0) { |
| 8498 | 7605 | $failed_items = $wpdb->get_results($wpdb->prepare( |
| 8499 | - "SELECT item_type, item_data, error_message, attempts | |
| 8500 | - FROM $table_name | |
| 8501 | - WHERE queue_id = %s | |
| 7606 | + "SELECT item_type, item_data, error_message, attempts | |
| 7607 | + FROM $table_name | |
| 7608 | + WHERE queue_id = %s | |
| 8502 | 7609 | AND status = 'failed' |
| 7610 | + AND attempts >= max_attempts | |
| 8503 | 7611 | ORDER BY id DESC |
| 8504 | 7612 | LIMIT 50", |
| 8505 | 7613 | $queue_id |
| 8506 | 7614 | )); |