PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.7
MxChat – AI Chatbot & Content Generation for WordPress v3.1.7
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 +114 -613 3.2.133.1.7 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'));
@@ -815,230 +817,8 @@
815 817 );
816 818 }
817 819
818 820 /**
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 821 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1042 822 * Works for both WordPress DB and Pinecone entries.
1043 823 */
1044 824 public function ajax_mxchat_save_entry_content() {
@@ -1213,23 +993,10 @@
1213 993 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1214 994 exit;
1215 995 }
1216 996
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 - ));
997 + // Fetch URL
998 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
1232 999
1233 1000 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1234 1001 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1235 1002 set_transient('mxchat_admin_notice_error',
@@ -3063,12 +2830,11 @@
3063 2830 foreach ($primary_indexes as $path => $source) {
3064 2831 $url = trailingslashit($site_url) . $path;
3065 2832
3066 2833 $response = wp_remote_head($url, array(
3067 - 'timeout' => 10,
2834 + 'timeout' => 3, // Short timeout
3068 2835 'sslverify' => false,
3069 - 'redirection' => 1,
3070 - 'user-agent' => mxchat_ingest_user_agent(),
2836 + 'redirection' => 1
3071 2837 ));
3072 2838
3073 2839 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3074 2840 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3126,14 +2892,10 @@
3126 2892 private function parse_sitemap_index($url) {
3127 2893 $sub_sitemaps = array();
3128 2894
3129 2895 $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 - ),
2896 + 'timeout' => 5,
2897 + 'sslverify' => false
3136 2898 ));
3137 2899
3138 2900 if (is_wp_error($response)) {
3139 2901 return $sub_sitemaps;
@@ -3184,14 +2946,10 @@
3184 2946 * Get URL count from a sitemap
3185 2947 */
3186 2948 private function get_sitemap_url_count($url) {
3187 2949 $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 - ),
2950 + 'timeout' => 10,
2951 + 'sslverify' => false
3194 2952 ));
3195 2953
3196 2954 if (is_wp_error($response)) {
3197 2955 return 0;
@@ -3214,11 +2972,10 @@
3214 2972 $sitemaps = array();
3215 2973 $robots_url = trailingslashit($site_url) . 'robots.txt';
3216 2974
3217 2975 $response = wp_remote_get($robots_url, array(
3218 - 'timeout' => 15,
3219 - 'sslverify' => false,
3220 - 'user-agent' => mxchat_ingest_user_agent(),
2976 + 'timeout' => 5,
2977 + 'sslverify' => false
3221 2978 ));
3222 2979
3223 2980 if (is_wp_error($response)) {
3224 2981 return $sitemaps;
@@ -3708,22 +3465,9 @@
3708 3465 }
3709 3466
3710 3467 // Get bot_id from request
3711 3468 $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 -
3469 +
3726 3470 // Process only ONE post at a time to avoid request size issues
3727 3471 $post_id = reset($post_ids);
3728 3472 $post = get_post($post_id);
3729 3473
@@ -3829,57 +3573,23 @@
3829 3573 }
3830 3574
3831 3575 // ADD ACF FIELDS SUPPORT
3832 3576 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3833 - $pdf_extracted_count = 0;
3834 3577 if (!empty($acf_fields)) {
3835 3578 $acf_content_parts = array();
3836 - $pdf_attachment_ids = array();
3837 -
3579 +
3838 3580 foreach ($acf_fields as $field_name => $field_value) {
3839 3581 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3840 -
3582 +
3841 3583 if (!empty($formatted_value)) {
3842 3584 $field_label = ucwords(str_replace('_', ' ', $field_name));
3843 3585 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3844 3586 }
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 3587 }
3853 -
3588 +
3854 3589 if (!empty($acf_content_parts)) {
3855 3590 $content .= "\n\n" . implode("\n", $acf_content_parts);
3856 3591 }
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 3592 }
3883 3593
3884 3594 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3885 3595 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4009,9 +3719,8 @@
4009 3719 'title' => $post->post_title,
4010 3720 'operation_type' => $operation_type,
4011 3721 'vector_id' => $vector_id,
4012 3722 'acf_fields_found' => $acf_field_count,
4013 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4014 3723 'content_preview' => substr($content, 0, 100) . '...',
4015 3724 'bot_id' => $bot_id
4016 3725 ));
4017 3726 exit;
@@ -4426,20 +4135,9 @@
4426 4135
4427 4136 // Get bot-specific options
4428 4137 $bot_options = $this->get_bot_options($bot_id);
4429 4138 $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 -
4139 +
4442 4140 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4443 4141 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4444 4142
4445 4143 // Determine provider and endpoint
@@ -5357,197 +5055,8 @@
5357 5055 return implode(', ', array_filter($text_parts));
5358 5056 }
5359 5057
5360 5058 /**
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 5059 * Handle ACF save - fires after ACF fields are saved
5551 5060 * This ensures ACF field data is available when syncing to knowledge base
5552 5061 */
5553 5062 public function mxchat_handle_acf_save($post_id) {
@@ -5655,33 +5164,40 @@
5655 5164 // If the post was previously published but is now not published, remove from knowledge base
5656 5165 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5657 5166 // Use the stored URL from when it was published, or fall back to current permalink
5658 5167 $source_url = $previous_url ?: get_permalink($post_id);
5168 +
5169 + if ($source_url) {
5170 + // Check if Pinecone is enabled
5171 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5172 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5659 5173
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');
5174 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5175 + // Delete from Pinecone
5176 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5177 + } else {
5178 + // Delete from WordPress DB
5179 + global $wpdb;
5180 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5181 +
5182 + $result = $wpdb->delete(
5183 + $table_name,
5184 + array('source_url' => $source_url),
5185 + array('%s')
5186 + );
5187 + }
5663 5188 }
5664 -
5189 +
5665 5190 // Clean up the transients and exit early
5666 5191 delete_transient($previous_status_key);
5667 5192 delete_transient($previous_url_key);
5668 5193 return;
5669 5194 }
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 -
5195 +
5680 5196 // Store the current status for next time (if this is an update)
5681 5197 if ($update) {
5682 5198 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5683 -
5199 +
5684 5200 // If the post is currently published, also store its URL
5685 5201 if ($post->post_status === 'publish') {
5686 5202 $current_url = get_permalink($post_id);
5687 5203 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
@@ -5790,9 +5306,8 @@
5790 5306 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5791 5307 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5792 5308 if (!empty($acf_fields)) {
5793 5309 $acf_content_parts = array();
5794 - $pdf_attachment_ids = array();
5795 5310
5796 5311 foreach ($acf_fields as $field_name => $field_value) {
5797 5312 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5798 5313 if (!empty($formatted_value)) {
@@ -5799,44 +5314,13 @@
5799 5314 // Convert field name to readable label
5800 5315 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5801 5316 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5802 5317 }
5803 -
5804 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5805 5318 }
5806 5319
5807 5320 if (!empty($acf_content_parts)) {
5808 5321 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5809 5322 }
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 5323 }
5840 5324
5841 5325 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5842 5326 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -5943,14 +5427,12 @@
5943 5427 if (!$should_sync) {
5944 5428 return;
5945 5429 }
5946 5430
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);
5431 + // Get the URL before post is deleted
5432 + $source_url = get_permalink($post_id);
5951 5433 if (!$source_url) {
5952 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5434 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
5953 5435 return;
5954 5436 }
5955 5437
5956 5438 // Use chunk-aware deletion (handles both chunked and non-chunked content)
@@ -5958,36 +5440,56 @@
5958 5440
5959 5441 if (is_wp_error($delete_result)) {
5960 5442 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5961 5443 }
5444 +}
5962 5445
5963 - delete_transient('mxchat_prev_url_' . $post_id);
5964 - delete_transient('mxchat_prev_status_' . $post_id);
5965 -}
5966 5446
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 - }
5447 + /**
5448 + * Deletes data from Pinecone using a source URL
5449 + */
5450 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
5451 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5452 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5980 5453
5981 - $current = get_permalink($post_id);
5982 - if (!$current) {
5983 - return '';
5984 - }
5985 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5986 -}
5454 + if (empty($host) || empty($api_key)) {
5455 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
5456 + return false;
5457 + }
5987 5458
5459 + $api_endpoint = "https://{$host}/vectors/delete";
5460 + $vector_id = md5($source_url);
5988 5461
5462 + $request_body = array(
5463 + 'ids' => array($vector_id)
5464 + );
5989 5465
5466 + $response = wp_remote_post($api_endpoint, array(
5467 + 'headers' => array(
5468 + 'Api-Key' => $api_key,
5469 + 'accept' => 'application/json',
5470 + 'content-type' => 'application/json'
5471 + ),
5472 + 'body' => wp_json_encode($request_body),
5473 + 'timeout' => 30
5474 + ));
5475 +
5476 + if (is_wp_error($response)) {
5477 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
5478 + return false;
5479 + }
5480 +
5481 + $response_code = wp_remote_retrieve_response_code($response);
5482 + if ($response_code !== 200) {
5483 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
5484 + return false;
5485 + }
5486 +
5487 + return true;
5488 + }
5489 +
5490 +
5491 +
5990 5492 public function mxchat_handle_product_change($post_id, $post, $update) {
5991 5493 if ($post->post_type !== 'product') {
5992 5494 return;
5993 5495 }
@@ -6139,18 +5641,28 @@
6139 5641 if (get_post_type($post_id) !== 'product') {
6140 5642 return;
6141 5643 }
6142 5644
6143 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6144 - if (!$source_url) {
6145 - return;
6146 - }
5645 + $source_url = get_permalink($post_id);
6147 5646
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');
5647 + // Check if Pinecone is enabled
5648 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5649 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6150 5650
6151 - delete_transient('mxchat_prev_url_' . $post_id);
6152 - delete_transient('mxchat_prev_status_' . $post_id);
5651 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5652 + // Delete from Pinecone
5653 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5654 + } else {
5655 + // Delete from WordPress DB
5656 + global $wpdb;
5657 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5658 +
5659 + $wpdb->delete(
5660 + $table_name,
5661 + array('source_url' => $source_url),
5662 + array('%s')
5663 + );
5664 + }
6153 5665 }
6154 5666
6155 5667 /**
6156 5668 * Handle individual Pinecone content deletion
@@ -6854,16 +6366,16 @@
6854 6366 wp_send_json_error('Unauthorized access');
6855 6367 exit;
6856 6368 }
6857 6369
6858 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6370 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6859 6371 $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');
6372 +
6373 + if (empty($tag_slug)) {
6374 + wp_send_json_error('Tag slug is required');
6863 6375 exit;
6864 6376 }
6865 -
6377 +
6866 6378 // Validate role restriction
6867 6379 $valid_roles = array_keys($this->mxchat_get_role_options());
6868 6380 if (!in_array($role_restriction, $valid_roles)) {
6869 6381 wp_send_json_error('Invalid role restriction');
@@ -6868,27 +6380,16 @@
6868 6380 if (!in_array($role_restriction, $valid_roles)) {
6869 6381 wp_send_json_error('Invalid role restriction');
6870 6382 exit;
6871 6383 }
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');
6384 +
6385 + // Check if tag exists in WordPress
6386 + $term = get_term_by('slug', $tag_slug, 'post_tag');
6878 6387 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.');
6388 + wp_send_json_error('Tag does not exist in WordPress');
6883 6389 exit;
6884 6390 }
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 -
6391 +
6891 6392 // Get existing mappings
6892 6393 $mappings = get_option('mxchat_tag_role_mappings', array());
6893 6394
6894 6395 // Check if mapping already exists
@@ -7758,9 +7259,9 @@
7758 7259 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7759 7260 $response = wp_remote_get($url, array(
7760 7261 'timeout' => $is_likely_pdf ? 120 : 30,
7761 7262 'redirection' => 5,
7762 - 'user-agent' => mxchat_ingest_user_agent(),
7263 + 'user-agent' => 'MxChat/1.0'
7763 7264 ));
7764 7265
7765 7266 if (is_wp_error($response)) {
7766 7267 return $response;