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 -785 3.2.133.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,23 +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 — send an honest, versioned MXChat crawler UA (not a spoofed
1218 - // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1219 - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1220 - // from the site's own media library, which route through this same call).
1221 - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1222 - // the browser-only Accept-Language fingerprint is dropped so it stays
1223 - // coherent with a bot identity.
1224 - $response = wp_remote_get($submitted_url, array(
1225 - 'timeout' => 30,
1226 - 'sslverify' => false,
1227 - 'user-agent' => mxchat_ingest_user_agent(),
1228 - 'headers' => array(
1229 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1230 - ),
1231 - ));
973 + // Fetch URL
974 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
1232 975
1233 976 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1234 977 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1235 978 set_transient('mxchat_admin_notice_error',
@@ -3063,12 +2806,11 @@
3063 2806 foreach ($primary_indexes as $path => $source) {
3064 2807 $url = trailingslashit($site_url) . $path;
3065 2808
3066 2809 $response = wp_remote_head($url, array(
3067 - 'timeout' => 10,
2810 + 'timeout' => 3, // Short timeout
3068 2811 'sslverify' => false,
3069 - 'redirection' => 1,
3070 - 'user-agent' => mxchat_ingest_user_agent(),
2812 + 'redirection' => 1
3071 2813 ));
3072 2814
3073 2815 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3074 2816 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3126,14 +2868,10 @@
3126 2868 private function parse_sitemap_index($url) {
3127 2869 $sub_sitemaps = array();
3128 2870
3129 2871 $response = wp_remote_get($url, array(
3130 - 'timeout' => 30,
3131 - 'sslverify' => false,
3132 - 'user-agent' => mxchat_ingest_user_agent(),
3133 - 'headers' => array(
3134 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3135 - ),
2872 + 'timeout' => 5,
2873 + 'sslverify' => false
3136 2874 ));
3137 2875
3138 2876 if (is_wp_error($response)) {
3139 2877 return $sub_sitemaps;
@@ -3184,14 +2922,10 @@
3184 2922 * Get URL count from a sitemap
3185 2923 */
3186 2924 private function get_sitemap_url_count($url) {
3187 2925 $response = wp_remote_get($url, array(
3188 - 'timeout' => 30,
3189 - 'sslverify' => false,
3190 - 'user-agent' => mxchat_ingest_user_agent(),
3191 - 'headers' => array(
3192 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3193 - ),
2926 + 'timeout' => 10,
2927 + 'sslverify' => false
3194 2928 ));
3195 2929
3196 2930 if (is_wp_error($response)) {
3197 2931 return 0;
@@ -3214,11 +2948,10 @@
3214 2948 $sitemaps = array();
3215 2949 $robots_url = trailingslashit($site_url) . 'robots.txt';
3216 2950
3217 2951 $response = wp_remote_get($robots_url, array(
3218 - 'timeout' => 15,
3219 - 'sslverify' => false,
3220 - 'user-agent' => mxchat_ingest_user_agent(),
2952 + 'timeout' => 5,
2953 + 'sslverify' => false
3221 2954 ));
3222 2955
3223 2956 if (is_wp_error($response)) {
3224 2957 return $sitemaps;
@@ -3708,22 +3441,9 @@
3708 3441 }
3709 3442
3710 3443 // Get bot_id from request
3711 3444 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3712 -
3713 - // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3714 - // don't re-check on every batch; the default is OFF for installs that haven't set it.
3715 - $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3716 - $mxchat_options = get_option('mxchat_options', array());
3717 - if (!is_array($mxchat_options)) {
3718 - $mxchat_options = array();
3719 - }
3720 - $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3721 - if ($prior_default !== $extract_acf_pdfs) {
3722 - $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3723 - update_option('mxchat_options', $mxchat_options);
3724 - }
3725 -
3445 +
3726 3446 // Process only ONE post at a time to avoid request size issues
3727 3447 $post_id = reset($post_ids);
3728 3448 $post = get_post($post_id);
3729 3449
@@ -3829,57 +3549,23 @@
3829 3549 }
3830 3550
3831 3551 // ADD ACF FIELDS SUPPORT
3832 3552 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3833 - $pdf_extracted_count = 0;
3834 3553 if (!empty($acf_fields)) {
3835 3554 $acf_content_parts = array();
3836 - $pdf_attachment_ids = array();
3837 -
3555 +
3838 3556 foreach ($acf_fields as $field_name => $field_value) {
3839 3557 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3840 -
3558 +
3841 3559 if (!empty($formatted_value)) {
3842 3560 $field_label = ucwords(str_replace('_', ' ', $field_name));
3843 3561 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3844 3562 }
3845 -
3846 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
3847 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
3848 - // still lands in the KB but the heavier PDF parsing is skipped.
3849 - if ($extract_acf_pdfs) {
3850 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
3851 - }
3852 3563 }
3853 -
3564 +
3854 3565 if (!empty($acf_content_parts)) {
3855 3566 $content .= "\n\n" . implode("\n", $acf_content_parts);
3856 3567 }
3857 -
3858 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
3859 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
3860 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
3861 - $pdf_sections = array();
3862 - foreach ($pdf_attachment_ids as $att_id) {
3863 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
3864 - if (!empty($pdf_text)) {
3865 - $pdf_title = get_the_title($att_id);
3866 - $pdf_url = wp_get_attachment_url($att_id);
3867 - $header = 'PDF Attachment';
3868 - if (!empty($pdf_title)) {
3869 - $header .= ': ' . $pdf_title;
3870 - }
3871 - if (!empty($pdf_url)) {
3872 - $header .= ' (' . $pdf_url . ')';
3873 - }
3874 - $pdf_sections[] = $header . "\n" . $pdf_text;
3875 - $pdf_extracted_count++;
3876 - }
3877 - }
3878 - if (!empty($pdf_sections)) {
3879 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
3880 - }
3881 - }
3882 3568 }
3883 3569
3884 3570 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3885 3571 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4009,9 +3695,8 @@
4009 3695 'title' => $post->post_title,
4010 3696 'operation_type' => $operation_type,
4011 3697 'vector_id' => $vector_id,
4012 3698 'acf_fields_found' => $acf_field_count,
4013 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4014 3699 'content_preview' => substr($content, 0, 100) . '...',
4015 3700 'bot_id' => $bot_id
4016 3701 ));
4017 3702 exit;
@@ -4426,20 +4111,9 @@
4426 4111
4427 4112 // Get bot-specific options
4428 4113 $bot_options = $this->get_bot_options($bot_id);
4429 4114 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4430 -
4431 - // Opt-in: when the custom provider is selected for embeddings, index through
4432 - // the same custom endpoint the query path uses so stored vectors and query
4433 - // vectors share a model. Returns the vector array on success, or an error
4434 - // string on failure (this function's existing failure contract).
4435 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4436 - if (!class_exists('MxChat_Utils')) {
4437 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4438 - }
4439 - return MxChat_Utils::generate_embedding_custom($text, $options);
4440 - }
4441 -
4115 +
4442 4116 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4443 4117 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4444 4118
4445 4119 // Determine provider and endpoint
@@ -5357,197 +5031,8 @@
5357 5031 return implode(', ', array_filter($text_parts));
5358 5032 }
5359 5033
5360 5034 /**
5361 - * Walk an ACF field value tree and collect attachment IDs for any value that
5362 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5363 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5364 - * plain URL string), and recurses through repeater/group/flexible content.
5365 - *
5366 - * @param mixed $value The ACF field value (any depth)
5367 - * @param array $out Accumulator (passed by reference) for attachment IDs
5368 - * @param int $depth Recursion guard
5369 - */
5370 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5371 - if ($depth > 6) {
5372 - return; // prevent runaway recursion on circular/very-deep structures
5373 - }
5374 -
5375 - if (empty($value)) {
5376 - return;
5377 - }
5378 -
5379 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5380 - if (is_array($value)) {
5381 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5382 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5383 - if ($looks_like_attachment) {
5384 - $att_id = 0;
5385 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5386 - $att_id = (int) $value['ID'];
5387 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5388 - $att_id = (int) $value['id'];
5389 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5390 - $att_id = (int) attachment_url_to_postid($value['url']);
5391 - }
5392 -
5393 - $is_pdf = false;
5394 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5395 - $is_pdf = true;
5396 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5397 - $is_pdf = true;
5398 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5399 - $is_pdf = true;
5400 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5401 - $is_pdf = true;
5402 - }
5403 -
5404 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5405 - $out[] = $att_id;
5406 - }
5407 - // An array node that represents one attachment doesn't contain other
5408 - // attachments inside it — done with this branch.
5409 - return;
5410 - }
5411 -
5412 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5413 - foreach ($value as $sub) {
5414 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5415 - }
5416 - return;
5417 - }
5418 -
5419 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5420 - if (is_numeric($value)) {
5421 - $att_id = (int) $value;
5422 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5423 - $out[] = $att_id;
5424 - }
5425 - return;
5426 - }
5427 -
5428 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5429 - if (is_string($value)) {
5430 - $trimmed = trim($value);
5431 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5432 - $att_id = (int) attachment_url_to_postid($trimmed);
5433 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5434 - $out[] = $att_id;
5435 - }
5436 - }
5437 - return;
5438 - }
5439 -}
5440 -
5441 -/**
5442 - * Heuristic: does this URL/string look like a PDF reference?
5443 - * Tolerates query strings and fragments (#page=2).
5444 - */
5445 -private function mxchat_url_looks_like_pdf($url) {
5446 - if (!is_string($url) || $url === '') {
5447 - return false;
5448 - }
5449 - // Strip query + fragment before checking extension
5450 - $path = preg_replace('/[?#].*$/', '', $url);
5451 - return (bool) preg_match('/\.pdf$/i', $path);
5452 -}
5453 -
5454 -/**
5455 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5456 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5457 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5458 - * only parse the same PDF once unless the file changes on disk.
5459 - *
5460 - * @param int $attachment_id
5461 - * @return string Extracted plain text, or '' on failure.
5462 - */
5463 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5464 - $attachment_id = (int) $attachment_id;
5465 - if ($attachment_id <= 0) {
5466 - return '';
5467 - }
5468 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5469 - return '';
5470 - }
5471 -
5472 - $pdf_path = get_attached_file($attachment_id);
5473 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5474 - return '';
5475 - }
5476 -
5477 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5478 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5479 - $default_max_bytes = 25 * 1024 * 1024;
5480 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5481 - if ($max_bytes > 0) {
5482 - $file_size = @filesize($pdf_path);
5483 - if ($file_size !== false && $file_size > $max_bytes) {
5484 - error_log(sprintf(
5485 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5486 - $attachment_id,
5487 - basename($pdf_path),
5488 - $file_size,
5489 - $max_bytes
5490 - ));
5491 - return '';
5492 - }
5493 - }
5494 -
5495 - $mtime = @filemtime($pdf_path);
5496 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5497 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5498 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5499 - return (string) $cached['text'];
5500 - }
5501 -
5502 - $text = '';
5503 - try {
5504 - if (function_exists('mxchat_load_pdf_parser')) {
5505 - mxchat_load_pdf_parser();
5506 - }
5507 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5508 - return '';
5509 - }
5510 - $parser = new \Smalot\PdfParser\Parser();
5511 - $pdf = $parser->parseFile($pdf_path);
5512 - $pages = $pdf->getPages();
5513 - $page_texts = array();
5514 - foreach ($pages as $page) {
5515 - $page_text = '';
5516 - try {
5517 - $page_text = $page->getText();
5518 - } catch (\Exception $e) {
5519 - $page_text = '';
5520 - }
5521 - if (!empty($page_text)) {
5522 - $page_texts[] = $page_text;
5523 - }
5524 - }
5525 - $text = trim(implode("\n\n", $page_texts));
5526 - } catch (\Exception $e) {
5527 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5528 - return '';
5529 - } catch (\Throwable $e) {
5530 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5531 - return '';
5532 - }
5533 -
5534 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5535 - // The chunker downstream will still split this into multiple vectors.
5536 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5537 - if ($max_len > 0 && strlen($text) > $max_len) {
5538 - $text = substr($text, 0, $max_len);
5539 - }
5540 -
5541 - update_post_meta($attachment_id, $cache_meta_key, array(
5542 - 'mtime' => (int) $mtime,
5543 - 'text' => $text,
5544 - ));
5545 -
5546 - return $text;
5547 -}
5548 -
5549 -/**
5550 5035 * Handle ACF save - fires after ACF fields are saved
5551 5036 * This ensures ACF field data is available when syncing to knowledge base
5552 5037 */
5553 5038 public function mxchat_handle_acf_save($post_id) {
@@ -5655,33 +5140,40 @@
5655 5140 // If the post was previously published but is now not published, remove from knowledge base
5656 5141 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5657 5142 // Use the stored URL from when it was published, or fall back to current permalink
5658 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';
5659 5149
5660 - if ($source_url) {
5661 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5662 - 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 + }
5663 5164 }
5664 -
5165 +
5665 5166 // Clean up the transients and exit early
5666 5167 delete_transient($previous_status_key);
5667 5168 delete_transient($previous_url_key);
5668 5169 return;
5669 5170 }
5670 -
5671 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5672 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5673 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5674 - $current_url = get_permalink($post_id);
5675 - if ($current_url && $current_url !== $previous_url) {
5676 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5677 - }
5678 - }
5679 -
5171 +
5680 5172 // Store the current status for next time (if this is an update)
5681 5173 if ($update) {
5682 5174 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5683 -
5175 +
5684 5176 // If the post is currently published, also store its URL
5685 5177 if ($post->post_status === 'publish') {
5686 5178 $current_url = get_permalink($post_id);
5687 5179 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
@@ -5790,9 +5282,8 @@
5790 5282 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5791 5283 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5792 5284 if (!empty($acf_fields)) {
5793 5285 $acf_content_parts = array();
5794 - $pdf_attachment_ids = array();
5795 5286
5796 5287 foreach ($acf_fields as $field_name => $field_value) {
5797 5288 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5798 5289 if (!empty($formatted_value)) {
@@ -5799,44 +5290,13 @@
5799 5290 // Convert field name to readable label
5800 5291 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5801 5292 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5802 5293 }
5803 -
5804 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5805 5294 }
5806 5295
5807 5296 if (!empty($acf_content_parts)) {
5808 5297 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5809 5298 }
5810 -
5811 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
5812 - // Mirrors the per-batch checkbox the manual content selector has; the
5813 - // 25 MB size cap lives in the shared extractor so it applies in both
5814 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
5815 - // editor save is expensive and most sites don't want it.
5816 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
5817 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
5818 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
5819 - $pdf_sections = array();
5820 - foreach ($pdf_attachment_ids as $att_id) {
5821 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
5822 - if (!empty($pdf_text)) {
5823 - $pdf_title = get_the_title($att_id);
5824 - $pdf_url = wp_get_attachment_url($att_id);
5825 - $header = 'PDF Attachment';
5826 - if (!empty($pdf_title)) {
5827 - $header .= ': ' . $pdf_title;
5828 - }
5829 - if (!empty($pdf_url)) {
5830 - $header .= ' (' . $pdf_url . ')';
5831 - }
5832 - $pdf_sections[] = $header . "\n" . $pdf_text;
5833 - }
5834 - }
5835 - if (!empty($pdf_sections)) {
5836 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
5837 - }
5838 - }
5839 5299 }
5840 5300
5841 5301 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5842 5302 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -5943,14 +5403,12 @@
5943 5403 if (!$should_sync) {
5944 5404 return;
5945 5405 }
5946 5406
5947 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
5948 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
5949 - // real vector IDs stored under the original URL.
5950 - $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);
5951 5409 if (!$source_url) {
5952 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5410 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
5953 5411 return;
5954 5412 }
5955 5413
5956 5414 // Use chunk-aware deletion (handles both chunked and non-chunked content)
@@ -5958,36 +5416,56 @@
5958 5416
5959 5417 if (is_wp_error($delete_result)) {
5960 5418 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5961 5419 }
5420 +}
5962 5421
5963 - delete_transient('mxchat_prev_url_' . $post_id);
5964 - delete_transient('mxchat_prev_status_' . $post_id);
5965 -}
5966 5422
5967 -/**
5968 - * Resolve the source URL for a post being trashed/deleted.
5969 - *
5970 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
5971 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
5972 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
5973 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
5974 - */
5975 -private function mxchat_resolve_pre_trash_url($post_id) {
5976 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
5977 - if (!empty($previous_url)) {
5978 - return $previous_url;
5979 - }
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'] ?? '';
5980 5429
5981 - $current = get_permalink($post_id);
5982 - if (!$current) {
5983 - return '';
5984 - }
5985 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5986 -}
5430 + if (empty($host) || empty($api_key)) {
5431 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
5432 + return false;
5433 + }
5987 5434
5435 + $api_endpoint = "https://{$host}/vectors/delete";
5436 + $vector_id = md5($source_url);
5988 5437
5438 + $request_body = array(
5439 + 'ids' => array($vector_id)
5440 + );
5989 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 +
5990 5468 public function mxchat_handle_product_change($post_id, $post, $update) {
5991 5469 if ($post->post_type !== 'product') {
5992 5470 return;
5993 5471 }
@@ -6139,18 +5617,28 @@
6139 5617 if (get_post_type($post_id) !== 'product') {
6140 5618 return;
6141 5619 }
6142 5620
6143 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6144 - if (!$source_url) {
6145 - return;
6146 - }
5621 + $source_url = get_permalink($post_id);
6147 5622
6148 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6149 - 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';
6150 5626
6151 - delete_transient('mxchat_prev_url_' . $post_id);
6152 - 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 + }
6153 5641 }
6154 5642
6155 5643 /**
6156 5644 * Handle individual Pinecone content deletion
@@ -6854,16 +6342,16 @@
6854 6342 wp_send_json_error('Unauthorized access');
6855 6343 exit;
6856 6344 }
6857 6345
6858 - $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']) : '';
6859 6347 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6860 -
6861 - if (empty($tag_input)) {
6862 - 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');
6863 6351 exit;
6864 6352 }
6865 -
6353 +
6866 6354 // Validate role restriction
6867 6355 $valid_roles = array_keys($this->mxchat_get_role_options());
6868 6356 if (!in_array($role_restriction, $valid_roles)) {
6869 6357 wp_send_json_error('Invalid role restriction');
@@ -6868,27 +6356,16 @@
6868 6356 if (!in_array($role_restriction, $valid_roles)) {
6869 6357 wp_send_json_error('Invalid role restriction');
6870 6358 exit;
6871 6359 }
6872 -
6873 - // Resolve the tag by slug first, then fall back to its display name, so users can
6874 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
6875 - // labeled by name but previously validated by slug only, producing the confusing
6876 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
6877 - $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');
6878 6363 if (!$term) {
6879 - $term = get_term_by('name', $tag_input, 'post_tag');
6880 - }
6881 - if (!$term) {
6882 - 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');
6883 6365 exit;
6884 6366 }
6885 -
6886 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
6887 - // compares against each post's tag slugs, so the stored key must be a slug,
6888 - // never the raw (possibly display-name) input.
6889 - $tag_slug = $term->slug;
6890 -
6367 +
6891 6368 // Get existing mappings
6892 6369 $mappings = get_option('mxchat_tag_role_mappings', array());
6893 6370
6894 6371 // Check if mapping already exists
@@ -7572,27 +7049,13 @@
7572 7049
7573 7050 $result = false;
7574 7051 $error_message = '';
7575 7052
7576 - // Read item directly from DB to get queue_id and preserve special chars in item_data
7577 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
7578 - $db_item = $wpdb->get_row($wpdb->prepare(
7579 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
7580 - $item_id
7581 - ));
7582 - $item_queue_id = $db_item ? $db_item->queue_id : '';
7583 - if ($db_item && !empty($db_item->item_data)) {
7584 - $db_data = json_decode($db_item->item_data, true);
7585 - if (is_array($db_data)) {
7586 - $item_data = $db_data;
7587 - }
7588 - }
7589 -
7590 7053 switch ($item_type) {
7591 7054 case 'url':
7592 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
7055 + $result = $this->mxchat_process_queue_url($item_data, $bot_id);
7593 7056 break;
7594 -
7057 +
7595 7058 case 'pdf_page':
7596 7059 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
7597 7060 break;
7598 7061
@@ -7600,37 +7063,11 @@
7600 7063 throw new Exception('Unknown item type: ' . $item_type);
7601 7064 }
7602 7065
7603 7066 if (is_wp_error($result)) {
7604 - $error_code = $result->get_error_code();
7605 - // Content errors (empty page, sanitization) are permanent — retrying won't help
7606 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
7607 - if (in_array($error_code, $permanent_codes)) {
7608 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
7609 - $current_item = $wpdb->get_row($wpdb->prepare(
7610 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
7611 - ));
7612 - $wpdb->update(
7613 - $table_name,
7614 - array(
7615 - 'status' => 'failed',
7616 - 'error_message' => $result->get_error_message(),
7617 - 'attempts' => $current_item ? $current_item->max_attempts : 3
7618 - ),
7619 - array('id' => $item_id),
7620 - array('%s', '%s', '%d'),
7621 - array('%d')
7622 - );
7623 - wp_send_json_error(array(
7624 - 'message' => $result->get_error_message(),
7625 - 'permanent_failure' => true,
7626 - 'item_id' => $item_id
7627 - ));
7628 - return;
7629 - }
7630 7067 throw new Exception($result->get_error_message());
7631 7068 }
7632 -
7069 +
7633 7070 if ($result === false) {
7634 7071 throw new Exception('Processing returned false - item may be empty or invalid');
7635 7072 }
7636 7073
@@ -7706,9 +7143,9 @@
7706 7143
7707 7144 /**
7708 7145 * Process a URL from the queue
7709 7146 */
7710 -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') {
7711 7148 $url = isset($item_data['url']) ? $item_data['url'] : '';
7712 7149
7713 7150 if (empty($url)) {
7714 7151 return new WP_Error('invalid_url', 'URL is empty');
@@ -7756,11 +7193,11 @@
7756 7193
7757 7194 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
7758 7195 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7759 7196 $response = wp_remote_get($url, array(
7760 - 'timeout' => $is_likely_pdf ? 120 : 30,
7197 + 'timeout' => $is_likely_pdf ? 60 : 30,
7761 7198 'redirection' => 5,
7762 - 'user-agent' => mxchat_ingest_user_agent(),
7199 + 'user-agent' => 'MxChat/1.0'
7763 7200 ));
7764 7201
7765 7202 if (is_wp_error($response)) {
7766 7203 return $response;
@@ -7767,14 +7204,14 @@
7767 7204 }
7768 7205
7769 7206 $response_code = wp_remote_retrieve_response_code($response);
7770 7207 if ($response_code !== 200) {
7771 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
7208 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
7772 7209 }
7773 7210
7774 - // 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
7775 7212 if ($this->mxchat_is_pdf_url($url, $response)) {
7776 - 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);
7777 7214 }
7778 7215
7779 7216 $html = wp_remote_retrieve_body($response);
7780 7217
@@ -7804,89 +7241,11 @@
7804 7241 return $result;
7805 7242 }
7806 7243
7807 7244 /**
7808 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
7809 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
7810 - * 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.
7811 7247 */
7812 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
7813 - set_time_limit(120); // PDFs need extra time for download + parsing
7814 -
7815 - $upload_dir = wp_upload_dir();
7816 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7817 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7818 -
7819 - $response_body = wp_remote_retrieve_body($response);
7820 - if (empty($response_body)) {
7821 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
7822 - }
7823 -
7824 - if (!wp_mkdir_p(dirname($pdf_path))) {
7825 - return new WP_Error('dir_error', 'Failed to create upload directory');
7826 - }
7827 -
7828 - file_put_contents($pdf_path, $response_body);
7829 -
7830 - if (!file_exists($pdf_path)) {
7831 - return new WP_Error('save_error', 'Failed to save PDF file');
7832 - }
7833 -
7834 - try {
7835 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
7836 -
7837 - if ($total_pages === false || $total_pages < 1) {
7838 - wp_delete_file($pdf_path);
7839 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
7840 - }
7841 -
7842 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
7843 - $pages = array();
7844 - for ($i = 1; $i <= $total_pages; $i++) {
7845 - $pages[] = array(
7846 - 'pdf_path' => $pdf_path,
7847 - 'pdf_url' => $pdf_url,
7848 - 'page_number' => $i,
7849 - 'total_pages' => $total_pages
7850 - );
7851 - }
7852 -
7853 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
7854 - if (!empty($queue_id)) {
7855 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
7856 - } else {
7857 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
7858 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
7859 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
7860 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
7861 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
7862 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
7863 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
7864 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
7865 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
7866 - }
7867 -
7868 - if ($queued_count === 0) {
7869 - wp_delete_file($pdf_path);
7870 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
7871 - }
7872 -
7873 - // Return true so the original URL item is marked complete
7874 - // The new pdf_page items will be processed in subsequent batches
7875 - return true;
7876 -
7877 - } catch (Exception $e) {
7878 - if (file_exists($pdf_path)) {
7879 - wp_delete_file($pdf_path);
7880 - }
7881 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7882 - }
7883 -}
7884 -
7885 -/**
7886 - * Legacy: Process a PDF URL inline during sitemap queue processing.
7887 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
7888 - */
7889 7248 private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
7890 7249 set_time_limit(120); // PDFs need more time — downloading + parsing all pages
7891 7250
7892 7251 $upload_dir = wp_upload_dir();
@@ -7920,24 +7279,21 @@
7920 7279 return new WP_Error('no_pages', 'PDF has no pages');
7921 7280 }
7922 7281
7923 7282 $processed = 0;
7924 - $skipped_pages = array();
7925 7283
7926 7284 for ($i = 0; $i < $total_pages; $i++) {
7927 - $page_num = $i + 1;
7928 7285 $text = $pages[$i]->getText();
7929 7286 if (empty($text)) {
7930 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
7931 7287 continue;
7932 7288 }
7933 7289
7934 7290 $sanitized = $this->mxchat_sanitize_content_for_api($text);
7935 7291 if (empty($sanitized)) {
7936 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
7937 7292 continue;
7938 7293 }
7939 7294
7295 + $page_num = $i + 1;
7940 7296 $metadata = array(
7941 7297 'document_type' => 'pdf',
7942 7298 'total_pages' => $total_pages,
7943 7299 'current_page' => $page_num,
@@ -7961,12 +7317,8 @@
7961 7317
7962 7318 // Clean up the temp PDF file
7963 7319 wp_delete_file($pdf_path);
7964 7320
7965 - if (!empty($skipped_pages)) {
7966 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
7967 - }
7968 -
7969 7321 return $processed > 0 ? true : false;
7970 7322
7971 7323 } catch (Exception $e) {
7972 7324 if (file_exists($pdf_path)) {
@@ -8131,17 +7483,18 @@
8131 7483 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
8132 7484 }
8133 7485
8134 7486 $text = $pages[$page_number - 1]->getText();
8135 -
7487 +
8136 7488 if (empty($text)) {
8137 - 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;
8138 7491 }
8139 -
7492 +
8140 7493 $sanitized = $this->mxchat_sanitize_content_for_api($text);
8141 -
7494 +
8142 7495 if (empty($sanitized)) {
8143 - 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;
8144 7497 }
8145 7498
8146 7499 // Create metadata
8147 7500 $metadata = array(
@@ -8245,16 +7598,17 @@
8245 7598
8246 7599 // Calculate percentage
8247 7600 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
8248 7601
8249 - // Get failed items details (include all failed items, not just those that exhausted retries)
7602 + // Get failed items details
8250 7603 $failed_items = array();
8251 7604 if ($failed > 0) {
8252 7605 $failed_items = $wpdb->get_results($wpdb->prepare(
8253 - "SELECT item_type, item_data, error_message, attempts
8254 - FROM $table_name
8255 - WHERE queue_id = %s
7606 + "SELECT item_type, item_data, error_message, attempts
7607 + FROM $table_name
7608 + WHERE queue_id = %s
8256 7609 AND status = 'failed'
7610 + AND attempts >= max_attempts
8257 7611 ORDER BY id DESC
8258 7612 LIMIT 50",
8259 7613 $queue_id
8260 7614 ));