options = get_option('mxchat_options', array());
$this->mxchat_init_hooks();
$this->mxchat_init_role_hooks();
}
/**
* Initialize WordPress hooks for content processing
*
*/
private function mxchat_init_hooks() {
// Admin post handlers for form submissions
add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
add_action('admin_post_mxchat_submit_document_file', array($this, 'mxchat_handle_document_file_submission'));
add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
// AJAX handlers for real-time processing and status updates
add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
add_action('wp_ajax_mxchat_dismiss_completed_status', array($this, 'mxchat_ajax_dismiss_completed_status'));
add_action('wp_ajax_mxchat_get_content_list', array($this, 'ajax_mxchat_get_content_list'));
add_action('wp_ajax_mxchat_process_selected_content', array($this, 'ajax_mxchat_process_selected_content'));
add_action('wp_ajax_mxchat_save_inline_prompt', array($this, 'mxchat_save_inline_prompt'));
add_action('admin_post_mxchat_delete_pinecone_prompt', array($this, 'mxchat_handle_pinecone_prompt_delete'));
add_action('wp_ajax_mxchat_delete_pinecone_prompt', array($this, 'ajax_mxchat_delete_pinecone_prompt'));
add_action('wp_ajax_mxchat_delete_chunks_by_url', array($this, 'ajax_mxchat_delete_chunks_by_url'));
add_action('wp_ajax_mxchat_delete_wordpress_prompt', array($this, 'ajax_mxchat_delete_wordpress_prompt'));
add_action('wp_ajax_mxchat_bulk_delete_knowledge', array($this, 'ajax_mxchat_bulk_delete_knowledge'));
add_action('wp_ajax_mxchat_update_role_restriction', array($this, 'ajax_mxchat_update_role_restriction'));
// Queue-based processing AJAX handlers
add_action('wp_ajax_mxchat_get_next_queue_item', array($this, 'ajax_mxchat_get_next_queue_item'));
add_action('wp_ajax_mxchat_process_queue_item', array($this, 'ajax_mxchat_process_queue_item'));
add_action('wp_ajax_mxchat_get_queue_status', array($this, 'ajax_mxchat_get_queue_status'));
add_action('wp_ajax_mxchat_clear_queue', array($this, 'ajax_mxchat_clear_queue'));
add_action('wp_ajax_mxchat_retry_failed', array($this, 'ajax_mxchat_retry_failed'));
add_action('wp_ajax_mxchat_get_recent_entries', array($this, 'ajax_mxchat_get_recent_entries'));
add_action('wp_ajax_mxchat_detect_sitemaps', array($this, 'ajax_mxchat_detect_sitemaps'));
add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
// WordPress post management hooks
add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
// Authoritative unpublish detection: core hands this hook the REAL previous status, so
// removal no longer depends on the mxchat_prev_status_* transients (evictable by persistent
// object caches, never written by paths that bypass wp_update_post, e.g. plugins flipping
// post_status directly and calling wp_transition_post_status themselves).
add_action('transition_post_status', array($this, 'mxchat_handle_status_transition'), 10, 3);
// ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
// Priority 20 to run after ACF's own save (which runs at priority 10)
add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
// One-time cleanup for vectors orphaned by unpublishes that predate the
// transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished'));
// In-place repair for RTL KB rows imported in visual order before the
// 32bf9e normalizer existed: wp mxchat rtl-repair (plan d1e6f7)
WP_CLI::add_command('mxchat rtl-repair', array($this, 'cli_rtl_repair'));
}
add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
// WooCommerce product hooks (if WooCommerce is active)
if (class_exists('WooCommerce')) {
add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
add_action('save_post_product', array($this, 'mxchat_handle_product_change'), 10, 3);
add_action('wp_trash_post', array($this, 'mxchat_handle_product_delete'));
add_action('before_delete_post', array($this, 'mxchat_handle_product_delete'));
}
}
/**
* Get current options (refreshed)
*/
private function mxchat_get_options() {
if (empty($this->options)) {
$this->options = get_option('mxchat_options', array());
}
return $this->options;
}
// ========================================
// MAIN CONTENT SUBMISSION HANDLERS
// ========================================
public function mxchat_handle_content_submission() {
// Check if the form was submitted and the user has permission.
if (!isset($_POST['submit_content']) || !current_user_can('manage_options')) {
return;
}
// Verify the nonce.
$nonce = isset($_POST['mxchat_submit_content_nonce']) ? sanitize_text_field(wp_unslash($_POST['mxchat_submit_content_nonce'])) : '';
if (!wp_verify_nonce($nonce, 'mxchat_submit_content_action')) {
wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
}
// Sanitize the inputs.
// Use wp_kses_post to allow safe HTML and wp_unslash to remove WordPress added slashes
$article_content = wp_kses_post(wp_unslash($_POST['article_content']));
$article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
// Get bot_id from form submission
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
// Get bot-specific options and API key
$bot_options = $this->get_bot_options($bot_id);
$options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
// Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
$preflight = MxChat_Utils::embedding_preflight($options);
if (!$preflight['ok']) {
set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
$api_key = $preflight['api_key'];
// Use centralized utility function with bot_id
$result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
if (is_wp_error($result)) {
set_transient('mxchat_admin_notice_error',
esc_html__('Error storing content: ', 'mxchat') . $result->get_error_message(),
30
);
} else {
set_transient('mxchat_admin_notice_success',
esc_html__('Content successfully submitted!', 'mxchat'),
30
);
}
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
/**
* Handle the "YouTube" KB import source (admin-post form submission).
*
* Per-video description mode:
* - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
* If no usable transcript, index the metadata anyway, tell the admin,
* and bounce back with the manual box pre-filled (never fail silently).
* - manual: the admin's own description is what gets indexed; metadata rides along.
*
* The row is stored with content_type 'youtube' and source_url = the canonical
* watch URL, so re-importing the same video UPDATES the entry (source_url
* duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
* "augment a metadata-only entry" path.
*/
public function mxchat_handle_youtube_submission() {
if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
wp_die(esc_html__('Unauthorized access', 'mxchat'));
}
check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
$redirect_url = admin_url('admin.php?page=mxchat-prompts');
$youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
$video_id = MxChat_Utils::parse_youtube_id($youtube_url);
if (empty($video_id)) {
set_transient('mxchat_admin_notice_error',
esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
$description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
$manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
// Resolve the embedding decision exactly like the sibling handlers —
// custom-provider-aware (plan cbd5fd).
$bot_options = $this->get_bot_options($bot_id);
$options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
$preflight = MxChat_Utils::embedding_preflight($options);
if (!$preflight['ok']) {
set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$api_key = $preflight['api_key'];
// Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
// manual mode it enriches the indexed text with the real title/channel.
$meta = $this->mxchat_fetch_youtube_oembed($video_id);
$video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
$video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
$header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
if ($video_channel !== '') {
$header_lines .= 'Channel: ' . $video_channel . "\n";
}
$header_lines .= 'URL: ' . $canonical_url . "\n\n";
$transcript_missing = false;
if ($description_mode === 'manual') {
if ($manual_description === '') {
set_transient('mxchat_admin_notice_error',
esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$indexed_text = $header_lines . $manual_description;
} else {
$transcript = $this->mxchat_fetch_youtube_transcript($video_id);
if (strlen($transcript) >= 200) {
$indexed_text = $header_lines . $transcript;
} else {
// Graceful fallback: captions disabled / blocked / no speech. Auto
// reliably gets metadata; it does NOT guarantee a transcript.
$transcript_missing = true;
if ($video_title === '' && $video_channel === '') {
// Both halves failed — nothing meaningful to index.
set_transient('mxchat_admin_notice_error',
esc_html__('Could not retrieve any information for that video (no metadata and no captions). Please check the URL, or use the manual description option.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$indexed_text = $header_lines . sprintf(
/* translators: 1: video title, 2: channel name */
__('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
$video_title !== '' ? $video_title : $canonical_url,
$video_channel !== '' ? $video_channel : 'YouTube'
);
}
}
$result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
if (is_wp_error($result)) {
set_transient('mxchat_admin_notice_error',
esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
if ($transcript_missing) {
set_transient('mxchat_admin_notice_success',
esc_html__('Video indexed from its title and channel — no captions were available for a transcript. The form below is pre-filled: write your own description and import again to improve matching (it updates the same entry).', 'mxchat'),
30
);
// Bounce back with prefill args so the page reopens the YouTube form in
// manual mode with the URL + fetched title ready to augment.
$redirect_url = add_query_arg(array(
'mxchat_yt_prefill' => '1',
'yt_url' => rawurlencode($canonical_url),
'yt_title' => rawurlencode($video_title),
), $redirect_url);
} else {
set_transient('mxchat_admin_notice_success',
esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
30
);
}
wp_safe_redirect(esc_url_raw($redirect_url));
exit;
}
/**
* Fetch YouTube oEmbed metadata for a video (no API key required).
* Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
*/
private function mxchat_fetch_youtube_oembed($video_id) {
$oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
$response = wp_remote_get($oembed_url, array('timeout' => 15));
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return array();
}
$data = json_decode(wp_remote_retrieve_body($response), true);
return is_array($data) ? $data : array();
}
/**
* Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
* YouTube's unofficial timedtext route (the caption track list embedded in the
* watch page), which YouTube has broken before and will break again. Every
* failure mode returns '' so a break degrades to the metadata-only import path
* instead of erroring the whole submission. Do not let anything in here throw.
*/
private function mxchat_fetch_youtube_transcript($video_id) {
$watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
// First try the honest ingest UA; some responses omit the player config for
// bot UAs, so retry once with a browser UA before giving up.
$user_agents = array(
mxchat_ingest_user_agent(),
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
);
$tracks = array();
foreach ($user_agents as $ua) {
$response = wp_remote_get($watch_url, array(
'timeout' => 20,
'user-agent' => $ua,
));
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
continue;
}
$body = wp_remote_retrieve_body($response);
if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
continue;
}
$decoded = json_decode($m[1], true);
if (is_array($decoded) && !empty($decoded)) {
$tracks = $decoded;
break;
}
}
if (empty($tracks)) {
return '';
}
// Prefer an English track, else take the first offered.
$chosen = null;
foreach ($tracks as $track) {
if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
$chosen = $track;
break;
}
}
if ($chosen === null) {
$chosen = $tracks[0];
}
if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
return '';
}
$timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
return '';
}
$xml = wp_remote_retrieve_body($timedtext);
if (!is_string($xml) || strpos($xml, 'caption — strip tags, decode the
// double-encoded entities timedtext ships, collapse whitespace.
$text = preg_replace('/<[^>]+>/', ' ', $xml);
$text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = trim(preg_replace('/\s+/u', ' ', $text));
return $text;
}
public function mxchat_is_pdf_url($url, $response) {
$content_type = wp_remote_retrieve_header($response, 'content-type');
$file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
// Check Content-Disposition header for .pdf filename (Google Drive sends this)
$disposition = wp_remote_retrieve_header($response, 'content-disposition');
$has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false;
return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition;
}
public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') {
if (!current_user_can('manage_options')) {
return false;
}
$pdf_url = esc_url_raw($pdf_url);
$upload_dir = wp_upload_dir();
if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
return false;
}
$pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
$pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
$response_body = wp_remote_retrieve_body($response);
if (empty($response_body)) {
return false;
}
if (!wp_mkdir_p(dirname($pdf_path))) {
return false;
}
try {
file_put_contents($pdf_path, $response_body);
if (!file_exists($pdf_path)) {
throw new Exception(__('Failed to save PDF file', 'mxchat'));
}
$total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
if ($total_pages === false || $total_pages < 1) {
throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
}
// Create unique queue ID
$queue_id = 'pdf_' . md5($pdf_url . time());
// Create array of pages to process
$pages = array();
for ($i = 1; $i <= $total_pages; $i++) {
$pages[] = array(
'pdf_path' => $pdf_path,
'pdf_url' => $pdf_url,
'page_number' => $i,
'total_pages' => $total_pages
);
}
// Add pages to queue
$queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
if ($queued_count === 0) {
wp_delete_file($pdf_path);
throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
}
// Store queue metadata
$this->mxchat_set_queue_meta($queue_id, 'source_url', $pdf_url);
$this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
$this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
$this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
$this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
$this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
// Store queue ID in transient for status tracking
set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
set_transient('mxchat_last_pdf_url', $pdf_url, DAY_IN_SECONDS);
return 'queued';
} catch (Exception $e) {
if (file_exists($pdf_path)) {
wp_delete_file($pdf_path);
}
return $e->getMessage();
}
}
/**
* Handle direct PDF file upload from the knowledge base page
*/
public function mxchat_handle_pdf_file_submission() {
if (!isset($_POST['submit_pdf_file']) || !current_user_can('manage_options')) {
wp_die(esc_html__('Unauthorized access', 'mxchat'));
}
check_admin_referer('mxchat_submit_pdf_file_action', 'mxchat_submit_pdf_file_nonce');
$redirect_url = admin_url('admin.php?page=mxchat-prompts');
// Validate file upload
if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
$error_code = isset($_FILES['pdf_file']['error']) ? $_FILES['pdf_file']['error'] : UPLOAD_ERR_NO_FILE;
$error_messages = array(
UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a PDF file.', 'mxchat'),
UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
);
$error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
set_transient('mxchat_admin_notice_error', $error_msg, 30);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$file = $_FILES['pdf_file'];
// Validate MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if ($mime_type !== 'application/pdf') {
set_transient('mxchat_admin_notice_error',
esc_html__('Invalid file type. Only PDF files are accepted.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
// Validate extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'pdf') {
set_transient('mxchat_admin_notice_error',
esc_html__('Invalid file extension. Only .pdf files are accepted.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
$original_filename = sanitize_file_name($file['name']);
$upload_dir = wp_upload_dir();
if (isset($upload_dir['error']) && $upload_dir['error'] !== false) {
set_transient('mxchat_admin_notice_error',
esc_html__('WordPress upload directory is not writable.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$pdf_filename = sanitize_file_name('mxchat_kb_' . time() . '.pdf');
$pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename;
if (!wp_mkdir_p(dirname($pdf_path))) {
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to create upload directory.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to save uploaded PDF file.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
try {
$total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path);
if ($total_pages === false || $total_pages < 1) {
throw new Exception(__('Invalid PDF: Unable to parse or no pages found', 'mxchat'));
}
// Use original filename as the source identifier
$source_label = 'upload://' . $original_filename;
$queue_id = 'pdf_' . md5($source_label . time());
$pages = array();
for ($i = 1; $i <= $total_pages; $i++) {
$pages[] = array(
'pdf_path' => $pdf_path,
'pdf_url' => $source_label,
'page_number' => $i,
'total_pages' => $total_pages,
);
}
$queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id);
if ($queued_count === 0) {
wp_delete_file($pdf_path);
throw new Exception(__('Failed to add PDF pages to processing queue', 'mxchat'));
}
$this->mxchat_set_queue_meta($queue_id, 'source_url', $source_label);
$this->mxchat_set_queue_meta($queue_id, 'queue_type', 'pdf');
$this->mxchat_set_queue_meta($queue_id, 'total_items', $total_pages);
$this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
$this->mxchat_set_queue_meta($queue_id, 'pdf_path', $pdf_path);
$this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
set_transient('mxchat_active_queue_pdf', $queue_id, DAY_IN_SECONDS);
set_transient('mxchat_last_pdf_url', $source_label, DAY_IN_SECONDS);
set_transient('mxchat_admin_notice_success',
sprintf(
esc_html__('PDF "%s" (%d pages) queued for processing. Processing will start automatically.', 'mxchat'),
esc_html($original_filename),
$total_pages
),
30
);
} catch (Exception $e) {
if (file_exists($pdf_path)) {
wp_delete_file($pdf_path);
}
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to process uploaded PDF: ', 'mxchat') . esc_html($e->getMessage()),
30
);
}
wp_safe_redirect(esc_url($redirect_url));
exit;
}
/**
* Handle direct document upload (.docx / .txt / .md) from the knowledge base
* page (plan 0485e5). Unlike PDF Upload there is no per-page queue: the text
* extracts in one pass and routes through submit_content_to_db, whose chunker
* takes over for long content. The uploaded file is read from the PHP temp
* file and never persisted — only its extracted text enters the KB.
*
* Source identity matches PDF Upload's scheme: upload://, stable
* across re-uploads so a re-import REPLACES (delete_chunks_for_url + upsert
* per identity) instead of duplicating.
*/
public function mxchat_handle_document_file_submission() {
if (!isset($_POST['submit_document_file']) || !current_user_can('manage_options')) {
wp_die(esc_html__('Unauthorized access', 'mxchat'));
}
check_admin_referer('mxchat_submit_document_file_action', 'mxchat_submit_document_file_nonce');
$redirect_url = admin_url('admin.php?page=mxchat-prompts');
if (empty($_FILES['document_file']) || $_FILES['document_file']['error'] !== UPLOAD_ERR_OK) {
$error_code = isset($_FILES['document_file']['error']) ? $_FILES['document_file']['error'] : UPLOAD_ERR_NO_FILE;
$error_messages = array(
UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a document.', 'mxchat'),
UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
);
$error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
set_transient('mxchat_admin_notice_error', $error_msg, 30);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$file = $_FILES['document_file'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
// Per-extension MIME expectations. finfo commonly reports .docx as
// application/zip (it IS a Zip container) and .md as plain text.
$mime_ok = false;
if ($ext === 'docx') {
$mime_ok = in_array($mime_type, array(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip',
), true);
} elseif ($ext === 'txt' || $ext === 'md') {
$mime_ok = (strpos((string) $mime_type, 'text/') === 0);
}
if (!$mime_ok) {
set_transient('mxchat_admin_notice_error',
esc_html__('Invalid or unreadable document. Accepted types: .docx, .txt, .md.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
$original_filename = sanitize_file_name($file['name']);
// ---- Extract text (ONE extractor for .docx — the word handler's) ----
if ($ext === 'docx') {
$text = MXChat_Word_Handler::extract_docx_text($file['tmp_name']);
if ($text === false) {
set_transient('mxchat_admin_notice_error',
esc_html__('The .docx file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
} else {
// .txt / .md read as-is. Markdown keeps its syntax on purpose —
// headings are useful retrieval signal.
$text = (string) file_get_contents($file['tmp_name']);
$text = wp_check_invalid_utf8($text);
$text = trim($text);
}
if ($text === '') {
set_transient('mxchat_admin_notice_error',
esc_html__('The uploaded document contains no readable text.', 'mxchat'),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
// Size cap — same pdf_max_pages setting the PDF/toolbar paths use, but
// estimated by CHARACTERS (~2500/page): the .docx cleaner collapses all
// newlines to spaces, so a paragraph count reads 1 for any Word file.
// Processing is synchronous — an unbounded document risks a timeout.
$options = get_option('mxchat_options', array());
$max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
$estimated_pages = (int) ceil(strlen($text) / 2500);
if ($estimated_pages > $max_pages) {
set_transient('mxchat_admin_notice_error',
sprintf(
esc_html__('The document is too large (about %1$d pages; the limit is %2$d). Split it into smaller files, or raise the PDF max pages setting.', 'mxchat'),
$estimated_pages,
$max_pages
),
30
);
wp_safe_redirect(esc_url($redirect_url));
exit;
}
// Embedding API key — bot-aware, same shape as the direct-content handler.
$api_key = '';
if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
$bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
$api_key = $bot_options['api_key'] ?? '';
}
if (empty($api_key)) {
$api_key = $options['api_key'] ?? '';
}
// Stable identity — PDF Upload's scheme. A re-upload of the same filename
// replaces: clear old chunks first (covers a doc shrinking below the chunk
// threshold, where the single-vector path would not clean them), then
// submit — the chunked path re-deletes harmlessly.
$source_label = 'upload://' . $original_filename;
MxChat_Utils::delete_chunks_for_url($source_label, $bot_id);
$result = MxChat_Utils::submit_content_to_db($text, $source_label, $api_key, null, $bot_id, 'document');
if (is_wp_error($result)) {
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to import the document: ', 'mxchat') . esc_html($result->get_error_message()),
30
);
} else {
set_transient('mxchat_admin_notice_success',
sprintf(
esc_html__('Document "%s" imported into the knowledge base.', 'mxchat'),
esc_html($original_filename)
),
30
);
}
wp_safe_redirect(esc_url($redirect_url));
exit;
}
/**
* Validate PDF and count pages with multiple parser attempts
*/
private function mxchat_validate_and_count_pdf_pages($pdf_path) {
// Method 1: Try with Smalot PDF Parser (your current method)
try {
mxchat_load_pdf_parser();
$parser = new \Smalot\PdfParser\Parser();
$pdf = $parser->parseFile($pdf_path);
$pages = $pdf->getPages();
$page_count = count($pages);
if ($page_count > 0) {
//error_log('PDF parsed successfully with Smalot parser: ' . $page_count . ' pages');
return $page_count;
}
} catch (Exception $e) {
//error_log('Smalot PDF parser failed: ' . $e->getMessage());
}
// Method 2: Try with pdfinfo command (if available)
if (function_exists('shell_exec') && !$this->mxchat_is_shell_disabled()) {
try {
$command = 'pdfinfo ' . escapeshellarg($pdf_path) . ' 2>&1';
$output = shell_exec($command);
if ($output && preg_match('/Pages:\s*(\d+)/', $output, $matches)) {
$page_count = intval($matches[1]);
if ($page_count > 0) {
//error_log('PDF parsed successfully with pdfinfo: ' . $page_count . ' pages');
return $page_count;
}
}
} catch (Exception $e) {
//error_log('pdfinfo command failed: ' . $e->getMessage());
}
}
// Method 3: Try to repair PDF and parse again
try {
$repaired_path = $this->mxchat_attempt_pdf_repair($pdf_path);
if ($repaired_path && $repaired_path !== $pdf_path) {
mxchat_load_pdf_parser();
$parser = new \Smalot\PdfParser\Parser();
$pdf = $parser->parseFile($repaired_path);
$pages = $pdf->getPages();
$page_count = count($pages);
if ($page_count > 0) {
// Replace original with repaired version
copy($repaired_path, $pdf_path);
unlink($repaired_path);
//error_log('PDF repaired and parsed successfully: ' . $page_count . ' pages');
return $page_count;
}
// Clean up repaired file if it didn't work
unlink($repaired_path);
}
} catch (Exception $e) {
//error_log('PDF repair attempt failed: ' . $e->getMessage());
}
// Method 4: Manual PDF structure analysis (basic page count)
try {
$page_count = $this->mxchat_manual_pdf_page_count($pdf_path);
if ($page_count > 0) {
//error_log('PDF page count determined manually: ' . $page_count . ' pages');
return $page_count;
}
} catch (Exception $e) {
//error_log('Manual PDF analysis failed: ' . $e->getMessage());
}
//error_log('All PDF parsing methods failed for: ' . $pdf_path);
return false;
}
/**
* Check if shell_exec is disabled
*/
private function mxchat_is_shell_disabled() {
$disabled = explode(',', ini_get('disable_functions'));
return in_array('shell_exec', $disabled);
}
/**
* Attempt to repair PDF using basic methods
*/
private function mxchat_attempt_pdf_repair($pdf_path) {
try {
$content = file_get_contents($pdf_path);
if (!$content) {
return false;
}
// Check if PDF starts with proper header
if (substr($content, 0, 4) !== '%PDF') {
// Try to find PDF header in the content
$header_pos = strpos($content, '%PDF');
if ($header_pos !== false && $header_pos < 1024) {
// Remove junk before PDF header
$content = substr($content, $header_pos);
$repaired_path = $pdf_path . '.repaired';
file_put_contents($repaired_path, $content);
return $repaired_path;
}
}
// Check for EOF marker
$content = rtrim($content);
if (!preg_match('/%%EOF\s*$/', $content)) {
// Add EOF marker if missing
$content .= "\n%%EOF";
$repaired_path = $pdf_path . '.repaired';
file_put_contents($repaired_path, $content);
return $repaired_path;
}
} catch (Exception $e) {
//error_log('PDF repair error: ' . $e->getMessage());
}
return false;
}
/**
* Manual PDF page counting by analyzing PDF structure
*/
private function mxchat_manual_pdf_page_count($pdf_path) {
try {
$content = file_get_contents($pdf_path);
if (!$content) {
return 0;
}
// Method 1: Count /Type /Page objects
$page_count = preg_match_all('/\/Type\s*\/Page[^s]/', $content);
if ($page_count > 0) {
return $page_count;
}
// Method 2: Look for /Count in pages object
if (preg_match('/\/Type\s*\/Pages.*?\/Count\s+(\d+)/', $content, $matches)) {
return intval($matches[1]);
}
// Method 3: Count page references
$page_count = preg_match_all('/\d+\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page/', $content);
if ($page_count > 0) {
return $page_count;
}
} catch (Exception $e) {
//error_log('Manual PDF analysis error: ' . $e->getMessage());
}
return 0;
}
public function mxchat_save_inline_prompt() {
// DEBUG: Log what we're receiving
//error_log('=== MXCHAT DEBUG ===');
//error_log('POST data: ' . print_r($_POST, true));
//error_log('Nonce from POST: ' . ($_POST['_ajax_nonce'] ?? 'NOT FOUND'));
// Check for nonce security
check_ajax_referer('mxchat_save_inline_nonce', '_ajax_nonce');
// If we get here, nonce passed
//error_log('Nonce verification PASSED');
// Verify permissions
if (!current_user_can('manage_options')) {
wp_send_json_error(esc_html__('Permission denied.', 'mxchat'));
return;
}
global $wpdb;
$table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
// Validate and sanitize input data
$prompt_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
$article_content = isset($_POST['article_content']) ? wp_kses_post(wp_unslash($_POST['article_content'])) : '';
$article_url = isset($_POST['article_url']) ? esc_url_raw($_POST['article_url']) : '';
if ($prompt_id > 0 && !empty($article_content)) {
// Re-generate the embedding vector for the updated content
$embedding_vector = $this->mxchat_generate_embedding($article_content);
if (is_array($embedding_vector)) {
// Serialize the embedding vector before storing it
$embedding_vector_serialized = serialize($embedding_vector);
// Update the prompt in the database
$updated = $wpdb->update(
$table_name,
array(
'article_content' => $article_content,
'embedding_vector' => $embedding_vector_serialized,
'source_url' => $article_url,
),
array('id' => $prompt_id),
array('%s', '%s', '%s'),
array('%d')
);
if ($updated !== false) {
wp_send_json_success();
} else {
MxChat_Admin::mxchat_log_debug('knowledge_error', 'Database update failed when saving knowledge entry');
wp_send_json_error(esc_html__('Database update failed.', 'mxchat'));
}
} else {
MxChat_Admin::mxchat_log_debug('embedding_error', 'Embedding generation failed for knowledge entry');
wp_send_json_error(esc_html__('Embedding generation failed.', 'mxchat'));
}
} else {
wp_send_json_error(esc_html__('Invalid data.', 'mxchat'));
}
}
/**
* AJAX: Get full content for editing — reassembles chunks if needed.
* Works for both WordPress DB and Pinecone entries.
*/
/**
* Sanitize a knowledge entry's source_url from an AJAX request WITHOUT destroying
* its identity. sanitize_text_field() strips percent-encoded octets (%20, %D7%A9…),
* so a percent-encoded URL — every non-ASCII permalink — would md5 to a DIFFERENT
* id than the one it was stored under: reads miss the entry and saves write an
* orphan copy while the original keeps its stale text. URLs get esc_url_raw
* (identity-preserving, matches what import stored); non-URL keys (mxchat://,
* _ungrouped_) keep the old sanitizer.
*/
private function sanitize_entry_source_url( $raw ) {
$raw = trim( (string) $raw );
if ( preg_match( '#^https?://#i', $raw ) ) {
return esc_url_raw( $raw );
}
return sanitize_text_field( $raw );
}
public function ajax_mxchat_get_entry_content() {
check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
if ( ! current_user_can('manage_options') ) {
wp_send_json_error( array( 'message' => 'Permission denied.' ) );
}
$source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
$entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
$data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
if ( $data_source === 'pinecone' ) {
// Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
// destroy them, so re-read the raw value for this branch only.
$vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
// Pinecone: fetch vectors by source_url, reassemble chunks
$content = $this->get_pinecone_entry_content( $source_url, $vector_id, $bot_id );
} else {
// WordPress DB
$content = $this->get_wordpress_entry_content( $source_url, $entry_id );
}
if ( is_wp_error( $content ) ) {
wp_send_json_error( array( 'message' => $content->get_error_message() ) );
}
wp_send_json_success( $content );
}
/**
* Get content from WordPress DB — reassembles chunks by source_url.
*/
private function get_wordpress_entry_content( $source_url, $entry_id ) {
global $wpdb;
$table = $wpdb->prefix . 'mxchat_system_prompt_content';
// If we have a source_url, check for chunks
if ( ! empty( $source_url ) && strpos( $source_url, 'mxchat://' ) !== 0 ) {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
$source_url
) );
if ( $rows && count( $rows ) > 1 ) {
// Multiple rows = chunked. Reassemble.
$chunks = array();
foreach ( $rows as $row ) {
$parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
$index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
$chunks[ $index ] = $parsed['text'];
}
ksort( $chunks );
return array(
'content' => implode( "\n\n", $chunks ),
'source_url' => $source_url,
'is_chunked' => true,
'chunk_count' => count( $chunks ),
'content_type' => $rows[0]->content_type,
);
} elseif ( $rows && count( $rows ) === 1 ) {
$parsed = MxChat_Chunker::parse_stored_chunk( $rows[0]->article_content );
return array(
'content' => $parsed['text'],
'source_url' => $source_url,
'entry_id' => $rows[0]->id,
'is_chunked' => false,
'content_type' => $rows[0]->content_type,
);
}
}
// Fallback: fetch by ID
if ( $entry_id > 0 ) {
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
$entry_id
) );
if ( $row ) {
$parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
return array(
'content' => $parsed['text'],
'source_url' => $row->source_url,
'entry_id' => $row->id,
'is_chunked' => false,
'content_type' => $row->content_type,
);
}
}
return new WP_Error( 'not_found', 'Entry not found.' );
}
/**
* Get content from Pinecone — fetches vectors by source_url, reassembles chunks.
*/
private function get_pinecone_entry_content( $source_url, $entry_id, $bot_id ) {
if ( ! class_exists('MxChat_Pinecone_Manager') ) {
return new WP_Error( 'pinecone_unavailable', 'Pinecone manager not available.' );
}
// Get Pinecone config
if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
$pinecone_options = get_option('mxchat_pinecone_addon_options');
$api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
$host = $pinecone_options['mxchat_pinecone_host'] ?? '';
$namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
} else {
$bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
$api_key = $bot_config['api_key'] ?? '';
$host = $bot_config['host'] ?? '';
$namespace = $bot_config['namespace'] ?? '';
}
if ( empty($host) || empty($api_key) ) {
return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
}
// Manual entries carry no source_url (their vector id is a minted manual_* string,
// not md5 of anything the row can hand us) — fetch the exact vector instead.
// '_ungrouped_' is the table view's synthetic display key for such rows.
if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
$vector_ids = array( $entry_id );
} else {
// List vectors with the source_url prefix
$base_id = md5( $source_url );
$vector_ids = array( $base_id );
// Find chunk vectors
// NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
// A POST is answered 200-with-an-empty-body, which reads as "no vectors".
$list_url = "https://{$host}/vectors/list";
$list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
if ( ! empty($namespace) ) {
$list_params['namespace'] = $namespace;
}
$list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
'timeout' => 15,
) );
if ( ! is_wp_error($list_resp) ) {
$list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
if ( ! empty($list_data['vectors']) ) {
foreach ( $list_data['vectors'] as $v ) {
$vector_ids[] = $v['id'];
}
}
}
}
// Fetch vectors with metadata
// NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
// repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
// the query string explicitly.
$fetch_query = array();
foreach ( $vector_ids as $fetch_vid ) {
$fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
}
if ( ! empty($namespace) ) {
$fetch_query[] = 'namespace=' . rawurlencode( $namespace );
}
$fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
'timeout' => 15,
) );
if ( is_wp_error($fetch_resp) ) {
return new WP_Error( 'pinecone_fetch', 'Failed to fetch from Pinecone.' );
}
$fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
$vectors = $fetch_data['vectors'] ?? array();
if ( empty($vectors) ) {
return new WP_Error( 'not_found', 'Entry not found in Pinecone.' );
}
// Reassemble chunks
$chunks = array();
$content_type = 'content';
foreach ( $vectors as $vid => $vector ) {
$meta = $vector['metadata'] ?? array();
$text = $meta['text'] ?? '';
$index = $meta['chunk_index'] ?? 0;
$content_type = $meta['type'] ?? 'content';
$chunks[ intval($index) ] = $text;
}
ksort( $chunks );
return array(
'content' => implode( "\n\n", $chunks ),
'source_url' => $source_url,
'is_chunked' => count($chunks) > 1,
'chunk_count' => count($chunks),
'content_type' => $content_type,
);
}
/**
* AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
* WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
* entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
*/
public function ajax_mxchat_inspect_entry() {
check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
if ( ! current_user_can('manage_options') ) {
wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
}
$source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
$entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
$data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
if ( $data_source === 'pinecone' ) {
$result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
} else {
$result = $this->inspect_wordpress_entry( $source_url, $entry_id );
}
if ( is_wp_error( $result ) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
}
wp_send_json_success( $result );
}
/**
* Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
* but returns each STORED chunk's exact text + length (no implode), plus the assembled
* embedded text. This shows what is actually in the index, not a re-derivation from the post.
*/
private function inspect_wordpress_entry( $source_url, $entry_id ) {
global $wpdb;
$table = $wpdb->prefix . 'mxchat_system_prompt_content';
$rows = array();
// Group by the real stored source_url — this INCLUDES "mxchat://" manual
// Direct Content entries (the spec's manual-entry case), which share one
// source_url across their chunk rows. Only the synthetic "_ungrouped_"
// display key (invented by the table view for rows with no source_url) is
// excluded; those fall through to the entry_id lookup below.
if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
$source_url
) );
}
// Fallback / manual "Direct Content" entries: fetch the single row by id.
if ( empty( $rows ) && $entry_id > 0 ) {
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
$entry_id
) );
if ( $row ) {
$rows = array( $row );
}
}
if ( empty( $rows ) ) {
return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
}
$chunks = array();
$content_type = '';
foreach ( $rows as $row ) {
$parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
$text = isset( $parsed['text'] ) ? $parsed['text'] : '';
$index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
$content_type = $row->content_type;
$chunks[] = array(
'index' => $index,
'text' => $text,
'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
'row_id' => intval( $row->id ),
);
}
usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
$assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
return array(
'store' => 'wordpress',
'source_url' => $source_url,
'content_type' => $content_type,
'is_chunked' => count( $chunks ) > 1,
'chunk_count' => count( $chunks ),
'assembled' => $assembled,
'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
'chunks' => array_values( $chunks ),
// WP-DB storage carries no separate vector metadata; surface that fact
// rather than letting the owner guess (the spec's taxonomy question).
'metadata' => array(),
'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'),
);
}
/**
* Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
* but keeps each vector's text + metadata instead of imploding, so the owner can
* confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
* are present per chunk. READ-ONLY.
*/
private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
if ( ! class_exists('MxChat_Pinecone_Manager') ) {
return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
}
if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
$pinecone_options = get_option('mxchat_pinecone_addon_options');
$api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
$host = $pinecone_options['mxchat_pinecone_host'] ?? '';
$namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
} else {
$bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
$api_key = $bot_config['api_key'] ?? '';
$host = $bot_config['host'] ?? '';
$namespace = $bot_config['namespace'] ?? '';
}
if ( empty($host) || empty($api_key) ) {
return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
}
$base_id = md5( $source_url );
$vector_ids = array( $base_id );
// NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
// A POST is answered 200-with-an-empty-body, which reads as "no vectors".
$list_url = "https://{$host}/vectors/list";
$list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
if ( ! empty($namespace) ) {
$list_params['namespace'] = $namespace;
}
$list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
'timeout' => 15,
) );
if ( ! is_wp_error($list_resp) ) {
$list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
if ( ! empty($list_data['vectors']) ) {
foreach ( $list_data['vectors'] as $v ) {
$vector_ids[] = $v['id'];
}
}
}
// NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
// repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
// the query string explicitly.
$fetch_query = array();
foreach ( $vector_ids as $fetch_vid ) {
$fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
}
if ( ! empty($namespace) ) {
$fetch_query[] = 'namespace=' . rawurlencode( $namespace );
}
$fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
'timeout' => 15,
) );
if ( is_wp_error($fetch_resp) ) {
return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
}
$fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
$vectors = $fetch_data['vectors'] ?? array();
if ( empty($vectors) ) {
return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
}
// Whitelisted metadata fields the spec calls out — shown so devs can confirm
// what is (and is NOT) stored per vector.
$meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
$chunks = array();
$content_type = '';
foreach ( $vectors as $vid => $vector ) {
$meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
$text = $meta['text'] ?? '';
$index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
$content_type = $meta['type'] ?? $content_type;
$clean_meta = array();
foreach ( $meta_fields as $field ) {
if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
$clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
}
}
$chunks[] = array(
'index' => $index,
'text' => $text,
'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
'vector_id' => (string) $vid,
'metadata' => $clean_meta,
);
}
usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
$assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
return array(
'store' => 'pinecone',
'source_url' => $source_url,
'content_type' => $content_type,
'is_chunked' => count( $chunks ) > 1,
'chunk_count' => count( $chunks ),
'assembled' => $assembled,
'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
'chunks' => array_values( $chunks ),
'metadata' => array(),
'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'),
);
}
/**
* AJAX: Save edited content — re-chunks and re-embeds as needed.
* Works for both WordPress DB and Pinecone entries.
*/
public function ajax_mxchat_save_entry_content() {
check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
if ( ! current_user_can('manage_options') ) {
wp_send_json_error( array( 'message' => 'Permission denied.' ) );
}
$source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
$entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
$content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
$data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
$content_type = isset($_POST['content_type']) ? sanitize_key($_POST['content_type']) : 'content';
if ( empty($content) ) {
wp_send_json_error( array( 'message' => 'Content cannot be empty.' ) );
}
// Get the embedding API key
$options = get_option('mxchat_options', array());
$api_key = '';
if ( $bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager') ) {
$bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
$api_key = $bot_options['api_key'] ?? '';
}
if ( empty($api_key) ) {
$api_key = $options['api_key'] ?? '';
}
if ( $data_source === 'pinecone' ) {
// Pinecone branch. The WP-DB manual-entry delete below must never run here:
// Pinecone ids are strings, and absint() on a digit-leading md5 hash would
// yield a real (unrelated) WP row id.
$raw_vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
$is_manual_single = empty($source_url) || strpos($source_url, '_ungrouped_') === 0;
$is_manual_chunked = strpos($source_url, 'mxchat://') === 0;
if ( $is_manual_single || $is_manual_chunked ) {
// Manual content: remove the old vectors first, then store as fresh manual
// content — submit_content_to_db mints a new unique identity (manual_* id
// for a single vector, an mxchat:// chunk prefix if it now chunks).
if ( $is_manual_chunked ) {
// Minted identity: base + chunk vectors share the md5(mxchat://...) prefix.
MxChat_Utils::delete_chunks_for_url( $source_url, $bot_id );
} elseif ( ! empty($raw_vector_id) && class_exists('MxChat_Pinecone_Manager') ) {
$pinecone_manager = MxChat_Pinecone_Manager::get_instance();
$pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options( $bot_id );
if ( ! empty($pinecone_options['mxchat_pinecone_api_key']) && ! empty($pinecone_options['mxchat_pinecone_host']) ) {
$pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
$raw_vector_id,
$pinecone_options['mxchat_pinecone_api_key'],
$pinecone_options['mxchat_pinecone_host'],
$pinecone_options['mxchat_pinecone_namespace'] ?? ''
);
}
}
$result = MxChat_Utils::submit_content_to_db( $content, '', $api_key, null, $bot_id, $content_type );
} else {
// URL-sourced entry: identity is md5(source_url). submit_content_to_db
// handles delete-old-chunks → re-chunk → re-embed → store, and sweeps
// stale chunk vectors when the content now fits in a single vector.
$result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, md5($source_url), $bot_id, $content_type );
}
} else {
global $wpdb;
$table = $wpdb->prefix . 'mxchat_system_prompt_content';
// If source_url is empty but we have an entry_id, look it up
if ( empty($source_url) && $entry_id > 0 ) {
$row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
if ( $row && ! empty($row->source_url) ) {
$source_url = $row->source_url;
}
}
// For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
// so submit_content_to_db creates a replacement instead of a duplicate
// Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
$is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
$wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
// Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
// instead of reusing the shared URL (which would mass-delete other entries with the same URL)
if ( $is_legacy_manual ) {
$source_url = '';
}
}
// Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
$vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
// submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
$result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
}
if ( is_wp_error($result) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
}
wp_send_json_success( array( 'message' => 'Content saved and re-embedded successfully.' ) );
}
public function mxchat_get_pdf_processing_status($pdf_url) {
$pdf_url = esc_url_raw($pdf_url);
$status = get_transient(sanitize_key('mxchat_pdf_status_' . md5($pdf_url)));
if (!$status || !is_array($status)) {
return false;
}
// Check for stalled processing (no updates for 5 minutes)
if ($status['status'] === 'processing' && (time() - absint($status['last_update'])) > 300) {
$status['status'] = 'error';
$status['error'] = __('PDF processing appears to be stalled. No updates for over 5 minutes.', 'mxchat');
// Save the updated status
set_transient(
sanitize_key('mxchat_pdf_status_' . md5($pdf_url)),
array_map('sanitize_text_field', $status),
DAY_IN_SECONDS
);
}
$result = array(
'total_pages' => absint($status['total_pages']),
'processed_pages' => absint($status['processed_pages']),
'failed_pages' => absint($status['failed_pages'] ?? 0),
'percentage' => ($status['total_pages'] > 0)
? round((absint($status['processed_pages']) / absint($status['total_pages'])) * 100)
: 0,
'status' => sanitize_text_field($status['status']),
'last_update' => human_time_diff(absint($status['last_update']), time()) . ' ' . esc_html__('ago', 'mxchat'),
'failed_pages_list' => isset($status['failed_pages_list']) ? $status['failed_pages_list'] : array(),
'completion_summary' => isset($status['completion_summary']) ? $status['completion_summary'] : null
);
// Add error message if present
if (isset($status['error']) && !empty($status['error'])) {
$result['error'] = sanitize_text_field($status['error']);
}
return $result;
}
public function mxchat_handle_sitemap_submission() {
// Check if the form was submitted and verify permissions
if (!isset($_POST['submit_sitemap']) || !current_user_can('manage_options')) {
wp_die(esc_html__('Unauthorized access', 'mxchat'));
}
// Verify nonce
check_admin_referer('mxchat_submit_sitemap_action', 'mxchat_submit_sitemap_nonce');
// Validate URL
if (!isset($_POST['sitemap_url']) || empty($_POST['sitemap_url'])) {
set_transient('mxchat_admin_notice_error',
esc_html__('Please provide a valid URL.', 'mxchat'),
30
);
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
$submitted_url = esc_url_raw($_POST['sitemap_url']);
// Convert Google Drive sharing URLs to direct download URLs
if ( strpos($submitted_url, 'drive.google.com') !== false ) {
$file_id = '';
if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) {
$file_id = $m[1];
} elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) {
$file_id = $m[1];
}
if ( ! empty($file_id) ) {
$submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id;
}
}
// Get bot_id from form submission
$bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
// Get bot-specific options and validate the embedding decision —
// custom-provider-aware (plan cbd5fd).
$bot_options = $this->get_bot_options($bot_id);
$options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
$preflight = MxChat_Utils::embedding_preflight($options);
if (!$preflight['ok']) {
set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
$api_key = $preflight['api_key'];
// Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
// browser). Stale browser UAs are exactly what WAFs like SiteGround's
// ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
// from the site's own media library, which route through this same call).
// See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
// the browser-only Accept-Language fingerprint is dropped so it stays
// coherent with a bot identity.
$response = wp_remote_get($submitted_url, array(
'timeout' => 30,
'sslverify' => false,
'user-agent' => mxchat_ingest_user_agent(),
'headers' => array(
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
),
));
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
$error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response);
set_transient('mxchat_admin_notice_error',
sprintf(
esc_html__('Failed to fetch the URL: %s', 'mxchat'),
esc_html($error_message)
),
30
);
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
$content_type = wp_remote_retrieve_header($response, 'content-type');
$body_content = wp_remote_retrieve_body($response);
if (empty($body_content)) {
set_transient('mxchat_admin_notice_error',
esc_html__('Empty response received from URL.', 'mxchat'),
30
);
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
// Handle PDF URL
if ($this->mxchat_is_pdf_url($submitted_url, $response)) {
$result = $this->mxchat_handle_pdf_for_knowledge_base($submitted_url, $response, $bot_id);
if ($result === 'queued') {
set_transient('mxchat_admin_notice_success',
esc_html__('PDF queued for processing. Processing will start automatically.', 'mxchat'),
30
);
} else {
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to queue PDF processing: ', 'mxchat') . esc_html($result),
30
);
}
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
// Handle Sitemap XML
if (strpos($content_type, 'xml') !== false || strpos($body_content, 'mxchat_handle_sitemap_for_knowledge_base($xml, $submitted_url, $bot_id);
if ($result === 'queued') {
set_transient('mxchat_admin_notice_success',
esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
30
);
} else {
// Surface the reason the handler already computed (embedding pre-flight,
// empty sitemap, queue failure). The old message pointed at the status
// area, which is empty on this path — nothing was ever queued.
if (is_string($result) && $result !== '') {
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
30
);
} else {
set_transient('mxchat_admin_notice_error',
esc_html__('Failed to queue sitemap processing.', 'mxchat'),
30
);
}
}
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
// Handle Regular URL (single page)
$page_content = $this->mxchat_extract_main_content($body_content);
$sanitized_content = $this->mxchat_sanitize_content_for_api($page_content);
//error_log('[MXCHAT-URL-DEBUG] URL: ' . $submitted_url);
//error_log('[MXCHAT-URL-DEBUG] Extracted page_content length: ' . strlen($page_content));
//error_log('[MXCHAT-URL-DEBUG] Sanitized content length: ' . strlen($sanitized_content));
//error_log('[MXCHAT-URL-DEBUG] Content preview: ' . substr($sanitized_content, 0, 500));
if (empty($sanitized_content)) {
set_transient('mxchat_admin_notice_error',
esc_html__('No valid content found on the provided URL.', 'mxchat'),
30
);
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
// For single URLs, process immediately using submit_content_to_db
// This handles chunking automatically for large content
$db_result = MxChat_Utils::submit_content_to_db(
$sanitized_content,
$submitted_url,
$api_key,
null,
$bot_id,
'url' // content_type
);
if (is_wp_error($db_result)) {
$error_message = esc_html__('Failed to store content in database: ', 'mxchat') . esc_html($db_result->get_error_message());
set_transient('mxchat_admin_notice_error', $error_message, 30);
} else {
$success_message = esc_html__('URL content successfully submitted!', 'mxchat');
set_transient('mxchat_admin_notice_success', $success_message, 30);
}
wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
exit;
}
public function mxchat_get_single_url_status() {
$status = get_transient('mxchat_single_url_status');
if (!$status) {
return null;
}
// Add human-readable time
if (isset($status['timestamp'])) {
$status['human_time'] = human_time_diff(strtotime($status['timestamp']), current_time('timestamp')) . ' ' . __('ago', 'mxchat');
}
return $status;
}
public function mxchat_handle_sitemap_for_knowledge_base($xml, $sitemap_url, $bot_id = 'default') {
if (!current_user_can('manage_options')) {
return false;
}
try {
$sitemap_url = esc_url_raw($sitemap_url);
if (!$xml || !is_object($xml)) {
throw new Exception(__('Invalid XML object provided', 'mxchat'));
}
// Get bot-specific embedding API for validation
$bot_options = $this->get_bot_options($bot_id);
$options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
// Test the embedding API before processing
$test_phrase = "Test embedding generation for MxChat";
$test_result = $this->mxchat_generate_embedding($test_phrase, $bot_id);
if (is_string($test_result)) {
throw new Exception(__('Embedding API validation failed: ', 'mxchat') . $test_result);
}
if (!is_array($test_result)) {
throw new Exception(__('Embedding API returned unexpected result type. Please check your configuration.', 'mxchat'));
}
// Extract URLs from sitemap
$urls = array();
foreach ($xml->url as $url_element) {
$url = esc_url_raw((string)$url_element->loc);
if ($url) {
$urls[] = array('url' => $url);
}
}
$total_urls = count($urls);
if ($total_urls < 1) {
throw new Exception(__('No valid URLs found in sitemap', 'mxchat'));
}
// Create unique queue ID
$queue_id = 'sitemap_' . md5($sitemap_url . time());
// Add URLs to queue
$queued_count = $this->mxchat_add_to_queue($queue_id, 'url', $urls, $bot_id);
if ($queued_count === 0) {
throw new Exception(__('Failed to add URLs to processing queue', 'mxchat'));
}
// Store queue metadata
$this->mxchat_set_queue_meta($queue_id, 'source_url', $sitemap_url);
$this->mxchat_set_queue_meta($queue_id, 'queue_type', 'sitemap');
$this->mxchat_set_queue_meta($queue_id, 'total_items', $total_urls);
$this->mxchat_set_queue_meta($queue_id, 'bot_id', $bot_id);
$this->mxchat_set_queue_meta($queue_id, 'created_at', current_time('mysql'));
// Store queue ID in transient for status tracking
set_transient('mxchat_active_queue_sitemap', $queue_id, DAY_IN_SECONDS);
set_transient('mxchat_last_sitemap_url', $sitemap_url, DAY_IN_SECONDS);
return 'queued';
} catch (Exception $e) {
$error_message = $e->getMessage();
//error_log(sprintf(esc_html__('Error preparing sitemap for processing: %s', 'mxchat'), esc_html($error_message)));
return $error_message;
}
}
/**
* Remove shortcode tags but preserve the content inside them
* Example: [vc_column]Hello World[/vc_column] becomes "Hello World"
*
* @param string $content The content containing shortcodes
* @return string Content with shortcode tags removed but inner content preserved
*/
/**
* Single-pass HTML entity decode for text entering the knowledge base.
* The corpus should hold what a human reads: a stored `&` consumes
* extra tokens, distorts the vector away from the form a visitor's
* question uses, and can be quoted back verbatim in an answer.
* Deliberately NOT looped to a fixed point — a stored `&` is a
* legitimate literal `&` and must not collapse further (data loss).
* UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
* paths call this at their output points so the treatment cannot drift.
* (Plan d2c92e.)
*/
private function mxchat_decode_entities_for_indexing($text) {
return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Price lines for a product's indexed text, pinned to the store's BASE currency.
*
* The four product assembly paths each used to call get_woocommerce_currency_symbol()
* with no argument, which resolves the currency active on the CURRENT request.
* Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
* request, so whichever currency the store happened to be serving when an import ran
* was frozen into every product it indexed. The amounts have the mirror problem: the
* woocommerce_product_get_* filters convert prices in the 'view' context but not in
* 'edit', so a converted amount could be paired with an unconverted symbol and produce
* a price that is not merely wrong but incoherent.
*
* Base currency option + 'edit' context makes both halves agree and makes the output
* independent of when the import ran. The currency CODE is emitted alongside the symbol
* so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
* (Plan 7403ec.)
*/
private function mxchat_product_price_lines($product) {
if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
return '';
}
$currency = get_option('woocommerce_currency');
$currency = is_string($currency) ? trim($currency) : '';
$symbol = ($currency !== '')
? get_woocommerce_currency_symbol($currency)
: get_woocommerce_currency_symbol();
$symbol = $this->mxchat_decode_entities_for_indexing($symbol);
$regular_price = $product->get_regular_price('edit');
$sale_price = $product->get_sale_price('edit');
$price = $product->get_price('edit');
$lines = '';
if (!empty($regular_price)) {
$lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
} elseif (!empty($price)) {
$lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
}
if (!empty($sale_price) && $sale_price !== $regular_price) {
$lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
}
if ($product->is_type('variable')) {
list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
$lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
. " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
}
}
return $lines;
}
/**
* One indexed price amount, labelled with its currency code.
*
* "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
* is kept so a quoted price still reads naturally. Falls back to the old symbol-only
* shape when WooCommerce has no base currency configured, and drops the parenthetical
* when the symbol is absent or IS the code (several currencies have no distinct glyph).
*/
private function mxchat_format_indexed_price($amount, $currency, $symbol) {
$amount = (string) $amount;
if ($currency === '') {
return $symbol . $amount;
}
if ($symbol === '' || $symbol === $currency) {
return $currency . ' ' . $amount;
}
return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
}
/**
* Min/max variation price read from the variations themselves in 'edit' context.
*
* get_variation_price() reads WooCommerce's display price cache, which multi-currency
* plugins populate with converted values — the same defect the rest of this helper
* exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
* the store's own price formatting, and (null, null) when no variation carries a price.
*/
private function mxchat_variation_price_range($product) {
$min_raw = null;
$max_raw = null;
$min_val = null;
$max_val = null;
$children = method_exists($product, 'get_children') ? $product->get_children() : array();
foreach ($children as $child_id) {
$variation = wc_get_product($child_id);
if (!$variation) {
continue;
}
$raw = $variation->get_price('edit');
if ($raw === '' || $raw === null) {
continue;
}
$val = (float) $raw;
if ($min_val === null || $val < $min_val) {
$min_val = $val;
$min_raw = $raw;
}
if ($max_val === null || $val > $max_val) {
$max_val = $val;
$max_raw = $raw;
}
}
return array($min_raw, $max_raw);
}
private function strip_shortcode_tags_preserve_content($content) {
// Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
// Content between tags is inherently preserved since only brackets are targeted
$result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
return ($result !== null) ? $result : $content;
}
public function mxchat_sanitize_content_for_api($content) {
//error_log('[MXCHAT-SANITIZE] Original content preview: ' . substr($content, 0, 500) . '...');
// Remove shortcode tags but PRESERVE content inside them
$content = $this->strip_shortcode_tags_preserve_content($content);
// Remove script, style tags, and HTML comments
$content = preg_replace('/