PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.3
MxChat – AI Chatbot & Content Generation for WordPress v3.1.3
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 +135 -1163 3.2.113.1.3 View file →
@@ -56,12 +56,12 @@
56 56 add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
57 57 add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
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 - add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
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 60
61 + // Hook for content deletion
62 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
63 +
64 64 // WordPress post management hooks
65 65 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
66 66 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
67 67 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
@@ -161,13 +161,9 @@
161 161 public function mxchat_is_pdf_url($url, $response) {
162 162 $content_type = wp_remote_retrieve_header($response, 'content-type');
163 163 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
164 164
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;
165 + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf';
170 166 }
171 167
172 168
173 169 public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
@@ -624,491 +620,8 @@
624 620 }
625 621 }
626 622
627 623
628 -/**
629 - * AJAX: Get full content for editing — reassembles chunks if needed.
630 - * Works for both WordPress DB and Pinecone entries.
631 - */
632 -public function ajax_mxchat_get_entry_content() {
633 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
634 -
635 - if ( ! current_user_can('manage_options') ) {
636 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
637 - }
638 -
639 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
640 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
641 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
642 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
643 -
644 - if ( $data_source === 'pinecone' ) {
645 - // Pinecone: fetch vectors by source_url, reassemble chunks
646 - $content = $this->get_pinecone_entry_content( $source_url, $entry_id, $bot_id );
647 - } else {
648 - // WordPress DB
649 - $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
650 - }
651 -
652 - if ( is_wp_error( $content ) ) {
653 - wp_send_json_error( array( 'message' => $content->get_error_message() ) );
654 - }
655 -
656 - wp_send_json_success( $content );
657 -}
658 -
659 -/**
660 - * Get content from WordPress DB — reassembles chunks by source_url.
661 - */
662 -private function get_wordpress_entry_content( $source_url, $entry_id ) {
663 - global $wpdb;
664 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
665 -
666 - // If we have a source_url, check for chunks
667 - if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
668 - $rows = $wpdb->get_results( $wpdb->prepare(
669 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
670 - $source_url
671 - ) );
672 -
673 - if ( $rows && count( $rows ) > 1 ) {
674 - // Multiple rows = chunked. Reassemble.
675 - $chunks = array();
676 - foreach ( $rows as $row ) {
677 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
678 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
679 - $chunks[ $index ] = $parsed['text'];
680 - }
681 - ksort( $chunks );
682 - return array(
683 - 'content' => implode( "\n\n", $chunks ),
684 - 'source_url' => $source_url,
685 - 'is_chunked' => true,
686 - 'chunk_count' => count( $chunks ),
687 - 'content_type' => $rows[0]->content_type,
688 - );
689 - } elseif ( $rows && count( $rows ) === 1 ) {
690 - $parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
691 - return array(
692 - 'content' => $parsed['text'],
693 - 'source_url' => $source_url,
694 - 'entry_id' => $rows[0]->id,
695 - 'is_chunked' => false,
696 - 'content_type' => $rows[0]->content_type,
697 - );
698 - }
699 - }
700 -
701 - // Fallback: fetch by ID
702 - if ( $entry_id > 0 ) {
703 - $row = $wpdb->get_row( $wpdb->prepare(
704 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
705 - $entry_id
706 - ) );
707 - if ( $row ) {
708 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
709 - return array(
710 - 'content' => $parsed['text'],
711 - 'source_url' => $row->source_url,
712 - 'entry_id' => $row->id,
713 - 'is_chunked' => false,
714 - 'content_type' => $row->content_type,
715 - );
716 - }
717 - }
718 -
719 - return new WP_Error( 'not_found', 'Entry not found.' );
720 -}
721 -
722 -/**
723 - * Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
724 - */
725 -private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
726 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
727 - return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
728 - }
729 -
730 - // Get Pinecone config
731 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
732 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
733 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
734 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
735 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
736 - } else {
737 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
738 - $api_key = $bot_config['api_key'] ?? '';
739 - $host = $bot_config['host'] ?? '';
740 - $namespace = $bot_config['namespace'] ?? '';
741 - }
742 -
743 - if ( empty($host) || empty($api_key) ) {
744 - return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
745 - }
746 -
747 - // List vectors with the source_url prefix
748 - $base_id = md5( $source_url );
749 - $vector_ids = array( $base_id );
750 -
751 - // Find chunk vectors
752 - $list_url = "https://{$host}/vectors/list";
753 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
754 - if ( ! empty($namespace) ) {
755 - $list_body['namespace'] = $namespace;
756 - }
757 -
758 - $list_resp = wp_remote_post( $list_url, array(
759 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
760 - 'body' => wp_json_encode( $list_body ),
761 - 'timeout' => 15,
762 - ) );
763 -
764 - if ( ! is_wp_error($list_resp) ) {
765 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
766 - if ( ! empty($list_data['vectors']) ) {
767 - foreach ( $list_data['vectors'] as $v ) {
768 - $vector_ids[] = $v['id'];
769 - }
770 - }
771 - }
772 -
773 - // Fetch vectors with metadata
774 - $fetch_url = "https://{$host}/vectors/fetch";
775 - $fetch_body = array( 'ids' => $vector_ids );
776 - if ( ! empty($namespace) ) {
777 - $fetch_body['namespace'] = $namespace;
778 - }
779 -
780 - $fetch_resp = wp_remote_post( $fetch_url, array(
781 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
782 - 'body' => wp_json_encode( $fetch_body ),
783 - 'timeout' => 15,
784 - ) );
785 -
786 - if ( is_wp_error($fetch_resp) ) {
787 - return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
788 - }
789 -
790 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
791 - $vectors = $fetch_data['vectors'] ?? array();
792 -
793 - if ( empty($vectors) ) {
794 - return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
795 - }
796 -
797 - // Reassemble chunks
798 - $chunks = array();
799 - $content_type = 'content';
800 - foreach ( $vectors as $vid => $vector ) {
801 - $meta = $vector['metadata'] ?? array();
802 - $text = $meta['text'] ?? '';
803 - $index = $meta['chunk_index'] ?? 0;
804 - $content_type = $meta['type'] ?? 'content';
805 - $chunks[ intval($index) ] = $text;
806 - }
807 - ksort( $chunks );
808 -
809 - return array(
810 - 'content' => implode( "\n\n", $chunks ),
811 - 'source_url' => $source_url,
812 - 'is_chunked' => count($chunks) > 1,
813 - 'chunk_count' => count($chunks),
814 - 'content_type' => $content_type,
815 - );
816 -}
817 -
818 -/**
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 - * AJAX: Save edited content — re-chunks and re-embeds as needed.
1042 - * Works for both WordPress DB and Pinecone entries.
1043 - */
1044 -public function ajax_mxchat_save_entry_content() {
1045 - check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
1046 -
1047 - if ( ! current_user_can('manage_options') ) {
1048 - wp_send_json_error( array( 'message' => 'Permission denied.' ) );
1049 - }
1050 -
1051 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1052 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1053 - $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
1054 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1055 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1056 - $content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
1057 -
1058 - if ( empty($content) ) {
1059 - wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
1060 - }
1061 -
1062 - // Get the embedding API key
1063 - $options = get_option('mxchat_options', array());
1064 - $api_key = '';
1065 -
1066 - if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
1067 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1068 - $api_key = $bot_options['api_key'] ?? '';
1069 - }
1070 - if ( empty($api_key) ) {
1071 - $api_key = $options['api_key'] ?? '';
1072 - }
1073 -
1074 - global $wpdb;
1075 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1076 -
1077 - // If source_url is empty but we have an entry_id, look it up
1078 - if ( empty($source_url) && $entry_id > 0 && $data_source === 'wordpress' ) {
1079 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1080 - if ( $row && ! empty($row->source_url) ) {
1081 - $source_url = $row->source_url;
1082 - }
1083 - }
1084 -
1085 - // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1086 - // 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) ) {
1090 - $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 - }
1097 -
1098 - // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1099 - $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1100 -
1101 - // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1102 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1103 -
1104 - if ( is_wp_error($result) ) {
1105 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1106 - }
1107 -
1108 - wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
1109 -}
1110 -
1111 624 public function mxchat_get_pdf_processing_status($pdf_url) {
1112 625 $pdf_url = esc_url_raw($pdf_url);
1113 626 $status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
1114 627
@@ -1170,22 +683,9 @@
1170 683 exit;
1171 684 }
1172 685
1173 686 $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 -
687 +
1188 688 // Get bot_id from form submission
1189 689 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1190 690
1191 691 // Get bot-specific options and validate API key
@@ -1213,18 +713,10 @@
1213 713 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1214 714 exit;
1215 715 }
1216 716
1217 - // Fetch URL — use browser-like headers so servers with bot protection don't block us
1218 - $response = wp_remote_get($submitted_url, array(
1219 - 'timeout' => 30,
1220 - 'sslverify' => false,
1221 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
1222 - 'headers' => array(
1223 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1224 - 'Accept-Language' => 'en-US,en;q=0.9',
1225 - ),
1226 - ));
717 + // Fetch URL
718 + $response = wp_remote_get($submitted_url, array('timeout' => 30));
1227 719
1228 720 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1229 721 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
1230 722 set_transient('mxchat_admin_notice_error',
@@ -2131,10 +1623,8 @@
2131 1623 'preview_length' => $preview_length,
2132 1624 'source_url' => $entry->source_url,
2133 1625 'has_link' => !empty($entry->source_url) && strpos($entry->source_url, 'mxchat://') !== 0,
2134 1626 'chunk_metadata' => $chunk_metadata,
2135 - 'bot_id' => $entry->bot_id ?? 'default',
2136 - 'edit_nonce' => wp_create_nonce('mxchat_edit_entry_nonce'),
2137 1627 'delete_nonce' => wp_create_nonce('mxchat_delete_prompt_nonce')
2138 1628 );
2139 1629 }
2140 1630
@@ -2340,22 +1830,10 @@
2340 1830 <?php else : ?>
2341 1831 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2342 1832 <?php endif; ?>
2343 1833 </td>
2344 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2345 - <?php if ($data_source !== 'pinecone') : ?>
1834 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2346 1835 <button type="button"
2347 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2348 - data-source-url="<?php echo esc_attr($source_url); ?>"
2349 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2350 - data-data-source="<?php echo esc_attr($data_source); ?>"
2351 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2352 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2353 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2354 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2355 - </button>
2356 - <?php endif; ?>
2357 - <button type="button"
2358 1836 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2359 1837 data-source-url="<?php echo esc_attr($source_url); ?>"
2360 1838 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2361 1839 data-data-source="<?php echo esc_attr($data_source); ?>"
@@ -2808,22 +2286,10 @@
2808 2286 <?php else : ?>
2809 2287 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2810 2288 <?php endif; ?>
2811 2289 </td>
2812 - <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2813 - <?php if ($data_source !== 'pinecone') : ?>
2290 + <td class="mxchat-actions-cell" style="padding: 12px 16px;">
2814 2291 <button type="button"
2815 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2816 - data-source-url="<?php echo esc_attr($source_url); ?>"
2817 - data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2818 - data-data-source="<?php echo esc_attr($data_source); ?>"
2819 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2820 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2821 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2822 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2823 - </button>
2824 - <?php endif; ?>
2825 - <button type="button"
2826 2292 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2827 2293 data-source-url="<?php echo esc_attr($source_url); ?>"
2828 2294 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
2829 2295 data-data-source="<?php echo esc_attr($data_source); ?>"
@@ -2953,19 +2419,9 @@
2953 2419 <?php else : ?>
2954 2420 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2955 2421 <?php endif; ?>
2956 2422 </td>
2957 - <td style="padding: 12px 16px; white-space: nowrap;">
2958 - <button type="button"
2959 - class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2960 - data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
2961 - data-entry-id="<?php echo esc_attr($prompt->id); ?>"
2962 - data-data-source="<?php echo esc_attr($data_source); ?>"
2963 - data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2964 - data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2965 - title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2966 - <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2967 - </button>
2423 + <td style="padding: 12px 16px;">
2968 2424 <button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-wordpress" data-entry-id="<?php echo esc_attr($prompt->id); ?>" data-bot-id="<?php echo esc_attr($current_bot_id); ?>" data-nonce="<?php echo wp_create_nonce('mxchat_delete_wordpress_prompt_nonce'); ?>" style="color: var(--mxch-error);">
2969 2425 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2970 2426 </button>
2971 2427 </td>
@@ -3058,12 +2514,11 @@
3058 2514 foreach ($primary_indexes as $path => $source) {
3059 2515 $url = trailingslashit($site_url) . $path;
3060 2516
3061 2517 $response = wp_remote_head($url, array(
3062 - 'timeout' => 10,
2518 + 'timeout' => 3, // Short timeout
3063 2519 'sslverify' => false,
3064 - 'redirection' => 1,
3065 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
2520 + 'redirection' => 1
3066 2521 ));
3067 2522
3068 2523 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
3069 2524 // Found a sitemap index - parse it to get sub-sitemaps
@@ -3121,15 +2576,10 @@
3121 2576 private function parse_sitemap_index($url) {
3122 2577 $sub_sitemaps = array();
3123 2578
3124 2579 $response = wp_remote_get($url, array(
3125 - 'timeout' => 30,
3126 - 'sslverify' => false,
3127 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3128 - 'headers' => array(
3129 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3130 - 'Accept-Language' => 'en-US,en;q=0.9',
3131 - ),
2580 + 'timeout' => 5,
2581 + 'sslverify' => false
3132 2582 ));
3133 2583
3134 2584 if (is_wp_error($response)) {
3135 2585 return $sub_sitemaps;
@@ -3180,15 +2630,10 @@
3180 2630 * Get URL count from a sitemap
3181 2631 */
3182 2632 private function get_sitemap_url_count($url) {
3183 2633 $response = wp_remote_get($url, array(
3184 - 'timeout' => 30,
3185 - 'sslverify' => false,
3186 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
3187 - 'headers' => array(
3188 - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
3189 - 'Accept-Language' => 'en-US,en;q=0.9',
3190 - ),
2634 + 'timeout' => 10,
2635 + 'sslverify' => false
3191 2636 ));
3192 2637
3193 2638 if (is_wp_error($response)) {
3194 2639 return 0;
@@ -3211,11 +2656,10 @@
3211 2656 $sitemaps = array();
3212 2657 $robots_url = trailingslashit($site_url) . 'robots.txt';
3213 2658
3214 2659 $response = wp_remote_get($robots_url, array(
3215 - 'timeout' => 15,
3216 - 'sslverify' => false,
3217 - 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
2660 + 'timeout' => 5,
2661 + 'sslverify' => false
3218 2662 ));
3219 2663
3220 2664 if (is_wp_error($response)) {
3221 2665 return $sitemaps;
@@ -3705,22 +3149,9 @@
3705 3149 }
3706 3150
3707 3151 // Get bot_id from request
3708 3152 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3709 -
3710 - // ACF→PDF extraction is opt-in per import batch. Persist the last-used value so users
3711 - // don't re-check on every batch; the default is OFF for installs that haven't set it.
3712 - $extract_acf_pdfs = !empty($_POST['extract_acf_pdfs']) && $_POST['extract_acf_pdfs'] !== 'false';
3713 - $mxchat_options = get_option('mxchat_options', array());
3714 - if (!is_array($mxchat_options)) {
3715 - $mxchat_options = array();
3716 - }
3717 - $prior_default = !empty($mxchat_options['acf_pdf_extract_default']);
3718 - if ($prior_default !== $extract_acf_pdfs) {
3719 - $mxchat_options['acf_pdf_extract_default'] = $extract_acf_pdfs ? 1 : 0;
3720 - update_option('mxchat_options', $mxchat_options);
3721 - }
3722 -
3153 +
3723 3154 // Process only ONE post at a time to avoid request size issues
3724 3155 $post_id = reset($post_ids);
3725 3156 $post = get_post($post_id);
3726 3157
@@ -3826,57 +3257,23 @@
3826 3257 }
3827 3258
3828 3259 // ADD ACF FIELDS SUPPORT
3829 3260 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3830 - $pdf_extracted_count = 0;
3831 3261 if (!empty($acf_fields)) {
3832 3262 $acf_content_parts = array();
3833 - $pdf_attachment_ids = array();
3834 -
3263 +
3835 3264 foreach ($acf_fields as $field_name => $field_value) {
3836 3265 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3837 -
3266 +
3838 3267 if (!empty($formatted_value)) {
3839 3268 $field_label = ucwords(str_replace('_', ' ', $field_name));
3840 3269 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3841 3270 }
3842 -
3843 - // Walk this field's value tree for any PDF attachment references and queue them for extraction.
3844 - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text
3845 - // still lands in the KB but the heavier PDF parsing is skipped.
3846 - if ($extract_acf_pdfs) {
3847 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
3848 - }
3849 3271 }
3850 -
3272 +
3851 3273 if (!empty($acf_content_parts)) {
3852 3274 $content .= "\n\n" . implode("\n", $acf_content_parts);
3853 3275 }
3854 -
3855 - // Extract text from each unique PDF found in ACF fields and append as a labeled section
3856 - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
3857 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
3858 - $pdf_sections = array();
3859 - foreach ($pdf_attachment_ids as $att_id) {
3860 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
3861 - if (!empty($pdf_text)) {
3862 - $pdf_title = get_the_title($att_id);
3863 - $pdf_url = wp_get_attachment_url($att_id);
3864 - $header = 'PDF Attachment';
3865 - if (!empty($pdf_title)) {
3866 - $header .= ': ' . $pdf_title;
3867 - }
3868 - if (!empty($pdf_url)) {
3869 - $header .= ' (' . $pdf_url . ')';
3870 - }
3871 - $pdf_sections[] = $header . "\n" . $pdf_text;
3872 - $pdf_extracted_count++;
3873 - }
3874 - }
3875 - if (!empty($pdf_sections)) {
3876 - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
3877 - }
3878 - }
3879 3276 }
3880 3277
3881 3278 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3882 3279 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4006,9 +3403,8 @@
4006 3403 'title' => $post->post_title,
4007 3404 'operation_type' => $operation_type,
4008 3405 'vector_id' => $vector_id,
4009 3406 'acf_fields_found' => $acf_field_count,
4010 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4011 3407 'content_preview' => substr($content, 0, 100) . '...',
4012 3408 'bot_id' => $bot_id
4013 3409 ));
4014 3410 exit;
@@ -4423,20 +3819,9 @@
4423 3819
4424 3820 // Get bot-specific options
4425 3821 $bot_options = $this->get_bot_options($bot_id);
4426 3822 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4427 -
4428 - // Opt-in: when the custom provider is selected for embeddings, index through
4429 - // the same custom endpoint the query path uses so stored vectors and query
4430 - // vectors share a model. Returns the vector array on success, or an error
4431 - // string on failure (this function's existing failure contract).
4432 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4433 - if (!class_exists('MxChat_Utils')) {
4434 - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4435 - }
4436 - return MxChat_Utils::generate_embedding_custom($text, $options);
4437 - }
4438 -
3823 +
4439 3824 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4440 3825 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4441 3826
4442 3827 // Determine provider and endpoint
@@ -5354,197 +4739,8 @@
5354 4739 return implode(', ', array_filter($text_parts));
5355 4740 }
5356 4741
5357 4742 /**
5358 - * Walk an ACF field value tree and collect attachment IDs for any value that
5359 - * resolves to a PDF in the WordPress media library. Handles the three shapes
5360 - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5361 - * plain URL string), and recurses through repeater/group/flexible content.
5362 - *
5363 - * @param mixed $value The ACF field value (any depth)
5364 - * @param array $out Accumulator (passed by reference) for attachment IDs
5365 - * @param int $depth Recursion guard
5366 - */
5367 -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5368 - if ($depth > 6) {
5369 - return; // prevent runaway recursion on circular/very-deep structures
5370 - }
5371 -
5372 - if (empty($value)) {
5373 - return;
5374 - }
5375 -
5376 - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5377 - if (is_array($value)) {
5378 - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5379 - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5380 - if ($looks_like_attachment) {
5381 - $att_id = 0;
5382 - if (!empty($value['ID']) && is_numeric($value['ID'])) {
5383 - $att_id = (int) $value['ID'];
5384 - } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5385 - $att_id = (int) $value['id'];
5386 - } elseif (!empty($value['url']) && is_string($value['url'])) {
5387 - $att_id = (int) attachment_url_to_postid($value['url']);
5388 - }
5389 -
5390 - $is_pdf = false;
5391 - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5392 - $is_pdf = true;
5393 - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5394 - $is_pdf = true;
5395 - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5396 - $is_pdf = true;
5397 - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5398 - $is_pdf = true;
5399 - }
5400 -
5401 - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5402 - $out[] = $att_id;
5403 - }
5404 - // An array node that represents one attachment doesn't contain other
5405 - // attachments inside it — done with this branch.
5406 - return;
5407 - }
5408 -
5409 - // Recurse: repeater rows, flexible-content layouts, groups, etc.
5410 - foreach ($value as $sub) {
5411 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5412 - }
5413 - return;
5414 - }
5415 -
5416 - // Plain numeric attachment ID (ACF File field set to "Return: ID")
5417 - if (is_numeric($value)) {
5418 - $att_id = (int) $value;
5419 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5420 - $out[] = $att_id;
5421 - }
5422 - return;
5423 - }
5424 -
5425 - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5426 - if (is_string($value)) {
5427 - $trimmed = trim($value);
5428 - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5429 - $att_id = (int) attachment_url_to_postid($trimmed);
5430 - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5431 - $out[] = $att_id;
5432 - }
5433 - }
5434 - return;
5435 - }
5436 -}
5437 -
5438 -/**
5439 - * Heuristic: does this URL/string look like a PDF reference?
5440 - * Tolerates query strings and fragments (#page=2).
5441 - */
5442 -private function mxchat_url_looks_like_pdf($url) {
5443 - if (!is_string($url) || $url === '') {
5444 - return false;
5445 - }
5446 - // Strip query + fragment before checking extension
5447 - $path = preg_replace('/[?#].*$/', '', $url);
5448 - return (bool) preg_match('/\.pdf$/i', $path);
5449 -}
5450 -
5451 -/**
5452 - * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5453 - * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5454 - * Result is cached on the attachment as post_meta keyed by file mtime so we
5455 - * only parse the same PDF once unless the file changes on disk.
5456 - *
5457 - * @param int $attachment_id
5458 - * @return string Extracted plain text, or '' on failure.
5459 - */
5460 -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5461 - $attachment_id = (int) $attachment_id;
5462 - if ($attachment_id <= 0) {
5463 - return '';
5464 - }
5465 - if (get_post_mime_type($attachment_id) !== 'application/pdf') {
5466 - return '';
5467 - }
5468 -
5469 - $pdf_path = get_attached_file($attachment_id);
5470 - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
5471 - return '';
5472 - }
5473 -
5474 - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
5475 - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
5476 - $default_max_bytes = 25 * 1024 * 1024;
5477 - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
5478 - if ($max_bytes > 0) {
5479 - $file_size = @filesize($pdf_path);
5480 - if ($file_size !== false && $file_size > $max_bytes) {
5481 - error_log(sprintf(
5482 - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
5483 - $attachment_id,
5484 - basename($pdf_path),
5485 - $file_size,
5486 - $max_bytes
5487 - ));
5488 - return '';
5489 - }
5490 - }
5491 -
5492 - $mtime = @filemtime($pdf_path);
5493 - $cache_meta_key = '_mxchat_acf_pdf_text_v1';
5494 - $cached = get_post_meta($attachment_id, $cache_meta_key, true);
5495 - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
5496 - return (string) $cached['text'];
5497 - }
5498 -
5499 - $text = '';
5500 - try {
5501 - if (function_exists('mxchat_load_pdf_parser')) {
5502 - mxchat_load_pdf_parser();
5503 - }
5504 - if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
5505 - return '';
5506 - }
5507 - $parser = new \Smalot\PdfParser\Parser();
5508 - $pdf = $parser->parseFile($pdf_path);
5509 - $pages = $pdf->getPages();
5510 - $page_texts = array();
5511 - foreach ($pages as $page) {
5512 - $page_text = '';
5513 - try {
5514 - $page_text = $page->getText();
5515 - } catch (\Exception $e) {
5516 - $page_text = '';
5517 - }
5518 - if (!empty($page_text)) {
5519 - $page_texts[] = $page_text;
5520 - }
5521 - }
5522 - $text = trim(implode("\n\n", $page_texts));
5523 - } catch (\Exception $e) {
5524 - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
5525 - return '';
5526 - } catch (\Throwable $e) {
5527 - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
5528 - return '';
5529 - }
5530 -
5531 - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
5532 - // The chunker downstream will still split this into multiple vectors.
5533 - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
5534 - if ($max_len > 0 && strlen($text) > $max_len) {
5535 - $text = substr($text, 0, $max_len);
5536 - }
5537 -
5538 - update_post_meta($attachment_id, $cache_meta_key, array(
5539 - 'mtime' => (int) $mtime,
5540 - 'text' => $text,
5541 - ));
5542 -
5543 - return $text;
5544 -}
5545 -
5546 -/**
5547 4743 * Handle ACF save - fires after ACF fields are saved
5548 4744 * This ensures ACF field data is available when syncing to knowledge base
5549 4745 */
5550 4746 public function mxchat_handle_acf_save($post_id) {
@@ -5652,33 +4848,40 @@
5652 4848 // If the post was previously published but is now not published, remove from knowledge base
5653 4849 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5654 4850 // Use the stored URL from when it was published, or fall back to current permalink
5655 4851 $source_url = $previous_url ?: get_permalink($post_id);
4852 +
4853 + if ($source_url) {
4854 + // Check if Pinecone is enabled
4855 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
4856 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5656 4857
5657 - if ($source_url) {
5658 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5659 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
4858 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
4859 + // Delete from Pinecone
4860 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
4861 + } else {
4862 + // Delete from WordPress DB
4863 + global $wpdb;
4864 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4865 +
4866 + $result = $wpdb->delete(
4867 + $table_name,
4868 + array('source_url' => $source_url),
4869 + array('%s')
4870 + );
4871 + }
5660 4872 }
5661 -
4873 +
5662 4874 // Clean up the transients and exit early
5663 4875 delete_transient($previous_status_key);
5664 4876 delete_transient($previous_url_key);
5665 4877 return;
5666 4878 }
5667 -
5668 - // Slug/permalink rename while still published: delete the old vectors before upserting new ones.
5669 - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL.
5670 - if ($post->post_status === 'publish' && !empty($previous_url)) {
5671 - $current_url = get_permalink($post_id);
5672 - if ($current_url && $current_url !== $previous_url) {
5673 - MxChat_Utils::delete_chunks_for_url($previous_url, 'default');
5674 - }
5675 - }
5676 -
4879 +
5677 4880 // Store the current status for next time (if this is an update)
5678 4881 if ($update) {
5679 4882 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5680 -
4883 +
5681 4884 // If the post is currently published, also store its URL
5682 4885 if ($post->post_status === 'publish') {
5683 4886 $current_url = get_permalink($post_id);
5684 4887 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
@@ -5787,9 +4990,8 @@
5787 4990 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5788 4991 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5789 4992 if (!empty($acf_fields)) {
5790 4993 $acf_content_parts = array();
5791 - $pdf_attachment_ids = array();
5792 4994
5793 4995 foreach ($acf_fields as $field_name => $field_value) {
5794 4996 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5795 4997 if (!empty($formatted_value)) {
@@ -5796,44 +4998,13 @@
5796 4998 // Convert field name to readable label
5797 4999 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5798 5000 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5799 5001 }
5800 -
5801 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5802 5002 }
5803 5003
5804 5004 if (!empty($acf_content_parts)) {
5805 5005 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5806 5006 }
5807 -
5808 - // Gate the auto-sync PDF-extraction loop behind an opt-in option.
5809 - // Mirrors the per-batch checkbox the manual content selector has; the
5810 - // 25 MB size cap lives in the shared extractor so it applies in both
5811 - // paths regardless. Default OFF — re-parsing every ACF PDF on every
5812 - // editor save is expensive and most sites don't want it.
5813 - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1';
5814 - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) {
5815 - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
5816 - $pdf_sections = array();
5817 - foreach ($pdf_attachment_ids as $att_id) {
5818 - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
5819 - if (!empty($pdf_text)) {
5820 - $pdf_title = get_the_title($att_id);
5821 - $pdf_url = wp_get_attachment_url($att_id);
5822 - $header = 'PDF Attachment';
5823 - if (!empty($pdf_title)) {
5824 - $header .= ': ' . $pdf_title;
5825 - }
5826 - if (!empty($pdf_url)) {
5827 - $header .= ' (' . $pdf_url . ')';
5828 - }
5829 - $pdf_sections[] = $header . "\n" . $pdf_text;
5830 - }
5831 - }
5832 - if (!empty($pdf_sections)) {
5833 - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
5834 - }
5835 - }
5836 5007 }
5837 5008
5838 5009 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5839 5010 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -5940,14 +5111,12 @@
5940 5111 if (!$should_sync) {
5941 5112 return;
5942 5113 }
5943 5114
5944 - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing
5945 - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the
5946 - // real vector IDs stored under the original URL.
5947 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
5115 + // Get the URL before post is deleted
5116 + $source_url = get_permalink($post_id);
5948 5117 if (!$source_url) {
5949 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5118 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
5950 5119 return;
5951 5120 }
5952 5121
5953 5122 // Use chunk-aware deletion (handles both chunked and non-chunked content)
@@ -5955,36 +5124,56 @@
5955 5124
5956 5125 if (is_wp_error($delete_result)) {
5957 5126 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5958 5127 }
5128 +}
5959 5129
5960 - delete_transient('mxchat_prev_url_' . $post_id);
5961 - delete_transient('mxchat_prev_status_' . $post_id);
5962 -}
5963 5130
5964 -/**
5965 - * Resolve the source URL for a post being trashed/deleted.
5966 - *
5967 - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so
5968 - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or
5969 - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status
5970 - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix.
5971 - */
5972 -private function mxchat_resolve_pre_trash_url($post_id) {
5973 - $previous_url = get_transient('mxchat_prev_url_' . $post_id);
5974 - if (!empty($previous_url)) {
5975 - return $previous_url;
5976 - }
5131 + /**
5132 + * Deletes data from Pinecone using a source URL
5133 + */
5134 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
5135 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5136 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5977 5137
5978 - $current = get_permalink($post_id);
5979 - if (!$current) {
5980 - return '';
5981 - }
5982 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5983 -}
5138 + if (empty($host) || empty($api_key)) {
5139 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
5140 + return false;
5141 + }
5984 5142
5143 + $api_endpoint = "https://{$host}/vectors/delete";
5144 + $vector_id = md5($source_url);
5985 5145
5146 + $request_body = array(
5147 + 'ids' => array($vector_id)
5148 + );
5986 5149
5150 + $response = wp_remote_post($api_endpoint, array(
5151 + 'headers' => array(
5152 + 'Api-Key' => $api_key,
5153 + 'accept' => 'application/json',
5154 + 'content-type' => 'application/json'
5155 + ),
5156 + 'body' => wp_json_encode($request_body),
5157 + 'timeout' => 30
5158 + ));
5159 +
5160 + if (is_wp_error($response)) {
5161 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
5162 + return false;
5163 + }
5164 +
5165 + $response_code = wp_remote_retrieve_response_code($response);
5166 + if ($response_code !== 200) {
5167 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
5168 + return false;
5169 + }
5170 +
5171 + return true;
5172 + }
5173 +
5174 +
5175 +
5987 5176 public function mxchat_handle_product_change($post_id, $post, $update) {
5988 5177 if ($post->post_type !== 'product') {
5989 5178 return;
5990 5179 }
@@ -6136,18 +5325,28 @@
6136 5325 if (get_post_type($post_id) !== 'product') {
6137 5326 return;
6138 5327 }
6139 5328
6140 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6141 - if (!$source_url) {
6142 - return;
6143 - }
5329 + $source_url = get_permalink($post_id);
6144 5330
6145 - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
6146 - MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5331 + // Check if Pinecone is enabled
5332 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5333 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6147 5334
6148 - delete_transient('mxchat_prev_url_' . $post_id);
6149 - delete_transient('mxchat_prev_status_' . $post_id);
5335 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5336 + // Delete from Pinecone
5337 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5338 + } else {
5339 + // Delete from WordPress DB
5340 + global $wpdb;
5341 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5342 +
5343 + $wpdb->delete(
5344 + $table_name,
5345 + array('source_url' => $source_url),
5346 + array('%s')
5347 + );
5348 + }
6150 5349 }
6151 5350
6152 5351 /**
6153 5352 * Handle individual Pinecone content deletion
@@ -6851,16 +6050,16 @@
6851 6050 wp_send_json_error('Unauthorized access');
6852 6051 exit;
6853 6052 }
6854 6053
6855 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6054 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6856 6055 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6857 -
6858 - if (empty($tag_input)) {
6859 - wp_send_json_error('Please enter a tag name or slug');
6056 +
6057 + if (empty($tag_slug)) {
6058 + wp_send_json_error('Tag slug is required');
6860 6059 exit;
6861 6060 }
6862 -
6061 +
6863 6062 // Validate role restriction
6864 6063 $valid_roles = array_keys($this->mxchat_get_role_options());
6865 6064 if (!in_array($role_restriction, $valid_roles)) {
6866 6065 wp_send_json_error('Invalid role restriction');
@@ -6865,27 +6064,16 @@
6865 6064 if (!in_array($role_restriction, $valid_roles)) {
6866 6065 wp_send_json_error('Invalid role restriction');
6867 6066 exit;
6868 6067 }
6869 -
6870 - // Resolve the tag by slug first, then fall back to its display name, so users can
6871 - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
6872 - // labeled by name but previously validated by slug only, producing the confusing
6873 - // "Tag does not exist in WordPress" error when a real tag's name was typed.)
6874 - $term = get_term_by('slug', $tag_input, 'post_tag');
6068 +
6069 + // Check if tag exists in WordPress
6070 + $term = get_term_by('slug', $tag_slug, 'post_tag');
6875 6071 if (!$term) {
6876 - $term = get_term_by('name', $tag_input, 'post_tag');
6877 - }
6878 - if (!$term) {
6879 - wp_send_json_error('No tag with that name or slug exists yet. Create it under Posts → Tags first, then enter its name or slug.');
6072 + wp_send_json_error('Tag does not exist in WordPress');
6880 6073 exit;
6881 6074 }
6882 -
6883 - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
6884 - // compares against each post's tag slugs, so the stored key must be a slug,
6885 - // never the raw (possibly display-name) input.
6886 - $tag_slug = $term->slug;
6887 -
6075 +
6888 6076 // Get existing mappings
6889 6077 $mappings = get_option('mxchat_tag_role_mappings', array());
6890 6078
6891 6079 // Check if mapping already exists
@@ -7569,27 +6757,13 @@
7569 6757
7570 6758 $result = false;
7571 6759 $error_message = '';
7572 6760
7573 - // Read item directly from DB to get queue_id and preserve special chars in item_data
7574 - // (POST round-trip through JS mangles characters like apostrophes in URLs)
7575 - $db_item = $wpdb->get_row($wpdb->prepare(
7576 - "SELECT queue_id, item_data FROM $table_name WHERE id = %d",
7577 - $item_id
7578 - ));
7579 - $item_queue_id = $db_item ? $db_item->queue_id : '';
7580 - if ($db_item && !empty($db_item->item_data)) {
7581 - $db_data = json_decode($db_item->item_data, true);
7582 - if (is_array($db_data)) {
7583 - $item_data = $db_data;
7584 - }
7585 - }
7586 -
7587 6761 switch ($item_type) {
7588 6762 case 'url':
7589 - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id);
6763 + $result = $this->mxchat_process_queue_url($item_data, $bot_id);
7590 6764 break;
7591 -
6765 +
7592 6766 case 'pdf_page':
7593 6767 $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id);
7594 6768 break;
7595 6769
@@ -7597,37 +6771,11 @@
7597 6771 throw new Exception('Unknown item type: ' . $item_type);
7598 6772 }
7599 6773
7600 6774 if (is_wp_error($result)) {
7601 - $error_code = $result->get_error_code();
7602 - // Content errors (empty page, sanitization) are permanent — retrying won't help
7603 - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found');
7604 - if (in_array($error_code, $permanent_codes)) {
7605 - // Mark as permanently failed — set attempts = max_attempts so it won't be retried
7606 - $current_item = $wpdb->get_row($wpdb->prepare(
7607 - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id
7608 - ));
7609 - $wpdb->update(
7610 - $table_name,
7611 - array(
7612 - 'status' => 'failed',
7613 - 'error_message' => $result->get_error_message(),
7614 - 'attempts' => $current_item ? $current_item->max_attempts : 3
7615 - ),
7616 - array('id' => $item_id),
7617 - array('%s', '%s', '%d'),
7618 - array('%d')
7619 - );
7620 - wp_send_json_error(array(
7621 - 'message' => $result->get_error_message(),
7622 - 'permanent_failure' => true,
7623 - 'item_id' => $item_id
7624 - ));
7625 - return;
7626 - }
7627 6775 throw new Exception($result->get_error_message());
7628 6776 }
7629 -
6777 +
7630 6778 if ($result === false) {
7631 6779 throw new Exception('Processing returned false - item may be empty or invalid');
7632 6780 }
7633 6781
@@ -7703,9 +6851,9 @@
7703 6851
7704 6852 /**
7705 6853 * Process a URL from the queue
7706 6854 */
7707 -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') {
6855 +private function mxchat_process_queue_url($item_data, $bot_id = 'default') {
7708 6856 $url = isset($item_data['url']) ? $item_data['url'] : '';
7709 6857
7710 6858 if (empty($url)) {
7711 6859 return new WP_Error('invalid_url', 'URL is empty');
@@ -7751,11 +6899,10 @@
7751 6899 // If WooCommerce extraction failed, fall through to HTML extraction
7752 6900 }
7753 6901
7754 6902 // Fetch URL content (fallback for non-products or when WooCommerce extraction fails)
7755 - $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7756 6903 $response = wp_remote_get($url, array(
7757 - 'timeout' => $is_likely_pdf ? 120 : 30,
6904 + 'timeout' => 30,
7758 6905 'redirection' => 5,
7759 6906 'user-agent' => 'MxChat/1.0'
7760 6907 ));
7761 6908
@@ -7764,16 +6911,11 @@
7764 6911 }
7765 6912
7766 6913 $response_code = wp_remote_retrieve_response_code($response);
7767 6914 if ($response_code !== 200) {
7768 - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url);
6915 + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error');
7769 6916 }
7770 6917
7771 - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline
7772 - if ($this->mxchat_is_pdf_url($url, $response)) {
7773 - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id);
7774 - }
7775 -
7776 6918 $html = wp_remote_retrieve_body($response);
7777 6919
7778 6920 if (empty($html)) {
7779 6921 return new WP_Error('empty_response', 'Empty response body');
@@ -7801,180 +6943,8 @@
7801 6943 return $result;
7802 6944 }
7803 6945
7804 6946 /**
7805 - * Expand a PDF URL into per-page queue items using the standard PDF pipeline.
7806 - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count,
7807 - * and adds pdf_page items to the same queue so they process with full progress tracking.
7808 - */
7809 -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') {
7810 - set_time_limit(120); // PDFs need extra time for download + parsing
7811 -
7812 - $upload_dir = wp_upload_dir();
7813 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7814 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7815 -
7816 - $response_body = wp_remote_retrieve_body($response);
7817 - if (empty($response_body)) {
7818 - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url);
7819 - }
7820 -
7821 - if (!wp_mkdir_p(dirname($pdf_path))) {
7822 - return new WP_Error('dir_error', 'Failed to create upload directory');
7823 - }
7824 -
7825 - file_put_contents($pdf_path, $response_body);
7826 -
7827 - if (!file_exists($pdf_path)) {
7828 - return new WP_Error('save_error', 'Failed to save PDF file');
7829 - }
7830 -
7831 - try {
7832 - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
7833 -
7834 - if ($total_pages === false || $total_pages < 1) {
7835 - wp_delete_file($pdf_path);
7836 - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url);
7837 - }
7838 -
7839 - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base
7840 - $pages = array();
7841 - for ($i = 1; $i <= $total_pages; $i++) {
7842 - $pages[] = array(
7843 - 'pdf_path' => $pdf_path,
7844 - 'pdf_url' => $pdf_url,
7845 - 'page_number' => $i,
7846 - 'total_pages' => $total_pages
7847 - );
7848 - }
7849 -
7850 - // Add pdf_page items to the SAME queue so the JS picks them up automatically
7851 - if (!empty($queue_id)) {
7852 - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
7853 - } else {
7854 - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow)
7855 - $new_queue_id = 'pdf_' . md5($pdf_url . time());
7856 - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id);
7857 - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url);
7858 - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf');
7859 - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages);
7860 - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id);
7861 - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path);
7862 - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql'));
7863 - }
7864 -
7865 - if ($queued_count === 0) {
7866 - wp_delete_file($pdf_path);
7867 - return new WP_Error('queue_error', 'Failed to add PDF pages to queue');
7868 - }
7869 -
7870 - // Return true so the original URL item is marked complete
7871 - // The new pdf_page items will be processed in subsequent batches
7872 - return true;
7873 -
7874 - } catch (Exception $e) {
7875 - if (file_exists($pdf_path)) {
7876 - wp_delete_file($pdf_path);
7877 - }
7878 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7879 - }
7880 -}
7881 -
7882 -/**
7883 - * Legacy: Process a PDF URL inline during sitemap queue processing.
7884 - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only.
7885 - */
7886 -private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') {
7887 - set_time_limit(120); // PDFs need more time — downloading + parsing all pages
7888 -
7889 - $upload_dir = wp_upload_dir();
7890 - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf');
7891 - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
7892 -
7893 - $response_body = wp_remote_retrieve_body($response);
7894 - if (empty($response_body)) {
7895 - return new WP_Error('empty_pdf', 'Empty PDF response');
7896 - }
7897 -
7898 - if (!wp_mkdir_p(dirname($pdf_path))) {
7899 - return new WP_Error('dir_error', 'Failed to create upload directory');
7900 - }
7901 -
7902 - file_put_contents($pdf_path, $response_body);
7903 -
7904 - if (!file_exists($pdf_path)) {
7905 - return new WP_Error('save_error', 'Failed to save PDF file');
7906 - }
7907 -
7908 - try {
7909 - mxchat_load_pdf_parser();
7910 - $parser = new \Smalot\PdfParser\Parser();
7911 - $pdf = $parser->parseFile($pdf_path);
7912 - $pages = $pdf->getPages();
7913 - $total_pages = count($pages);
7914 -
7915 - if ($total_pages < 1) {
7916 - wp_delete_file($pdf_path);
7917 - return new WP_Error('no_pages', 'PDF has no pages');
7918 - }
7919 -
7920 - $processed = 0;
7921 - $skipped_pages = array();
7922 -
7923 - for ($i = 0; $i < $total_pages; $i++) {
7924 - $page_num = $i + 1;
7925 - $text = $pages[$i]->getText();
7926 - if (empty($text)) {
7927 - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
7928 - continue;
7929 - }
7930 -
7931 - $sanitized = $this->mxchat_sanitize_content_for_api($text);
7932 - if (empty($sanitized)) {
7933 - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content';
7934 - continue;
7935 - }
7936 -
7937 - $metadata = array(
7938 - 'document_type' => 'pdf',
7939 - 'total_pages' => $total_pages,
7940 - 'current_page' => $page_num,
7941 - 'source_url' => $pdf_url,
7942 - );
7943 -
7944 - $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
7945 - $page_url = esc_url($pdf_url . '#page=' . $page_num);
7946 -
7947 - MxChat_Utils::submit_content_to_db(
7948 - $content_with_metadata,
7949 - $page_url,
7950 - $api_key,
7951 - null,
7952 - $bot_id,
7953 - 'pdf'
7954 - );
7955 -
7956 - $processed++;
7957 - }
7958 -
7959 - // Clean up the temp PDF file
7960 - wp_delete_file($pdf_path);
7961 -
7962 - if (!empty($skipped_pages)) {
7963 - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages));
7964 - }
7965 -
7966 - return $processed > 0 ? true : false;
7967 -
7968 - } catch (Exception $e) {
7969 - if (file_exists($pdf_path)) {
7970 - wp_delete_file($pdf_path);
7971 - }
7972 - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage());
7973 - }
7974 -}
7975 -
7976 -/**
7977 6947 * Extract WooCommerce product content including pricing
7978 6948 *
7979 6949 * @param string $url The product URL
7980 6950 * @return string|false Product content with pricing, or false if not found
@@ -8128,17 +7098,18 @@
8128 7098 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
8129 7099 }
8130 7100
8131 7101 $text = $pages[$page_number - 1]->getText();
8132 -
7102 +
8133 7103 if (empty($text)) {
8134 - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
7104 + // Not an error - just an empty page
7105 + return false;
8135 7106 }
8136 -
7107 +
8137 7108 $sanitized = $this->mxchat_sanitize_content_for_api($text);
8138 -
7109 +
8139 7110 if (empty($sanitized)) {
8140 - return new WP_Error('empty_after_sanitization', 'Page ' . $page_number . ': Text was extracted but contained only special characters, control codes, or unsupported content that was removed during cleanup');
7111 + return false;
8141 7112 }
8142 7113
8143 7114 // Create metadata
8144 7115 $metadata = array(
@@ -8242,16 +7213,17 @@
8242 7213
8243 7214 // Calculate percentage
8244 7215 $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0;
8245 7216
8246 - // Get failed items details (include all failed items, not just those that exhausted retries)
7217 + // Get failed items details
8247 7218 $failed_items = array();
8248 7219 if ($failed > 0) {
8249 7220 $failed_items = $wpdb->get_results($wpdb->prepare(
8250 - "SELECT item_type, item_data, error_message, attempts
8251 - FROM $table_name
8252 - WHERE queue_id = %s
7221 + "SELECT item_type, item_data, error_message, attempts
7222 + FROM $table_name
7223 + WHERE queue_id = %s
8253 7224 AND status = 'failed'
7225 + AND attempts >= max_attempts
8254 7226 ORDER BY id DESC
8255 7227 LIMIT 50",
8256 7228 $queue_id
8257 7229 ));