PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.4
MxChat – AI Chatbot & Content Generation for WordPress v3.1.4
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | admin/class-knowledge-manager.php +139 -1031 3.2.153.1.4 View file →
@@ -31,9 +31,8 @@
31 31 // Admin post handlers for form submissions
32 32 add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 33 add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 34 add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
35 - add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
36 35 add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
37 36
38 37 // AJAX handlers for real-time processing and status updates
39 38 add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
@@ -59,10 +58,12 @@
59 58 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
60 59 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
61 60 add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
62 61 add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
63 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
64 62
63 + // Hook for content deletion
64 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
65 +
65 66 // WordPress post management hooks
66 67 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
67 68 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
68 69 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
@@ -158,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 ));