diff --git a/classes/Visualizer/Module/AI.php b/classes/Visualizer/Module/AI.php new file mode 100644 index 000000000..3fbea7f3a --- /dev/null +++ b/classes/Visualizer/Module/AI.php @@ -0,0 +1,1770 @@ +_addAjaxAction( self::ACTION_GENERATE_CONFIG, 'generateConfiguration' ); + $this->_addAjaxAction( self::ACTION_ANALYZE_CHART_IMAGE, 'analyzeChartImage' ); + + // Prevent PHP warnings from contaminating AJAX responses + add_action( 'admin_init', array( $this, 'suppressAjaxWarnings' ) ); + } + + /** + * Suppresses PHP warnings during AJAX requests to prevent JSON contamination. + * + * @since 3.12.0 + * + * @access public + * @return void + */ + public function suppressAjaxWarnings() { + if ( wp_doing_ajax() ) { + ini_set( 'display_errors', '0' ); + } + } + + /** + * Handles AJAX request to generate configuration using AI. + * + * @since 3.12.0 + * + * @access public + * @return void + */ + public function generateConfiguration() { + // Verify nonce + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'visualizer-ai-generate' ) ) { + error_log( 'Visualizer AI: Invalid nonce' ); + wp_send_json_error( array( 'message' => esc_html__( 'Invalid nonce.', 'visualizer' ) ) ); + } + + // Check permissions + if ( ! current_user_can( 'edit_posts' ) ) { + error_log( 'Visualizer AI: Insufficient permissions' ); + wp_send_json_error( array( 'message' => esc_html__( 'Insufficient permissions.', 'visualizer' ) ) ); + } + + $model = isset( $_POST['model'] ) ? sanitize_text_field( $_POST['model'] ) : 'openai'; + $prompt = isset( $_POST['prompt'] ) ? sanitize_textarea_field( $_POST['prompt'] ) : ''; + $chart_type = isset( $_POST['chart_type'] ) ? sanitize_text_field( $_POST['chart_type'] ) : ''; + $chart_library = isset( $_POST['chart_library'] ) ? sanitize_text_field( $_POST['chart_library'] ) : 'Google Charts'; + $chat_history = isset( $_POST['chat_history'] ) ? json_decode( stripslashes( $_POST['chat_history'] ), true ) : array(); + $current_config = isset( $_POST['current_config'] ) ? sanitize_textarea_field( $_POST['current_config'] ) : ''; + + if ( empty( $prompt ) ) { + error_log( 'Visualizer AI: Empty prompt' ); + wp_send_json_error( array( 'message' => esc_html__( 'Please provide a prompt.', 'visualizer' ) ) ); + } + + // Generate configuration based on selected model + $result = $this->_callAIModel( $model, $prompt, $chart_type, $chart_library, $chat_history, $current_config ); + + if ( is_wp_error( $result ) ) { + error_log( 'Visualizer AI: Error: ' . $result->get_error_message() ); + wp_send_json_error( array( 'message' => $result->get_error_message() ) ); + } + + wp_send_json_success( $result ); + } + + /** + * Handles AJAX request to analyze chart image using AI vision. + * + * @since 3.12.0 + * + * @access public + * @return void + */ + public function analyzeChartImage() { + // Prevent any output before JSON response + ini_set( 'display_errors', '0' ); + while ( ob_get_level() ) { + ob_end_clean(); + } + ob_start(); + + // Verify nonce + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'visualizer-ai-image' ) ) { + error_log( 'Visualizer AI: Invalid nonce' ); + ob_end_clean(); + wp_send_json_error( array( 'message' => esc_html__( 'Invalid nonce.', 'visualizer' ) ) ); + } + + // Check permissions + if ( ! current_user_can( 'edit_posts' ) ) { + error_log( 'Visualizer AI: Insufficient permissions' ); + ob_end_clean(); + wp_send_json_error( array( 'message' => esc_html__( 'Insufficient permissions.', 'visualizer' ) ) ); + } + + // Get image data + if ( ! isset( $_POST['image'] ) || empty( $_POST['image'] ) ) { + error_log( 'Visualizer AI: No image provided' ); + ob_end_clean(); + wp_send_json_error( array( 'message' => esc_html__( 'Please provide an image.', 'visualizer' ) ) ); + } + + $image_data = $_POST['image']; + $model = isset( $_POST['model'] ) ? sanitize_text_field( $_POST['model'] ) : 'openai'; + + error_log( 'Visualizer AI: Model: ' . $model ); + error_log( 'Visualizer AI: Image data length: ' . strlen( $image_data ) ); + + // Analyze image using AI vision + $result = $this->_analyzeChartImageWithAI( $model, $image_data ); + + if ( is_wp_error( $result ) ) { + error_log( 'Visualizer AI: Error: ' . $result->get_error_message() ); + ob_end_clean(); + wp_send_json_error( array( 'message' => $result->get_error_message() ) ); + } + + error_log( 'Visualizer AI: Image analysis success' ); + ob_end_clean(); + wp_send_json_success( $result ); + } + + /** + * Calls the appropriate AI model API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $model The AI model to use. + * @param string $prompt The user prompt. + * @param string $chart_type The chart type. + * @param string $chart_library The chart library (Google Charts or ChartJS). + * @param array $chat_history Previous conversation history. + * @param string $current_config Current manual configuration. + * + * @return array|WP_Error The response with message and optional configuration. + */ + private function _callAIModel( $model, $prompt, $chart_type, $chart_library = 'Google Charts', $chat_history = array(), $current_config = '' ) { + switch ( $model ) { + case 'openai': + return $this->_callOpenAI( $prompt, $chart_type, $chart_library, $chat_history, $current_config ); + case 'gemini': + return $this->_callGemini( $prompt, $chart_type, $chart_library, $chat_history, $current_config ); + case 'claude': + return $this->_callClaude( $prompt, $chart_type, $chart_library, $chat_history, $current_config ); + default: + return new WP_Error( 'invalid_model', esc_html__( 'Invalid AI model selected.', 'visualizer' ) ); + } + } + + /** + * Creates the system prompt for AI models. + * + * @since 3.12.0 + * + * @access private + * + * @param string $chart_type The chart type. + * @param string $chart_library The chart library (Google Charts or ChartJS). + * + * @return string The system prompt. + */ + private function _createSystemPrompt( $chart_type, $chart_library = 'Google Charts' ) { + $chart_options = $this->_getChartTypeOptions( $chart_type, $chart_library ); + $library_name = strtolower( $chart_library ) === 'chartjs' ? 'Chart.js' : 'Google Charts'; + + if ( strtolower( $chart_library ) === 'chartjs' ) { + return 'You are a helpful Chart.js (ChartJS) v3+ API expert assistant. You help users customize their ' . $chart_type . ' charts through conversation. + +IMPORTANT CHARTJS STRUCTURE: +Chart.js uses a specific configuration structure. You MUST follow these rules: + +1. PLUGINS go under "plugins" object: + - legend: plugins.legend + - title: plugins.title + - tooltip: plugins.tooltip + Example: {"plugins": {"legend": {"display": true, "position": "bottom"}}} + +2. SCALES go under "scales" object: + - y-axis: scales.y + - x-axis: scales.x + Example: {"scales": {"y": {"beginAtZero": true}}} + +3. DATASET PROPERTIES go at root level (these configure data appearance): + - backgroundColor + - borderColor + - borderWidth + Example: {"backgroundColor": ["#e74c3c", "#3498db"], "borderWidth": 2} + +RESPONSE FORMAT: +When providing configuration, structure your response like this: +[Your explanation here] + +JSON_START +{"property": "value"} +JSON_END + +Example - Configuring legend: +I\'ll move the legend to the right side with larger red text. + +JSON_START +{"plugins": {"legend": {"position": "right", "labels": {"color": "red", "font": {"size": 14}}}}} +JSON_END + +' . $chart_options . ' + +Remember: Be conversational, provide context, and only include the properties that need to change!'; + } + + return 'You are a helpful ' . $library_name . ' API expert assistant. You help users customize their ' . $chart_type . ' charts through conversation. + +IMPORTANT INSTRUCTIONS: +1. You are chatting with a user who wants to customize their chart. Be friendly, conversational, and helpful. +2. When the user asks what they can customize, provide specific suggestions for ' . $chart_type . ' charts. +3. When the user wants to make changes, provide the configuration in TWO parts: + - First, explain what you\'re doing in plain English + - Then, provide ONLY the JSON configuration needed (no markdown, no code blocks, just the raw JSON object) +4. IMPORTANT: Only include the specific properties being changed. Do not include the entire configuration. +5. For ' . $chart_type . ' charts, these are the most useful customization options: +' . $chart_options . ' + +RESPONSE FORMAT: +When providing configuration, structure your response like this: +[Your explanation here] + +JSON_START +{"property": "value"} +JSON_END + +Example: +I\'ll make the pie slices use vibrant colors and add a legend on the right side. + +JSON_START +{"colors": ["#e74c3c", "#3498db", "#2ecc71", "#f39c12"], "legend": {"position": "right"}} +JSON_END + +Remember: Be conversational, provide context, and only include the properties that need to change!'; + } + + /** + * Gets chart-specific customization options. + * + * @since 3.12.0 + * + * @access private + * + * @param string $chart_type The chart type. + * @param string $chart_library The chart library. + * + * @return string Chart-specific options description. + */ + private function _getChartTypeOptions( $chart_type, $chart_library = 'Google Charts' ) { + // Return ChartJS options if using ChartJS library + if ( strtolower( $chart_library ) === 'chartjs' ) { + return $this->_getChartJSOptions( $chart_type ); + } + + // Return Google Charts options (default) + $options = array( + 'pie' => ' + - colors: Array of colors for pie slices ["#e74c3c", "#3498db", "#2ecc71"] + - pieHole: Number 0-1 for donut chart (0.4 makes a donut) + - pieSliceText: "percentage", "value", "label", or "none" + - slices: Configure individual slices {0: {offset: 0.1, color: "#e74c3c"}} + - is3D: true/false for 3D effect + - legend: {position: "right", alignment: "center", textStyle: {color: "#000", fontSize: 12}} + - chartArea: {width: "80%", height: "80%"} + - backgroundColor: "#ffffff" or {fill: "#f0f0f0"} + - pieSliceBorderColor: "#ffffff" + - pieSliceTextStyle: {color: "#000", fontSize: 14}', + + 'line' => ' + - colors: Array of line colors ["#e74c3c", "#3498db", "#2ecc71"] + - curveType: "none" or "function" (for smooth curves) + - lineWidth: Number (default 2) + - pointSize: Number (default 0, size of data points) + - vAxis: {title: "Y Axis", minValue: 0, maxValue: 100, ticks: [0, 25, 50, 75, 100], textStyle: {color: "#000"}} + - hAxis: {title: "X Axis", slantedText: true, textStyle: {color: "#000"}} + - legend: {position: "bottom", alignment: "center"} + - series: {0: {lineWidth: 5}, 1: {lineDashStyle: [4, 4]}} + - chartArea: {width: "80%", height: "70%"} + - backgroundColor: "#ffffff"', + + 'bar' => ' + - colors: Array of bar colors ["#e74c3c", "#3498db"] + - isStacked: true/false or "percent" or "relative" + - vAxis: {title: "Categories", textStyle: {color: "#000", fontSize: 12}} + - hAxis: {title: "Values", minValue: 0, ticks: [0, 10, 20, 30]} + - legend: {position: "top"} + - bar: {groupWidth: "75%"} + - chartArea: {width: "70%", height: "80%"}', + + 'column' => ' + - colors: Array of column colors ["#e74c3c", "#3498db"] + - isStacked: true/false or "percent" + - vAxis: {title: "Values", minValue: 0, gridlines: {color: "#ccc"}} + - hAxis: {title: "Categories", slantedText: true} + - legend: {position: "top"} + - bar: {groupWidth: "75%"} + - chartArea: {width: "80%", height: "70%"}', + + 'area' => ' + - colors: Array of area colors ["#e74c3c", "#3498db"] + - isStacked: true/false or "percent" + - areaOpacity: Number 0-1 (default 0.3) + - vAxis: {title: "Values", minValue: 0} + - hAxis: {title: "Time"} + - legend: {position: "bottom"} + - chartArea: {width: "80%", height: "70%"}', + ); + + return isset( $options[ $chart_type ] ) ? $options[ $chart_type ] : $options['line']; + } + + /** + * Gets Chart.js-specific customization options. + * + * @since 3.12.0 + * + * @access private + * + * @param string $chart_type The chart type. + * + * @return string Chart.js-specific options description. + */ + private function _getChartJSOptions( $chart_type ) { + $options = array( + 'pie' => ' +COMMON CUSTOMIZATIONS FOR PIE CHARTS: + +Legend (goes under plugins.legend): +{"plugins": {"legend": {"display": true, "position": "top|bottom|left|right", "labels": {"color": "red", "font": {"size": 14, "family": "Arial", "weight": "bold"}}}}} + +Title (goes under plugins.title): +{"plugins": {"title": {"display": true, "text": "My Chart Title", "color": "#333", "font": {"size": 18}}}} + +Colors (dataset properties at root): +{"backgroundColor": ["#e74c3c", "#3498db", "#2ecc71", "#f39c12"], "borderColor": "#fff", "borderWidth": 2} + +Donut hole (dataset property): +{"cutout": "50%"} - creates donut with 50% center cutout + +Chart size (dataset property): +{"radius": "90%"} - controls pie size (percentage of canvas)', + + 'doughnut' => ' +COMMON CUSTOMIZATIONS FOR DOUGHNUT CHARTS: + +Legend (goes under plugins.legend): +{"plugins": {"legend": {"display": true, "position": "top|bottom|left|right", "labels": {"color": "red", "font": {"size": 14}}}}} + +Title (goes under plugins.title): +{"plugins": {"title": {"display": true, "text": "My Chart Title"}}} + +Colors (dataset properties at root): +{"backgroundColor": ["#e74c3c", "#3498db", "#2ecc71"], "borderColor": "#fff", "borderWidth": 2} + +Donut size (dataset property): +{"cutout": "70%"} - larger number = bigger hole', + + 'line' => ' +COMMON CUSTOMIZATIONS FOR LINE CHARTS: + +Legend (goes under plugins.legend): +{"plugins": {"legend": {"display": true, "position": "bottom", "labels": {"color": "#666", "font": {"size": 12}}}}} + +Y-Axis (goes under scales.y): +{"scales": {"y": {"beginAtZero": true, "title": {"display": true, "text": "Values"}, "ticks": {"color": "#666"}, "grid": {"color": "#e0e0e0"}}}} + +X-Axis (goes under scales.x): +{"scales": {"x": {"title": {"display": true, "text": "Time"}, "ticks": {"color": "#666"}}}} + +Line appearance (dataset properties at root): +{"borderColor": "#e74c3c", "backgroundColor": "rgba(231, 76, 60, 0.2)", "borderWidth": 3, "tension": 0.4, "fill": true, "pointRadius": 4, "pointBackgroundColor": "#e74c3c"} + +tension: 0 = straight lines, 0.4 = smooth curves', + + 'bar' => ' +COMMON CUSTOMIZATIONS FOR BAR CHARTS: + +Legend (goes under plugins.legend): +{"plugins": {"legend": {"display": true, "position": "top"}}} + +Y-Axis (goes under scales.y): +{"scales": {"y": {"beginAtZero": true, "title": {"display": true, "text": "Values"}, "ticks": {"color": "#666"}}}} + +X-Axis (goes under scales.x): +{"scales": {"x": {"title": {"display": true, "text": "Categories"}}}} + +Bar appearance (dataset properties at root): +{"backgroundColor": ["#e74c3c", "#3498db", "#2ecc71"], "borderColor": "#333", "borderWidth": 1, "borderRadius": 5} + +For horizontal bars (dataset property): +{"indexAxis": "y"}', + + 'horizontalBar' => ' +Same as bar chart. Use {"indexAxis": "y"} to make bars horizontal.', + ); + + return isset( $options[ $chart_type ] ) ? $options[ $chart_type ] : $options['line']; + } + + /** + * Calls OpenAI API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $prompt The user prompt. + * @param string $chart_type The chart type. + * @param string $chart_library The chart library. + * @param array $chat_history Previous conversation history. + * @param string $current_config Current manual configuration. + * + * @return array|WP_Error The response with message and optional configuration. + */ + private function _callOpenAI( $prompt, $chart_type, $chart_library = 'Google Charts', $chat_history = array(), $current_config = '' ) { + $api_key = get_option( 'visualizer_openai_api_key', '' ); + + if ( empty( $api_key ) ) { + error_log( 'Visualizer AI: OpenAI API key not configured' ); + return new WP_Error( 'no_api_key', esc_html__( 'OpenAI API key is not configured.', 'visualizer' ) ); + } + + // Build messages array + $messages = array( + array( + 'role' => 'system', + 'content' => $this->_createSystemPrompt( $chart_type, $chart_library ), + ), + ); + + // Add context about current configuration if exists + if ( ! empty( $current_config ) ) { + $messages[] = array( + 'role' => 'system', + 'content' => 'The user currently has this configuration: ' . $current_config, + ); + } + + // Add chat history + if ( ! empty( $chat_history ) ) { + foreach ( $chat_history as $msg ) { + $messages[] = array( + 'role' => $msg['role'], + 'content' => $msg['content'], + ); + } + } + + // Add current prompt + $messages[] = array( + 'role' => 'user', + 'content' => $prompt, + ); + + $request_body = array( + 'model' => 'gpt-4', + 'messages' => $messages, + 'temperature' => 0.7, + ); + + $response = wp_remote_post( + 'https://api.openai.com/v1/chat/completions', + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $request_body ), + 'timeout' => 30, + ) + ); + + if ( is_wp_error( $response ) ) { + error_log( 'Visualizer AI: OpenAI HTTP Error: ' . $response->get_error_message() ); + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + + error_log( 'Visualizer AI: OpenAI Response Code: ' . $response_code ); + + $body = json_decode( $response_body, true ); + + if ( isset( $body['error'] ) ) { + error_log( 'Visualizer AI: OpenAI API Error: ' . $body['error']['message'] ); + return new WP_Error( 'api_error', $body['error']['message'] ); + } + + if ( ! isset( $body['choices'][0]['message']['content'] ) ) { + error_log( 'Visualizer AI: Invalid OpenAI response structure' ); + return new WP_Error( 'invalid_response', esc_html__( 'Invalid response from OpenAI.', 'visualizer' ) ); + } + + $content = $body['choices'][0]['message']['content']; + error_log( 'Visualizer AI: OpenAI Content: ' . $content ); + + return $this->_parseResponse( $content ); + } + + /** + * Calls Google Gemini API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $prompt The user prompt. + * @param string $chart_type The chart type. + * @param string $chart_library The chart library. + * @param array $chat_history Previous conversation history. + * @param string $current_config Current manual configuration. + * + * @return array|WP_Error The response with message and optional configuration. + */ + private function _callGemini( $prompt, $chart_type, $chart_library = 'Google Charts', $chat_history = array(), $current_config = '' ) { + $api_key = get_option( 'visualizer_gemini_api_key', '' ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'no_api_key', esc_html__( 'Gemini API key is not configured.', 'visualizer' ) ); + } + + // Build the full prompt with context + $full_prompt = $this->_createSystemPrompt( $chart_type, $chart_library ) . "\n\n"; + + if ( ! empty( $current_config ) ) { + $full_prompt .= 'Current configuration: ' . $current_config . "\n\n"; + } + + if ( ! empty( $chat_history ) ) { + foreach ( $chat_history as $msg ) { + $role = $msg['role'] === 'user' ? 'User' : 'Assistant'; + $full_prompt .= $role . ': ' . $msg['content'] . "\n\n"; + } + } + + $full_prompt .= 'User: ' . $prompt; + + $response = wp_remote_post( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=' . $api_key, + array( + 'headers' => array( + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'contents' => array( + array( + 'parts' => array( + array( 'text' => $full_prompt ), + ), + ), + ), + 'generationConfig' => array( + 'temperature' => 0.7, + ), + ) + ), + 'timeout' => 30, + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error'] ) ) { + return new WP_Error( 'api_error', $body['error']['message'] ); + } + + if ( ! isset( $body['candidates'][0]['content']['parts'][0]['text'] ) ) { + return new WP_Error( 'invalid_response', esc_html__( 'Invalid response from Gemini.', 'visualizer' ) ); + } + + return $this->_parseResponse( $body['candidates'][0]['content']['parts'][0]['text'] ); + } + + /** + * Calls Anthropic Claude API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $prompt The user prompt. + * @param string $chart_type The chart type. + * @param string $chart_library The chart library. + * @param array $chat_history Previous conversation history. + * @param string $current_config Current manual configuration. + * + * @return array|WP_Error The response with message and optional configuration. + */ + private function _callClaude( $prompt, $chart_type, $chart_library = 'Google Charts', $chat_history = array(), $current_config = '' ) { + $api_key = get_option( 'visualizer_claude_api_key', '' ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'no_api_key', esc_html__( 'Claude API key is not configured.', 'visualizer' ) ); + } + + // Build system prompt with context + $system_prompt = $this->_createSystemPrompt( $chart_type, $chart_library ); + if ( ! empty( $current_config ) ) { + $system_prompt .= "\n\nCurrent configuration: " . $current_config; + } + + // Build messages array + $messages = array(); + + // Add chat history + if ( ! empty( $chat_history ) ) { + foreach ( $chat_history as $msg ) { + $messages[] = array( + 'role' => $msg['role'], + 'content' => $msg['content'], + ); + } + } + + // Add current prompt + $messages[] = array( + 'role' => 'user', + 'content' => $prompt, + ); + + $response = wp_remote_post( + 'https://api.anthropic.com/v1/messages', + array( + 'headers' => array( + 'x-api-key' => $api_key, + 'anthropic-version' => '2023-06-01', + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'model' => 'claude-3-5-sonnet-20241022', + 'max_tokens' => 1024, + 'system' => $system_prompt, + 'messages' => $messages, + ) + ), + 'timeout' => 30, + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error'] ) ) { + return new WP_Error( 'api_error', $body['error']['message'] ); + } + + if ( ! isset( $body['content'][0]['text'] ) ) { + return new WP_Error( 'invalid_response', esc_html__( 'Invalid response from Claude.', 'visualizer' ) ); + } + + return $this->_parseResponse( $body['content'][0]['text'] ); + } + + /** + * Parses AI response to extract message and configuration. + * + * @since 3.12.0 + * + * @access private + * + * @param string $text The AI response text. + * + * @return array The parsed response with message and optional configuration. + */ + private function _parseResponse( $text ) { + error_log( 'Visualizer AI: Parsing response: ' . substr( $text, 0, 200 ) . '...' ); + + $result = array( + 'message' => '', + 'configuration' => null, + ); + + // Check for JSON_START and JSON_END markers + if ( preg_match( '/JSON_START\s*(.*?)\s*JSON_END/s', $text, $matches ) ) { + error_log( 'Visualizer AI: Found JSON markers' ); + + // Extract message (everything before JSON_START) + $message = preg_replace( '/JSON_START.*?JSON_END/s', '', $text ); + $result['message'] = trim( $message ); + + // Extract and validate JSON + $json_text = trim( $matches[1] ); + json_decode( $json_text ); + + if ( json_last_error() === JSON_ERROR_NONE ) { + $result['configuration'] = $json_text; + error_log( 'Visualizer AI: Successfully extracted JSON configuration' ); + } else { + error_log( 'Visualizer AI: JSON validation error: ' . json_last_error_msg() ); + $result['message'] .= "\n\n(Note: I tried to provide a configuration, but it had formatting issues.)"; + } + } else { + // No JSON markers, might be a conversational response or JSON in markdown + error_log( 'Visualizer AI: No JSON markers found, checking for JSON object' ); + + // Try to find JSON object in text + if ( preg_match( '/\{[\s\S]*\}/U', $text, $json_matches ) ) { + $json_text = $json_matches[0]; + json_decode( $json_text ); + + if ( json_last_error() === JSON_ERROR_NONE ) { + // Remove the JSON from the message + $message = str_replace( $json_text, '', $text ); + // Also remove markdown code blocks + $message = preg_replace( '/```json\s*/', '', $message ); + $message = preg_replace( '/```\s*/', '', $message ); + + $result['message'] = trim( $message ); + $result['configuration'] = $json_text; + error_log( 'Visualizer AI: Extracted JSON from text' ); + } else { + // No valid JSON, treat entire response as message + $result['message'] = trim( $text ); + error_log( 'Visualizer AI: No valid JSON found, treating as pure message' ); + } + } else { + // No JSON at all, pure conversational response + $result['message'] = trim( $text ); + error_log( 'Visualizer AI: Pure conversational response, no JSON' ); + } + } + + // If message is empty, use a default + if ( empty( $result['message'] ) && ! empty( $result['configuration'] ) ) { + $result['message'] = 'Here\'s the configuration you requested:'; + } + + return $result; + } + + /** + * Analyzes chart image using AI vision. + * + * @since 3.12.0 + * + * @access private + * + * @param string $model The AI model to use. + * @param string $image_data Base64 encoded image data. + * + * @return array|WP_Error The analysis result or WP_Error on failure. + */ + private function _analyzeChartImageWithAI( $model, $image_data ) { + error_log( 'Visualizer AI: Analyzing image with model: ' . $model ); + + switch ( $model ) { + case 'openai': + return $this->_analyzeImageWithOpenAI( $image_data ); + case 'gemini': + return $this->_analyzeImageWithGemini( $image_data ); + case 'claude': + return $this->_analyzeImageWithClaude( $image_data ); + default: + return new WP_Error( 'invalid_model', esc_html__( 'Invalid AI model selected.', 'visualizer' ) ); + } + } + + /** + * Analyzes chart image using OpenAI Vision API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $image_data Base64 encoded image data. + * + * @return array|WP_Error The analysis result or WP_Error on failure. + */ + private function _analyzeImageWithOpenAI( $image_data ) { + error_log( 'Visualizer AI: Analyzing image with OpenAI Vision' ); + + $api_key = get_option( 'visualizer_openai_api_key', '' ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'no_api_key', esc_html__( 'OpenAI API key is not configured.', 'visualizer' ) ); + } + + $prompt = 'You are a data visualization expert helping to extract and recreate chart data. Analyze this chart image to extract all information needed to recreate it accurately. + +Your task is to analyze the visual chart and provide structured data that can be used to recreate it. This is for data extraction and visualization purposes. + +IMPORTANT: Pay careful attention to extracting accurate data values. Study the Y-axis scale and gridlines carefully. If a bar or line point falls between gridlines, INTERPOLATE the value - do not round to the nearest gridline. Example: If gridlines are at 10 and 20, and a bar reaches 60% between them, the value is 16. + +STEP 1: IDENTIFY CHART TYPE +Examine the chart carefully to determine the correct type. + +SUPPORTED CHART TYPES: +- tabular (table with rows and columns of data) +- pie (circular chart with slices, can be donut with hole in center) +- line (data points connected by lines) +- bar (horizontal bars) +- column (vertical bars/columns) +- area (filled area under line) +- scatter (individual data points, no connecting lines) +- bubble (scatter with varying point sizes) +- geo (geographic/map visualization) +- gauge (meter/speedometer style) +- candlestick (financial chart with open/high/low/close) +- timeline (horizontal timeline events) +- combo (CRITICAL: chart with MULTIPLE visualization types - e.g., columns AND lines together) +- radar (spider/radar chart) +- polarArea (polar area chart) + +CRITICAL - COMBO CHART DETECTION: +If you see BOTH columns/bars AND lines in the SAME chart, this is a COMBO chart, NOT a column or line chart! +Example: Sales shown as columns + Average shown as a line = COMBO chart +Look for: Multiple data series displayed with different visual types (some as bars, some as lines) + +STEP 2: VISUAL LAYOUT ANALYSIS + +Look carefully at WHERE the legend is located (right/bottom/top/left/none) and extract the exact title text. + +STEP 3: CHART-TYPE-SPECIFIC ANALYSIS + +For PIE CHARTS: +- Extract colors for each slice in order +- Check if percentages or labels shown on slices +- Detect 2D vs 3D, donut style +- Note legend position + +For COMBO CHARTS: +- CRITICAL: Identify which data series should be columns and which should be lines +- Set "seriesType": "bars" as default +- Use "series": {1: {"type": "line"}} to specify which series differ from default +- Example: First series columns, second series line + +For BAR/COLUMN/LINE CHARTS: +- Extract colors for each data series +- Note axis titles and gridline visibility +- Check for data labels on bars or points + +STEP 4: COLOR EXTRACTION +Extract colors in exact order. Use hex codes (e.g., #3366CC, #DC3912, #FF9900). + +STEP 5: DATA EXTRACTION + +Extract data values CAREFULLY by reading the Y-axis scale and gridlines. INTERPOLATE values between gridlines - do not round. Example: If gridlines are at 10 and 20, and a bar reaches 60% between them, use 16 not 10 or 20. Values should be accurate within 5-10% of visual appearance. + +CSV DATA FORMAT (MANDATORY): +- Row 1: Column headers +- Row 2: Data types (string, number, date, datetime, boolean, timeofday) +- Row 3+: Actual data values + +Example for PIE: +Category,Value +string,number +Product A,35 +Product B,25 +Product C,40 + +Example for LINE/COLUMN: +Month,Sales,Expenses +string,number,number +Jan,1000,800 +Feb,1200,900 + +Example for COMBO (columns + lines): +Month,Sales,Average +string,number,number +Jan,1000,850 +Feb,1200,900 +(Note: In styling, specify which series is line vs column using "series" property) + +Example with ANNOTATIONS (data labels on points): +Month,Sales,Annotation +string,number,string +Jan,1000,Peak +Feb,800,null +Mar,1200,Record + + +STEP 6: FORMAT YOUR RESPONSE + +FORMAT YOUR RESPONSE EXACTLY AS FOLLOWS: +CHART_TYPE: [pie/line/bar/column/area/scatter/etc] +TITLE: [exact title text or "Untitled" if none] +CSV_DATA: +[csv data with headers, data types on row 2, then actual data] +STYLING: +[VALID JSON - see structure below] + +STYLING JSON - INCLUDE ALL APPLICABLE PROPERTIES: + +For PIE CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2", "#color3"], + "legend": {"position": "bottom"}, + "pieSliceText": "percentage", + "pieSliceTextStyle": {"fontSize": 12}, + "pieHole": 0, + "is3D": false, + "chartArea": {"width": "90%", "height": "80%"} +} + +For BAR/COLUMN CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "top"}, + "vAxis": {"title": "Y Axis Title", "gridlines": {"color": "#e0e0e0"}}, + "hAxis": {"title": "X Axis Title"}, + "isStacked": false, + "chartArea": {"width": "70%", "height": "70%"} +} + +For LINE/AREA CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "right"}, + "vAxis": {"title": "Y Axis Title"}, + "hAxis": {"title": "X Axis Title"}, + "lineWidth": 2, + "pointSize": 5, + "chartArea": {"width": "80%", "height": "70%"} +} + +For COMBO CHARTS (columns + lines together): +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "bottom"}, + "seriesType": "bars", + "series": { + "1": {"type": "line", "lineWidth": 2, "pointSize": 4} + }, + "vAxis": {"title": "Y Axis Title"}, + "hAxis": {"title": "X Axis Title"}, + "chartArea": {"width": "80%", "height": "70%"} +} + +For BUBBLE CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1"], + "legend": {"position": "right"}, + "bubble": {"textStyle": {"fontSize": 11}}, + "vAxis": {"title": "Y Axis"}, + "hAxis": {"title": "X Axis"} +} + +For GEO CHARTS: +{ + "title": "Exact Title From Image", + "colorAxis": {"colors": ["#e0e0e0", "#0066cc"]}, + "region": "world" +} + +For GAUGE CHARTS: +{ + "title": "Exact Title From Image", + "redFrom": 90, + "redTo": 100, + "yellowFrom": 75, + "yellowTo": 90, + "greenFrom": 0, + "greenTo": 75, + "minorTicks": 5 +} + +For SCATTER CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1"], + "pointSize": 3, + "vAxis": {"title": "Y Axis"}, + "hAxis": {"title": "X Axis"} +} + +CRITICAL RULES: +1. CHART TYPE: If you see columns AND lines together, use "combo" not "column"! +2. DATA VALUES: Interpolate between gridlines, do not round. Must be accurate within 5-10%. +3. LEGEND POSITION: Check carefully - right/left/top/bottom? +4. COLORS: Extract in exact order, use hex codes +5. STYLING must be valid JSON with double quotes +6. For combo charts: Use "seriesType" and "series" object to specify types'; + + $messages = array( + array( + 'role' => 'user', + 'content' => array( + array( + 'type' => 'text', + 'text' => $prompt, + ), + array( + 'type' => 'image_url', + 'image_url' => array( + 'url' => $image_data, + ), + ), + ), + ), + ); + + $request_body = array( + 'model' => 'gpt-4o', + 'messages' => $messages, + 'max_tokens' => 2000, + ); + + $response = wp_remote_post( + 'https://api.openai.com/v1/chat/completions', + array( + 'headers' => array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $request_body ), + 'timeout' => 60, + ) + ); + + if ( is_wp_error( $response ) ) { + error_log( 'Visualizer AI: OpenAI Vision HTTP Error: ' . $response->get_error_message() ); + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + + error_log( 'Visualizer AI: OpenAI Vision Response Code: ' . $response_code ); + + $body = json_decode( $response_body, true ); + + if ( isset( $body['error'] ) ) { + error_log( 'Visualizer AI: OpenAI Vision API Error: ' . $body['error']['message'] ); + return new WP_Error( 'api_error', $body['error']['message'] ); + } + + if ( ! isset( $body['choices'][0]['message']['content'] ) ) { + return new WP_Error( 'invalid_response', esc_html__( 'Invalid response from OpenAI Vision.', 'visualizer' ) ); + } + + $content = $body['choices'][0]['message']['content']; + error_log( 'Visualizer AI: OpenAI Vision Content: ' . substr( $content, 0, 500 ) ); + + return $this->_parseImageAnalysisResponse( $content ); + } + + /** + * Analyzes chart image using Google Gemini Vision API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $image_data Base64 encoded image data. + * + * @return array|WP_Error The analysis result or WP_Error on failure. + */ + private function _analyzeImageWithGemini( $image_data ) { + error_log( 'Visualizer AI: Analyzing image with Gemini Vision' ); + + $api_key = get_option( 'visualizer_gemini_api_key', '' ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'no_api_key', esc_html__( 'Google Gemini API key is not configured.', 'visualizer' ) ); + } + + // Extract base64 data from data URL + $image_parts = explode( ',', $image_data ); + $base64_image = isset( $image_parts[1] ) ? $image_parts[1] : $image_data; + + $prompt = 'You are a data visualization expert helping to extract and recreate chart data. Analyze this chart image to extract all information needed to recreate it accurately. + +Your task is to analyze the visual chart and provide structured data that can be used to recreate it. This is for data extraction and visualization purposes. + +IMPORTANT: Pay careful attention to extracting accurate data values. Study the Y-axis scale and gridlines carefully. If a bar or line point falls between gridlines, INTERPOLATE the value - do not round to the nearest gridline. Example: If gridlines are at 10 and 20, and a bar reaches 60% between them, the value is 16. + +STEP 1: IDENTIFY CHART TYPE +Examine the chart carefully to determine the correct type. + +SUPPORTED CHART TYPES: +- tabular (table with rows and columns of data) +- pie (circular chart with slices, can be donut with hole in center) +- line (data points connected by lines) +- bar (horizontal bars) +- column (vertical bars/columns) +- area (filled area under line) +- scatter (individual data points, no connecting lines) +- bubble (scatter with varying point sizes) +- geo (geographic/map visualization) +- gauge (meter/speedometer style) +- candlestick (financial chart with open/high/low/close) +- timeline (horizontal timeline events) +- combo (CRITICAL: chart with MULTIPLE visualization types - e.g., columns AND lines together) +- radar (spider/radar chart) +- polarArea (polar area chart) + +CRITICAL - COMBO CHART DETECTION: +If you see BOTH columns/bars AND lines in the SAME chart, this is a COMBO chart, NOT a column or line chart! +Example: Sales shown as columns + Average shown as a line = COMBO chart +Look for: Multiple data series displayed with different visual types (some as bars, some as lines) + +STEP 2: VISUAL LAYOUT ANALYSIS + +Look carefully at WHERE the legend is located (right/bottom/top/left/none) and extract the exact title text. + +STEP 3: CHART-TYPE-SPECIFIC ANALYSIS + +For PIE CHARTS: +- Extract colors for each slice in order +- Check if percentages or labels shown on slices +- Detect 2D vs 3D, donut style +- Note legend position + +For COMBO CHARTS: +- CRITICAL: Identify which data series should be columns and which should be lines +- Set "seriesType": "bars" as default +- Use "series": {1: {"type": "line"}} to specify which series differ from default +- Example: First series columns, second series line + +For BAR/COLUMN/LINE CHARTS: +- Extract colors for each data series +- Note axis titles and gridline visibility +- Check for data labels on bars or points + +STEP 4: COLOR EXTRACTION +Extract colors in exact order. Use hex codes (e.g., #3366CC, #DC3912, #FF9900). + +STEP 5: DATA EXTRACTION + +Extract data values CAREFULLY by reading the Y-axis scale and gridlines. INTERPOLATE values between gridlines - do not round. Example: If gridlines are at 10 and 20, and a bar reaches 60% between them, use 16 not 10 or 20. Values should be accurate within 5-10% of visual appearance. + +CSV DATA FORMAT (MANDATORY): +- Row 1: Column headers +- Row 2: Data types (string, number, date, datetime, boolean, timeofday) +- Row 3+: Actual data values + +Example for PIE: +Category,Value +string,number +Product A,35 +Product B,25 +Product C,40 + +Example for LINE/COLUMN: +Month,Sales,Expenses +string,number,number +Jan,1000,800 +Feb,1200,900 + +Example for COMBO (columns + lines): +Month,Sales,Average +string,number,number +Jan,1000,850 +Feb,1200,900 +(Note: In styling, specify which series is line vs column using "series" property) + +Example with ANNOTATIONS (data labels on points): +Month,Sales,Annotation +string,number,string +Jan,1000,Peak +Feb,800,null +Mar,1200,Record + + +STEP 6: FORMAT YOUR RESPONSE + +FORMAT YOUR RESPONSE EXACTLY AS FOLLOWS: +CHART_TYPE: [pie/line/bar/column/area/scatter/etc] +TITLE: [exact title text or "Untitled" if none] +CSV_DATA: +[csv data with headers, data types on row 2, then actual data] +STYLING: +[VALID JSON - see structure below] + +STYLING JSON - INCLUDE ALL APPLICABLE PROPERTIES: + +For PIE CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2", "#color3"], + "legend": {"position": "bottom"}, + "pieSliceText": "percentage", + "pieSliceTextStyle": {"fontSize": 12}, + "pieHole": 0, + "is3D": false, + "chartArea": {"width": "90%", "height": "80%"} +} + +For BAR/COLUMN CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "top"}, + "vAxis": {"title": "Y Axis Title", "gridlines": {"color": "#e0e0e0"}}, + "hAxis": {"title": "X Axis Title"}, + "isStacked": false, + "chartArea": {"width": "70%", "height": "70%"} +} + +For LINE/AREA CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "right"}, + "vAxis": {"title": "Y Axis Title"}, + "hAxis": {"title": "X Axis Title"}, + "lineWidth": 2, + "pointSize": 5, + "chartArea": {"width": "80%", "height": "70%"} +} + +For COMBO CHARTS (columns + lines together): +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "bottom"}, + "seriesType": "bars", + "series": { + "1": {"type": "line", "lineWidth": 2, "pointSize": 4} + }, + "vAxis": {"title": "Y Axis Title"}, + "hAxis": {"title": "X Axis Title"}, + "chartArea": {"width": "80%", "height": "70%"} +} + +For BUBBLE CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1"], + "legend": {"position": "right"}, + "bubble": {"textStyle": {"fontSize": 11}}, + "vAxis": {"title": "Y Axis"}, + "hAxis": {"title": "X Axis"} +} + +For GEO CHARTS: +{ + "title": "Exact Title From Image", + "colorAxis": {"colors": ["#e0e0e0", "#0066cc"]}, + "region": "world" +} + +For GAUGE CHARTS: +{ + "title": "Exact Title From Image", + "redFrom": 90, + "redTo": 100, + "yellowFrom": 75, + "yellowTo": 90, + "greenFrom": 0, + "greenTo": 75, + "minorTicks": 5 +} + +For SCATTER CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1"], + "pointSize": 3, + "vAxis": {"title": "Y Axis"}, + "hAxis": {"title": "X Axis"} +} + +CRITICAL RULES: +1. CHART TYPE: If you see columns AND lines together, use "combo" not "column"! +2. DATA VALUES: Interpolate between gridlines, do not round. Must be accurate within 5-10%. +3. LEGEND POSITION: Check carefully - right/left/top/bottom? +4. COLORS: Extract in exact order, use hex codes +5. STYLING must be valid JSON with double quotes +6. For combo charts: Use "seriesType" and "series" object to specify types'; + + $request_body = array( + 'contents' => array( + array( + 'parts' => array( + array( 'text' => $prompt ), + array( + 'inline_data' => array( + 'mime_type' => 'image/jpeg', + 'data' => $base64_image, + ), + ), + ), + ), + ), + ); + + $response = wp_remote_post( + 'https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key=' . $api_key, + array( + 'headers' => array( + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $request_body ), + 'timeout' => 60, + ) + ); + + if ( is_wp_error( $response ) ) { + error_log( 'Visualizer AI: Gemini Vision HTTP Error: ' . $response->get_error_message() ); + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + + error_log( 'Visualizer AI: Gemini Vision Response Code: ' . $response_code ); + + $body = json_decode( $response_body, true ); + + if ( isset( $body['error'] ) ) { + error_log( 'Visualizer AI: Gemini Vision API Error: ' . $body['error']['message'] ); + return new WP_Error( 'api_error', $body['error']['message'] ); + } + + if ( ! isset( $body['candidates'][0]['content']['parts'][0]['text'] ) ) { + return new WP_Error( 'invalid_response', esc_html__( 'Invalid response from Gemini Vision.', 'visualizer' ) ); + } + + $content = $body['candidates'][0]['content']['parts'][0]['text']; + error_log( 'Visualizer AI: Gemini Vision Content: ' . substr( $content, 0, 500 ) ); + + return $this->_parseImageAnalysisResponse( $content ); + } + + /** + * Analyzes chart image using Anthropic Claude Vision API. + * + * @since 3.12.0 + * + * @access private + * + * @param string $image_data Base64 encoded image data. + * + * @return array|WP_Error The analysis result or WP_Error on failure. + */ + private function _analyzeImageWithClaude( $image_data ) { + error_log( 'Visualizer AI: Analyzing image with Claude Vision' ); + + $api_key = get_option( 'visualizer_claude_api_key', '' ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'no_api_key', esc_html__( 'Anthropic Claude API key is not configured.', 'visualizer' ) ); + } + + // Extract base64 data and media type from data URL + $image_parts = explode( ',', $image_data ); + $base64_image = isset( $image_parts[1] ) ? $image_parts[1] : $image_data; + + // Detect media type from data URL + $media_type = 'image/jpeg'; + if ( preg_match( '/data:(image\/[^;]+)/', $image_parts[0], $matches ) ) { + $media_type = $matches[1]; + } + + $prompt = 'You are a data visualization expert helping to extract and recreate chart data. Analyze this chart image to extract all information needed to recreate it accurately. + +Your task is to analyze the visual chart and provide structured data that can be used to recreate it. This is for data extraction and visualization purposes. + +IMPORTANT: Pay careful attention to extracting accurate data values. Study the Y-axis scale and gridlines carefully. If a bar or line point falls between gridlines, INTERPOLATE the value - do not round to the nearest gridline. Example: If gridlines are at 10 and 20, and a bar reaches 60% between them, the value is 16. + +STEP 1: IDENTIFY CHART TYPE +Examine the chart carefully to determine the correct type. + +SUPPORTED CHART TYPES: +- tabular (table with rows and columns of data) +- pie (circular chart with slices, can be donut with hole in center) +- line (data points connected by lines) +- bar (horizontal bars) +- column (vertical bars/columns) +- area (filled area under line) +- scatter (individual data points, no connecting lines) +- bubble (scatter with varying point sizes) +- geo (geographic/map visualization) +- gauge (meter/speedometer style) +- candlestick (financial chart with open/high/low/close) +- timeline (horizontal timeline events) +- combo (CRITICAL: chart with MULTIPLE visualization types - e.g., columns AND lines together) +- radar (spider/radar chart) +- polarArea (polar area chart) + +CRITICAL - COMBO CHART DETECTION: +If you see BOTH columns/bars AND lines in the SAME chart, this is a COMBO chart, NOT a column or line chart! +Example: Sales shown as columns + Average shown as a line = COMBO chart +Look for: Multiple data series displayed with different visual types (some as bars, some as lines) + +STEP 2: VISUAL LAYOUT ANALYSIS + +Look carefully at WHERE the legend is located (right/bottom/top/left/none) and extract the exact title text. + +STEP 3: CHART-TYPE-SPECIFIC ANALYSIS + +For PIE CHARTS: +- Extract colors for each slice in order +- Check if percentages or labels shown on slices +- Detect 2D vs 3D, donut style +- Note legend position + +For COMBO CHARTS: +- CRITICAL: Identify which data series should be columns and which should be lines +- Set "seriesType": "bars" as default +- Use "series": {1: {"type": "line"}} to specify which series differ from default +- Example: First series columns, second series line + +For BAR/COLUMN/LINE CHARTS: +- Extract colors for each data series +- Note axis titles and gridline visibility +- Check for data labels on bars or points + +STEP 4: COLOR EXTRACTION +Extract colors in exact order. Use hex codes (e.g., #3366CC, #DC3912, #FF9900). + +STEP 5: DATA EXTRACTION + +Extract data values CAREFULLY by reading the Y-axis scale and gridlines. INTERPOLATE values between gridlines - do not round. Example: If gridlines are at 10 and 20, and a bar reaches 60% between them, use 16 not 10 or 20. Values should be accurate within 5-10% of visual appearance. + +CSV DATA FORMAT (MANDATORY): +- Row 1: Column headers +- Row 2: Data types (string, number, date, datetime, boolean, timeofday) +- Row 3+: Actual data values + +Example for PIE: +Category,Value +string,number +Product A,35 +Product B,25 +Product C,40 + +Example for LINE/COLUMN: +Month,Sales,Expenses +string,number,number +Jan,1000,800 +Feb,1200,900 + +Example for COMBO (columns + lines): +Month,Sales,Average +string,number,number +Jan,1000,850 +Feb,1200,900 +(Note: In styling, specify which series is line vs column using "series" property) + +Example with ANNOTATIONS (data labels on points): +Month,Sales,Annotation +string,number,string +Jan,1000,Peak +Feb,800,null +Mar,1200,Record + + +STEP 6: FORMAT YOUR RESPONSE + +FORMAT YOUR RESPONSE EXACTLY AS FOLLOWS: +CHART_TYPE: [pie/line/bar/column/area/scatter/etc] +TITLE: [exact title text or "Untitled" if none] +CSV_DATA: +[csv data with headers, data types on row 2, then actual data] +STYLING: +[VALID JSON - see structure below] + +STYLING JSON - INCLUDE ALL APPLICABLE PROPERTIES: + +For PIE CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2", "#color3"], + "legend": {"position": "bottom"}, + "pieSliceText": "percentage", + "pieSliceTextStyle": {"fontSize": 12}, + "pieHole": 0, + "is3D": false, + "chartArea": {"width": "90%", "height": "80%"} +} + +For BAR/COLUMN CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "top"}, + "vAxis": {"title": "Y Axis Title", "gridlines": {"color": "#e0e0e0"}}, + "hAxis": {"title": "X Axis Title"}, + "isStacked": false, + "chartArea": {"width": "70%", "height": "70%"} +} + +For LINE/AREA CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "right"}, + "vAxis": {"title": "Y Axis Title"}, + "hAxis": {"title": "X Axis Title"}, + "lineWidth": 2, + "pointSize": 5, + "chartArea": {"width": "80%", "height": "70%"} +} + +For COMBO CHARTS (columns + lines together): +{ + "title": "Exact Title From Image", + "colors": ["#color1", "#color2"], + "legend": {"position": "bottom"}, + "seriesType": "bars", + "series": { + "1": {"type": "line", "lineWidth": 2, "pointSize": 4} + }, + "vAxis": {"title": "Y Axis Title"}, + "hAxis": {"title": "X Axis Title"}, + "chartArea": {"width": "80%", "height": "70%"} +} + +For BUBBLE CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1"], + "legend": {"position": "right"}, + "bubble": {"textStyle": {"fontSize": 11}}, + "vAxis": {"title": "Y Axis"}, + "hAxis": {"title": "X Axis"} +} + +For GEO CHARTS: +{ + "title": "Exact Title From Image", + "colorAxis": {"colors": ["#e0e0e0", "#0066cc"]}, + "region": "world" +} + +For GAUGE CHARTS: +{ + "title": "Exact Title From Image", + "redFrom": 90, + "redTo": 100, + "yellowFrom": 75, + "yellowTo": 90, + "greenFrom": 0, + "greenTo": 75, + "minorTicks": 5 +} + +For SCATTER CHARTS: +{ + "title": "Exact Title From Image", + "colors": ["#color1"], + "pointSize": 3, + "vAxis": {"title": "Y Axis"}, + "hAxis": {"title": "X Axis"} +} + +CRITICAL RULES: +1. CHART TYPE: If you see columns AND lines together, use "combo" not "column"! +2. DATA VALUES: Interpolate between gridlines, do not round. Must be accurate within 5-10%. +3. LEGEND POSITION: Check carefully - right/left/top/bottom? +4. COLORS: Extract in exact order, use hex codes +5. STYLING must be valid JSON with double quotes +6. For combo charts: Use "seriesType" and "series" object to specify types'; + + $request_body = array( + 'model' => 'claude-3-5-sonnet-20241022', + 'max_tokens' => 2000, + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + array( + 'type' => 'image', + 'source' => array( + 'type' => 'base64', + 'media_type' => $media_type, + 'data' => $base64_image, + ), + ), + array( + 'type' => 'text', + 'text' => $prompt, + ), + ), + ), + ), + ); + + $response = wp_remote_post( + 'https://api.anthropic.com/v1/messages', + array( + 'headers' => array( + 'x-api-key' => $api_key, + 'anthropic-version' => '2023-06-01', + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $request_body ), + 'timeout' => 60, + ) + ); + + if ( is_wp_error( $response ) ) { + error_log( 'Visualizer AI: Claude Vision HTTP Error: ' . $response->get_error_message() ); + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + + error_log( 'Visualizer AI: Claude Vision Response Code: ' . $response_code ); + + $body = json_decode( $response_body, true ); + + if ( isset( $body['error'] ) ) { + error_log( 'Visualizer AI: Claude Vision API Error: ' . $body['error']['message'] ); + return new WP_Error( 'api_error', $body['error']['message'] ); + } + + if ( ! isset( $body['content'][0]['text'] ) ) { + return new WP_Error( 'invalid_response', esc_html__( 'Invalid response from Claude Vision.', 'visualizer' ) ); + } + + $content = $body['content'][0]['text']; + error_log( 'Visualizer AI: Claude Vision Content: ' . substr( $content, 0, 500 ) ); + + return $this->_parseImageAnalysisResponse( $content ); + } + + /** + * Parses the image analysis response from AI. + * + * @since 3.12.0 + * + * @access private + * + * @param string $text The AI response text. + * + * @return array The parsed result with chart_type, title, csv_data, and styling. + */ + private function _parseImageAnalysisResponse( $text ) { + error_log( 'Visualizer AI: Parsing image analysis response' ); + + $result = array( + 'chart_type' => '', + 'title' => '', + 'csv_data' => '', + 'styling' => '{}', + ); + + // Extract chart type + if ( preg_match( '/CHART_TYPE:\s*(.+)/i', $text, $matches ) ) { + $chart_type = strtolower( trim( $matches[1] ) ); + // Map common variations to Visualizer chart types + $type_map = array( + 'pie' => 'pie', + 'line' => 'line', + 'bar' => 'bar', + 'column' => 'column', + 'area' => 'area', + 'scatter' => 'scatter', + 'geo' => 'geo', + 'gauge' => 'gauge', + 'candlestick' => 'candlestick', + 'histogram' => 'histogram', + 'table' => 'table', + 'tabular' => 'tabular', + 'combo' => 'combo', + 'bubble' => 'bubble', + 'timeline' => 'timeline', + 'radar' => 'radar', + 'polararea' => 'polarArea', + 'polar area' => 'polarArea', + ); + $result['chart_type'] = isset( $type_map[ $chart_type ] ) ? $type_map[ $chart_type ] : 'column'; + } + + // Extract title + if ( preg_match( '/TITLE:\s*(.+)/i', $text, $matches ) ) { + $result['title'] = trim( $matches[1] ); + } + + // Extract CSV data + if ( preg_match( '/CSV_DATA:\s*\n(.*?)(?=\nSTYLING:|$)/si', $text, $matches ) ) { + $csv_data = trim( $matches[1] ); + // Remove markdown code blocks if present + $csv_data = preg_replace( '/^```[a-z]*\n/', '', $csv_data ); + $csv_data = preg_replace( '/\n```$/', '', $csv_data ); + $result['csv_data'] = trim( $csv_data ); + } + + // Extract styling JSON + if ( preg_match( '/STYLING:\s*\n(.*?)$/si', $text, $matches ) ) { + $styling_text = trim( $matches[1] ); + // Try to extract JSON from the text + if ( preg_match( '/(\{.*\})/s', $styling_text, $json_matches ) ) { + $potential_json = trim( $json_matches[1] ); + + // Try to convert JavaScript object notation to valid JSON + // Replace single quotes with double quotes (but not inside strings) + $potential_json = preg_replace( "/'/", '"', $potential_json ); + + // Try to add quotes around unquoted keys + // This regex finds patterns like {key: or ,key: and converts to {"key": + $potential_json = preg_replace( '/(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/', '$1"$2":', $potential_json ); + + // Validate it's proper JSON + json_decode( $potential_json ); + if ( json_last_error() === JSON_ERROR_NONE ) { + $result['styling'] = $potential_json; + error_log( 'Visualizer AI: Valid styling JSON extracted' ); + } else { + error_log( 'Visualizer AI: Invalid styling JSON, using empty object. Error: ' . json_last_error_msg() ); + $result['styling'] = '{}'; + } + } + } + + error_log( 'Visualizer AI: Parsed chart type: ' . $result['chart_type'] ); + error_log( 'Visualizer AI: Parsed title: ' . $result['title'] ); + error_log( 'Visualizer AI: CSV data length: ' . strlen( $result['csv_data'] ) ); + error_log( 'Visualizer AI: Styling: ' . substr( $result['styling'], 0, 200 ) ); + + return $result; + } +} diff --git a/classes/Visualizer/Module/Admin.php b/classes/Visualizer/Module/Admin.php index 2c02da8d0..28a222ac8 100644 --- a/classes/Visualizer/Module/Admin.php +++ b/classes/Visualizer/Module/Admin.php @@ -746,6 +746,16 @@ public function registerAdminMenu() { 'admin.php?page=' . Visualizer_Plugin::NAME . '&vaction=addnew' ); + // Add AI Settings submenu + add_submenu_page( + Visualizer_Plugin::NAME, + __( 'AI Settings', 'visualizer' ), + __( 'AI Settings', 'visualizer' ), + 'edit_posts', + 'visualizer-ai-settings', + array( $this, 'renderAISettingsPage' ) + ); + $this->_supportPage = add_submenu_page( Visualizer_Plugin::NAME, __( 'Support', 'visualizer' ), @@ -969,6 +979,19 @@ public function renderSupportPage() { include_once VISUALIZER_ABSPATH . '/templates/support.php'; } + /** + * Renders AI Settings page. + * + * @since 3.12.0 + * + * @access public + * @return void + */ + public function renderAISettingsPage() { + $render = new Visualizer_Render_Page_AISettings(); + $render->render(); + } + /** * Renders visualizer library page. * diff --git a/classes/Visualizer/Module/Chart.php b/classes/Visualizer/Module/Chart.php index e31133b1a..6f303464d 100644 --- a/classes/Visualizer/Module/Chart.php +++ b/classes/Visualizer/Module/Chart.php @@ -375,7 +375,7 @@ public function getCharts() { * * @access private * - * @param WP_Post|null $chart The chart object. + * @param WP_Post $chart The chart object. * * @return array The array of chart data. */ @@ -638,6 +638,9 @@ public function renderChartPages() { wp_register_style( 'visualizer-frame', VISUALIZER_ABSURL . 'css/frame.css', array( 'visualizer-chosen' ), Visualizer_Plugin::VERSION ); wp_register_script( 'visualizer-frame', VISUALIZER_ABSURL . 'js/frame.js', array( 'visualizer-chosen', 'jquery-ui-accordion', 'jquery-ui-tabs' ), Visualizer_Plugin::VERSION, true ); + wp_register_script( 'visualizer-ai-config', VISUALIZER_ABSURL . 'js/ai-config.js', array( 'jquery', 'visualizer-frame' ), Visualizer_Plugin::VERSION, true ); + wp_register_script( 'visualizer-ai-chart-from-image', VISUALIZER_ABSURL . 'js/ai-chart-from-image.js', array( 'jquery' ), Visualizer_Plugin::VERSION, true ); + wp_register_script( 'visualizer-ai-chart-data-populate', VISUALIZER_ABSURL . 'js/ai-chart-data-populate.js', array( 'jquery' ), Visualizer_Plugin::VERSION, true ); wp_register_script( 'visualizer-customization', $this->get_user_customization_js(), array(), null, true ); wp_register_script( 'visualizer-render', @@ -853,6 +856,8 @@ private function _handleDataAndSettingsPage() { wp_enqueue_script( 'visualizer-preview' ); wp_enqueue_script( 'visualizer-chosen' ); wp_enqueue_script( 'visualizer-render' ); + wp_enqueue_script( 'visualizer-ai-config' ); + wp_enqueue_script( 'visualizer-ai-chart-data-populate' ); if ( Visualizer_Module::can_show_feature( 'simple-editor' ) ) { wp_enqueue_script( 'visualizer-editor-simple' ); @@ -920,6 +925,17 @@ private function _handleDataAndSettingsPage() { ) ); + wp_localize_script( + 'visualizer-ai-config', + 'visualizerAI', + array( + 'nonce' => wp_create_nonce( 'visualizer-ai-generate' ), + 'chart_type' => $data['type'], + 'chart_library' => isset( $data['library'] ) && ! empty( $data['library'] ) ? $data['library'] : 'Google Charts', + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + ) + ); + $render = new Visualizer_Render_Page_Data(); $render->chart = $this->_chart; $render->type = $data['type']; @@ -961,7 +977,14 @@ private function _handleTypesPage() { if ( Visualizer_Module_Admin::checkChartStatus( $type ) ) { if ( empty( $library ) ) { // library cannot be empty. + error_log( 'Visualizer: Library is empty! Available POST data: ' . print_r( $_POST, true ) ); do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, 'Chart library empty while creating the chart! Aborting...', 'error', __FILE__, __LINE__ ); + // Show error message instead of blank screen + echo '
'; + echo '

Error: Chart Library Not Selected

'; + echo '

Please select a chart library and try again.

'; + echo '

Go Back

'; + echo '
'; return; } @@ -980,9 +1003,15 @@ private function _handleTypesPage() { Visualizer_Module_Utility::set_defaults( $this->_chart ); // redirect to next tab - // changed by Ash/Upwork - wp_redirect( esc_url_raw( add_query_arg( 'tab', 'settings' ) ) ); - + $redirect_url = esc_url_raw( add_query_arg( 'tab', 'settings' ) ); + wp_redirect( $redirect_url ); + exit; + } else { + echo '
'; + echo '

Error: Invalid Chart Type

'; + echo '

The selected chart type is not available.

'; + echo '

Go Back

'; + echo '
'; return; } } @@ -992,6 +1021,27 @@ private function _handleTypesPage() { $render->chart = $this->_chart; wp_enqueue_style( 'visualizer-frame' ); wp_enqueue_script( 'visualizer-frame' ); + wp_enqueue_script( 'visualizer-ai-chart-from-image' ); + + // Localize script for AI image analysis + $has_openai = ! empty( get_option( 'visualizer_openai_api_key', '' ) ); + $has_gemini = ! empty( get_option( 'visualizer_gemini_api_key', '' ) ); + $has_claude = ! empty( get_option( 'visualizer_claude_api_key', '' ) ); + + wp_localize_script( + 'visualizer-ai-chart-from-image', + 'visualizerAI', + array( + 'nonce_image' => wp_create_nonce( 'visualizer-ai-image' ), + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'has_openai' => $has_openai, + 'has_gemini' => $has_gemini, + 'has_claude' => $has_claude, + 'chart_types' => Visualizer_Module_Admin::_getChartTypesLocalized( false, false, false, 'types' ), + 'pro_url' => tsdk_utmify( Visualizer_Plugin::PRO_TEASER_URL, 'aichartimage', 'chartfromimage' ), + ) + ); + wp_iframe( array( $render, 'render' ) ); } @@ -1136,6 +1186,9 @@ private function handleTabularData() { * @access public */ public function uploadData() { + // Prevent any PHP warnings/errors from contaminating the response + ini_set( 'display_errors', '0' ); + // if this is being called internally from pro and VISUALIZER_DO_NOT_DIE is set. // otherwise, assume this is a normal web request. $can_die = ! ( defined( 'VISUALIZER_DO_NOT_DIE' ) && VISUALIZER_DO_NOT_DIE ); diff --git a/classes/Visualizer/Render/Page/AISettings.php b/classes/Visualizer/Render/Page/AISettings.php new file mode 100644 index 000000000..94da8f911 --- /dev/null +++ b/classes/Visualizer/Render/Page/AISettings.php @@ -0,0 +1,428 @@ +'; + echo '

' . esc_html__( 'Visualizer AI Settings', 'visualizer' ) . '

'; + + // Check if PRO features are locked + $is_locked = ! Visualizer_Module_Admin::proFeaturesLocked(); + + if ( $is_locked ) { + // Show locked state with upgrade message + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo '

' . esc_html__( 'AI Features - Premium Feature', 'visualizer' ) . '

'; + echo '

' . esc_html__( 'AI-powered chart creation and configuration is available exclusively in Visualizer PRO. Upgrade now to unlock:', 'visualizer' ) . '

'; + echo '
    '; + echo '
  • āœ“ ' . esc_html__( 'AI Chart Configuration Assistant', 'visualizer' ) . '
  • '; + echo '
  • āœ“ ' . esc_html__( 'Create Charts from Images', 'visualizer' ) . '
  • '; + echo '
  • āœ“ ' . esc_html__( 'Natural Language Chart Customization', 'visualizer' ) . '
  • '; + echo '
  • āœ“ ' . esc_html__( 'Support for ChatGPT, Gemini & Claude', 'visualizer' ) . '
  • '; + echo '
'; + echo ''; + echo esc_html__( 'Upgrade to PRO', 'visualizer' ); + echo ''; + echo '
'; + echo '
'; + } + + // Wrap the form in a div that will be overlaid if locked + echo '
'; + + // Check if form was submitted + if ( ! $is_locked && isset( $_POST['visualizer_ai_settings_nonce'] ) && wp_verify_nonce( $_POST['visualizer_ai_settings_nonce'], 'visualizer_ai_settings' ) ) { + $this->_saveSettings(); + + // Display validation results + $validation_results = get_transient( 'visualizer_ai_validation_results' ); + if ( $validation_results ) { + delete_transient( 'visualizer_ai_validation_results' ); + + foreach ( $validation_results as $provider => $result ) { + $notice_class = 'notice-success'; + if ( $result['status'] === 'error' ) { + $notice_class = 'notice-error'; + } elseif ( $result['status'] === 'removed' ) { + $notice_class = 'notice-info'; + } + + echo '

' . esc_html( $result['message'] ) . '

'; + } + } else { + echo '

' . esc_html__( 'Settings saved successfully.', 'visualizer' ) . '

'; + } + } + + // Get saved API keys + $openai_key = get_option( 'visualizer_openai_api_key', '' ); + $gemini_key = get_option( 'visualizer_gemini_api_key', '' ); + $claude_key = get_option( 'visualizer_claude_api_key', '' ); + + // Mask the keys for display + $openai_key_display = $this->_maskAPIKey( $openai_key ); + $gemini_key_display = $this->_maskAPIKey( $gemini_key ); + $claude_key_display = $this->_maskAPIKey( $claude_key ); + + echo '
'; + wp_nonce_field( 'visualizer_ai_settings', 'visualizer_ai_settings_nonce' ); + + echo ''; + + // OpenAI API Key + echo ''; + echo ''; + echo ''; + echo ''; + + // Gemini API Key + echo ''; + echo ''; + echo ''; + echo ''; + + // Claude API Key + echo ''; + echo ''; + echo ''; + echo ''; + + echo '
'; + echo ''; + echo '

' . esc_html__( 'Enter your OpenAI API key to enable ChatGPT integration.', 'visualizer' ) . ' ' . esc_html__( 'Get API Key', 'visualizer' ) . '

'; + echo '
'; + echo ''; + echo '

' . esc_html__( 'Enter your Google Gemini API key.', 'visualizer' ) . ' ' . esc_html__( 'Get API Key', 'visualizer' ) . '

'; + echo '
'; + echo ''; + echo '

' . esc_html__( 'Enter your Anthropic Claude API key.', 'visualizer' ) . ' ' . esc_html__( 'Get API Key', 'visualizer' ) . '

'; + echo '
'; + + echo '

'; + echo ''; + echo '

'; + + echo '
'; + + echo '
'; // End opacity wrapper + + if ( $is_locked ) { + echo '
'; // End position relative wrapper + } + + echo ''; // End wrap + } + + /** + * Saves AI settings. + * + * @since 3.12.0 + * + * @access private + * @return void + */ + private function _saveSettings() { + // Get current keys + $current_openai = get_option( 'visualizer_openai_api_key', '' ); + $current_gemini = get_option( 'visualizer_gemini_api_key', '' ); + $current_claude = get_option( 'visualizer_claude_api_key', '' ); + + $validation_results = array(); + + // Handle OpenAI key + if ( isset( $_POST['visualizer_openai_api_key'] ) ) { + $new_key = sanitize_text_field( $_POST['visualizer_openai_api_key'] ); + + // Allow empty value to remove key - but only show notification if key existed + if ( empty( $new_key ) ) { + if ( ! empty( $current_openai ) ) { + delete_option( 'visualizer_openai_api_key' ); + $validation_results['openai'] = array( 'status' => 'removed', 'message' => __( 'OpenAI API key removed.', 'visualizer' ) ); + } + } elseif ( $new_key !== $this->_maskAPIKey( $current_openai ) ) { + // Validate new key before saving + $validation = $this->_validateAPIKey( 'openai', $new_key ); + if ( $validation['valid'] ) { + update_option( 'visualizer_openai_api_key', $new_key ); + $validation_results['openai'] = array( 'status' => 'success', 'message' => __( 'OpenAI API key saved and validated successfully.', 'visualizer' ) ); + } else { + $validation_results['openai'] = array( 'status' => 'error', 'message' => sprintf( __( 'OpenAI API key validation failed: %s', 'visualizer' ), $validation['error'] ) ); + } + } + } + + // Handle Gemini key + if ( isset( $_POST['visualizer_gemini_api_key'] ) ) { + $new_key = sanitize_text_field( $_POST['visualizer_gemini_api_key'] ); + + // Allow empty value to remove key - but only show notification if key existed + if ( empty( $new_key ) ) { + if ( ! empty( $current_gemini ) ) { + delete_option( 'visualizer_gemini_api_key' ); + $validation_results['gemini'] = array( 'status' => 'removed', 'message' => __( 'Gemini API key removed.', 'visualizer' ) ); + } + } elseif ( $new_key !== $this->_maskAPIKey( $current_gemini ) ) { + // Validate new key before saving + $validation = $this->_validateAPIKey( 'gemini', $new_key ); + if ( $validation['valid'] ) { + update_option( 'visualizer_gemini_api_key', $new_key ); + $validation_results['gemini'] = array( 'status' => 'success', 'message' => __( 'Gemini API key saved and validated successfully.', 'visualizer' ) ); + } else { + $validation_results['gemini'] = array( 'status' => 'error', 'message' => sprintf( __( 'Gemini API key validation failed: %s', 'visualizer' ), $validation['error'] ) ); + } + } + } + + // Handle Claude key + if ( isset( $_POST['visualizer_claude_api_key'] ) ) { + $new_key = sanitize_text_field( $_POST['visualizer_claude_api_key'] ); + + // Allow empty value to remove key - but only show notification if key existed + if ( empty( $new_key ) ) { + if ( ! empty( $current_claude ) ) { + delete_option( 'visualizer_claude_api_key' ); + $validation_results['claude'] = array( 'status' => 'removed', 'message' => __( 'Claude API key removed.', 'visualizer' ) ); + } + } elseif ( $new_key !== $this->_maskAPIKey( $current_claude ) ) { + // Validate new key before saving + $validation = $this->_validateAPIKey( 'claude', $new_key ); + if ( $validation['valid'] ) { + update_option( 'visualizer_claude_api_key', $new_key ); + $validation_results['claude'] = array( 'status' => 'success', 'message' => __( 'Claude API key saved and validated successfully.', 'visualizer' ) ); + } else { + $validation_results['claude'] = array( 'status' => 'error', 'message' => sprintf( __( 'Claude API key validation failed: %s', 'visualizer' ), $validation['error'] ) ); + } + } + } + + // Store validation results for display + set_transient( 'visualizer_ai_validation_results', $validation_results, 60 ); + } + + /** + * Validates an API key by making a test request. + * + * @since 3.12.0 + * + * @access private + * @param string $provider The API provider ('openai', 'gemini', 'claude'). + * @param string $api_key The API key to validate. + * @return array{valid: bool, error?: string} Validation result. + */ + private function _validateAPIKey( $provider, $api_key ) { + switch ( $provider ) { + case 'openai': + return $this->_validateOpenAIKey( $api_key ); + case 'gemini': + return $this->_validateGeminiKey( $api_key ); + case 'claude': + return $this->_validateClaudeKey( $api_key ); + default: + return array( 'valid' => false, 'error' => 'Invalid provider' ); + } + } + + /** + * Validates OpenAI API key. + * + * @since 3.12.0 + * + * @access private + * @param string $api_key The API key to validate. + * @return array{valid: bool, error?: string} Validation result. + */ + private function _validateOpenAIKey( $api_key ) { + $response = wp_remote_post( + 'https://api.openai.com/v1/chat/completions', + array( + 'timeout' => 10, + 'headers' => array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'model' => 'gpt-3.5-turbo', + 'messages' => array( + array( + 'role' => 'user', + 'content' => 'Hi', + ), + ), + 'max_tokens' => 5, + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return array( 'valid' => false, 'error' => $response->get_error_message() ); + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Invalid API key'; + return array( 'valid' => false, 'error' => $error ); + } + + return array( 'valid' => true ); + } + + /** + * Validates Gemini API key. + * + * @since 3.12.0 + * + * @access private + * @param string $api_key The API key to validate. + * @return array{valid: bool, error?: string} Validation result. + */ + private function _validateGeminiKey( $api_key ) { + $response = wp_remote_post( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=' . $api_key, + array( + 'timeout' => 10, + 'headers' => array( + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'contents' => array( + array( + 'parts' => array( + array( + 'text' => 'Hi', + ), + ), + ), + ), + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return array( 'valid' => false, 'error' => $response->get_error_message() ); + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Invalid API key'; + return array( 'valid' => false, 'error' => $error ); + } + + return array( 'valid' => true ); + } + + /** + * Validates Claude API key. + * + * @since 3.12.0 + * + * @access private + * @param string $api_key The API key to validate. + * @return array{valid: bool, error?: string} Validation result. + */ + private function _validateClaudeKey( $api_key ) { + $response = wp_remote_post( + 'https://api.anthropic.com/v1/messages', + array( + 'timeout' => 10, + 'headers' => array( + 'x-api-key' => $api_key, + 'anthropic-version' => '2023-06-01', + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'model' => 'claude-3-haiku-20240307', + 'max_tokens' => 10, + 'messages' => array( + array( + 'role' => 'user', + 'content' => 'Hi', + ), + ), + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return array( 'valid' => false, 'error' => $response->get_error_message() ); + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Invalid API key'; + return array( 'valid' => false, 'error' => $error ); + } + + return array( 'valid' => true ); + } + +} diff --git a/classes/Visualizer/Render/Page/Types.php b/classes/Visualizer/Render/Page/Types.php index e58b3e554..7b76ecdd7 100644 --- a/classes/Visualizer/Render/Page/Types.php +++ b/classes/Visualizer/Render/Page/Types.php @@ -53,6 +53,94 @@ protected function _toHTML() { */ protected function _renderContent() { echo '
'; + + // AI Image Upload Section + $has_ai_keys = ! empty( get_option( 'visualizer_openai_api_key', '' ) ) || + ! empty( get_option( 'visualizer_gemini_api_key', '' ) ) || + ! empty( get_option( 'visualizer_claude_api_key', '' ) ); + + // Check if PRO features are locked + $is_pro_locked = ! Visualizer_Module_Admin::proFeaturesLocked(); + + // Determine what kind of lock to show + $show_api_lock = ! $has_ai_keys && ! $is_pro_locked; // No API keys but has PRO + $show_pro_lock = $is_pro_locked; // Free version - needs PRO upgrade + + // Build the wrapper with appropriate classes for PRO upsell + $wrapper_class = ''; + if ( $show_pro_lock ) { + $wrapper_class = apply_filters( 'visualizer_pro_upsell_class', 'only-pro-feature', 'chart-from-image' ); + } + + echo '
'; + echo '
'; + echo '
'; + + if ( $show_api_lock ) { + // Show API key configuration lock (for PRO users without API keys) + echo '
'; + echo '
'; + echo ''; + echo '

' . esc_html__( 'AI Features - API Key Required', 'visualizer' ) . '

'; + echo '

' . esc_html__( 'Configure your AI API key to use AI-powered chart creation from images.', 'visualizer' ) . '

'; + echo ''; + echo esc_html__( 'Configure AI Settings', 'visualizer' ); + echo ''; + echo '
'; + echo '
'; + } + + echo '

' . esc_html__( 'Create Chart from Image', 'visualizer' ) . '

'; + echo '

' . esc_html__( 'Upload or drag & drop an image of a chart and AI will detect the chart type, extract data, and recreate it for you.', 'visualizer' ) . '

'; + + // Drag and drop zone + echo '
'; + echo ''; + echo '

' . esc_html__( 'Drag & drop your chart image here', 'visualizer' ) . '

'; + echo '

' . esc_html__( 'or', 'visualizer' ) . '

'; + echo ''; + echo ''; + echo '
'; + + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
'; + + echo ''; + + echo ''; + echo ''; + echo '
'; // End #ai-chart-from-image + + // Add PRO upsell overlay if locked (free version) + if ( $show_pro_lock ) { + // Add the upgrade overlay HTML + echo '
'; + echo '
'; + echo '
'; + echo '

' . esc_html__( 'Upgrade to PRO to activate this feature!', 'visualizer' ) . '

'; + echo '' . esc_html__( 'Upgrade Now', 'visualizer' ) . ''; + echo '
'; + echo '
'; + echo '
'; + } + + echo '
'; // End position: relative wrapper + echo '
'; // End only-pro-feature wrapper + + echo '
' . esc_html__( '— OR —', 'visualizer' ) . '
'; + echo '
' . $this->render_chart_selection() . '
'; foreach ( $this->types as $type => $array ) { // add classes to each box that identifies the libraries this chart type supports. diff --git a/classes/Visualizer/Render/Sidebar.php b/classes/Visualizer/Render/Sidebar.php index 79e400b43..4efa7b2d4 100644 --- a/classes/Visualizer/Render/Sidebar.php +++ b/classes/Visualizer/Render/Sidebar.php @@ -203,7 +203,7 @@ protected function _renderAdvancedSettings() { '
' . $this->_renderManualConfigExample() . '' ), '', - array( 'rows' => 5 ) + array( 'rows' => 5, 'id' => 'visualizer-manual-config' ) ); self::_renderSectionEnd(); @@ -839,4 +839,141 @@ protected function _renderChartControlsSettings() { ); self::_renderSectionEnd(); } + + /** + * Renders AI Configuration group. + * + * @access protected + * @return void + */ + protected function _renderAIConfigurationGroup() { + // Don't render AI Configuration for DataTable charts + // DataTable charts don't use JSON configuration like other charts + if ( isset( $this->type ) && $this->type === 'tabular' ) { + return; + } + + // Check if PRO features are locked + // proFeaturesLocked() returns TRUE when PRO is active (unlocked) + // proFeaturesLocked() returns FALSE when free version (locked) + if ( Visualizer_Module_Admin::proFeaturesLocked() ) { + // PRO version - render normally without lock + self::_renderGroupStart( esc_html__( 'AI Configuration Assistant', 'visualizer' ) ); + } else { + // Free version - render with lock icon and wrapper + self::_renderGroupStart( esc_html__( 'AI Configuration Assistant', 'visualizer' ) . '', '', apply_filters( 'visualizer_pro_upsell_class', 'only-pro-feature', 'chart-ai-configuration' ), 'vz-ai-configuration' ); + echo '
'; + } + + self::_renderSectionStart(); + self::_renderSectionDescription( + sprintf( + // translators: %1$s - HTML link tag, %2$s - HTML closing link tag. + esc_html__( 'Chat with AI to customize your chart. Ask questions, get suggestions, or describe what you want. The AI understands your chart type and current configuration. %1$sConfigure API keys%2$s', 'visualizer' ), + '', + '' + ) + ); + self::_renderSectionEnd(); + + // Check if any AI API key is configured + $has_openai = ! empty( get_option( 'visualizer_openai_api_key', '' ) ); + $has_gemini = ! empty( get_option( 'visualizer_gemini_api_key', '' ) ); + $has_claude = ! empty( get_option( 'visualizer_claude_api_key', '' ) ); + $has_any_api_key = $has_openai || $has_gemini || $has_claude; + + self::_renderSectionStart( esc_html__( 'Settings', 'visualizer' ), $has_any_api_key ); + + $ai_models = array(); + if ( $has_openai ) { + $ai_models['openai'] = esc_html__( 'ChatGPT (OpenAI)', 'visualizer' ); + } + if ( $has_gemini ) { + $ai_models['gemini'] = esc_html__( 'Google Gemini', 'visualizer' ); + } + if ( $has_claude ) { + $ai_models['claude'] = esc_html__( 'Anthropic Claude', 'visualizer' ); + } + + if ( ! empty( $ai_models ) ) { + self::_renderSelectItem( + esc_html__( 'AI Model', 'visualizer' ), + 'ai_model', + 'openai', + $ai_models, + '', + false, + array( 'visualizer-ai-model-select' ) + ); + } else { + // No API keys configured - show message + self::_renderSectionDescription( + '' . sprintf( + // translators: %1$s - HTML link tag, %2$s - HTML closing link tag. + esc_html__( 'No AI API keys configured. %1$sConfigure your API keys%2$s to use AI features.', 'visualizer' ), + '', + '' + ) . '' + ); + } + + self::_renderSectionEnd(); + + self::_renderSectionStart( esc_html__( 'AI Chat', 'visualizer' ), $has_any_api_key ); + + if ( ! $has_any_api_key ) { + echo '
'; + } + + echo '
'; + echo '
'; + echo '
'; + echo '
'; + + echo '
'; + echo ''; + echo '
'; + + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
'; + + echo '
'; + echo ''; + echo '
'; + + echo '
'; + + if ( ! $has_any_api_key ) { + echo '
'; + } + + self::_renderSectionEnd(); + + // Add upsell overlay if locked (free version) + if ( ! Visualizer_Module_Admin::proFeaturesLocked() ) { + // Add the upgrade overlay HTML + echo '
'; + echo '
'; + echo '
'; + echo '

' . esc_html__( 'Upgrade to PRO to activate this feature!', 'visualizer' ) . '

'; + echo '' . esc_html__( 'Upgrade Now', 'visualizer' ) . ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; // End position: relative wrapper + } + + self::_renderGroupEnd(); + } } diff --git a/classes/Visualizer/Render/Sidebar/ChartJS.php b/classes/Visualizer/Render/Sidebar/ChartJS.php index d4da43b31..0c305e5f1 100644 --- a/classes/Visualizer/Render/Sidebar/ChartJS.php +++ b/classes/Visualizer/Render/Sidebar/ChartJS.php @@ -198,6 +198,9 @@ protected function _renderChartTitleSettings() { * @access protected */ protected function _renderGeneralSettings() { + // AI Configuration Group - render first + $this->_renderAIConfigurationGroup(); + self::_renderGroupStart( esc_html__( 'General Settings', 'visualizer' ) ); self::_renderSectionStart(); self::_renderSectionDescription( esc_html__( 'Configure title, font styles, tooltip, legend and else settings for the chart.', 'visualizer' ) ); diff --git a/classes/Visualizer/Render/Sidebar/Google.php b/classes/Visualizer/Render/Sidebar/Google.php index 3f7cd8198..688d1e36d 100644 --- a/classes/Visualizer/Render/Sidebar/Google.php +++ b/classes/Visualizer/Render/Sidebar/Google.php @@ -171,6 +171,9 @@ protected function _renderRoleField( $index ) { * @access protected */ protected function _renderGeneralSettings() { + // AI Configuration Group - render first + $this->_renderAIConfigurationGroup(); + self::_renderGroupStart( esc_html__( 'General Settings', 'visualizer' ) ); self::_renderSectionStart(); self::_renderSectionDescription( esc_html__( 'Configure title, font styles, tooltip, legend and else settings for the chart.', 'visualizer' ) ); diff --git a/css/frame.css b/css/frame.css index 4dcef45df..5a5164d7a 100644 --- a/css/frame.css +++ b/css/frame.css @@ -1097,7 +1097,8 @@ button#editor-chart-button { .only-pro-feature input, .only-pro-feature button, -.only-pro-feature select { +.only-pro-feature select, +.only-pro-feature textarea { cursor: not-allowed !important; pointer-events: none; } diff --git a/index.php b/index.php index 8fca53ce4..f4f61692a 100644 --- a/index.php +++ b/index.php @@ -121,6 +121,7 @@ function visualizer_launch() { if ( is_admin() || defined( 'WP_TESTS_DOMAIN' ) ) { // set admin modules $plugin->setModule( Visualizer_Module_Admin::NAME ); + $plugin->setModule( Visualizer_Module_AI::NAME ); } // set frontend modules diff --git a/js/ai-chart-data-populate.js b/js/ai-chart-data-populate.js new file mode 100644 index 000000000..702224397 --- /dev/null +++ b/js/ai-chart-data-populate.js @@ -0,0 +1,405 @@ +(function($) { + 'use strict'; + + $(document).ready(function() { + // Check if there's AI-generated chart data in sessionStorage + var chartDataStr = sessionStorage.getItem('visualizer_ai_chart_data'); + + // Check if there's pending styling to apply (after chart data upload) + var pendingStyling = sessionStorage.getItem('visualizer_ai_pending_styling'); + + if (pendingStyling && !chartDataStr) { + // Chart data was already uploaded, now apply the styling + console.log('Found pending styling to apply'); + setTimeout(function() { + applyStoredStyling(); + }, 1500); + return; + } + + if (!chartDataStr) { + return; + } + + console.log('AI Chart Data found in sessionStorage'); + + try { + var chartData = JSON.parse(chartDataStr); + console.log('Parsed chart data:', chartData); + + // Clear the sessionStorage + sessionStorage.removeItem('visualizer_ai_chart_data'); + + // Wait for the page to fully load before populating + // The editor needs some time to initialize + // Also check if vizUpdateHTML function exists (it's set when editor is ready) + var attempts = 0; + var maxAttempts = 20; + + function waitForEditor() { + attempts++; + console.log('Waiting for editor... attempt', attempts); + + if (typeof window.vizUpdateHTML !== 'undefined' || attempts >= maxAttempts) { + console.log('Editor ready or max attempts reached. Starting data population...'); + populateChartData(chartData); + } else { + setTimeout(waitForEditor, 300); + } + } + + setTimeout(waitForEditor, 1000); + } catch (e) { + console.error('Error parsing AI chart data:', e); + sessionStorage.removeItem('visualizer_ai_chart_data'); + } + }); + + function applyStoredStyling() { + var stylingStr = sessionStorage.getItem('visualizer_ai_pending_styling'); + if (!stylingStr) { + console.log('No pending styling to apply'); + return; + } + + console.log('Applying stored styling...'); + + try { + var styling = JSON.parse(stylingStr); + var formatted = JSON.stringify(styling, null, 2); + + var manualConfigTextarea = $('#visualizer-manual-config, textarea[name="manual"]'); + console.log('Found manual config textarea:', manualConfigTextarea.length); + + if (manualConfigTextarea.length) { + manualConfigTextarea.val(formatted); + console.log('Set manual config with styling'); + + // Clear the pending styling + sessionStorage.removeItem('visualizer_ai_pending_styling'); + + // Trigger events to update preview + setTimeout(function() { + try { + manualConfigTextarea.trigger('change'); + manualConfigTextarea.trigger('keyup'); + $('textarea[name="manual"]').trigger('change'); + console.log('Triggered preview update'); + + showNotification('Chart colors and styling from image applied successfully!', 'success'); + } catch (e) { + console.warn('Error triggering preview update:', e); + // Still show success since the styling was set + showNotification('Chart colors and styling applied!', 'success'); + } + }, 500); + } else { + console.error('Manual config textarea not found'); + } + } catch (e) { + console.error('Error applying styling:', e); + sessionStorage.removeItem('visualizer_ai_pending_styling'); + } + } + + function populateChartData(chartData) { + console.log('Populating chart data...', chartData); + + // Clean chart data - remove markdown code blocks if present + if (chartData.csv_data) { + chartData.csv_data = chartData.csv_data.replace(/^```[a-z]*\n?/m, '').replace(/\n?```$/m, '').trim(); + console.log('Cleaned chart data:', chartData.csv_data.substring(0, 200)); + } + + // Set the chart title if there's a title field + if (chartData.title && $('input[name="title"]').length) { + $('input[name="title"]').val(chartData.title); + console.log('Set title:', chartData.title); + } + + // Store styling for later (after chart data upload completes) + if (chartData.styling && chartData.styling !== '{}') { + sessionStorage.setItem('visualizer_ai_pending_styling', chartData.styling); + console.log('Stored styling for later application'); + } + + // Import chart data first - styling will be applied after this completes + if (chartData.csv_data) { + importCSVData(chartData.csv_data); + } else { + console.warn('No chart data to import'); + // If there's no chart data but there's styling, apply it now + applyStoredStyling(); + } + } + + function importCSVData(csvData) { + console.log('Importing chart data from image...'); + console.log('Data length:', csvData.length); + console.log('Chart data:', csvData.substring(0, 200)); + + // Check if we're using the simple editor + var editedText = $('#edited_text'); + console.log('Found #edited_text:', editedText.length); + + if (editedText.length) { + // Use the text editor + console.log('Using text editor method'); + editedText.val(csvData); + + // Check if the editor button needs to be clicked first to show the editor + var editorButton = $('#editor-button'); + console.log('Found editor button:', editorButton.length); + console.log('Editor button current state:', editorButton.attr('data-current')); + + // Mimic the exact behavior from simple-editor.js + setTimeout(function() { + console.log('Setting chart-data, chart-data-src, and editor-type values'); + $('#chart-data').val(csvData); + // Set source to 'text' to indicate manual data input + $('#chart-data-src').val('text'); + + // CRITICAL: Set editor-type - this is required by uploadData() + // There might be TWO elements with this name: + // 1. A dropdown select#viz-editor-type (when PRO with simple-editor feature) + // 2. A hidden input[name="editor-type"] + + // First, check for the dropdown + var editorTypeDropdown = $('#viz-editor-type'); + var editorTypeHidden = $('input[name="editor-type"]'); + + console.log('Found editor type dropdown:', editorTypeDropdown.length); + console.log('Found editor type hidden input:', editorTypeHidden.length); + + // For AI CSV upload, we ALWAYS want 'text' editor-type + // because we're providing CSV string format, not JSON array + + // Update dropdown if it exists + if (editorTypeDropdown.length) { + console.log('Current dropdown value:', editorTypeDropdown.val()); + editorTypeDropdown.val('text'); + console.log('Set dropdown to: text'); + } + + // Update or create hidden input + if (editorTypeHidden.length) { + console.log('Current hidden input value:', editorTypeHidden.val()); + editorTypeHidden.val('text'); + console.log('Updated hidden input to: text'); + } else { + // Create it if it doesn't exist + editorTypeHidden = $(''); + $('#editor-form').append(editorTypeHidden); + console.log('Created hidden input with value: text'); + } + + console.log('Chart data length:', csvData.length); + console.log('Chart data source set to: text'); + console.log('Editor type set to: text'); + + // Lock the canvas (shows loading spinner) + var canvas = $('#canvas'); + console.log('Found canvas:', canvas.length); + if (canvas.length && typeof canvas.lock === 'function') { + console.log('Locking canvas'); + canvas.lock(); + } + + // Submit the form + console.log('Submitting editor form'); + var editorForm = $('#editor-form'); + console.log('Found editor form:', editorForm.length); + + if (editorForm.length) { + console.log('Form action:', editorForm.attr('action')); + console.log('Form method:', editorForm.attr('method')); + console.log('Form target:', editorForm.attr('target')); + + // Watch for the iframe to load (form submission complete) + var iframe = $('#thehole, iframe[name="thehole"]'); + console.log('Found iframe:', iframe.length); + + if (iframe.length) { + // Watch for iframe to load and check its content + iframe.one('load', function() { + console.log('Iframe loaded after form submission'); + + try { + var iframeContent = iframe.contents(); + var iframeBody = iframeContent.find('body').html(); + console.log('Iframe content length:', iframeBody ? iframeBody.length : 0); + console.log('Iframe body (first 1000 chars):', iframeBody ? iframeBody.substring(0, 1000) : 'empty'); + + // Check for specific error patterns + if (iframeBody) { + if (iframeBody.indexOf('error') > -1 || iframeBody.indexOf('critical error') > -1) { + console.error('Error detected in iframe response'); + showNotification('Data upload failed. Please check your data format and try again. Check browser console for details.', 'error'); + } else if (iframeBody.indexOf(' -1 && iframeBody.indexOf('') > -1) { + console.error('PHP warning/error detected in response:', iframeBody.substring(0, 200)); + showNotification('Server error occurred. Check console for details.', 'error'); + } else if (iframeBody.trim().length < 10) { + console.log('Response appears to be empty or minimal - might be success'); + } else { + console.log('Response contains content but no obvious errors'); + } + } + } catch (e) { + console.warn('Could not read iframe content (cross-origin?):', e); + } + }); + + // Watch for vizUpdateHTML to be called + var originalVizUpdateHTML = window.vizUpdateHTML; + var vizUpdateCalled = false; + + window.vizUpdateHTML = function(editor, sidebar) { + console.log('vizUpdateHTML called - data processed successfully!'); + console.log('Editor:', editor); + console.log('Sidebar:', sidebar); + vizUpdateCalled = true; + + // Call the original function + if (originalVizUpdateHTML) { + originalVizUpdateHTML(editor, sidebar); + } + + // Unlock canvas and show success + setTimeout(function() { + if (canvas.length && typeof canvas.unlock === 'function') { + console.log('Unlocking canvas'); + canvas.unlock(); + } + showNotification('Chart created successfully from image!', 'success'); + + // Restore original function + window.vizUpdateHTML = originalVizUpdateHTML; + }, 500); + }; + + // Fallback: If vizUpdateHTML isn't called within 5 seconds, check what happened + setTimeout(function() { + if (!vizUpdateCalled) { + console.warn('vizUpdateHTML not called after 5 seconds'); + // Restore original function + window.vizUpdateHTML = originalVizUpdateHTML; + + // Check if data was actually uploaded by inspecting the page + var hasData = $('#edited_text').val().length > 0; + console.log('Has data in edited_text:', hasData); + + if (hasData) { + console.log('Data exists, reloading page to display chart...'); + window.location.reload(); + } else { + console.error('Form submitted but no data found. Check server logs.'); + if (canvas.length && typeof canvas.unlock === 'function') { + canvas.unlock(); + } + showNotification('Data upload may have failed. Check browser console and server logs.', 'error'); + } + } + }, 5000); + } + + console.log('About to submit form with data:', { + 'chart-data': $('#chart-data').val().substring(0, 100), + 'chart-data-src': $('#chart-data-src').val(), + 'editor-type': $('input[name="editor-type"]').val(), + 'chart-data-full-length': $('#chart-data').val().length, + 'form-action': editorForm.attr('action') + }); + + // Log the full CSV data for debugging + console.log('Full CSV data being submitted:'); + console.log($('#chart-data').val()); + + // Check if any other hidden fields exist that might interfere + console.log('All hidden inputs in form:'); + editorForm.find('input[type="hidden"]').each(function() { + console.log(' -', $(this).attr('name'), '=', $(this).val().substring(0, 50)); + }); + + editorForm.submit(); + } else { + console.error('Editor form not found!'); + console.log('Available forms:', $('form').map(function() { + return $(this).attr('id') || $(this).attr('name') || 'unnamed'; + }).get()); + + // Unlock canvas if form not found + if (canvas.length && typeof canvas.unlock === 'function') { + canvas.unlock(); + } + } + }, 500); + + showNotification('Chart data loaded successfully! Processing chart...', 'success'); + } else { + console.warn('Text editor not found. Trying alternative methods...'); + + // Try direct chart-data field + var chartDataField = $('#chart-data'); + console.log('Found #chart-data:', chartDataField.length); + + if (chartDataField.length) { + console.log('Using direct chart-data field'); + chartDataField.val(csvData); + + // Try to submit the form + var form = chartDataField.closest('form'); + if (form.length) { + console.log('Submitting form'); + form.submit(); + } + } else { + console.error('Could not find any data input method'); + console.log('Available inputs:', $('input, textarea').map(function() { + return $(this).attr('id') || $(this).attr('name'); + }).get()); + + showNotification('Chart type selected, but please manually add this chart data:\n\n' + csvData, 'warning'); + } + } + } + + function showNotification(message, type) { + type = type || 'info'; + + var bgColor = '#0073aa'; + if (type === 'success') { + bgColor = '#46b450'; + } else if (type === 'warning') { + bgColor = '#ffb900'; + } else if (type === 'error') { + bgColor = '#dc3232'; + } + + var notification = $('
') + .css({ + 'position': 'fixed', + 'top': '32px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'background': bgColor, + 'color': 'white', + 'padding': '15px 20px', + 'border-radius': '4px', + 'box-shadow': '0 2px 10px rgba(0,0,0,0.2)', + 'z-index': '999999', + 'max-width': '80%', + 'text-align': 'center', + 'font-size': '14px', + 'white-space': 'pre-wrap' + }) + .text(message) + .appendTo('body'); + + setTimeout(function() { + notification.fadeOut(function() { + notification.remove(); + }); + }, 5000); + } + +})(jQuery); diff --git a/js/ai-chart-from-image.js b/js/ai-chart-from-image.js new file mode 100644 index 000000000..395c53c43 --- /dev/null +++ b/js/ai-chart-from-image.js @@ -0,0 +1,294 @@ +(function($) { + 'use strict'; + + var selectedImage = null; + + $(document).ready(function() { + console.log('AI Chart from Image loaded'); + + // Handle choose image button click + $('#ai-upload-chart-image-btn').on('click', function(e) { + e.preventDefault(); + $('#ai-chart-image-upload').click(); + }); + + // Drag and drop support + var dropZone = $('#ai-image-drop-zone'); + + dropZone.on('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).css({ + 'border-color': '#0073aa', + 'background': '#e8f4f8' + }); + }); + + dropZone.on('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).css({ + 'border-color': '#ddd', + 'background': '#fafafa' + }); + }); + + dropZone.on('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).css({ + 'border-color': '#ddd', + 'background': '#fafafa' + }); + + var files = e.originalEvent.dataTransfer.files; + if (files.length > 0) { + handleFileSelection(files[0]); + } + }); + + // Handle file selection + $('#ai-chart-image-upload').on('change', function(e) { + var file = e.target.files[0]; + if (file) { + handleFileSelection(file); + } + }); + + // Handle generate chart button click + $('#ai-generate-from-image-btn').on('click', function(e) { + e.preventDefault(); + generateChartFromImage(); + }); + }); + + function handleFileSelection(file) { + console.log('File selected:', file.name, file.type, file.size); + + // Validate file type + if (!file.type.match('image.*')) { + showError('Please select a valid image file.'); + return; + } + + // Validate file size (max 10MB) + if (file.size > 10 * 1024 * 1024) { + showError('Image file is too large. Please select an image smaller than 10MB.'); + return; + } + + // Show filename + $('#ai-selected-filename').text(file.name); + + // Read and preview image + var reader = new FileReader(); + reader.onload = function(event) { + selectedImage = event.target.result; + + // Show preview + $('#ai-preview-img').attr('src', selectedImage); + $('#ai-image-preview').show(); + + // Show generate button + $('#ai-generate-from-image-btn').show(); + + // Hide any previous messages + $('#ai-image-error').hide(); + $('#ai-image-success').hide(); + }; + reader.readAsDataURL(file); + } + + function generateChartFromImage() { + if (!selectedImage) { + showError('Please select an image first.'); + return; + } + + console.log('Generating chart from image...'); + + // Hide messages + $('#ai-image-error').hide(); + $('#ai-image-success').hide(); + + // Show loading + $('#ai-image-loading').show(); + $('#ai-generate-from-image-btn').prop('disabled', true); + + // Get selected AI model (check if there's a model selector) + var model = 'openai'; // Default to OpenAI + if ($('.visualizer-ai-model-select').length) { + model = $('.visualizer-ai-model-select').val(); + } else { + // Determine which API key is configured + if (typeof visualizerAI !== 'undefined' && visualizerAI.has_gemini) { + model = 'gemini'; + } else if (typeof visualizerAI !== 'undefined' && visualizerAI.has_claude) { + model = 'claude'; + } + } + + var requestData = { + action: 'visualizer-ai-analyze-chart-image', + nonce: visualizerAI.nonce_image, + image: selectedImage, + model: model + }; + + console.log('Sending request with model:', model); + + $.ajax({ + url: visualizerAI.ajaxurl, + type: 'POST', + data: requestData, + success: function(response) { + console.log('Response:', response); + $('#ai-image-loading').hide(); + $('#ai-generate-from-image-btn').prop('disabled', false); + + if (response.success) { + var data = response.data; + console.log('Chart analysis data:', data); + + showSuccess('Chart analyzed successfully! Creating chart...'); + + // Create the chart with the extracted data + createChartWithData(data); + } else { + showError(data.message || 'Failed to analyze chart image.'); + } + }, + error: function(xhr, status, error) { + console.error('Error:', {xhr: xhr, status: status, error: error}); + $('#ai-image-loading').hide(); + $('#ai-generate-from-image-btn').prop('disabled', false); + + var errorMsg = 'Failed to analyze chart image. Please try again.'; + if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + errorMsg = xhr.responseJSON.data.message; + } + + showError(errorMsg); + } + }); + } + + function createChartWithData(data) { + console.log('Creating chart with data:', data); + + // Store the data for use in the next step + var chartType = data.chart_type || 'column'; + + // Check if chart type is available (not locked in free version) + if (typeof visualizerAI !== 'undefined' && visualizerAI.chart_types) { + var chartTypeInfo = visualizerAI.chart_types[chartType]; + console.log('Chart type info:', chartTypeInfo); + + if (chartTypeInfo && !chartTypeInfo.enabled) { + // Chart type is locked - show PRO upgrade message + showError('The "' + chartTypeInfo.name + '" chart type detected in your image is not available in the free version.'); + + // Show upgrade button + var upgradeHtml = '
'; + upgradeHtml += '

šŸ”’ Premium Feature

'; + upgradeHtml += '

This chart type requires Visualizer PRO. Upgrade now to use all chart types including ' + chartTypeInfo.name + '.

'; + upgradeHtml += ''; + upgradeHtml += 'Upgrade to PRO'; + upgradeHtml += ''; + upgradeHtml += '
'; + + $('#ai-image-error').after(upgradeHtml); + return; + } + } + + var chartData = { + type: chartType, + title: data.title || 'Untitled Chart', + csv_data: data.csv_data || '', + styling: data.styling || '{}' + }; + + // Store in sessionStorage for the next page + sessionStorage.setItem('visualizer_ai_chart_data', JSON.stringify(chartData)); + console.log('Stored chart data in sessionStorage'); + + // Select the chart type radio button + var typeRadio = $('input[name="type"][value="' + chartType + '"]'); + console.log('Found type radio:', typeRadio.length); + if (typeRadio.length) { + typeRadio.prop('checked', true); + typeRadio.closest('.type-label').addClass('type-label-selected'); + console.log('Selected chart type:', chartType); + } else { + console.error('Chart type radio not found:', chartType); + // Try to find available chart types + console.log('Available chart types:', $('input[name="type"]').map(function() { return $(this).val(); }).get()); + } + + // Select GoogleCharts as the library (NOT DataTable!) + var librarySelect = $('select[name="chart-library"]'); + console.log('Found library select:', librarySelect.length); + if (librarySelect.length) { + var availableLibs = librarySelect.find('option').map(function() { return $(this).val(); }).get(); + console.log('Available libraries:', availableLibs); + + // Prefer GoogleCharts, then ChartJS, avoid DataTable + var preferredLibrary = 'GoogleCharts'; + if (availableLibs.indexOf('GoogleCharts') !== -1) { + preferredLibrary = 'GoogleCharts'; + } else if (availableLibs.indexOf('ChartJS') !== -1) { + preferredLibrary = 'ChartJS'; + } else { + preferredLibrary = availableLibs[0]; // fallback to first + } + + console.log('Selecting library:', preferredLibrary); + librarySelect.val(preferredLibrary); + + // Also try using the viz-select-library class + $('.viz-select-library').val(preferredLibrary); + } else { + console.error('Library select not found'); + + // Alternative: Try to find it by class + librarySelect = $('.viz-select-library'); + if (librarySelect.length) { + console.log('Found library select by class'); + var availableLibs = librarySelect.find('option').map(function() { return $(this).val(); }).get(); + var preferredLibrary = availableLibs.indexOf('GoogleCharts') !== -1 ? 'GoogleCharts' : availableLibs[0]; + console.log('Selecting library:', preferredLibrary); + librarySelect.val(preferredLibrary); + } + } + + // Check form validity + var form = $('#viz-types-form'); + console.log('Form found:', form.length); + console.log('Selected type:', $('input[name="type"]:checked').val()); + console.log('Selected library:', $('select[name="chart-library"]').val()); + + // Trigger the form submission to move to the next step + showSuccess('Chart type detected: ' + chartType + '. Creating chart...'); + + setTimeout(function() { + console.log('Submitting form...'); + $('#viz-types-form').submit(); + }, 1500); + } + + function showError(message) { + $('#ai-image-error').text(message).show(); + setTimeout(function() { + $('#ai-image-error').fadeOut(); + }, 5000); + } + + function showSuccess(message) { + $('#ai-image-success').text(message).show(); + setTimeout(function() { + $('#ai-image-success').fadeOut(); + }, 3000); + } + +})(jQuery); diff --git a/js/ai-config.js b/js/ai-config.js new file mode 100644 index 000000000..1175dac69 --- /dev/null +++ b/js/ai-config.js @@ -0,0 +1,318 @@ +(function($) { + 'use strict'; + + var chatHistory = []; + var currentConfig = null; + + $(document).ready(function() { + if (typeof visualizerAI !== 'undefined') { + // Show welcome message with animation + var libraryName = visualizerAI.chart_library && visualizerAI.chart_library.toLowerCase() === 'chartjs' ? 'Chart.js' : 'Google Charts'; + setTimeout(function() { + addAIMessage('šŸ‘‹ Hello! I\'m your AI chart assistant. I can help you customize this ' + visualizerAI.chart_type + ' chart (' + libraryName + ').\n\n✨ Try a Quick Action above, choose a Preset, or ask me anything!'); + }, 300); + } else { + console.error('visualizerAI is not defined!'); + } + + // Initialize collapsible sections + initCollapsibleSections(); + + // Handle send message + $('#visualizer-ai-send-message').on('click', function(e) { + e.preventDefault(); + sendMessage(); + }); + + // Handle enter key in textarea + $('#visualizer-ai-prompt').on('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Handle clear chat + $('#visualizer-ai-clear-chat').on('click', function(e) { + e.preventDefault(); + clearChat(); + }); + + // Handle show suggestions + $('#visualizer-ai-show-suggestions').on('click', function(e) { + e.preventDefault(); + $('#visualizer-ai-prompt').val('What can I customize for this chart? Please give me some suggestions.'); + sendMessage(); + }); + }); + + function initCollapsibleSections() { + // Make all section titles collapsible + $('.viz-group-title').on('click', function() { + var $group = $(this).parent('.viz-group'); + $group.toggleClass('collapsed'); + + // Save state in localStorage + var groupId = $group.attr('id'); + if (groupId) { + var collapsed = $group.hasClass('collapsed'); + localStorage.setItem('visualizer_section_' + groupId, collapsed); + } + }); + + // Restore collapsed state from localStorage + $('.viz-group').each(function() { + var groupId = $(this).attr('id'); + if (groupId) { + var isCollapsed = localStorage.getItem('visualizer_section_' + groupId); + if (isCollapsed === 'true') { + $(this).addClass('collapsed'); + } + } + }); + } + + function sendMessage() { + var prompt = $('#visualizer-ai-prompt').val().trim(); + var model = $('.visualizer-ai-model-select').val(); + + if (!prompt) { + return; + } + + // Add user message to chat + addUserMessage(prompt); + + // Clear input + $('#visualizer-ai-prompt').val(''); + + // Show loading + $('.visualizer-ai-loading').show(); + $('#visualizer-ai-send-message').prop('disabled', true); + + // Get current manual configuration + var currentManualConfig = $('#visualizer-manual-config').val().trim(); + + var requestData = { + action: 'visualizer-ai-generate-config', + nonce: visualizerAI.nonce, + prompt: prompt, + model: model || 'openai', + chart_type: visualizerAI.chart_type, + chart_library: visualizerAI.chart_library, + chat_history: JSON.stringify(chatHistory), + current_config: currentManualConfig + }; + + $.ajax({ + url: visualizerAI.ajaxurl, + type: 'POST', + data: requestData, + success: function(response) { + $('.visualizer-ai-loading').hide(); + $('#visualizer-ai-send-message').prop('disabled', false); + + if (response.success) { + var data = response.data; + + // Add AI response to chat + addAIMessage(data.message); + + // Auto-apply configuration if provided (no preview, just apply) + if (data.configuration) { + currentConfig = data.configuration; + + addConfigPreview(data.configuration); + + // Auto-apply immediately + applyConfiguration(true); // Show success message + } + + // Add to history + chatHistory.push({ + role: 'user', + content: prompt + }); + chatHistory.push({ + role: 'assistant', + content: data.message + }); + } else { + addErrorMessage(data.message || 'An error occurred'); + } + }, + error: function(xhr, status, error) { + console.error('Error:', {xhr: xhr, status: status, error: error}); + $('.visualizer-ai-loading').hide(); + $('#visualizer-ai-send-message').prop('disabled', false); + + var errorMsg = 'An error occurred. Please try again.'; + if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + errorMsg = xhr.responseJSON.data.message; + } + + addErrorMessage(errorMsg); + } + }); + } + + function addUserMessage(message) { + var html = '
' + + '
' + + 'You:
' + escapeHtml(message) + + '
'; + + $('#visualizer-ai-messages').append(html); + scrollToBottom(); + } + + function addAIMessage(message) { + var html = '
' + + '
' + + 'AI Assistant:
' + escapeHtml(message).replace(/\n/g, '
') + + '
'; + + $('#visualizer-ai-messages').append(html); + scrollToBottom(); + } + + function addConfigPreview(config) { + try { + var parsed = JSON.parse(config); + var formatted = JSON.stringify(parsed, null, 2); + + var html = '
' + + '
' + + '
Configuration JSON:
' + + '
' + escapeHtml(formatted) + '
' + + '
'; + + $('#visualizer-ai-messages').append(html); + scrollToBottom(); + } catch (e) { + console.error('Error formatting config:', e); + } + } + + function addErrorMessage(message) { + var html = '
' + + '
' + + 'Error:
' + escapeHtml(message) + + '
'; + + $('#visualizer-ai-messages').append(html); + scrollToBottom(); + } + + function clearChat() { + chatHistory = []; + currentConfig = null; + $('#visualizer-ai-messages').empty(); + + // Show welcome message again + if (typeof visualizerAI !== 'undefined') { + addAIMessage('Chat cleared. How can I help you customize your ' + visualizerAI.chart_type + ' chart?'); + } + } + + function applyConfiguration(showMessage) { + if (!currentConfig) { + return; + } + + // Default to showing message if not specified + if (typeof showMessage === 'undefined') { + showMessage = true; + } + + var manualConfigTextarea = $('#visualizer-manual-config'); + + if (manualConfigTextarea.length) { + var existingConfig = manualConfigTextarea.val().trim(); + var finalConfig = currentConfig; + var wasMerged = false; + + // If there's existing configuration, merge them + if (existingConfig) { + try { + var existing = JSON.parse(existingConfig); + var newConfig = JSON.parse(currentConfig); + + // Deep merge + var merged = $.extend(true, {}, existing, newConfig); + finalConfig = JSON.stringify(merged, null, 2); + wasMerged = true; + + if (showMessage) { + addAIMessage('āœ“ I\'ve merged the new configuration with your existing settings and applied it!'); + } + } catch (e) { + console.error('Error merging configurations:', e); + try { + var parsed = JSON.parse(currentConfig); + finalConfig = JSON.stringify(parsed, null, 2); + } catch (e2) { + finalConfig = currentConfig; + } + } + } else { + try { + var parsed = JSON.parse(currentConfig); + finalConfig = JSON.stringify(parsed, null, 2); + } catch (e) { + finalConfig = currentConfig; + } + + if (showMessage) { + addAIMessage('āœ“ Configuration applied! Your chart preview should update automatically.'); + } + } + + manualConfigTextarea.val(finalConfig); + + // Trigger events to update preview + // Use setTimeout to ensure the value is set before triggering events + setTimeout(function() { + // Trigger on the ID selector + manualConfigTextarea.trigger('change'); + manualConfigTextarea.trigger('keyup'); + manualConfigTextarea.trigger('input'); + + // Also trigger on the name selector that preview.js uses + $('textarea[name="manual"]').trigger('change'); + $('textarea[name="manual"]').trigger('keyup'); + }, 100); + + // Don't scroll if we're auto-applying + if (showMessage) { + // Scroll to manual configuration + $('html, body').animate({ + scrollTop: manualConfigTextarea.offset().top - 100 + }, 500); + } + } else { + if (showMessage) { + addErrorMessage('Manual configuration field not found.'); + } + } + } + + function scrollToBottom() { + var container = $('#visualizer-ai-chat-container'); + if (container.length && container[0]) { + container.scrollTop(container[0].scrollHeight); + } + } + + function escapeHtml(text) { + var map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, function(m) { return map[m]; }); + } + +})(jQuery); diff --git a/js/frame.js b/js/frame.js index 95abd0ed7..0150fb04f 100644 --- a/js/frame.js +++ b/js/frame.js @@ -7,13 +7,7 @@ /* global vizHaveSettingsChanged */ (function ($) { - $(window).on('load', function(){ - let chart_select = $('#chart-select'); - if(chart_select.length > 0){ - // scroll to the selected chart type. - $('#chart-select')[0].scrollIntoView(); - } - }); + // Auto-scroll removed - scroll position is now managed in Types.php to keep AI image upload visible $(document).ready(function () { onReady(); @@ -258,7 +252,19 @@ } }); - enable_libraries_for($('input.type-radio:checked').val(), $typeVsLibrary); + // Get type vs library mapping from the select element + const rendererSelect = document.querySelector('select.viz-select-library'); + if (rendererSelect) { + const mappingData = rendererSelect.getAttribute('data-type-vs-library'); + if (mappingData) { + try { + const typeVsLibrary = JSON.parse(mappingData); + enable_libraries_for($('input.type-radio:checked').val(), typeVsLibrary); + } catch (e) { + console.error('Error parsing type-vs-library data:', e); + } + } + } } function enable_libraries_for($type, $typeVsLibrary) { @@ -270,7 +276,16 @@ $('select.viz-select-library option[value="' + $lib + '"]').removeClass('disabled').removeAttr('disabled'); $('select.viz-select-library option[value="' + $lib + '"] .premium-label').remove(); }); - $('select.viz-select-library').val( $('select.viz-select-library option:not(.disabled)').val() ); + + // Only change the selected value if the current selection is disabled or empty + var $select = $('select.viz-select-library'); + var currentVal = $select.val(); + var $currentOption = $select.find('option[value="' + currentVal + '"]'); + + // If current value is empty, disabled, or not in the enabled list, change to first enabled option + if (!currentVal || $currentOption.hasClass('disabled') || $currentOption.attr('disabled')) { + $select.val( $select.find('option:not(.disabled)').val() ); + } } function init_permissions(){