| @@ -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; |