PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.2
MxChat – AI Chatbot & Content Generation for WordPress v3.2.2
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 +25 -542 3.2.133.2.2 View file →
@@ -58,9 +58,8 @@
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
64 63 // WordPress post management hooks
65 64 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
66 65 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
@@ -815,230 +814,8 @@
815 814 );
816 815 }
817 816
818 817 /**
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 818 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1042 819 * Works for both WordPress DB and Pinecone entries.
1043 820 */
1044 821 public function ajax_mxchat_save_entry_content() {
@@ -1213,21 +990,16 @@
1213 990 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1214 991 exit;
1215 992 }
1216 993
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.
994 + // Fetch URL — use browser-like headers so servers with bot protection don't block us
1224 995 $response = wp_remote_get($submitted_url, array(
1225 996 'timeout' => 30,
1226 997 'sslverify' => false,
1227 - 'user-agent' => mxchat_ingest_user_agent(),
998 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
1228 999 'headers' => array(
1229 1000 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1001 + 'Accept-Language' => 'en-US,en;q=0.9',
1230 1002 ),
1231 1003 ));
1232 1004
1233 1005 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
@@ -3066,9 +2838,9 @@
3066 2838 $response = wp_remote_head($url, array(
3067 2839 'timeout' => 10,
3068 2840 'sslverify' => false,
3069 2841 'redirection' => 1,
3070 - 'user-agent' => mxchat_ingest_user_agent(),
2842 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3071 2843 ));
3072 2844
3073 2845 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3074 2846 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3128,11 +2900,12 @@
3128 2900
3129 2901 $response = wp_remote_get($url, array(
3130 2902 'timeout' => 30,
3131 2903 'sslverify' => false,
3132 - 'user-agent' => mxchat_ingest_user_agent(),
2904 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3133 2905 'headers' => array(
3134 2906 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2907 + 'Accept-Language' => 'en-US,en;q=0.9',
3135 2908 ),
3136 2909 ));
3137 2910
3138 2911 if (is_wp_error($response)) {
@@ -3186,11 +2959,12 @@
3186 2959 private function get_sitemap_url_count($url) {
3187 2960 $response = wp_remote_get($url, array(
3188 2961 'timeout' => 30,
3189 2962 'sslverify' => false,
3190 - 'user-agent' => mxchat_ingest_user_agent(),
2963 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3191 2964 'headers' => array(
3192 2965 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2966 + 'Accept-Language' => 'en-US,en;q=0.9',
3193 2967 ),
3194 2968 ));
3195 2969
3196 2970 if (is_wp_error($response)) {
@@ -3216,9 +2990,9 @@
3216 2990
3217 2991 $response = wp_remote_get($robots_url, array(
3218 2992 'timeout' => 15,
3219 2993 'sslverify' => false,
3220 - 'user-agent' => mxchat_ingest_user_agent(),
2994 + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3221 2995 ));
3222 2996
3223 2997 if (is_wp_error($response)) {
3224 2998 return $sitemaps;
@@ -3708,22 +3482,9 @@
3708 3482 }
3709 3483
3710 3484 // Get bot_id from request
3711 3485 $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 -
3486 +
3726 3487 // Process only ONE post at a time to avoid request size issues
3727 3488 $post_id = reset($post_ids);
3728 3489 $post = get_post($post_id);
3729 3490
@@ -3829,57 +3590,23 @@
3829 3590 }
3830 3591
3831 3592 // ADD ACF FIELDS SUPPORT
3832 3593 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3833 - $pdf_extracted_count = 0;
3834 3594 if (!empty($acf_fields)) {
3835 3595 $acf_content_parts = array();
3836 - $pdf_attachment_ids = array();
3837 -
3596 +
3838 3597 foreach ($acf_fields as $field_name => $field_value) {
3839 3598 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3840 -
3599 +
3841 3600 if (!empty($formatted_value)) {
3842 3601 $field_label = ucwords(str_replace('_', ' ', $field_name));
3843 3602 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3844 3603 }
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 3604 }
3853 -
3605 +
3854 3606 if (!empty($acf_content_parts)) {
3855 3607 $content .= "\n\n" . implode("\n", $acf_content_parts);
3856 3608 }
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 3609 }
3883 3610
3884 3611 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3885 3612 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4009,9 +3736,8 @@
4009 3736 'title' => $post->post_title,
4010 3737 'operation_type' => $operation_type,
4011 3738 'vector_id' => $vector_id,
4012 3739 'acf_fields_found' => $acf_field_count,
4013 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4014 3740 'content_preview' => substr($content, 0, 100) . '...',
4015 3741 'bot_id' => $bot_id
4016 3742 ));
4017 3743 exit;
@@ -4426,20 +4152,9 @@
4426 4152
4427 4153 // Get bot-specific options
4428 4154 $bot_options = $this->get_bot_options($bot_id);
4429 4155 $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 -
4156 +
4442 4157 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4443 4158 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4444 4159
4445 4160 // Determine provider and endpoint
@@ -5357,197 +5072,8 @@
5357 5072 return implode(', ', array_filter($text_parts));
5358 5073 }
5359 5074
5360 5075 /**
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 5076 * Handle ACF save - fires after ACF fields are saved
5551 5077 * This ensures ACF field data is available when syncing to knowledge base
5552 5078 */
5553 5079 public function mxchat_handle_acf_save($post_id) {
@@ -5790,9 +5316,8 @@
5790 5316 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5791 5317 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5792 5318 if (!empty($acf_fields)) {
5793 5319 $acf_content_parts = array();
5794 - $pdf_attachment_ids = array();
5795 5320
5796 5321 foreach ($acf_fields as $field_name => $field_value) {
5797 5322 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5798 5323 if (!empty($formatted_value)) {
@@ -5799,44 +5324,13 @@
5799 5324 // Convert field name to readable label
5800 5325 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5801 5326 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5802 5327 }
5803 -
5804 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5805 5328 }
5806 5329
5807 5330 if (!empty($acf_content_parts)) {
5808 5331 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5809 5332 }
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 5333 }
5840 5334
5841 5335 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5842 5336 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -6854,16 +6348,16 @@
6854 6348 wp_send_json_error('Unauthorized access');
6855 6349 exit;
6856 6350 }
6857 6351
6858 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6352 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6859 6353 $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');
6354 +
6355 + if (empty($tag_slug)) {
6356 + wp_send_json_error('Tag slug is required');
6863 6357 exit;
6864 6358 }
6865 -
6359 +
6866 6360 // Validate role restriction
6867 6361 $valid_roles = array_keys($this->mxchat_get_role_options());
6868 6362 if (!in_array($role_restriction, $valid_roles)) {
6869 6363 wp_send_json_error('Invalid role restriction');
@@ -6868,27 +6362,16 @@
6868 6362 if (!in_array($role_restriction, $valid_roles)) {
6869 6363 wp_send_json_error('Invalid role restriction');
6870 6364 exit;
6871 6365 }
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');
6366 +
6367 + // Check if tag exists in WordPress
6368 + $term = get_term_by('slug', $tag_slug, 'post_tag');
6878 6369 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.');
6370 + wp_send_json_error('Tag does not exist in WordPress');
6883 6371 exit;
6884 6372 }
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 -
6373 +
6891 6374 // Get existing mappings
6892 6375 $mappings = get_option('mxchat_tag_role_mappings', array());
6893 6376
6894 6377 // Check if mapping already exists
@@ -7758,9 +7241,9 @@
7758 7241 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7759 7242 $response = wp_remote_get($url, array(
7760 7243 'timeout' => $is_likely_pdf ? 120 : 30,
7761 7244 'redirection' => 5,
7762 - 'user-agent' => mxchat_ingest_user_agent(),
7245 + 'user-agent' => 'MxChat/1.0'
7763 7246 ));
7764 7247
7765 7248 if (is_wp_error($response)) {
7766 7249 return $response;