[ 'default', 'no-images', 'no-preview', ], 'csv_delimiter' => [ ',', "\t", ' ', ';', ], 'defer_rendering' => [ '', 'enabled', ], ]; private $plugin_url; /** * Constructor */ public function __construct() { $this->plugin_url = m_chart()->plugin_url(); add_action( 'admin_init', [ $this, 'admin_init' ] ); add_action( 'admin_menu', [ $this, 'admin_menu' ] ); // Runs after Freemius adds its own submenu links so we can relabel/reposition the Upgrade link // Freemius hooks its menu at WP_FS__LOWEST_PRIORITY so we go one higher add_action( 'admin_menu', [ $this, 'admin_submenu_links' ], ( defined( 'WP_FS__LOWEST_PRIORITY' ) ? WP_FS__LOWEST_PRIORITY : 999999998 ) + 1 ); add_action( 'admin_print_footer_scripts', [ $this, 'admin_print_footer_scripts' ] ); add_action( 'admin_head', [ $this, 'admin_head' ] ); add_action( 'current_screen', [ $this, 'current_screen' ] ); add_action( 'admin_footer', [ $this, 'admin_footer' ] ); add_action( 'wp_ajax_m_chart_export_csv', [ $this, 'ajax_export_csv' ] ); add_action( 'wp_ajax_m_chart_get_chart_args', [ $this, 'ajax_get_chart_args' ] ); add_action( 'wp_ajax_m_chart_import_csv', [ $this, 'ajax_import_csv' ] ); add_action( 'edit_form_before_permalink', [ $this, 'edit_form_before_permalink' ] ); add_action( 'manage_' . m_chart()->slug . '_posts_custom_column', [ $this, 'manage_posts_custom_column' ], 10, 2 ); add_action( 'm_chart_settings_admin', [ $this, 'm_chart_settings_admin' ] ); add_filter( 'manage_' . m_chart()->slug . '_posts_columns', [ $this, 'manage_posts_columns' ] ); } /** * Look for save settings submissions */ public function admin_init() { $this->save_settings(); add_action( 'admin_notices', [ $this, 'library_warning' ] ); } /** * Add settings admin page */ public function admin_menu() { add_submenu_page( 'edit.php?post_type=' . m_chart()->slug, esc_html__( 'M Chart Settings', 'm-chart' ), esc_html__( 'Settings', 'm-chart' ), 'manage_options', m_chart()->slug . '-settings', [ $this, 'm_chart_settings' ] ); } /** * Arrange the extra Charts submenu links * * Runs at a priority above Freemius so its pricing link already exists when we relabel and reposition it * Handles the Upgrade link, the Docs link, and the per library Add Chart links all in one place so ordering is predictable */ public function admin_submenu_links() { global $submenu; $menu_slug = 'edit.php?post_type=' . m_chart()->slug; // Freemius adds its own pricing/upgrade submenu link for free users // We relabel it to Upgrade and move it just above the Docs link // Parsing the page slug out of the upgrade URL lets us find Freemius's entry without relying on its internals // Repositioning leaves the page route registered so the link still works $pricing_slug = ''; $query = wp_parse_url( m_chart()->freemius()->get_upgrade_url(), PHP_URL_QUERY ); if ( $query ) { parse_str( $query, $query_args ); $pricing_slug = $query_args['page'] ?? ''; } if ( '' !== $pricing_slug && ! empty( $submenu[ $menu_slug ] ) ) { foreach ( $submenu[ $menu_slug ] as $position => $item ) { if ( isset( $item[2] ) && $item[2] === $pricing_slug ) { unset( $submenu[ $menu_slug ][ $position ] ); $item[0] = esc_html__( 'Upgrade', 'm-chart' ); $submenu[ $menu_slug ][99] = $item; break; } } } // Pin the Freemius Account link to a predictable order just below Settings // Parse the page slug out of the account URL so this survives the menu slug scheme rather than hardcoding it // Freemius's own ordering can drift against our ksort so we set an explicit position $account_slug = ''; $query = wp_parse_url( m_chart()->freemius()->get_account_url(), PHP_URL_QUERY ); if ( $query ) { parse_str( $query, $query_args ); $account_slug = $query_args['page'] ?? ''; } if ( '' !== $account_slug && ! empty( $submenu[ $menu_slug ] ) ) { foreach ( $submenu[ $menu_slug ] as $position => $item ) { if ( isset( $item[2] ) && $item[2] === $account_slug ) { unset( $submenu[ $menu_slug ][ $position ] ); $submenu[ $menu_slug ][96] = $item; break; } } } // Docs link — sits at the bottom of the Charts submenu // The third array element is the href; WordPress treats it as a full URL when it includes a scheme // target="_blank" is added by admin_print_footer_scripts() since WP's $submenu API doesn't accept link attributes $submenu[ $menu_slug ][100] = [ esc_html__( 'Docs', 'm-chart' ), 'edit_posts', 'https://docs.mch.art', ]; // If multiple libraries are active we'll give you the option of using each one // @TODO As written this will break if there's ever more than 10 active libraries... so yeah $libraries = m_chart()->get_libraries(); if ( 1 < count( $libraries ) ) { // Put the default library into the admin menu first $args = [ 'post_type' => m_chart()->slug, 'library' => m_chart()->get_library(), ]; $submenu[ $menu_slug ][10] = [ 'Add ' . $libraries[ m_chart()->get_library() ] . ' Chart', 'edit_posts', add_query_arg( $args, admin_url( 'post-new.php' ) ), ]; unset( $libraries[ m_chart()->get_library() ] ); // Add a Add Chart option for each active library that isn't the current default $key = 11; foreach ( $libraries as $library => $library_name ) { $args = [ 'post_type' => m_chart()->slug, 'library' => $library, ]; $submenu[ $menu_slug ][ $key ] = [ 'Add ' . $library_name . ' Chart', 'edit_posts', add_query_arg( $args, admin_url( 'post-new.php' ) ), ]; $key++; } } // Gotta sort them so they're in the right order if ( ! empty( $submenu[ $menu_slug ] ) ) { ksort( $submenu[ $menu_slug ] ); } } /** * Add target="_blank" to the Docs link in the Charts submenu * * WordPress's add_submenu_page() / $submenu API has no concept of link attributes * So we do it via a tiny footer script that runs on every admin page since the menu is global */ public function admin_print_footer_scripts() { ?> for the Charts admin menu * * Lives inline rather than in the main SCSS bundle because the sidebar renders on every admin page * and that bundle only enqueues on chart screens * * First block hides Freemius's "↳" sub-item arrow on the Account link * Printed for every user since the Account link shows for paying users too * * Second block renders the Upgrade submenu link as a filled pill button with a trailing trendingUp icon * Only printed for free users since that is when the Upgrade link exists * The pill mimics the Pro plugin's menu badge - accent fill via --wp-admin-theme-color, white text, darker accent on hover * The icon is the trendingUp icon from @wordpress/icons masked so background-color: currentColor tints it white to match the text */ public function admin_head() { // Freemius prefixes its sub-submenu items with a "↳" arrow via span.fs-submenu-item.fs-sub:before // The #adminmenu prefix beats that selector so we can drop the glyph on our Account link ?> freemius()->is_free_plan() ) { return; } // Parse the pricing page slug from the upgrade URL so the selector tracks the Freemius menu slug scheme // Matching the full page slug avoids also styling the Settings link $pricing_slug = ''; $query = wp_parse_url( m_chart()->freemius()->get_upgrade_url(), PHP_URL_QUERY ); if ( $query ) { parse_str( $query, $query_args ); $pricing_slug = $query_args['page'] ?? ''; } // No pricing slug means there's no Upgrade link to style if ( '' === $pricing_slug ) { return; } $pricing_attr = esc_attr( $pricing_slug ); ?> get_settings(); require_once __DIR__ . '/templates/m-chart-settings.php'; } /** * Check for and save M Chart settings */ public function save_settings() { if ( ! current_user_can( 'manage_options' ) ) { return; } // Check the nonce $nonce = $_POST[ m_chart()->slug ]['nonce'] ?? ''; if ( ! isset( $_POST[ m_chart()->slug ] ) || ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-settings' ) ) { return; } $previous_settings = m_chart()->get_settings(); $validated_settings = []; $submitted_settings = $_POST[ m_chart()->slug ]; $default_settings = apply_filters( 'm_chart_default_settings', m_chart()->settings ); foreach ( $default_settings as $setting => $default ) { if ( ! isset( $submitted_settings[ $setting ] ) ) { $validated_settings[ $setting ] = $default; continue; } // Default chart height is numeric so clamp it to the same range as the per-chart height field if ( 'default_height' === $setting ) { $validated_settings[ $setting ] = min( 1500, max( 300, absint( $submitted_settings[ $setting ] ) ) ); continue; } if ( isset( $this->safe_settings[ $setting ] ) ) { // If we've got an array of valid values lets check against that $safe_setting = $this->safe_settings[ $setting ]; if ( in_array( $submitted_settings[ $setting ], $safe_setting, true ) ) { $validated_settings[ $setting ] = $submitted_settings[ $setting ]; } else { $validated_settings[ $setting ] = $default; } } else { // Make sure the value is a string and matches the safe pattern before saving it // Non-scalar submissions (e.g. an array from a library-plugin-added setting) fall back o the default // Plugins that need to persist complex shapes should hook 'm_chart_validated_settings' below to inject their own validated value $value = $submitted_settings[ $setting ]; if ( is_string( $value ) && preg_match( '#^[a-zA-Z0-9-_]+$#', $value ) ) { $validated_settings[ $setting ] = $value; } else { $validated_settings[ $setting ] = $default; } } } // Allow third party libraries to further validate the settings $validated_settings = apply_filters( 'm_chart_validated_settings', $validated_settings, $submitted_settings ); update_option( m_chart()->slug, $validated_settings ); // Only flush rewrite rules when the embed endpoint is actually being toggled $previous_embeds = $previous_settings['embeds'] ?? ''; $current_embeds = $validated_settings['embeds'] ?? ''; if ( $previous_embeds !== $current_embeds ) { flush_rewrite_rules(); } add_action( 'admin_notices', [ $this, 'save_success' ] ); } /** * Display an admin notice that the settings have been saved */ public function save_success() { ?>

m_chart()->slug, 'posts_per_page' => 1, 'post_status' => 'any', 'tax_query' => [ [ 'taxonomy' => m_chart()->slug . '-library', 'field' => 'slug', 'terms' => 'highcharts', ], ], ] ); if ( ! $highcharts_check ) { return; } ?>

' . esc_html__( 'M Chart Highcharts Library', 'm-chart' ) . '', esc_html__( 'You have charts that require the M Chart Highcharts Library plugin.', 'm-chart' ) ); ?>

slug !== $screen->post_type ) { return; } // Only load these if we are on a post page if ( 'post' === $screen->base ) { // Jspreadsheet CE — needed by both chartjs (React) and other libraries (jQuery) wp_enqueue_style( 'jspreadsheet', $this->plugin_url . '/components/external/jspreadsheet/jspreadsheet.css', [], m_chart()->version ); wp_enqueue_script( 'jspreadsheet', $this->plugin_url . '/components/external/jspreadsheet/jspreadsheet.js', [ 'jsuites' ], m_chart()->version ); // jSuites — required by Jspreadsheet wp_enqueue_style( 'jsuites', $this->plugin_url . '/components/external/jsuites/jsuites.css', [], m_chart()->version ); wp_enqueue_script( 'jsuites', $this->plugin_url . '/components/external/jsuites/jsuites.js', [], m_chart()->version ); // Admin UI React app $admin_app_asset = require __DIR__ . '/admin-ui/index.asset.php'; wp_enqueue_script( 'm-chart-admin-ui', $this->plugin_url . '/components/admin-ui/index.js', array_merge( $admin_app_asset['dependencies'], [ 'wp-hooks' ] ), $admin_app_asset['version'], [ 'strategy' => 'defer' ] ); wp_set_script_translations( 'm-chart-admin-ui', 'm-chart', plugin_dir_path( __DIR__ ) . 'components/languages/' ); // We need the library and post ID for a bunch of stuff below $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : ''; $library = m_chart()->get_library(); if ( ! empty( $post_id ) ) { $library = m_chart()->get_post_meta( absint( $post_id ), 'library' ); } elseif ( 'post' === $screen->base && 'add' === $screen->action && isset( $_GET['library'] ) && m_chart()->is_valid_library( $_GET['library'] ) ) { $library = $_GET['library']; } if ( 'chartjs' === $library ) { // Chart.js libs — enqueued explicitly so the React preview has window.Chart and window // MChartHelper available before m-chart-admin-ui runs its plugin registration // We load every plugin regardless of immediate need when in the edit view since the user can switch chart types from the picker wp_enqueue_script( 'chartjs-helper' ); wp_enqueue_script( 'chartjs-datalabels' ); wp_enqueue_script( 'chartjs-treemap' ); wp_enqueue_script( 'chartjs-boxplot' ); } $post_meta = m_chart()->get_post_meta( $post_id ); $spreadsheet_data = empty( $post_meta['data'] ) ? [ [ '' ] ] : $post_meta['data']['sets']; unset( $post_meta['data'] ); // passed separately as spreadsheet_data // Collect library-specific config for the React admin app $type_options = []; $type_option_names = []; $themes = []; if ( m_chart()->library( $library ) ) { $library_class = m_chart()->library( $library ); $type_options = $library_class->type_options; $type_option_names = $library_class->type_option_names; foreach ( $library_class->get_themes() as $theme ) { $themes[] = [ 'slug' => $theme->slug, 'name' => $theme->name, ]; } } // Format unit terms as an array of {group, units} for easy JS mapping $unit_terms = []; foreach ( m_chart()->get_unit_terms() as $group => $units ) { $group_units = []; foreach ( $units as $unit ) { $group_units[] = [ 'name' => $unit->name, 'slug' => $unit->slug ]; } $unit_terms[] = [ 'group' => $group, 'units' => $group_units, ]; } $chart_image = m_chart()->get_chart_image( $post_id ); // Compute initial chart args for the React preview (React-enabled libraries, existing posts only) $initial_chart_args = null; if ( $post_id && m_chart()->library( $library ) ) { $initial_chart_args = m_chart()->library( $library )->get_chart_args( $post_id, m_chart()->get_chart_default_args, true, // force recompute false // don't store in cache ); } // Build CSV delimiter map for React's CsvControls component $csv_delimiters = []; foreach ( m_chart()->csv_delimiters as $delimiter => $delimiter_name ) { $csv_delimiters[ $delimiter ] = $delimiter_name; } $localize_data = [ 'slug' => m_chart()->slug, 'version' => m_chart()->version, 'refresh_counter' => 0, 'allow_form_submission' => false, 'request' => false, 'performance' => m_chart()->get_settings( 'performance' ), 'image_support' => apply_filters( 'm_chart_image_support', 'no', $library ), 'instant_preview_support' => apply_filters( 'm_chart_instant_preview_support', 'no', $library ), 'image_multiplier' => m_chart()->get_settings( 'image_multiplier' ), 'image_width' => m_chart()->get_settings( 'image_width' ), 'library' => $library, 'set_names' => m_chart()->get_post_meta( $post_id, 'set_names' ), 'post_id' => $post_id, 'nonce' => wp_create_nonce( m_chart()->slug . '-save-post' ), 'ajax_url' => admin_url( 'admin-ajax.php' ), 'post_meta' => $post_meta, 'spreadsheet_data' => $spreadsheet_data, 'type_options' => $type_options, 'type_option_names' => $type_option_names, 'themes' => $themes, 'unit_terms' => $unit_terms, 'image_url' => $chart_image ? esc_url( $chart_image['url'] ) : '', 'chart_args' => $initial_chart_args, 'csv_delimiters' => $csv_delimiters, 'default_delimiter' => m_chart()->get_settings( 'csv_delimiter' ), 'multi_sheet_types' => m_chart()->get_multi_sheet_types(), ]; wp_localize_script( 'm-chart-admin-ui', 'm_chart_admin', $localize_data ); do_action( 'm_chart_admin_scripts', $library, $post_id ); } // Admin panel CSS wp_enqueue_style( 'm-chart-admin', $this->plugin_url . '/components/css/m-chart-admin.css', [], m_chart()->version ); } /** * Add all of the metaboxes needed for the data and chart editing interface */ public function meta_boxes() { global $wp_meta_boxes; // Remove excerpt from its normal spot in the meta_boxes array so we can put it back in after the spreadsheet // Users can move metaboxes, but this helps put things in a reasonable place on the first visit $excerpt = $wp_meta_boxes[ m_chart()->slug ][ 'normal' ][ 'core' ][ 'postexcerpt' ]; unset( $wp_meta_boxes[ m_chart()->slug ][ 'normal' ][ 'core' ][ 'postexcerpt' ] ); add_meta_box( m_chart()->slug . '-spreadsheet', esc_html__( 'Data', 'm-chart' ), [ $this, 'spreadsheet_meta_box' ], m_chart()->slug, 'normal', 'high' ); add_meta_box( m_chart()->slug, esc_html__( 'Chart', 'm-chart' ), [ $this, 'chart_meta_box' ], m_chart()->slug, 'normal', 'high' ); $wp_meta_boxes[ m_chart()->slug ][ 'normal' ][ 'high' ][ 'postexcerpt' ] = $excerpt; // We are using our own interface for the units so we can remove the units taxonomy metabox remove_meta_box( m_chart()->slug . '-unitsdiv', m_chart()->slug, 'side' ); } /** * Displays the spreadsheet meta box * * @param object the WP post object as returned by the metabox API */ public function spreadsheet_meta_box( $post ) { echo '
'; echo ''; wp_nonce_field( m_chart()->slug . '-save-post', $this->get_field_name( 'nonce' ) ); } /** * Displays the chart meta box * * @param object the WP post object as returned by the metabox API */ public function chart_meta_box( $post ) { // Force an instance of 1 since we NEVER show more than one chart at a time inside the admin panel m_chart()->instance = 1; $post_meta = m_chart()->get_post_meta( $post->ID ); $image = m_chart()->get_chart_image( $post->ID ); $settings = m_chart()->get_settings(); require_once __DIR__ . '/templates/chart-meta-box.php'; } /** * Insert CSV Import and Export forms into the footer when editing charts */ public function admin_footer() { $screen = get_current_screen(); if ( 'post' !== $screen->base || m_chart()->slug !== $screen->post_type ) { return; } ?> slug !== $post->post_type ) { return; } echo '
'; } /** * Display some additional information about a chart * * @param string the name of the custom column being displayed * @param string the $post_id of the post being displayed in this row */ public function manage_posts_custom_column( $column, $post_id ) { if ( m_chart()->slug . '-type' !== $column && m_chart()->slug . '-library' !== $column ) { return; } $library = m_chart()->get_post_meta( $post_id, 'library' ); $library_instance = m_chart()->library( $library ); if ( ! $library_instance || $library_instance->library !== $library ) { ?> slug . '-type' === $column ) { $type = m_chart()->get_post_meta( $post_id, 'type' ); $type_name = $library_instance->type_option_names[ $type ]; ?> slug . '-library' === $column ) { $library_name = $library_instance->library_name; ?> get_settings(); require __DIR__ . '/templates/m-chart-settings-chartjs.php'; } /** * Add our custom column to the array of columns for charts * * @param array the array of columns * * @return array array of columns with the custom column added */ public function manage_posts_columns( $columns ) { $new_columns = []; foreach ( $columns as $column => $name ) { $new_columns[ $column ] = $name; if ( 'author' === $column || 'coauthors' === $column ) { $new_columns[ m_chart()->slug . '-type' ] = 'Type'; if ( 'yes' === m_chart()->get_settings( 'show_library' ) ) { $new_columns[ m_chart()->slug . '-library' ] = 'Library'; } } } return $new_columns; } /** * Hook to save_post action and save chart related post meta * * @param int the WP post ID of the post being saved */ public function save_post( $post_id ) { $post = get_post( $post_id ); // Check that this isn't an autosave if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } // Check post type if ( ! isset( $post->post_type ) || m_chart()->slug !== $post->post_type ) { return; } // Don't run on post revisions (almost always happens just before the real post is saved) if ( wp_is_post_revision( $post->ID ) ) { return; } // Make sure we've got some actual M Chart related data in the $_POST array if ( ! isset( $_POST[ m_chart()->slug ] ) ) { return; } // Check the nonce $nonce = $_POST[ m_chart()->slug ]['nonce'] ?? ''; if ( ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) ) { return; } unset( $_POST[ m_chart()->slug ]['nonce'] ); // Check the permissions if ( ! current_user_can( 'edit_post', $post->ID ) ) { return; } // If there's an image being passed attach it to the chart post $this->attach_image(); // Make sure we don't overwrite existing settings in the case someone hits update too quickly if ( isset( $_POST[ m_chart()->slug ]['library'] ) // Make sure the library value is clean and valid before trying to use it && m_chart()->is_valid_library( $_POST[ m_chart()->slug ]['library'] ) ) { $library = sanitize_key( $_POST[ m_chart()->slug ]['library'] ); // Load the library in question in case there's a filter/action we'll need m_chart()->library( $library ); // update_post_meta passes the $_POST values directly to validate_post_meta // validate_post_meta returns only valid post meta values and does data validation on each item m_chart()->update_post_meta( $post->ID, $_POST[ m_chart()->slug ] ); } } /** * Attach a given image to a chart post * * @param int the WP post ID of the post being saved * @param string a base64 encoded string of the image we want to attach */ public function attach_image() { $settings = m_chart()->get_settings(); // If the performance setting isn't turned to default we don't do this if ( 'default' !== $settings['performance'] ) { return false; } if ( ! is_numeric( $_POST['post_ID'] ?? '' ) ) { return false; } $post_id = absint( $_POST['post_ID'] ); // Make sure the library used on this post supports images if ( 'no' === apply_filters( 'm_chart_image_support', 'no', m_chart()->get_post_meta( $post_id, 'library' ) ) ) { return false; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return false; } if ( ! $post = get_post( $post_id ) ) { return false; } $img_data = $_POST[ m_chart()->slug ]['img'] ?? ''; if ( '' === $img_data ) { return false; } // Decode the image so we can save it $decoded_img = base64_decode( str_replace( 'data:image/png;base64,', '', $img_data ) ); // Reject anything that isn't a real PNG before writing it to disk if ( false === $decoded_img || '' === $decoded_img || "\x89PNG\r\n\x1a\n" !== substr( $decoded_img, 0, 8 ) ) { return false; } // Cap the image size at 5MB to keep a runaway client from filling disk if ( strlen( $decoded_img ) > 5 * 1024 * 1024 ) { return false; } // Check for an existing attached image $attachments = get_posts( [ 'post_type' => 'attachment', 'posts_per_page' => 1, 'post_parent' => $post->ID, 'meta_key' => m_chart()->slug . '-image', ] ); // If an existing image was found delete it foreach ( $attachments as $attachment ) { wp_delete_attachment( $attachment->ID, true ); } // Upload image to WP $file = wp_upload_bits( sanitize_title( $post->post_title . '-' . $post->ID ) . '.png', null, $decoded_img ); // START acting like media_sideload_image preg_match( '/[^\?]+\.(jpe?g|jpe|gif|png)\b/i', $file['file'], $matches ); $file_array['name'] = basename( $matches[0] ); $file_array['tmp_name'] = $file['file']; if ( is_wp_error( $file ) ) { @unlink( $file_array['tmp_name'] ); $file_array['tmp_name'] = ''; } $img_id = media_handle_sideload( $file_array, $post->ID, $post->post_title ); if ( is_wp_error( $img_id ) ) { @unlink( $file_array['tmp_name'] ); return $img_id; } // STOP acting like media_sideload_image // Set some meta on the attachment so we know it came from m-chart add_post_meta( $img_id, m_chart()->slug . '-image', $post->ID ); // Set the attachment as the chart's thumbnail update_post_meta( $post->ID, '_thumbnail_id', $img_id ); } /** * Parses an incoming CSV file and compiles it into an array * * @return array an array of the data from the imported CSV file ready for use in the chart meta */ public function ajax_import_csv() { $post = get_post( absint( $_POST['post_id'] ?? 0 ) ); // Check post type if ( ! isset( $post->post_type ) || m_chart()->slug !== $post->post_type ) { wp_send_json_error( esc_html__( 'Wrong post type', 'm-chart' ) ); } // Check the nonce $nonce = $_POST['nonce'] ?? ''; if ( ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) ) { wp_send_json_error( esc_html__( 'Invalid nonce', 'm-chart' ) ); } // Check the permissions if ( ! current_user_can( 'edit_post', $post->ID ) ) { wp_send_json_error( esc_html__( 'Wrong post type', 'm-chart' ) ); } // Make sure there's a CSV file if ( empty( $_FILES['import_csv_file']['name'] ) ) { wp_send_json_error( esc_html__( 'No file to import', 'm-chart' ) ); } // Check upload-level errors first if ( UPLOAD_ERR_OK !== ( $_FILES['import_csv_file']['error'] ?? UPLOAD_ERR_NO_FILE ) ) { wp_send_json_error( esc_html__( 'File upload error', 'm-chart' ) ); } if ( ! is_uploaded_file( $_FILES['import_csv_file']['tmp_name'] ) ) { wp_send_json_error( esc_html__( 'Invalid upload', 'm-chart' ) ); } // Verify both extension AND MIME (some browsers send text/plain for CSV) $file_check = wp_check_filetype_and_ext( $_FILES['import_csv_file']['tmp_name'], $_FILES['import_csv_file']['name'], [ 'csv' => 'text/csv', 'csv-alt' => 'text/plain' ] ); if ( 'csv' !== ( $file_check['ext'] ?? '' ) ) { wp_send_json_error( esc_html__( 'Only CSV files can be imported', 'm-chart' ) ); } // Do some validation on the CSV file (mirroring what WP does for this sort of thing) $csv_file = realpath( $_FILES['import_csv_file']['tmp_name'] ); if ( ! $csv_file ) { wp_send_json_error( esc_html__( 'File path not found', 'm-chart' ) ); } // Cap file size at 2MB to prevent resource exhaustion if ( filesize( $csv_file ) > 2 * 1024 * 1024 ) { wp_send_json_error( esc_html__( 'CSV file too large (max 2MB)', 'm-chart' ) ); } $csv_data = file_get_contents( $csv_file ); if ( '' === $csv_data ) { wp_send_json_error( esc_html__( 'CSV file was empty', 'm-chart' ) ); } // Get parseCSV library so we can use it to convert the CSV to a nice array // Yes, PHP does this natively now but I've run into trouble with malformed CSV that parsCSV handles fine require_once __DIR__ . '/external/parsecsv/parsecsv.lib.php'; $parse_csv = new parseCSV(); // The "\n" before and after is to deal with CSV files that don't have line breaks above and below the data // Which then seems to confuse parseCSV occasionally $csv_data = "\n" . trim( $csv_data ) . "\n"; // Set delimiter but check to make sure it's safe first $parse_csv->delimiter = isset( $_POST['csv_delimiter'] ) && in_array( $_POST['csv_delimiter'], $this->safe_settings[ 'csv_delimiter' ] ) ? $_POST['csv_delimiter'] : m_chart()->get_settings( 'csv_delimiter' ); // Parse the CSV $parse_csv->parse( $csv_data ); // This deals with Google Doc's crappy CSV exports which don't include columns at the end of a row if they are empty $data_array = $this->fix_csv_data( $parse_csv->data ); wp_send_json_success( $data_array ); } /** * Helper function makes sure that the data array has matching numbers of array elements for each row * CSV from some sources (Google Docs) doesn't include columns that are empty when they are at the end of a row (Why Google? WHY?) * * @param array an array of data as returned from the parseCSV class * * @return array the array of data with matching array value counts */ public function fix_csv_data( $data_array ) { $count = 0; // Get largest row count foreach ( $data_array as $data ) { $temp_count = count( $data ); $count = ( $temp_count > $count ) ? $temp_count : $count; } // Fix arrays so value counts match foreach ( $data_array as $key => $data ) { $temp_count = count( $data ); if ( $temp_count < $count ) { $difference = $count - $temp_count; for ( $i = 0; $i < $difference; $i++ ) { $data_array[ $key ][] = ''; } } } return $data_array; } /** * Converts data array into CSV and outputs it to the browser */ public function ajax_export_csv() { $post_id = $_REQUEST['post_id'] ?? ''; $nonce = $_REQUEST['nonce'] ?? ''; if ( ! is_numeric( $post_id ) || ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) || ! current_user_can( 'edit_post', absint( $post_id ) ) ) { wp_die( esc_html__( 'Unauthorized access', 'm-chart' ), esc_html__( 'You do not have permission to do that', 'm-chart' ), [ 'response' => 401 ] ); } $post = get_post( absint( $post_id ) ); // If the user passed a data value in their request we'll use it after validation if ( isset( $_POST['data'] ) && isset( $_POST['title'] ) ) { $data = m_chart()->validate_data( json_decode( stripslashes( $_POST['data'] ) ) ); $file_name = sanitize_title( $_POST['title'] ); } else { $data = m_chart()->get_post_meta( $post->ID, 'data' ); $file_name = sanitize_title( get_the_title( $post->ID ) ); } $set_name = sanitize_title( $_REQUEST['set_name'] ?? '' ); if ( empty( $data ) ) { return; } // Prevent CSV/formula injection by prefixing any cell that begins with a formula trigger so spreadsheet apps see it as a literal string array_walk_recursive( $data, function ( &$cell ) { $cell = $this->neutralize_csv_cell( $cell ); } ); require_once __DIR__ . '/external/parsecsv/parsecsv.lib.php'; $parse_csv = new parseCSV(); // Set delimiter $parse_csv->output_delimiter = m_chart()->get_settings( 'csv_delimiter' ); $parse_csv->output( $file_name . '-' . $set_name . '.csv', $data ); die; } /** * Prefix a cell value with a single quote when it starts with a character that Excel/Sheets/Numbers interpret as a formula trigger * * @param mixed $cell The raw cell value * @return string The cell value, prefixed with ' if it would otherwise execute */ public function neutralize_csv_cell( $cell ) { $cell = (string) $cell; if ( '' !== $cell && in_array( $cell[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) { return "'" . $cell; } return $cell; } /** * Returns JSON encoded chart args from $_POST values sent from the admin panel * * @return string a JSON encoded string containing all of the chart args needed to update an active chart */ public function ajax_get_chart_args() { // Check the nonce $nonce = $_POST['nonce'] ?? ''; if ( ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) ) { wp_send_json_error( esc_html__( 'Invalid nonce', 'm-chart' ) ); } // Does the post exist? (post_id is 0 for new charts that haven't been saved yet) $post_id = absint( $_POST['post_id'] ?? 0 ); if ( $post_id ) { if ( ! $post = get_post( $post_id ) ) { wp_send_json_error( esc_html__( 'Invalid post', 'm-chart' ) ); } if ( ! current_user_can( 'edit_post', $post->ID ) ) { wp_send_json_error( esc_html__( 'Permission error', 'm-chart' ) ); } } else { // New chart — no saved post yet, build a stub so the library can compute chart args if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( esc_html__( 'Permission error', 'm-chart' ) ); } $post = new WP_Post( (object) [ 'ID' => 0, 'post_title' => '', 'post_type' => m_chart()->slug, 'post_status' => 'auto-draft', ] ); } // Is this a valid library? $library_slug = $_POST['library'] ?? ''; if ( ! m_chart()->is_valid_library( $library_slug ) ) { wp_send_json_error( esc_html__( 'Invalid library', 'm-chart' ) ); } // This does get potentially overwritten later on // However, it's necessary for initial load on a new chart if ( 'highcharts' === $library_slug ) { $library = m_chart()->library( $library_slug ); } $library = apply_filters( 'm_chart_library_class', m_chart()->library_class, $library_slug ); // Make sure a third-party filter didn't replace the library with something unusable if ( ! is_object( $library ) || ! method_exists( $library, 'get_chart_args' ) ) { wp_send_json_error( esc_html__( 'Invalid library', 'm-chart' ) ); } // Set these values so that get_chart_args has them already available before we call it $library->args = m_chart()->get_chart_default_args; $library->post = $post; $library->post->post_title = sanitize_text_field( $_POST['title'] ?? '' ); // validate_post_meta returns only valid post meta values and does data validation on each item $library->post_meta = m_chart()->validate_post_meta( $_POST['post_meta'] ?? [] ); wp_send_json_success( $library->get_chart_args( $library->post->ID, $library->args, true, false ) ); } /** * Return a name spaced field name * * @param string the field name we want to name space * * @param string a name spaced field name */ public function get_field_name( $field_name, $parent_field_name = '' ) { if ( '' !== $parent_field_name ) { return m_chart()->slug . '[' . $parent_field_name . ']' . '[' . $field_name . ']'; } return m_chart()->slug . '[' . $field_name . ']'; } /** * Return a name spaced field id * * @param string the field id we want to name space * * @param string a name spaced field id */ public function get_field_id( $field_name ) { return m_chart()->slug . '-' . $field_name; } }