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 +138 -781 3.2.123.1.4 View file →
@@ -58,10 +58,12 @@
58 58 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
59 59 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
60 60 add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
61 61 add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
62 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
63 62
63 + // Hook for content deletion
64 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
65 +
64 66 // WordPress post management hooks
65 67 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
66 68 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
67 69 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
@@ -161,13 +163,9 @@
161 163 public function mxchat_is_pdf_url($url, $response) {
162 164 $content_type = wp_remote_retrieve_header($response, 'content-type');
163 165 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
164 166
165 - // Check Content-Disposition header for .pdf filename (Google Drive sends this)
166 - $disposition = wp_remote_retrieve_header($response, 'content-disposition');
167 - $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
168 -
169 - return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
167 + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf';
170 168 }
171 169
172 170
173 171 public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
@@ -815,230 +813,8 @@
815 813 );
816 814 }
817 815
818 816 /**
819 - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
820 - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
821 - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
822 - */
823 -public function ajax_mxchat_inspect_entry() {
824 - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
825 -
826 - if ( ! current_user_can('manage_options') ) {
827 - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
828 - }
829 -
830 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
831 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
832 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
833 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
834 -
835 - if ( $data_source === 'pinecone' ) {
836 - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
837 - } else {
838 - $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
839 - }
840 -
841 - if ( is_wp_error( $result ) ) {
842 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
843 - }
844 -
845 - wp_send_json_success( $result );
846 -}
847 -
848 -/**
849 - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
850 - * but returns each STORED chunk's exact text + length (no implode), plus the assembled
851 - * embedded text. This shows what is actually in the index, not a re-derivation from the post.
852 - */
853 -private function inspect_wordpress_entry( $source_url, $entry_id ) {
854 - global $wpdb;
855 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
856 -
857 - $rows = array();
858 -
859 - // Group by the real stored source_url — this INCLUDES "mxchat://" manual
860 - // Direct Content entries (the spec's manual-entry case), which share one
861 - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
862 - // display key (invented by the table view for rows with no source_url) is
863 - // excluded; those fall through to the entry_id lookup below.
864 - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
865 - $rows = $wpdb->get_results( $wpdb->prepare(
866 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
867 - $source_url
868 - ) );
869 - }
870 -
871 - // Fallback / manual "Direct Content" entries: fetch the single row by id.
872 - if ( empty( $rows ) && $entry_id > 0 ) {
873 - $row = $wpdb->get_row( $wpdb->prepare(
874 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
875 - $entry_id
876 - ) );
877 - if ( $row ) {
878 - $rows = array( $row );
879 - }
880 - }
881 -
882 - if ( empty( $rows ) ) {
883 - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
884 - }
885 -
886 - $chunks = array();
887 - $content_type = '';
888 - foreach ( $rows as $row ) {
889 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
890 - $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
891 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
892 - $content_type = $row->content_type;
893 - $chunks[] = array(
894 - 'index' => $index,
895 - 'text' => $text,
896 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
897 - 'row_id' => intval( $row->id ),
898 - );
899 - }
900 -
901 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
902 -
903 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
904 -
905 - return array(
906 - 'store' => 'wordpress',
907 - 'source_url' => $source_url,
908 - 'content_type' => $content_type,
909 - 'is_chunked' => count( $chunks ) > 1,
910 - 'chunk_count' => count( $chunks ),
911 - 'assembled' => $assembled,
912 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
913 - 'chunks' => array_values( $chunks ),
914 - // WP-DB storage carries no separate vector metadata; surface that fact
915 - // rather than letting the owner guess (the spec's taxonomy question).
916 - 'metadata' => array(),
917 - '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'),
918 - );
919 -}
920 -
921 -/**
922 - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
923 - * but keeps each vector's text + metadata instead of imploding, so the owner can
924 - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
925 - * are present per chunk. READ-ONLY.
926 - */
927 -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
928 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
929 - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
930 - }
931 -
932 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
933 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
934 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
935 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
936 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
937 - } else {
938 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
939 - $api_key = $bot_config['api_key'] ?? '';
940 - $host = $bot_config['host'] ?? '';
941 - $namespace = $bot_config['namespace'] ?? '';
942 - }
943 -
944 - if ( empty($host) || empty($api_key) ) {
945 - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
946 - }
947 -
948 - $base_id = md5( $source_url );
949 - $vector_ids = array( $base_id );
950 -
951 - $list_url = "https://{$host}/vectors/list";
952 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
953 - if ( ! empty($namespace) ) {
954 - $list_body['namespace'] = $namespace;
955 - }
956 -
957 - $list_resp = wp_remote_post( $list_url, array(
958 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
959 - 'body' => wp_json_encode( $list_body ),
960 - 'timeout' => 15,
961 - ) );
962 -
963 - if ( ! is_wp_error($list_resp) ) {
964 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
965 - if ( ! empty($list_data['vectors']) ) {
966 - foreach ( $list_data['vectors'] as $v ) {
967 - $vector_ids[] = $v['id'];
968 - }
969 - }
970 - }
971 -
972 - $fetch_url = "https://{$host}/vectors/fetch";
973 - $fetch_body = array( 'ids' => $vector_ids );
974 - if ( ! empty($namespace) ) {
975 - $fetch_body['namespace'] = $namespace;
976 - }
977 -
978 - $fetch_resp = wp_remote_post( $fetch_url, array(
979 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
980 - 'body' => wp_json_encode( $fetch_body ),
981 - 'timeout' => 15,
982 - ) );
983 -
984 - if ( is_wp_error($fetch_resp) ) {
985 - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
986 - }
987 -
988 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
989 - $vectors = $fetch_data['vectors'] ?? array();
990 -
991 - if ( empty($vectors) ) {
992 - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
993 - }
994 -
995 - // Whitelisted metadata fields the spec calls out — shown so devs can confirm
996 - // what is (and is NOT) stored per vector.
997 - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
998 - $chunks = array();
999 - $content_type = '';
1000 - foreach ( $vectors as $vid => $vector ) {
1001 - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1002 - $text = $meta['text'] ?? '';
1003 - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1004 - $content_type = $meta['type'] ?? $content_type;
1005 -
1006 - $clean_meta = array();
1007 - foreach ( $meta_fields as $field ) {
1008 - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1009 - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1010 - }
1011 - }
1012 -
1013 - $chunks[] = array(
1014 - 'index' => $index,
1015 - 'text' => $text,
1016 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1017 - 'vector_id' => (string) $vid,
1018 - 'metadata' => $clean_meta,
1019 - );
1020 - }
1021 -
1022 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1023 -
1024 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1025 -
1026 - return array(
1027 - 'store' => 'pinecone',
1028 - 'source_url' => $source_url,
1029 - 'content_type' => $content_type,
1030 - 'is_chunked' => count( $chunks ) > 1,
1031 - 'chunk_count' => count( $chunks ),
1032 - 'assembled' => $assembled,
1033 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1034 - 'chunks' => array_values( $chunks ),
1035 - 'metadata' => array(),
1036 - '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'),
1037 - );
1038 -}
1039 -
1040 -/**
1041 817 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1042 818 * Works for both WordPress DB and Pinecone entries.
1043 819 */
1044 820 public function ajax_mxchat_save_entry_content() {
@@ -1083,17 +859,10 @@
1083 859 }
1084 860
1085 861 // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1086 862 // so submit_content_to_db creates a replacement instead of a duplicate
1087 - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1088 - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1089 - 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) ) {
1090 864 $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1091 - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1092 - // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1093 - if ( $is_legacy_manual ) {
1094 - $source_url = '';
1095 - }
1096 865 }
1097 866
1098 867 // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1099 868 $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
@@ -1170,22 +939,9 @@
1170 939 exit;
1171 940 }
1172 941
1173 942 $submitted_url = esc_url_raw($_POST['sitemap_url']);
1174 -
1175 - // Convert Google Drive sharing URLs to direct download URLs
1176 - if ( strpos($submitted_url, 'drive.google.com') !== false ) {
1177 - $file_id = '';
1178 - if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
1179 - $file_id = $m[1];
1180 - } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
1181 - $file_id = $m[1];
1182 - }
1183 - if ( ! empty($file_id) ) {
1184 - $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
1185 - }
1186 - }
1187 -
943 +
1188 944 // Get bot_id from form submission
1189 945 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1190 946
1191 947 // Get bot-specific options and validate API key
@@ -1213,18 +969,10 @@
1213 969 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1214 970 exit;
1215 971 }
1216 972
1217 - // Fetch URL — use browser-like headers so servers with bot protection don't block us
1218 - $response = wp_remote_get($submitted_url, array(
1219 - 'timeout' => 30,
1220 - 'sslverify' => false,
1221 - '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',
1222 - 'headers' => array(
1223 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1224 - 'Accept-Language' => 'en-US,en;q=0.9',
1225 - ),
1226 - ));
973 + // Fetch URL
974 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
1227 975
1228 976 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1229 977 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1230 978 set_transient('mxchat_admin_notice_error',
@@ -3058,12 +2806,11 @@
3058 2806 foreach ($primary_indexes as $path => $source) {
3059 2807 $url = trailingslashit($site_url) . $path;
3060 2808
3061 2809 $response = wp_remote_head($url, array(
3062 - 'timeout' => 10,
2810 + 'timeout' => 3, // Short timeout
3063 2811 'sslverify' => false,
3064 - 'redirection' => 1,
3065 - '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',
2812 + 'redirection' => 1
3066 2813 ));
3067 2814
3068 2815 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3069 2816 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3121,15 +2868,10 @@
3121 2868 private function parse_sitemap_index($url) {
3122 2869 $sub_sitemaps = array();
3123 2870
3124 2871 $response = wp_remote_get($url, array(
3125 - 'timeout' => 30,
3126 - 'sslverify' => false,
3127 - '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',
3128 - 'headers' => array(
3129 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3130 - 'Accept-Language' => 'en-US,en;q=0.9',
3131 - ),
2872 + 'timeout' => 5,
2873 + 'sslverify' => false
3132 2874 ));
3133 2875
3134 2876 if (is_wp_error($response)) {
3135 2877 return $sub_sitemaps;
@@ -3180,15 +2922,10 @@
3180 2922 * Get URL count from a sitemap
3181 2923 */
3182 2924 private function get_sitemap_url_count($url) {
3183 2925 $response = wp_remote_get($url, array(
3184 - 'timeout' => 30,
3185 - 'sslverify' => false,
3186 - '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',
3187 - 'headers' => array(
3188 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3189 - 'Accept-Language' => 'en-US,en;q=0.9',
3190 - ),
2926 + 'timeout' => 10,
2927 + 'sslverify' => false
3191 2928 ));
3192 2929
3193 2930 if (is_wp_error($response)) {
3194 2931 return 0;
@@ -3211,11 +2948,10 @@
3211 2948 $sitemaps = array();
3212 2949 $robots_url = trailingslashit($site_url) . 'robots.txt';
3213 2950
3214 2951 $response = wp_remote_get($robots_url, array(
3215 - 'timeout' => 15,
3216 - 'sslverify' => false,
3217 - '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',
2952 + 'timeout' => 5,
2953 + 'sslverify' => false
3218 2954 ));
3219 2955
3220 2956 if (is_wp_error($response)) {
3221 2957 return $sitemaps;
@@ -3705,22 +3441,9 @@
3705 3441 }
3706 3442
3707 3443 // Get bot_id from request
3708 3444 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3709 -
3710 - // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3711 - // don't re-check on every batch; the default is OFF for installs that haven't set it.
3712 - $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3713 - $mxchat_options = get_option('mxchat_options', array());
3714 - if (!is_array($mxchat_options)) {
3715 - $mxchat_options = array();
3716 - }
3717 - $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3718 - if ($prior_default !== $extract_acf_pdfs) {
3719 - $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3720 - update_option('mxchat_options', $mxchat_options);
3721 - }
3722 -
3445 +
3723 3446 // Process only ONE post at a time to avoid request size issues
3724 3447 $post_id = reset($post_ids);
3725 3448 $post = get_post($post_id);
3726 3449
@@ -3826,57 +3549,23 @@
3826 3549 }
3827 3550
3828 3551 // ADD ACF FIELDS SUPPORT
3829 3552 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3830 - $pdf_extracted_count = 0;
3831 3553 if (!empty($acf_fields)) {
3832 3554 $acf_content_parts = array();
3833 - $pdf_attachment_ids = array();
3834 -
3555 +
3835 3556 foreach ($acf_fields as $field_name => $field_value) {
3836 3557 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3837 -
3558 +
3838 3559 if (!empty($formatted_value)) {
3839 3560 $field_label = ucwords(str_replace('_', ' ', $field_name));
3840 3561 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3841 3562 }
3842 -
3843 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
3844 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
3845 - // still lands in the KB but the heavier PDF parsing is skipped.
3846 - if ($extract_acf_pdfs) {
3847 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
3848 - }
3849 3563 }
3850 -
3564 +
3851 3565 if (!empty($acf_content_parts)) {
3852 3566 $content .= "\n\n" . implode("\n", $acf_content_parts);
3853 3567 }
3854 -
3855 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
3856 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
3857 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
3858 - $pdf_sections = array();
3859 - foreach ($pdf_attachment_ids as $att_id) {
3860 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
3861 - if (!empty($pdf_text)) {
3862 - $pdf_title = get_the_title($att_id);
3863 - $pdf_url = wp_get_attachment_url($att_id);
3864 - $header = 'PDF Attachment';
3865 - if (!empty($pdf_title)) {
3866 - $header .= ': ' . $pdf_title;
3867 - }
3868 - if (!empty($pdf_url)) {
3869 - $header .= ' (' . $pdf_url . ')';
3870 - }
3871 - $pdf_sections[] = $header . "\n" . $pdf_text;
3872 - $pdf_extracted_count++;
3873 - }
3874 - }
3875 - if (!empty($pdf_sections)) {
3876 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
3877 - }
3878 - }
3879 3568 }
3880 3569
3881 3570 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3882 3571 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4006,9 +3695,8 @@
4006 3695 'title' => $post->post_title,
4007 3696 'operation_type' => $operation_type,
4008 3697 'vector_id' => $vector_id,
4009 3698 'acf_fields_found' => $acf_field_count,
4010 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4011 3699 'content_preview' => substr($content, 0, 100) . '...',
4012 3700 'bot_id' => $bot_id
4013 3701 ));
4014 3702 exit;
@@ -4423,20 +4111,9 @@
4423 4111
4424 4112 // Get bot-specific options
4425 4113 $bot_options = $this->get_bot_options($bot_id);
4426 4114 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4427 -
4428 - // Opt-in: when the custom provider is selected for embeddings, index through
4429 - // the same custom endpoint the query path uses so stored vectors and query
4430 - // vectors share a model. Returns the vector array on success, or an error
4431 - // string on failure (this function's existing failure contract).
4432 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4433 - if (!class_exists('MxChat_Utils')) {
4434 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4435 - }
4436 - return MxChat_Utils::generate_embedding_custom($text, $options);
4437 - }
4438 -
4115 +
4439 4116 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4440 4117 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4441 4118
4442 4119 // Determine provider and endpoint
@@ -5354,197 +5031,8 @@
5354 5031 return implode(', ', array_filter($text_parts));
5355 5032 }
5356 5033
5357 5034 /**
5358 - * Walk an ACF field value tree and collect attachment IDs for any value that
5359 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5360 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5361 - * plain URL string), and recurses through repeater/group/flexible content.
5362 - *
5363 - * @param mixed $value The ACF field value (any depth)
5364 - * @param array $out Accumulator (passed by reference) for attachment IDs
5365 - * @param int $depth Recursion guard
5366 - */
5367 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5368 - if ($depth > 6) {
5369 - return; // prevent runaway recursion on circular/very-deep structures
5370 - }
5371 -
5372 - if (empty($value)) {
5373 - return;
5374 - }
5375 -
5376 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5377 - if (is_array($value)) {
5378 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5379 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5380 - if ($looks_like_attachment) {
5381 - $att_id = 0;
5382 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5383 - $att_id = (int) $value['ID'];
5384 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5385 - $att_id = (int) $value['id'];
5386 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5387 - $att_id = (int) attachment_url_to_postid($value['url']);
5388 - }
5389 -
5390 - $is_pdf = false;
5391 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5392 - $is_pdf = true;
5393 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5394 - $is_pdf = true;
5395 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5396 - $is_pdf = true;
5397 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5398 - $is_pdf = true;
5399 - }
5400 -
5401 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5402 - $out[] = $att_id;
5403 - }
5404 - // An array node that represents one attachment doesn't contain other
5405 - // attachments inside it — done with this branch.
5406 - return;
5407 - }
5408 -
5409 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5410 - foreach ($value as $sub) {
5411 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5412 - }
5413 - return;
5414 - }
5415 -
5416 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5417 - if (is_numeric($value)) {
5418 - $att_id = (int) $value;
5419 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5420 - $out[] = $att_id;
5421 - }
5422 - return;
5423 - }
5424 -
5425 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5426 - if (is_string($value)) {
5427 - $trimmed = trim($value);
5428 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5429 - $att_id = (int) attachment_url_to_postid($trimmed);
5430 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5431 - $out[] = $att_id;
5432 - }
5433 - }
5434 - return;
5435 - }
5436 -}
5437 -
5438 -/**
5439 - * Heuristic: does this URL/string look like a PDF reference?
5440 - * Tolerates query strings and fragments (#page=2).
5441 - */
5442 -private function mxchat_url_looks_like_pdf($url) {
5443 - if (!is_string($url) || $url === '') {
5444 - return false;
5445 - }
5446 - // Strip query + fragment before checking extension
5447 - $path = preg_replace('/[?#].*$/', '', $url);
5448 - return (bool) preg_match('/\.pdf$/i', $path);
5449 -}
5450 -
5451 -/**
5452 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5453 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5454 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5455 - * only parse the same PDF once unless the file changes on disk.
5456 - *
5457 - * @param int $attachment_id
5458 - * @return string Extracted plain text, or '' on failure.
5459 - */
5460 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5461 - $attachment_id = (int) $attachment_id;
5462 - if ($attachment_id <= 0) {
5463 - return '';
5464 - }
5465 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5466 - return '';
5467 - }
5468 -
5469 - $pdf_path = get_attached_file($attachment_id);
5470 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5471 - return '';
5472 - }
5473 -
5474 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5475 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5476 - $default_max_bytes = 25 * 1024 * 1024;
5477 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5478 - if ($max_bytes > 0) {
5479 - $file_size = @filesize($pdf_path);
5480 - if ($file_size !== false && $file_size > $max_bytes) {
5481 - error_log(sprintf(
5482 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5483 - $attachment_id,
5484 - basename($pdf_path),
5485 - $file_size,
5486 - $max_bytes
5487 - ));
5488 - return '';
5489 - }
5490 - }
5491 -
5492 - $mtime = @filemtime($pdf_path);
5493 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5494 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5495 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5496 - return (string) $cached['text'];
5497 - }
5498 -
5499 - $text = '';
5500 - try {
5501 - if (function_exists('mxchat_load_pdf_parser')) {
5502 - mxchat_load_pdf_parser();
5503 - }
5504 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5505 - return '';
5506 - }
5507 - $parser = new \Smalot\PdfParser\Parser();
5508 - $pdf = $parser->parseFile($pdf_path);
5509 - $pages = $pdf->getPages();
5510 - $page_texts = array();
5511 - foreach ($pages as $page) {
5512 - $page_text = '';
5513 - try {
5514 - $page_text = $page->getText();
5515 - } catch (\Exception $e) {
5516 - $page_text = '';
5517 - }
5518 - if (!empty($page_text)) {
5519 - $page_texts[] = $page_text;
5520 - }
5521 - }
5522 - $text = trim(implode("\n\n", $page_texts));
5523 - } catch (\Exception $e) {
5524 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5525 - return '';
5526 - } catch (\Throwable $e) {
5527 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5528 - return '';
5529 - }
5530 -
5531 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5532 - // The chunker downstream will still split this into multiple vectors.
5533 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5534 - if ($max_len > 0 && strlen($text) > $max_len) {
5535 - $text = substr($text, 0, $max_len);
5536 - }
5537 -
5538 - update_post_meta($attachment_id, $cache_meta_key, array(
5539 - 'mtime' => (int) $mtime,
5540 - 'text' => $text,
5541 - ));
5542 -
5543 - return $text;
5544 -}
5545 -
5546 -/**
5547 5035 * Handle ACF save - fires after ACF fields are saved
5548 5036 * This ensures ACF field data is available when syncing to knowledge base
5549 5037 */
5550 5038 public function mxchat_handle_acf_save($post_id) {
@@ -5652,33 +5140,40 @@
5652 5140 // If the post was previously published but is now not published, remove from knowledge base
5653 5141 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5654 5142 // Use the stored URL from when it was published, or fall back to current permalink
5655 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';
5656 5149
5657 - if ($source_url) {
5658 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5659 - 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 + }
5660 5164 }
5661 -
5165 +
5662 5166 // Clean up the transients and exit early
5663 5167 delete_transient($previous_status_key);
5664 5168 delete_transient($previous_url_key);
5665 5169 return;
5666 5170 }
5667 -
5668 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5669 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5670 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5671 - $current_url = get_permalink($post_id);
5672 - if ($current_url && $current_url !== $previous_url) {
5673 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5674 - }
5675 - }
5676 -
5171 +
5677 5172 // Store the current status for next time (if this is an update)
5678 5173 if ($update) {
5679 5174 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5680 -
5175 +
5681 5176 // If the post is currently published, also store its URL
5682 5177 if ($post->post_status === 'publish') {
5683 5178 $current_url = get_permalink($post_id);
5684 5179 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
@@ -5787,9 +5282,8 @@
5787 5282 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5788 5283 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5789 5284 if (!empty($acf_fields)) {
5790 5285 $acf_content_parts = array();
5791 - $pdf_attachment_ids = array();
5792 5286
5793 5287 foreach ($acf_fields as $field_name => $field_value) {
5794 5288 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5795 5289 if (!empty($formatted_value)) {
@@ -5796,44 +5290,13 @@
5796 5290 // Convert field name to readable label
5797 5291 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5798 5292 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5799 5293 }
5800 -
5801 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5802 5294 }
5803 5295
5804 5296 if (!empty($acf_content_parts)) {
5805 5297 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5806 5298 }
5807 -
5808 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
5809 - // Mirrors the per-batch checkbox the manual content selector has; the
5810 - // 25 MB size cap lives in the shared extractor so it applies in both
5811 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
5812 - // editor save is expensive and most sites don't want it.
5813 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
5814 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
5815 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
5816 - $pdf_sections = array();
5817 - foreach ($pdf_attachment_ids as $att_id) {
5818 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
5819 - if (!empty($pdf_text)) {
5820 - $pdf_title = get_the_title($att_id);
5821 - $pdf_url = wp_get_attachment_url($att_id);
5822 - $header = 'PDF Attachment';
5823 - if (!empty($pdf_title)) {
5824 - $header .= ': ' . $pdf_title;
5825 - }
5826 - if (!empty($pdf_url)) {
5827 - $header .= ' (' . $pdf_url . ')';
5828 - }
5829 - $pdf_sections[] = $header . "\n" . $pdf_text;
5830 - }
5831 - }
5832 - if (!empty($pdf_sections)) {
5833 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
5834 - }
5835 - }
5836 5299 }
5837 5300
5838 5301 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5839 5302 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -5940,14 +5403,12 @@
5940 5403 if (!$should_sync) {
5941 5404 return;
5942 5405 }
5943 5406
5944 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
5945 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
5946 - // real vector IDs stored under the original URL.
5947 - $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);
5948 5409 if (!$source_url) {
5949 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5410 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
5950 5411 return;
5951 5412 }
5952 5413
5953 5414 // Use chunk-aware deletion (handles both chunked and non-chunked content)
@@ -5955,36 +5416,56 @@
5955 5416
5956 5417 if (is_wp_error($delete_result)) {
5957 5418 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5958 5419 }
5420 +}
5959 5421
5960 - delete_transient('mxchat_prev_url_' . $post_id);
5961 - delete_transient('mxchat_prev_status_' . $post_id);
5962 -}
5963 5422
5964 -/**
5965 - * Resolve the source URL for a post being trashed/deleted.
5966 - *
5967 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
5968 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
5969 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
5970 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
5971 - */
5972 -private function mxchat_resolve_pre_trash_url($post_id) {
5973 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
5974 - if (!empty($previous_url)) {
5975 - return $previous_url;
5976 - }
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'] ?? '';
5977 5429
5978 - $current = get_permalink($post_id);
5979 - if (!$current) {
5980 - return '';
5981 - }
5982 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5983 -}
5430 + if (empty($host) || empty($api_key)) {
5431 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
5432 + return false;
5433 + }
5984 5434
5435 + $api_endpoint = "https://{$host}/vectors/delete";
5436 + $vector_id = md5($source_url);
5985 5437
5438 + $request_body = array(
5439 + 'ids' => array($vector_id)
5440 + );
5986 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 +
5987 5468 public function mxchat_handle_product_change($post_id, $post, $update) {
5988 5469 if ($post->post_type !== 'product') {
5989 5470 return;
5990 5471 }
@@ -6136,18 +5617,28 @@
6136 5617 if (get_post_type($post_id) !== 'product') {
6137 5618 return;
6138 5619 }
6139 5620
6140 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6141 - if (!$source_url) {
6142 - return;
6143 - }
5621 + $source_url = get_permalink($post_id);
6144 5622
6145 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6146 - 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';
6147 5626
6148 - delete_transient('mxchat_prev_url_' . $post_id);
6149 - 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 + }
6150 5641 }
6151 5642
6152 5643 /**
6153 5644 * Handle individual Pinecone content deletion
@@ -6851,16 +6342,16 @@
6851 6342 wp_send_json_error('Unauthorized access');
6852 6343 exit;
6853 6344 }
6854 6345
6855 - $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']) : '';
6856 6347 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6857 -
6858 - if (empty($tag_input)) {
6859 - 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');
6860 6351 exit;
6861 6352 }
6862 -
6353 +
6863 6354 // Validate role restriction
6864 6355 $valid_roles = array_keys($this->mxchat_get_role_options());
6865 6356 if (!in_array($role_restriction, $valid_roles)) {
6866 6357 wp_send_json_error('Invalid role restriction');
@@ -6865,27 +6356,16 @@
6865 6356 if (!in_array($role_restriction, $valid_roles)) {
6866 6357 wp_send_json_error('Invalid role restriction');
6867 6358 exit;
6868 6359 }
6869 -
6870 - // Resolve the tag by slug first, then fall back to its display name, so users can
6871 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
6872 - // labeled by name but previously validated by slug only, producing the confusing
6873 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
6874 - $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');
6875 6363 if (!$term) {
6876 - $term = get_term_by('name', $tag_input, 'post_tag');
6877 - }
6878 - if (!$term) {
6879 - 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');
6880 6365 exit;
6881 6366 }
6882 -
6883 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
6884 - // compares against each post's tag slugs, so the stored key must be a slug,
6885 - // never the raw (possibly display-name) input.
6886 - $tag_slug = $term->slug;
6887 -
6367 +
6888 6368 // Get existing mappings
6889 6369 $mappings = get_option('mxchat_tag_role_mappings', array());
6890 6370
6891 6371 // Check if mapping already exists
@@ -7569,27 +7049,13 @@
7569 7049
7570 7050 $result = false;
7571 7051 $error_message = '';
7572 7052
7573 - // Read item directly from DB to get queue_id and preserve special chars in item_data
7574 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
7575 - $db_item = $wpdb->get_row($wpdb->prepare(
7576 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
7577 - $item_id
7578 - ));
7579 - $item_queue_id = $db_item ? $db_item->queue_id : '';
7580 - if ($db_item && !empty($db_item->item_data)) {
7581 - $db_data = json_decode($db_item->item_data, true);
7582 - if (is_array($db_data)) {
7583 - $item_data = $db_data;
7584 - }
7585 - }
7586 -
7587 7053 switch ($item_type) {
7588 7054 case 'url':
7589 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
7055 + $result = $this->mxchat_process_queue_url($item_data, $bot_id);
7590 7056 break;
7591 -
7057 +
7592 7058 case 'pdf_page':
7593 7059 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
7594 7060 break;
7595 7061
@@ -7597,37 +7063,11 @@
7597 7063 throw new Exception('Unknown item type: ' . $item_type);
7598 7064 }
7599 7065
7600 7066 if (is_wp_error($result)) {
7601 - $error_code = $result->get_error_code();
7602 - // Content errors (empty page, sanitization) are permanent — retrying won't help
7603 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
7604 - if (in_array($error_code, $permanent_codes)) {
7605 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
7606 - $current_item = $wpdb->get_row($wpdb->prepare(
7607 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
7608 - ));
7609 - $wpdb->update(
7610 - $table_name,
7611 - array(
7612 - 'status' => 'failed',
7613 - 'error_message' => $result->get_error_message(),
7614 - 'attempts' => $current_item ? $current_item->max_attempts : 3
7615 - ),
7616 - array('id' => $item_id),
7617 - array('%s', '%s', '%d'),
7618 - array('%d')
7619 - );
7620 - wp_send_json_error(array(
7621 - 'message' => $result->get_error_message(),
7622 - 'permanent_failure' => true,
7623 - 'item_id' => $item_id
7624 - ));
7625 - return;
7626 - }
7627 7067 throw new Exception($result->get_error_message());
7628 7068 }
7629 -
7069 +
7630 7070 if ($result === false) {
7631 7071 throw new Exception('Processing returned false - item may be empty or invalid');
7632 7072 }
7633 7073
@@ -7703,9 +7143,9 @@
7703 7143
7704 7144 /**
7705 7145 * Process a URL from the queue
7706 7146 */
7707 -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') {
7708 7148 $url = isset($item_data['url']) ? $item_data['url'] : '';
7709 7149
7710 7150 if (empty($url)) {
7711 7151 return new WP_Error('invalid_url', 'URL is empty');
@@ -7753,9 +7193,9 @@
7753 7193
7754 7194 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
7755 7195 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7756 7196 $response = wp_remote_get($url, array(
7757 - 'timeout' => $is_likely_pdf ? 120 : 30,
7197 + 'timeout' => $is_likely_pdf ? 60 : 30,
7758 7198 'redirection' => 5,
7759 7199 'user-agent' => 'MxChat/1.0'
7760 7200 ));
7761 7201
@@ -7764,14 +7204,14 @@
7764 7204 }
7765 7205
7766 7206 $response_code = wp_remote_retrieve_response_code($response);
7767 7207 if ($response_code !== 200) {
7768 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
7208 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
7769 7209 }
7770 7210
7771 - // 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
7772 7212 if ($this->mxchat_is_pdf_url($url, $response)) {
7773 - 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);
7774 7214 }
7775 7215
7776 7216 $html = wp_remote_retrieve_body($response);
7777 7217
@@ -7801,89 +7241,11 @@
7801 7241 return $result;
7802 7242 }
7803 7243
7804 7244 /**
7805 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
7806 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
7807 - * 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.
7808 7247 */
7809 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
7810 - set_time_limit(120); // PDFs need extra time for download + parsing
7811 -
7812 - $upload_dir = wp_upload_dir();
7813 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7814 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7815 -
7816 - $response_body = wp_remote_retrieve_body($response);
7817 - if (empty($response_body)) {
7818 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
7819 - }
7820 -
7821 - if (!wp_mkdir_p(dirname($pdf_path))) {
7822 - return new WP_Error('dir_error', 'Failed to create upload directory');
7823 - }
7824 -
7825 - file_put_contents($pdf_path, $response_body);
7826 -
7827 - if (!file_exists($pdf_path)) {
7828 - return new WP_Error('save_error', 'Failed to save PDF file');
7829 - }
7830 -
7831 - try {
7832 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
7833 -
7834 - if ($total_pages === false || $total_pages < 1) {
7835 - wp_delete_file($pdf_path);
7836 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
7837 - }
7838 -
7839 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
7840 - $pages = array();
7841 - for ($i = 1; $i <= $total_pages; $i++) {
7842 - $pages[] = array(
7843 - 'pdf_path' => $pdf_path,
7844 - 'pdf_url' => $pdf_url,
7845 - 'page_number' => $i,
7846 - 'total_pages' => $total_pages
7847 - );
7848 - }
7849 -
7850 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
7851 - if (!empty($queue_id)) {
7852 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
7853 - } else {
7854 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
7855 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
7856 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
7857 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
7858 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
7859 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
7860 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
7861 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
7862 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
7863 - }
7864 -
7865 - if ($queued_count === 0) {
7866 - wp_delete_file($pdf_path);
7867 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
7868 - }
7869 -
7870 - // Return true so the original URL item is marked complete
7871 - // The new pdf_page items will be processed in subsequent batches
7872 - return true;
7873 -
7874 - } catch (Exception $e) {
7875 - if (file_exists($pdf_path)) {
7876 - wp_delete_file($pdf_path);
7877 - }
7878 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7879 - }
7880 -}
7881 -
7882 -/**
7883 - * Legacy: Process a PDF URL inline during sitemap queue processing.
7884 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
7885 - */
7886 7248 private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
7887 7249 set_time_limit(120); // PDFs need more time — downloading + parsing all pages
7888 7250
7889 7251 $upload_dir = wp_upload_dir();
@@ -7917,24 +7279,21 @@
7917 7279 return new WP_Error('no_pages', 'PDF has no pages');
7918 7280 }
7919 7281
7920 7282 $processed = 0;
7921 - $skipped_pages = array();
7922 7283
7923 7284 for ($i = 0; $i < $total_pages; $i++) {
7924 - $page_num = $i + 1;
7925 7285 $text = $pages[$i]->getText();
7926 7286 if (empty($text)) {
7927 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
7928 7287 continue;
7929 7288 }
7930 7289
7931 7290 $sanitized = $this->mxchat_sanitize_content_for_api($text);
7932 7291 if (empty($sanitized)) {
7933 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
7934 7292 continue;
7935 7293 }
7936 7294
7295 + $page_num = $i + 1;
7937 7296 $metadata = array(
7938 7297 'document_type' => 'pdf',
7939 7298 'total_pages' => $total_pages,
7940 7299 'current_page' => $page_num,
@@ -7958,12 +7317,8 @@
7958 7317
7959 7318 // Clean up the temp PDF file
7960 7319 wp_delete_file($pdf_path);
7961 7320
7962 - if (!empty($skipped_pages)) {
7963 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
7964 - }
7965 -
7966 7321 return $processed > 0 ? true : false;
7967 7322
7968 7323 } catch (Exception $e) {
7969 7324 if (file_exists($pdf_path)) {
@@ -8128,17 +7483,18 @@
8128 7483 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
8129 7484 }
8130 7485
8131 7486 $text = $pages[$page_number - 1]->getText();
8132 -
7487 +
8133 7488 if (empty($text)) {
8134 - 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;
8135 7491 }
8136 -
7492 +
8137 7493 $sanitized = $this->mxchat_sanitize_content_for_api($text);
8138 -
7494 +
8139 7495 if (empty($sanitized)) {
8140 - 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;
8141 7497 }
8142 7498
8143 7499 // Create metadata
8144 7500 $metadata = array(
@@ -8242,16 +7598,17 @@
8242 7598
8243 7599 // Calculate percentage
8244 7600 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
8245 7601
8246 - // Get failed items details (include all failed items, not just those that exhausted retries)
7602 + // Get failed items details
8247 7603 $failed_items = array();
8248 7604 if ($failed > 0) {
8249 7605 $failed_items = $wpdb->get_results($wpdb->prepare(
8250 - "SELECT item_type, item_data, error_message, attempts
8251 - FROM $table_name
8252 - WHERE queue_id = %s
7606 + "SELECT item_type, item_data, error_message, attempts
7607 + FROM $table_name
7608 + WHERE queue_id = %s
8253 7609 AND status = 'failed'
7610 + AND attempts >= max_attempts
8254 7611 ORDER BY id DESC
8255 7612 LIMIT 50",
8256 7613 $queue_id
8257 7614 ));