PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.0
MxChat – AI Chatbot & Content Generation for WordPress v3.2.0
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 +103 -579 3.2.113.2.0 View file →
@@ -58,10 +58,12 @@
58 58 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
59 59 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
60 60 add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
61 61 add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
62 - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
63 62
63 + // Hook for content deletion
64 + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1);
65 +
64 66 // WordPress post management hooks
65 67 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
66 68 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
67 69 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
@@ -815,230 +817,8 @@
815 817 );
816 818 }
817 819
818 820 /**
819 - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
820 - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
821 - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
822 - */
823 -public function ajax_mxchat_inspect_entry() {
824 - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
825 -
826 - if ( ! current_user_can('manage_options') ) {
827 - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
828 - }
829 -
830 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
831 - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
832 - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
833 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
834 -
835 - if ( $data_source === 'pinecone' ) {
836 - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
837 - } else {
838 - $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
839 - }
840 -
841 - if ( is_wp_error( $result ) ) {
842 - wp_send_json_error( array( 'message' => $result->get_error_message() ) );
843 - }
844 -
845 - wp_send_json_success( $result );
846 -}
847 -
848 -/**
849 - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
850 - * but returns each STORED chunk's exact text + length (no implode), plus the assembled
851 - * embedded text. This shows what is actually in the index, not a re-derivation from the post.
852 - */
853 -private function inspect_wordpress_entry( $source_url, $entry_id ) {
854 - global $wpdb;
855 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
856 -
857 - $rows = array();
858 -
859 - // Group by the real stored source_url — this INCLUDES "mxchat://" manual
860 - // Direct Content entries (the spec's manual-entry case), which share one
861 - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
862 - // display key (invented by the table view for rows with no source_url) is
863 - // excluded; those fall through to the entry_id lookup below.
864 - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
865 - $rows = $wpdb->get_results( $wpdb->prepare(
866 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
867 - $source_url
868 - ) );
869 - }
870 -
871 - // Fallback / manual "Direct Content" entries: fetch the single row by id.
872 - if ( empty( $rows ) && $entry_id > 0 ) {
873 - $row = $wpdb->get_row( $wpdb->prepare(
874 - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
875 - $entry_id
876 - ) );
877 - if ( $row ) {
878 - $rows = array( $row );
879 - }
880 - }
881 -
882 - if ( empty( $rows ) ) {
883 - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
884 - }
885 -
886 - $chunks = array();
887 - $content_type = '';
888 - foreach ( $rows as $row ) {
889 - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
890 - $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
891 - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
892 - $content_type = $row->content_type;
893 - $chunks[] = array(
894 - 'index' => $index,
895 - 'text' => $text,
896 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
897 - 'row_id' => intval( $row->id ),
898 - );
899 - }
900 -
901 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
902 -
903 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
904 -
905 - return array(
906 - 'store' => 'wordpress',
907 - 'source_url' => $source_url,
908 - 'content_type' => $content_type,
909 - 'is_chunked' => count( $chunks ) > 1,
910 - 'chunk_count' => count( $chunks ),
911 - 'assembled' => $assembled,
912 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
913 - 'chunks' => array_values( $chunks ),
914 - // WP-DB storage carries no separate vector metadata; surface that fact
915 - // rather than letting the owner guess (the spec's taxonomy question).
916 - 'metadata' => array(),
917 - 'metadata_note' => esc_html__('Stored in the local WordPress database. Only the assembled text shown here is embedded — there are no separate vector metadata fields (e.g. taxonomy terms are not stored unless they were injected into the text itself).', 'mxchat'),
918 - );
919 -}
920 -
921 -/**
922 - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
923 - * but keeps each vector's text + metadata instead of imploding, so the owner can
924 - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
925 - * are present per chunk. READ-ONLY.
926 - */
927 -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
928 - if ( ! class_exists('MxChat_Pinecone_Manager') ) {
929 - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
930 - }
931 -
932 - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
933 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
934 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
935 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
936 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
937 - } else {
938 - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
939 - $api_key = $bot_config['api_key'] ?? '';
940 - $host = $bot_config['host'] ?? '';
941 - $namespace = $bot_config['namespace'] ?? '';
942 - }
943 -
944 - if ( empty($host) || empty($api_key) ) {
945 - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
946 - }
947 -
948 - $base_id = md5( $source_url );
949 - $vector_ids = array( $base_id );
950 -
951 - $list_url = "https://{$host}/vectors/list";
952 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
953 - if ( ! empty($namespace) ) {
954 - $list_body['namespace'] = $namespace;
955 - }
956 -
957 - $list_resp = wp_remote_post( $list_url, array(
958 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
959 - 'body' => wp_json_encode( $list_body ),
960 - 'timeout' => 15,
961 - ) );
962 -
963 - if ( ! is_wp_error($list_resp) ) {
964 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
965 - if ( ! empty($list_data['vectors']) ) {
966 - foreach ( $list_data['vectors'] as $v ) {
967 - $vector_ids[] = $v['id'];
968 - }
969 - }
970 - }
971 -
972 - $fetch_url = "https://{$host}/vectors/fetch";
973 - $fetch_body = array( 'ids' => $vector_ids );
974 - if ( ! empty($namespace) ) {
975 - $fetch_body['namespace'] = $namespace;
976 - }
977 -
978 - $fetch_resp = wp_remote_post( $fetch_url, array(
979 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
980 - 'body' => wp_json_encode( $fetch_body ),
981 - 'timeout' => 15,
982 - ) );
983 -
984 - if ( is_wp_error($fetch_resp) ) {
985 - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
986 - }
987 -
988 - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
989 - $vectors = $fetch_data['vectors'] ?? array();
990 -
991 - if ( empty($vectors) ) {
992 - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
993 - }
994 -
995 - // Whitelisted metadata fields the spec calls out — shown so devs can confirm
996 - // what is (and is NOT) stored per vector.
997 - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
998 - $chunks = array();
999 - $content_type = '';
1000 - foreach ( $vectors as $vid => $vector ) {
1001 - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1002 - $text = $meta['text'] ?? '';
1003 - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1004 - $content_type = $meta['type'] ?? $content_type;
1005 -
1006 - $clean_meta = array();
1007 - foreach ( $meta_fields as $field ) {
1008 - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1009 - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1010 - }
1011 - }
1012 -
1013 - $chunks[] = array(
1014 - 'index' => $index,
1015 - 'text' => $text,
1016 - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1017 - 'vector_id' => (string) $vid,
1018 - 'metadata' => $clean_meta,
1019 - );
1020 - }
1021 -
1022 - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1023 -
1024 - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1025 -
1026 - return array(
1027 - 'store' => 'pinecone',
1028 - 'source_url' => $source_url,
1029 - 'content_type' => $content_type,
1030 - 'is_chunked' => count( $chunks ) > 1,
1031 - 'chunk_count' => count( $chunks ),
1032 - 'assembled' => $assembled,
1033 - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1034 - 'chunks' => array_values( $chunks ),
1035 - 'metadata' => array(),
1036 - 'metadata_note' => esc_html__('Stored in Pinecone. Each chunk above lists the vector metadata fields actually present — if a field you expect (such as taxonomy terms) is missing here, it was not stored as metadata and is only searchable if it appears in the embedded text.', 'mxchat'),
1037 - );
1038 -}
1039 -
1040 -/**
1041 821 * AJAX: Save edited content — re-chunks and re-embeds as needed.
1042 822 * Works for both WordPress DB and Pinecone entries.
1043 823 */
1044 824 public function ajax_mxchat_save_entry_content() {
@@ -3705,22 +3485,9 @@
3705 3485 }
3706 3486
3707 3487 // Get bot_id from request
3708 3488 $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 -
3489 +
3723 3490 // Process only ONE post at a time to avoid request size issues
3724 3491 $post_id = reset($post_ids);
3725 3492 $post = get_post($post_id);
3726 3493
@@ -3826,57 +3593,23 @@
3826 3593 }
3827 3594
3828 3595 // ADD ACF FIELDS SUPPORT
3829 3596 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3830 - $pdf_extracted_count = 0;
3831 3597 if (!empty($acf_fields)) {
3832 3598 $acf_content_parts = array();
3833 - $pdf_attachment_ids = array();
3834 -
3599 +
3835 3600 foreach ($acf_fields as $field_name => $field_value) {
3836 3601 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3837 -
3602 +
3838 3603 if (!empty($formatted_value)) {
3839 3604 $field_label = ucwords(str_replace('_', ' ', $field_name));
3840 3605 $acf_content_parts[] = $field_label . ": " . $formatted_value;
3841 3606 }
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 3607 }
3850 -
3608 +
3851 3609 if (!empty($acf_content_parts)) {
3852 3610 $content .= "\n\n" . implode("\n", $acf_content_parts);
3853 3611 }
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 3612 }
3880 3613
3881 3614 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3882 3615 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -4006,9 +3739,8 @@
4006 3739 'title' => $post->post_title,
4007 3740 'operation_type' => $operation_type,
4008 3741 'vector_id' => $vector_id,
4009 3742 'acf_fields_found' => $acf_field_count,
4010 - 'pdf_extracted_count' => (int) $pdf_extracted_count,
4011 3743 'content_preview' => substr($content, 0, 100) . '...',
4012 3744 'bot_id' => $bot_id
4013 3745 ));
4014 3746 exit;
@@ -4423,20 +4155,9 @@
4423 4155
4424 4156 // Get bot-specific options
4425 4157 $bot_options = $this->get_bot_options($bot_id);
4426 4158 $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 -
4159 +
4439 4160 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4440 4161 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4441 4162
4442 4163 // Determine provider and endpoint
@@ -5354,197 +5075,8 @@
5354 5075 return implode(', ', array_filter($text_parts));
5355 5076 }
5356 5077
5357 5078 /**
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 5079 * Handle ACF save - fires after ACF fields are saved
5548 5080 * This ensures ACF field data is available when syncing to knowledge base
5549 5081 */
5550 5082 public function mxchat_handle_acf_save($post_id) {
@@ -5652,33 +5184,40 @@
5652 5184 // If the post was previously published but is now not published, remove from knowledge base
5653 5185 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5654 5186 // Use the stored URL from when it was published, or fall back to current permalink
5655 5187 $source_url = $previous_url ?: get_permalink($post_id);
5188 +
5189 + if ($source_url) {
5190 + // Check if Pinecone is enabled
5191 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5192 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
5656 5193
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');
5194 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5195 + // Delete from Pinecone
5196 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5197 + } else {
5198 + // Delete from WordPress DB
5199 + global $wpdb;
5200 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5201 +
5202 + $result = $wpdb->delete(
5203 + $table_name,
5204 + array('source_url' => $source_url),
5205 + array('%s')
5206 + );
5207 + }
5660 5208 }
5661 -
5209 +
5662 5210 // Clean up the transients and exit early
5663 5211 delete_transient($previous_status_key);
5664 5212 delete_transient($previous_url_key);
5665 5213 return;
5666 5214 }
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 -
5215 +
5677 5216 // Store the current status for next time (if this is an update)
5678 5217 if ($update) {
5679 5218 set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS);
5680 -
5219 +
5681 5220 // If the post is currently published, also store its URL
5682 5221 if ($post->post_status === 'publish') {
5683 5222 $current_url = get_permalink($post_id);
5684 5223 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
@@ -5787,9 +5326,8 @@
5787 5326 // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5788 5327 $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5789 5328 if (!empty($acf_fields)) {
5790 5329 $acf_content_parts = array();
5791 - $pdf_attachment_ids = array();
5792 5330
5793 5331 foreach ($acf_fields as $field_name => $field_value) {
5794 5332 $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5795 5333 if (!empty($formatted_value)) {
@@ -5796,44 +5334,13 @@
5796 5334 // Convert field name to readable label
5797 5335 $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5798 5336 $acf_content_parts[] = $field_label . ": " . $formatted_value;
5799 5337 }
5800 -
5801 - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5802 5338 }
5803 5339
5804 5340 if (!empty($acf_content_parts)) {
5805 5341 $final_content .= "\n\n" . implode("\n", $acf_content_parts);
5806 5342 }
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 5343 }
5837 5344
5838 5345 // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5839 5346 $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
@@ -5940,14 +5447,12 @@
5940 5447 if (!$should_sync) {
5941 5448 return;
5942 5449 }
5943 5450
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);
5451 + // Get the URL before post is deleted
5452 + $source_url = get_permalink($post_id);
5948 5453 if (!$source_url) {
5949 - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id);
5454 + //error_log('MXChat: Failed to get permalink for post ' . $post_id);
5950 5455 return;
5951 5456 }
5952 5457
5953 5458 // Use chunk-aware deletion (handles both chunked and non-chunked content)
@@ -5955,36 +5460,56 @@
5955 5460
5956 5461 if (is_wp_error($delete_result)) {
5957 5462 //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message());
5958 5463 }
5464 +}
5959 5465
5960 - delete_transient('mxchat_prev_url_' . $post_id);
5961 - delete_transient('mxchat_prev_status_' . $post_id);
5962 -}
5963 5466
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 - }
5467 + /**
5468 + * Deletes data from Pinecone using a source URL
5469 + */
5470 + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) {
5471 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
5472 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
5977 5473
5978 - $current = get_permalink($post_id);
5979 - if (!$current) {
5980 - return '';
5981 - }
5982 - return preg_replace('#__trashed(/?)$#', '$1', $current);
5983 -}
5474 + if (empty($host) || empty($api_key)) {
5475 + //error_log('MXChat: Pinecone deletion failed - missing configuration');
5476 + return false;
5477 + }
5984 5478
5479 + $api_endpoint = "https://{$host}/vectors/delete";
5480 + $vector_id = md5($source_url);
5985 5481
5482 + $request_body = array(
5483 + 'ids' => array($vector_id)
5484 + );
5986 5485
5486 + $response = wp_remote_post($api_endpoint, array(
5487 + 'headers' => array(
5488 + 'Api-Key' => $api_key,
5489 + 'accept' => 'application/json',
5490 + 'content-type' => 'application/json'
5491 + ),
5492 + 'body' => wp_json_encode($request_body),
5493 + 'timeout' => 30
5494 + ));
5495 +
5496 + if (is_wp_error($response)) {
5497 + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message());
5498 + return false;
5499 + }
5500 +
5501 + $response_code = wp_remote_retrieve_response_code($response);
5502 + if ($response_code !== 200) {
5503 + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code);
5504 + return false;
5505 + }
5506 +
5507 + return true;
5508 + }
5509 +
5510 +
5511 +
5987 5512 public function mxchat_handle_product_change($post_id, $post, $update) {
5988 5513 if ($post->post_type !== 'product') {
5989 5514 return;
5990 5515 }
@@ -6136,18 +5661,28 @@
6136 5661 if (get_post_type($post_id) !== 'product') {
6137 5662 return;
6138 5663 }
6139 5664
6140 - $source_url = $this->mxchat_resolve_pre_trash_url($post_id);
6141 - if (!$source_url) {
6142 - return;
6143 - }
5665 + $source_url = get_permalink($post_id);
6144 5666
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');
5667 + // Check if Pinecone is enabled
5668 + $pinecone_options = get_option('mxchat_pinecone_addon_options', array());
5669 + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6147 5670
6148 - delete_transient('mxchat_prev_url_' . $post_id);
6149 - delete_transient('mxchat_prev_status_' . $post_id);
5671 + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
5672 + // Delete from Pinecone
5673 + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options);
5674 + } else {
5675 + // Delete from WordPress DB
5676 + global $wpdb;
5677 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5678 +
5679 + $wpdb->delete(
5680 + $table_name,
5681 + array('source_url' => $source_url),
5682 + array('%s')
5683 + );
5684 + }
6150 5685 }
6151 5686
6152 5687 /**
6153 5688 * Handle individual Pinecone content deletion
@@ -6851,16 +6386,16 @@
6851 6386 wp_send_json_error('Unauthorized access');
6852 6387 exit;
6853 6388 }
6854 6389
6855 - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6390 + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6856 6391 $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');
6392 +
6393 + if (empty($tag_slug)) {
6394 + wp_send_json_error('Tag slug is required');
6860 6395 exit;
6861 6396 }
6862 -
6397 +
6863 6398 // Validate role restriction
6864 6399 $valid_roles = array_keys($this->mxchat_get_role_options());
6865 6400 if (!in_array($role_restriction, $valid_roles)) {
6866 6401 wp_send_json_error('Invalid role restriction');
@@ -6865,27 +6400,16 @@
6865 6400 if (!in_array($role_restriction, $valid_roles)) {
6866 6401 wp_send_json_error('Invalid role restriction');
6867 6402 exit;
6868 6403 }
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');
6404 +
6405 + // Check if tag exists in WordPress
6406 + $term = get_term_by('slug', $tag_slug, 'post_tag');
6875 6407 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.');
6408 + wp_send_json_error('Tag does not exist in WordPress');
6880 6409 exit;
6881 6410 }
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 -
6411 +
6888 6412 // Get existing mappings
6889 6413 $mappings = get_option('mxchat_tag_role_mappings', array());
6890 6414
6891 6415 // Check if mapping already exists