_addAction( 'wp_abilities_api_categories_init', 'registerCategory' ); $this->_addAction( 'wp_abilities_api_init', 'registerAbilities' ); } /** * Registers the ability category. * * @access public * @return void */ public function registerCategory() { if ( ! function_exists( 'wp_register_ability_category' ) ) { return; } wp_register_ability_category( self::CATEGORY, array( 'label' => __( 'Visualizer', 'visualizer' ), 'description' => __( 'Create, read and refresh Visualizer charts and tables.', 'visualizer' ), ) ); } /** * Registers the abilities. * * @access public * @return void */ public function registerAbilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } $column_schema = array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string', 'description' => __( 'Column label.', 'visualizer' ), ), 'type' => array( 'type' => 'string', 'description' => __( 'Column data type.', 'visualizer' ), 'enum' => Visualizer_Source::getAllowedTypes(), ), ), 'required' => array( 'name', 'type' ), ); $embed_schema = array( 'type' => 'object', 'properties' => array( 'shortcode' => array( 'type' => 'string' ), 'block' => array( 'type' => 'string' ), ), ); $rows_schema = array( 'type' => 'array', 'description' => __( 'Data rows. Each row is an array of cell values in column order.', 'visualizer' ), 'items' => array( 'type' => 'array', ), ); $preview_schema = array( 'type' => 'object', 'properties' => array( 'columns' => array( 'type' => 'array', 'items' => $column_schema, ), 'rows' => $rows_schema, 'row_count' => array( 'type' => 'integer' ), ), ); wp_register_ability( 'visualizer/list-charts', array( 'label' => __( 'List charts', 'visualizer' ), 'description' => __( 'Lists the Visualizer charts the current user can manage, with their type, library, data source and shortcode.', 'visualizer' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'default' => array(), 'properties' => array( 'page' => array( 'type' => 'integer', 'description' => __( 'Page of results.', 'visualizer' ), 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'description' => __( 'Charts per page.', 'visualizer' ), 'default' => 20, 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE, ), 'type' => array( 'type' => 'string', 'description' => __( 'Only return charts of this type (for example pie, line, bar, tabular).', 'visualizer' ), ), 'search' => array( 'type' => 'string', 'description' => __( 'Search term matched against the chart settings (title), like the chart library search.', 'visualizer' ), ), ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'charts' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'title' => array( 'type' => 'string' ), 'type' => array( 'type' => 'string' ), 'library' => array( 'type' => 'string' ), 'source' => array( 'type' => 'string' ), 'modified' => array( 'type' => 'string' ), 'shortcode' => array( 'type' => 'string' ), ), ), ), 'total' => array( 'type' => 'integer' ), 'total_pages' => array( 'type' => 'integer' ), ), ), 'execute_callback' => array( $this, 'executeListCharts' ), 'permission_callback' => array( $this, 'canManageCharts' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); wp_register_ability( 'visualizer/get-chart', array( 'label' => __( 'Get chart', 'visualizer' ), 'description' => __( 'Returns the type, columns, rows, options, data source and embed markup (shortcode and block) of a chart. Source credentials are never returned.', 'visualizer' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'chart_id' => array( 'type' => 'integer', 'description' => __( 'The chart ID.', 'visualizer' ), 'minimum' => 1, ), ), 'required' => array( 'chart_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'title' => array( 'type' => 'string' ), 'type' => array( 'type' => 'string' ), 'library' => array( 'type' => 'string' ), 'columns' => array( 'type' => 'array', 'items' => $column_schema, ), 'rows' => $rows_schema, 'options' => array( 'type' => 'object', 'additionalProperties' => true, ), 'source' => array( 'type' => 'object', 'additionalProperties' => true, ), 'embed' => $embed_schema, ), ), 'execute_callback' => array( $this, 'executeGetChart' ), 'permission_callback' => array( $this, 'canEditChartInput' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); wp_register_ability( 'visualizer/upsert-chart', array( 'label' => __( 'Create or update chart', 'visualizer' ), 'description' => __( 'Creates a chart (no chart_id) or updates one (chart_id given) from typed columns, rows and options. With dry_run=true nothing is saved and the parsed data preview is returned. Chart types that need a higher plan are rejected.', 'visualizer' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'chart_id' => array( 'type' => 'integer', 'description' => __( 'The chart to update. Omit to create a new chart.', 'visualizer' ), 'minimum' => 1, ), 'title' => array( 'type' => 'string', 'description' => __( 'Chart name shown in the chart library.', 'visualizer' ), ), 'type' => array( 'type' => 'string', 'description' => __( 'Chart type, for example pie, line, bar, column, area, tabular. Required when creating.', 'visualizer' ), ), 'library' => array( 'type' => 'string', 'description' => __( 'Rendering library. Defaults to the first library the chart type supports.', 'visualizer' ), 'enum' => array( 'GoogleCharts', 'ChartJS', 'DataTable' ), ), 'columns' => array( 'type' => 'array', 'description' => __( 'Typed columns. Required when creating; when updating, send together with rows to replace the chart data.', 'visualizer' ), 'items' => $column_schema, ), 'rows' => $rows_schema, 'options' => array( 'type' => 'object', 'description' => __( 'Chart settings merged over the existing ones (for example title, legend, colors).', 'visualizer' ), 'additionalProperties' => true, ), 'dry_run' => array( 'type' => 'boolean', 'description' => __( 'Validate and return the parsed data preview without saving.', 'visualizer' ), 'default' => false, ), ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'created' => array( 'type' => 'boolean' ), 'embed' => $embed_schema, 'dry_run' => array( 'type' => 'boolean' ), 'valid' => array( 'type' => 'boolean' ), 'parsed_preview' => $preview_schema, 'errors' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ), 'execute_callback' => array( $this, 'executeUpsertChart' ), 'permission_callback' => array( $this, 'canUpsertChart' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); wp_register_ability( 'visualizer/set-data-source', array( 'label' => __( 'Set chart data source', 'visualizer' ), 'description' => __( 'Points a chart at a remote CSV/XLSX file, a JSON endpoint or a WordPress database query, imports the data and optionally saves a refresh interval. Requires a plan that includes the chosen source. With dry_run=true nothing is saved and the fetched preview is returned.', 'visualizer' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'chart_id' => array( 'type' => 'integer', 'description' => __( 'The chart ID.', 'visualizer' ), 'minimum' => 1, ), 'source' => array( 'type' => 'string', 'description' => __( 'Source kind.', 'visualizer' ), 'enum' => array( 'csv_url', 'json', 'db_query' ), ), 'url' => array( 'type' => 'string', 'description' => __( 'URL of the CSV/XLSX file (csv_url) or of the JSON endpoint (json). The CSV must have the labels in row 1 and the data types in row 2.', 'visualizer' ), ), 'json_root' => array( 'type' => 'string', 'description' => __( 'Root node that holds the rows, in the format the chart editor uses: "root" for the top level, "root>data>results" for nested nodes. Run with dry_run=true and no mapping to list the available roots.', 'visualizer' ), ), 'json_method' => array( 'type' => 'string', 'enum' => array( 'GET', 'POST' ), 'default' => 'GET', ), 'json_paging' => array( 'type' => 'string', 'description' => __( 'Node that holds the next page URL, same format as json_root.', 'visualizer' ), ), 'mapping' => array( 'type' => 'array', 'description' => __( 'JSON sources only: the JSON keys to use as columns, in order, with their data type. Run with dry_run=true first to see the available keys.', 'visualizer' ), 'items' => $column_schema, ), 'query' => array( 'type' => 'string', 'description' => __( 'db_query sources only: a single SELECT statement run against the WordPress database.', 'visualizer' ), ), 'refresh_interval' => array( 'type' => 'number', 'description' => __( 'Hours between automatic refreshes. -1 imports once. Only the intervals offered by the chart editor for the active plan are accepted.', 'visualizer' ), 'default' => -1, ), 'dry_run' => array( 'type' => 'boolean', 'default' => false, ), ), 'required' => array( 'chart_id', 'source' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'source' => array( 'type' => 'string' ), 'refresh_interval' => array( 'type' => 'number' ), 'dry_run' => array( 'type' => 'boolean' ), 'available_roots' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), 'available_keys' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), 'parsed_preview' => $preview_schema, 'embed' => $embed_schema, ), ), 'execute_callback' => array( $this, 'executeSetDataSource' ), 'permission_callback' => array( $this, 'canSetDataSource' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); wp_register_ability( 'visualizer/refresh-chart-data', array( 'label' => __( 'Refresh chart data', 'visualizer' ), 'description' => __( 'Fetches the current data for a chart from its saved remote CSV/XLSX, JSON or database source and stores it.', 'visualizer' ), 'category' => self::CATEGORY, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'chart_id' => array( 'type' => 'integer', 'description' => __( 'The chart ID.', 'visualizer' ), 'minimum' => 1, ), ), 'required' => array( 'chart_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'source' => array( 'type' => 'string' ), 'refreshed' => array( 'type' => 'boolean' ), 'row_count' => array( 'type' => 'integer' ), ), ), 'execute_callback' => array( $this, 'executeRefreshChartData' ), 'permission_callback' => array( $this, 'canEditChartInput' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); } // ------------------------------------------------------------------------- // Permissions // ------------------------------------------------------------------------- /** * Same capability as the chart library page and the charts list endpoint. * * @access public * @return bool */ public function canManageCharts() { return current_user_can( 'edit_posts' ); } /** * Same checks as the chart editor: edit_posts plus Visualizer_Module::can_edit_chart(). * * @access public * * @param mixed $input The ability input. * @return bool */ public function canEditChartInput( $input = array() ) { if ( ! current_user_can( 'edit_posts' ) ) { return false; } $chart_id = $this->getChartId( $input ); // A missing or invalid ID is reported by the execute callback. return ! $chart_id || ! $this->getChart( $chart_id ) || self::can_edit_chart( $chart_id ); } /** * Creating needs edit_posts; updating additionally needs can_edit_chart(). * * @access public * * @param mixed $input The ability input. * @return bool */ public function canUpsertChart( $input = array() ) { return $this->canEditChartInput( $input ); } /** * Chart editor checks, plus the database import checks for SQL sources. * * @access public * * @param mixed $input The ability input. * @return bool */ public function canSetDataSource( $input = array() ) { if ( ! $this->canEditChartInput( $input ) ) { return false; } if ( is_array( $input ) && isset( $input['source'] ) && 'db_query' === $input['source'] ) { // Same as Visualizer_Module_Chart::saveQuery(). return current_user_can( 'administrator' ) && is_super_admin(); } return true; } // ------------------------------------------------------------------------- // Execute callbacks // ------------------------------------------------------------------------- /** * Lists charts. * * @access public * * @param mixed $input The ability input. * @return array|WP_Error */ public function executeListCharts( $input = array() ) { $input = is_array( $input ) ? $input : array(); $page = isset( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1; $per_page = isset( $input['per_page'] ) ? absint( $input['per_page'] ) : 20; $per_page = min( self::MAX_PER_PAGE, max( 1, $per_page ) ); $query_args = array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, 'post_status' => 'publish', 'posts_per_page' => $per_page, 'paged' => $page, ); // Same restriction as Visualizer_Module_Chart::getCharts(). if ( ! current_user_can( 'edit_others_posts' ) ) { $query_args['author'] = get_current_user_id(); } $meta_query = array(); if ( ! empty( $input['type'] ) ) { $type = sanitize_text_field( $input['type'] ); if ( ! in_array( $type, Visualizer_Plugin::getChartTypes(), true ) ) { return new WP_Error( 'visualizer_invalid_type', __( 'Unknown chart type.', 'visualizer' ) ); } $meta_query[] = array( 'key' => Visualizer_Plugin::CF_CHART_TYPE, 'value' => $type, 'compare' => '=', ); } if ( ! empty( $input['search'] ) ) { $meta_query[] = array( 'key' => Visualizer_Plugin::CF_SETTINGS, 'value' => sanitize_text_field( $input['search'] ), 'compare' => 'LIKE', ); } if ( $meta_query ) { $query_args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } $query = new WP_Query( apply_filters( 'visualizer_query_args', $query_args ) ); $charts = array(); foreach ( $query->posts as $chart ) { if ( ! $chart instanceof WP_Post ) { continue; } $charts[] = array( 'id' => $chart->ID, 'title' => $this->getChartTitle( $chart ), 'type' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ), 'library' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ), 'source' => $this->getSourceKind( $chart->ID ), 'modified' => (string) $chart->post_modified_gmt, 'shortcode' => $this->getShortcode( $chart->ID ), ); } return array( 'charts' => $charts, 'total' => (int) $query->found_posts, 'total_pages' => (int) $query->max_num_pages, ); } /** * Returns one chart. * * @access public * * @param mixed $input The ability input. * @return array|WP_Error */ public function executeGetChart( $input = array() ) { $chart = $this->getEditableChart( $input ); if ( is_wp_error( $chart ) ) { return $chart; } $type = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ); $series = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SERIES, get_post_meta( $chart->ID, Visualizer_Plugin::CF_SERIES, true ), $chart->ID, $type ); $data = self::get_chart_data( $chart, $type ); $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true ); $settings = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $settings, $chart->ID, $type ); if ( ! is_array( $settings ) ) { $settings = array(); } // The chart preview image is a large data URI, not an option. unset( $settings['chart-img'] ); return array( 'id' => $chart->ID, 'title' => $this->getChartTitle( $chart ), 'type' => $type, 'library' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ), 'columns' => $this->seriesToColumns( $series ), 'rows' => $this->normalizeRows( $data ), 'options' => $settings, 'source' => $this->describeSource( $chart->ID ), 'embed' => $this->getEmbed( $chart->ID ), ); } /** * Creates or updates a chart from typed columns and rows. * * @access public * * @param mixed $input The ability input. * @return array|WP_Error */ public function executeUpsertChart( $input = array() ) { $input = is_array( $input ) ? $input : array(); $dry_run = ! empty( $input['dry_run'] ); $chart = null; if ( ! empty( $input['chart_id'] ) ) { $chart = $this->getEditableChart( $input ); if ( is_wp_error( $chart ) ) { return $chart; } } $has_data = isset( $input['columns'] ) || isset( $input['rows'] ); if ( ! $chart && ! $has_data ) { return new WP_Error( 'visualizer_missing_data', __( 'columns and rows are required to create a chart.', 'visualizer' ) ); } // Chart type and library, gated like the chart type page. $type = $chart ? (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ) : ''; if ( isset( $input['type'] ) && '' !== $input['type'] ) { $type = sanitize_text_field( $input['type'] ); } if ( '' === $type ) { return new WP_Error( 'visualizer_missing_type', __( 'type is required to create a chart.', 'visualizer' ) ); } $type_changed = ! $chart || isset( $input['type'] ) || isset( $input['library'] ); $library = $chart ? (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ) : ''; if ( $type_changed ) { $library = $this->resolveLibrary( $type, isset( $input['library'] ) ? (string) $input['library'] : ( isset( $input['type'] ) ? '' : $library ) ); if ( is_wp_error( $library ) ) { return $library; } } $source = null; if ( $has_data ) { $source = $this->buildManualSource( $input ); if ( is_wp_error( $source ) ) { if ( $dry_run ) { return array( 'dry_run' => true, 'valid' => false, 'errors' => $source->get_error_messages(), ); } return $source; } } $options = array(); if ( isset( $input['options'] ) ) { if ( ! is_array( $input['options'] ) ) { return new WP_Error( 'visualizer_invalid_options', __( 'options must be an object.', 'visualizer' ) ); } // Same sanitization as the settings form (Visualizer_Module_Chart::sanitizeSettings()). $options = map_deep( $input['options'], 'sanitize_textarea_field' ); unset( $options['chart-img'] ); } if ( $dry_run ) { $result = array( 'dry_run' => true, 'valid' => true, 'errors' => array(), ); if ( $source ) { $result['parsed_preview'] = $this->getPreview( $source->getSeries(), $source->getRawData() ); } return $result; } $created = false; $this->disableRevisionsTemporarily(); if ( ! $chart ) { // Same defaults as the chart creation flow in Visualizer_Module_Chart::renderChartPages(). $chart_id = wp_insert_post( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, 'post_title' => 'Visualization', 'post_author' => get_current_user_id(), 'post_status' => 'auto-draft', 'post_content' => $source->getData(), ), true ); if ( is_wp_error( $chart_id ) ) { return $chart_id; } add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, $type ); add_post_meta( $chart_id, Visualizer_Plugin::CF_DEFAULT_DATA, 0 ); add_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, $source->getSourceName() ); add_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $source->getSeries() ); add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, $library ); add_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, array( 'focusTarget' => 'datum' ) ); do_action( 'visualizer_pro_new_chart_defaults', $chart_id ); $chart = get_post( $chart_id ); if ( ! $chart instanceof WP_Post ) { return new WP_Error( 'visualizer_create_failed', __( 'The chart could not be created.', 'visualizer' ) ); } Visualizer_Module_Utility::set_defaults( $chart ); $created = true; } else { if ( $type_changed ) { update_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, $type ); update_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, $library ); } if ( $source ) { $this->clearSourceMeta( $chart->ID, true ); $this->persistSource( $chart->ID, $source ); } } // Settings, stored the way the settings form stores them. $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true ); if ( ! is_array( $settings ) ) { $settings = array(); } $settings = array_merge( $settings, $options ); $post_update = array( 'ID' => $chart->ID ); if ( isset( $input['title'] ) && '' !== trim( (string) $input['title'] ) ) { $title = sanitize_text_field( $input['title'] ); $settings['backend-title'] = $title; $post_update['post_title'] = $title; } $internal_title = ''; if ( ! empty( $settings['title'] ) ) { $internal_title = is_array( $settings['title'] ) ? ( isset( $settings['title']['text'] ) ? $settings['title']['text'] : '' ) : $settings['title']; } $settings['internal_title'] = '' !== (string) $internal_title ? $internal_title : $chart->ID; if ( empty( $settings['pieResidueSliceLabel'] ) ) { $settings['pieResidueSliceLabel'] = esc_html__( 'Other', 'visualizer' ); } update_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, $settings ); if ( $created ) { $post_update['post_status'] = 'publish'; } if ( count( $post_update ) > 1 ) { wp_update_post( $post_update ); } $this->clearChartCache( $chart->ID ); return array( 'id' => $chart->ID, 'created' => $created, 'embed' => $this->getEmbed( $chart->ID ), ); } /** * Configures a remote CSV/XLSX, JSON or database source for a chart. * * @access public * * @param mixed $input The ability input. * @return array|WP_Error */ public function executeSetDataSource( $input = array() ) { $input = is_array( $input ) ? $input : array(); $chart = $this->getEditableChart( $input ); if ( is_wp_error( $chart ) ) { return $chart; } $kind = isset( $input['source'] ) ? sanitize_key( $input['source'] ) : ''; $dry_run = ! empty( $input['dry_run'] ); $features = array( 'csv_url' => 'import-url', 'json' => 'import-url', 'db_query' => 'db-query', ); if ( ! isset( $features[ $kind ] ) ) { return new WP_Error( 'visualizer_invalid_source', __( 'source must be one of csv_url, json or db_query.', 'visualizer' ) ); } if ( ! $this->isFeatureAvailable( $features[ $kind ] ) ) { return $this->planError( 'visualizer_feature_unavailable', __( 'This data source is not available on the current Visualizer plan.', 'visualizer' ), $features[ $kind ] ); } $schedule_types = array( 'csv_url' => 'csv', 'json' => 'json', 'db_query' => 'db', ); $hours = $this->resolveInterval( $input, $schedule_types[ $kind ], $chart->ID ); if ( is_wp_error( $hours ) ) { return $hours; } switch ( $kind ) { case 'csv_url': return $this->setCsvUrlSource( $chart, $input, $hours, $dry_run ); case 'json': return $this->setJsonSource( $chart, $input, $hours, $dry_run ); default: return $this->setQuerySource( $chart, $input, $hours, $dry_run ); } } /** * Re-fetches the data of a chart from its saved source. * * @access public * * @param mixed $input The ability input. * @return array|WP_Error */ public function executeRefreshChartData( $input = array() ) { $chart = $this->getEditableChart( $input ); if ( is_wp_error( $chart ) ) { return $chart; } $source_class = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_SOURCE, true ); switch ( $source_class ) { case 'Visualizer_Source_Query': case 'Visualizer_Source_Json': // Handled by Visualizer_Module_Setup::refresh_db_for_chart(). delete_post_meta( $chart->ID, Visualizer_Plugin::CF_ERROR ); apply_filters( 'visualizer_schedule_refresh_chart', $chart, $chart->ID, true ); $error = get_post_meta( $chart->ID, Visualizer_Plugin::CF_ERROR, true ); if ( ! empty( $error ) ) { return new WP_Error( 'visualizer_refresh_failed', sanitize_text_field( $error ) ); } break; case 'Visualizer_Source_Csv_Remote': case 'Visualizer_Source_Xlsx_Remote': $url = $this->getRemoteFileUrl( $chart ); if ( '' === $url ) { return new WP_Error( 'visualizer_no_source', __( 'The chart has no saved source URL.', 'visualizer' ) ); } $source = $this->fetchSource( new $source_class( $url ) ); if ( is_wp_error( $source ) ) { return $source; } $this->disableRevisionsTemporarily(); $this->persistSource( $chart->ID, $source ); break; default: return new WP_Error( 'visualizer_not_refreshable', __( 'The chart data was entered manually or uploaded as a file, so there is no source to refresh from.', 'visualizer' ) ); } $this->clearChartCache( $chart->ID ); $chart = get_post( $chart->ID ); $rows = $chart instanceof WP_Post ? self::get_chart_data( $chart, '' ) : array(); return array( 'id' => (int) $input['chart_id'], 'source' => $this->getSourceKind( (int) $input['chart_id'] ), 'refreshed' => true, 'row_count' => is_array( $rows ) ? count( $rows ) : 0, ); } // ------------------------------------------------------------------------- // Source helpers // ------------------------------------------------------------------------- /** * Remote CSV/XLSX source, same flow as the "Import from URL" form in Visualizer_Module_Chart::uploadData(). * * @param WP_Post $chart The chart. * @param array $input The ability input. * @param float $hours The refresh interval. * @param bool $dry_run Whether to skip saving. * @return array|WP_Error */ private function setCsvUrlSource( $chart, $input, $hours, $dry_run ) { $url = $this->validateUrl( $input ); if ( is_wp_error( $url ) ) { return $url; } $extension = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ); $source = 'xlsx' === $extension ? new Visualizer_Source_Xlsx_Remote( $url ) : new Visualizer_Source_Csv_Remote( $url ); $source = $this->fetchSource( $source ); if ( is_wp_error( $source ) ) { return $source; } if ( $dry_run ) { return array( 'id' => $chart->ID, 'source' => 'csv_url', 'dry_run' => true, 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ), ); } $this->disableRevisionsTemporarily(); $this->clearSourceMeta( $chart->ID, $hours < 0 ); $this->persistSource( $chart->ID, $source ); if ( $hours >= 0 ) { apply_filters( 'visualizer_pro_chart_schedule', $chart->ID, $url, $hours ); } $this->clearChartCache( $chart->ID ); return array( 'id' => $chart->ID, 'source' => 'csv_url', 'refresh_interval' => $hours, 'dry_run' => false, 'embed' => $this->getEmbed( $chart->ID ), ); } /** * JSON source, same flow as Visualizer_Module_Chart::setJsonData(). * * @param WP_Post $chart The chart. * @param array $input The ability input. * @param float $hours The refresh interval. * @param bool $dry_run Whether to skip saving. * @return array|WP_Error */ private function setJsonSource( $chart, $input, $hours, $dry_run ) { $url = $this->validateUrl( $input ); if ( is_wp_error( $url ) ) { return $url; } $params = array( 'url' => $url, 'root' => ( isset( $input['json_root'] ) && is_string( $input['json_root'] ) && '' !== trim( $input['json_root'] ) ) ? sanitize_text_field( $input['json_root'] ) : 'root', 'method' => ( isset( $input['json_method'] ) && 'POST' === strtoupper( (string) $input['json_method'] ) ) ? 'POST' : 'GET', ); if ( ! empty( $input['json_paging'] ) && is_string( $input['json_paging'] ) ) { $params['paging'] = sanitize_text_field( $input['json_paging'] ); } // Credentials saved from the chart editor are reused for the same host only; they are never accepted or returned here. $saved_headers = get_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_HEADERS, true ); $saved_url = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_URL, true ); $auth = array(); if ( is_array( $saved_headers ) && ! empty( $saved_headers['auth'] ) && '' !== $saved_url && wp_parse_url( $saved_url, PHP_URL_HOST ) === wp_parse_url( $url, PHP_URL_HOST ) && wp_parse_url( $saved_url, PHP_URL_SCHEME ) === wp_parse_url( $url, PHP_URL_SCHEME ) ) { $auth = $saved_headers['auth']; if ( is_array( $auth ) && isset( $auth['username'], $auth['password'] ) ) { $params['username'] = $auth['username']; $params['password'] = $auth['password']; } elseif ( is_string( $auth ) ) { $params['auth'] = $auth; } } // First pass: discover the keys available under the chosen root. $probe = $this->fetchSource( new Visualizer_Source_Json( $params ) ); if ( is_wp_error( $probe ) ) { return $probe; } $raw = $probe->getRawData(); $first = $raw ? reset( $raw ) : null; $keys = is_array( $first ) ? array_map( 'strval', array_keys( $first ) ) : array(); $mapping = isset( $input['mapping'] ) ? $input['mapping'] : array(); if ( ! $keys && ( $mapping || ! $dry_run ) ) { return new WP_Error( 'visualizer_fetch_failed', __( 'Unable to fetch data from the endpoint. Check the URL and the root.', 'visualizer' ) ); } if ( ! $mapping ) { if ( $dry_run ) { $roots = ( new Visualizer_Source_Json( $params ) )->fetchRoots(); return array( 'id' => $chart->ID, 'source' => 'json', 'dry_run' => true, 'available_roots' => is_array( $roots ) ? array_values( array_map( 'strval', $roots ) ) : array(), 'available_keys' => $keys, ); } return new WP_Error( 'visualizer_missing_mapping', __( 'mapping is required for JSON sources. Run with dry_run=true to list the available keys.', 'visualizer' ) ); } $columns = $this->validateColumns( $mapping ); if ( is_wp_error( $columns ) ) { return $columns; } $params['header'] = array(); $params['type'] = array(); foreach ( $columns as $column ) { if ( ! in_array( $column['name'], $keys, true ) ) { /* translators: %s: JSON key. */ return new WP_Error( 'visualizer_invalid_mapping', sprintf( __( 'The key "%s" does not exist in the JSON data.', 'visualizer' ), $column['name'] ) ); } $params['header'][] = $column['name']; $params['type'][ $column['name'] ] = $column['type']; } $source = new Visualizer_Source_Json( $params ); try { $source->fetchFromEditableTable(); } catch ( Exception $e ) { return new WP_Error( 'visualizer_fetch_failed', __( 'The data could not be parsed with the given column types.', 'visualizer' ) ); } if ( ! $source->getSeries() ) { return new WP_Error( 'visualizer_invalid_mapping', __( 'No columns could be mapped.', 'visualizer' ) ); } if ( $dry_run ) { return array( 'id' => $chart->ID, 'source' => 'json', 'dry_run' => true, 'available_keys' => $keys, 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ), ); } $this->disableRevisionsTemporarily(); $this->clearSourceMeta( $chart->ID, true ); update_post_meta( $chart->ID, Visualizer_Plugin::CF_EDITABLE_TABLE, true ); $this->persistSource( $chart->ID, $source ); update_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_URL, $params['url'] ); update_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_ROOT, $params['root'] ); $headers = array( 'method' => $params['method'] ); if ( $auth ) { $headers['auth'] = $auth; } add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_HEADERS, $headers ); if ( ! empty( $params['paging'] ) ) { add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_PAGING, $params['paging'] ); } // Same as Visualizer_Module_Chart::setJsonSchedule(). $schedules = get_option( Visualizer_Plugin::CF_JSON_SCHEDULE, array() ); $schedules = is_array( $schedules ) ? $schedules : array(); if ( $hours >= 0 ) { add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_SCHEDULE, $hours ); $schedules[ $chart->ID ] = time() + $hours * HOUR_IN_SECONDS; } else { unset( $schedules[ $chart->ID ] ); } update_option( Visualizer_Plugin::CF_JSON_SCHEDULE, $schedules ); $this->clearChartCache( $chart->ID ); return array( 'id' => $chart->ID, 'source' => 'json', 'refresh_interval' => $hours, 'dry_run' => false, 'embed' => $this->getEmbed( $chart->ID ), ); } /** * WordPress database query source, same flow as Visualizer_Module_Chart::saveQuery(). * * @param WP_Post $chart The chart. * @param array $input The ability input. * @param float $hours The refresh interval. * @param bool $dry_run Whether to skip saving. * @return array|WP_Error */ private function setQuerySource( $chart, $input, $hours, $dry_run ) { // Repeated here because the permission callback only sees the raw input. if ( ! current_user_can( 'administrator' ) || ! is_super_admin() ) { return new WP_Error( 'visualizer_forbidden', __( 'Action not allowed for this user.', 'visualizer' ) ); } $query = isset( $input['query'] ) && is_string( $input['query'] ) ? trim( trim( $input['query'] ), ';' ) : ''; if ( '' === $query ) { return new WP_Error( 'visualizer_missing_query', __( 'query is required for db_query sources.', 'visualizer' ) ); } // No connection parameters: the query always runs against the WordPress database. $source = new Visualizer_Source_Query( $query, $chart->ID, array( 'db_type' => Visualizer_Plugin::WP_DB_NAME ) ); $source->fetch( false ); $error = $source->get_error(); if ( ! empty( $error ) ) { return new WP_Error( 'visualizer_query_failed', sanitize_text_field( $error ) ); } if ( ! $source->getSeries() ) { return new WP_Error( 'visualizer_query_failed', __( 'The query returned no rows.', 'visualizer' ) ); } if ( $dry_run ) { return array( 'id' => $chart->ID, 'source' => 'db_query', 'dry_run' => true, 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ), ); } $this->disableRevisionsTemporarily(); $this->clearSourceMeta( $chart->ID, true ); update_post_meta( $chart->ID, Visualizer_Plugin::CF_DB_QUERY, $query ); update_post_meta( $chart->ID, Visualizer_Plugin::CF_DB_SCHEDULE, $hours ); $this->persistSource( $chart->ID, $source ); $schedules = get_option( Visualizer_Plugin::CF_DB_SCHEDULE, array() ); $schedules = is_array( $schedules ) ? $schedules : array(); $schedules[ $chart->ID ] = time() + $hours * HOUR_IN_SECONDS; update_option( Visualizer_Plugin::CF_DB_SCHEDULE, $schedules ); $this->clearChartCache( $chart->ID ); return array( 'id' => $chart->ID, 'source' => 'db_query', 'refresh_interval' => $hours, 'dry_run' => false, 'embed' => $this->getEmbed( $chart->ID ), ); } /** * Builds a CSV source from typed columns and rows, the format the manual data editor submits. * * @param array $input The ability input. * @return Visualizer_Source|WP_Error */ private function buildManualSource( $input ) { $columns = $this->validateColumns( isset( $input['columns'] ) ? $input['columns'] : null ); if ( is_wp_error( $columns ) ) { return $columns; } $rows = isset( $input['rows'] ) ? $input['rows'] : null; if ( ! is_array( $rows ) || ! $rows ) { return new WP_Error( 'visualizer_invalid_rows', __( 'rows must be a non-empty array of arrays.', 'visualizer' ) ); } if ( count( $rows ) > self::MAX_ROWS ) { /* translators: %d: maximum number of rows. */ return new WP_Error( 'visualizer_too_many_rows', sprintf( __( 'A maximum of %d rows is accepted.', 'visualizer' ), self::MAX_ROWS ) ); } $errors = new WP_Error(); $lines = array( wp_list_pluck( $columns, 'name' ), wp_list_pluck( $columns, 'type' ) ); foreach ( array_values( $rows ) as $index => $row ) { if ( ! is_array( $row ) || count( $row ) > count( $columns ) ) { /* translators: %d: row number. */ $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %d is not an array or has more cells than columns.', 'visualizer' ), $index + 1 ) ); continue; } $line = array(); foreach ( array_values( $row ) as $position => $cell ) { if ( is_bool( $cell ) ) { $cell = $cell ? 'true' : 'false'; } elseif ( null === $cell ) { $cell = ''; } elseif ( ! is_scalar( $cell ) ) { /* translators: %d: row number. */ $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %d contains a value that is not a string, number or boolean.', 'visualizer' ), $index + 1 ) ); continue 2; } $cell = sanitize_text_field( (string) $cell ); if ( 'number' === $columns[ $position ]['type'] && '' !== $cell && ! is_numeric( str_replace( ',', '', $cell ) ) ) { /* translators: 1: row number, 2: column label. */ $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %1$d: "%2$s" expects a number.', 'visualizer' ), $index + 1, $columns[ $position ]['name'] ) ); continue 2; } $line[] = $cell; } $lines[] = array_pad( $line, count( $columns ), '' ); if ( count( $errors->get_error_messages() ) >= 20 ) { break; } } if ( $errors->has_errors() ) { return $errors; } $tmpfile = wp_tempnam( Visualizer_Plugin::NAME ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $handle = $tmpfile ? fopen( $tmpfile, 'w' ) : false; if ( ! $handle ) { return new WP_Error( 'visualizer_tmp_file', __( 'A temporary file could not be created.', 'visualizer' ) ); } foreach ( $lines as $line ) { fputcsv( $handle, $line, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose $source = $this->fetchSource( new Visualizer_Source_Csv( $tmpfile ) ); wp_delete_file( $tmpfile ); if ( ! is_wp_error( $source ) && count( $source->getSeries() ) !== count( $columns ) ) { return new WP_Error( 'visualizer_invalid_columns', __( 'The columns could not be parsed. Check that every column has a label and a type.', 'visualizer' ) ); } return $source; } /** * Runs fetch() on a source and converts failures to WP_Error. * * @param Visualizer_Source $source The source. * @return Visualizer_Source|WP_Error */ private function fetchSource( $source ) { try { $fetched = $source->fetch(); } catch ( Exception $e ) { $fetched = false; } $error = $source->get_error(); if ( ! $fetched || ! empty( $error ) ) { return new WP_Error( 'visualizer_fetch_failed', ! empty( $error ) ? sanitize_text_field( $error ) : __( 'Could not parse data. Check the format and try again.', 'visualizer' ) ); } return $source; } /** * Stores the series and data of a fetched source, as the editors do after an import. * * @param int $chart_id The chart ID. * @param Visualizer_Source $source The fetched source. * @return void */ private function persistSource( $chart_id, $source ) { wp_update_post( array( 'ID' => $chart_id, 'post_content' => $source->getData(), ) ); update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $source->getSeries() ); update_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, $source->getSourceName() ); update_post_meta( $chart_id, Visualizer_Plugin::CF_DEFAULT_DATA, 0 ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_ERROR ); } /** * Removes the parameters of the previous source, as Visualizer_Module_Chart::uploadData() does before an import. * * @param int $chart_id The chart ID. * @param bool $remove_schedule Whether to remove the remote file schedule too. * @return void */ private function clearSourceMeta( $chart_id, $remove_schedule ) { if ( $remove_schedule ) { apply_filters( 'visualizer_pro_remove_schedule', $chart_id ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_URL ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_SCHEDULE ); } delete_post_meta( $chart_id, Visualizer_Plugin::CF_FILTER_CONFIG ); delete_post_meta( $chart_id, '__transient-' . Visualizer_Plugin::CF_FILTER_CONFIG ); delete_post_meta( $chart_id, '__transient-' . Visualizer_Plugin::CF_DB_QUERY ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_DB_SCHEDULE ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_REMOTE_DB_PARAMS ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_PAGING ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_ERROR ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_EDITOR ); delete_post_meta( $chart_id, Visualizer_Plugin::CF_EDITABLE_TABLE ); } /** * Clears the cached chart output. * * @param int $chart_id The chart ID. * @return void */ private function clearChartCache( $chart_id ) { delete_transient( Visualizer_Plugin::CF_CHART_CACHE . '_' . $chart_id ); } // ------------------------------------------------------------------------- // Validation helpers // ------------------------------------------------------------------------- /** * Extracts the chart ID from the input. * * @param mixed $input The ability input. * @return int */ private function getChartId( $input ) { return ( is_array( $input ) && isset( $input['chart_id'] ) ) ? absint( $input['chart_id'] ) : 0; } /** * Returns the chart post, or null when the ID is not a chart. * * @param int $chart_id The chart ID. * @return WP_Post|null */ private function getChart( $chart_id ) { $chart = $chart_id ? get_post( $chart_id ) : null; return ( $chart instanceof WP_Post && Visualizer_Plugin::CPT_VISUALIZER === $chart->post_type ) ? $chart : null; } /** * Returns the chart from the input when the current user can edit it. * * @param mixed $input The ability input. * @return WP_Post|WP_Error */ private function getEditableChart( $input ) { $chart = $this->getChart( $this->getChartId( $input ) ); if ( ! $chart ) { return new WP_Error( 'visualizer_chart_not_found', __( 'Chart not found.', 'visualizer' ) ); } if ( ! self::can_edit_chart( $chart->ID ) ) { return new WP_Error( 'visualizer_forbidden', __( 'You do not have permission to perform this action.', 'visualizer' ) ); } return $chart; } /** * Validates a list of {name, type} columns. * * @param mixed $columns The columns input. * @return array|WP_Error */ private function validateColumns( $columns ) { if ( ! is_array( $columns ) || ! $columns ) { return new WP_Error( 'visualizer_invalid_columns', __( 'columns must be a non-empty array of {name, type} objects.', 'visualizer' ) ); } if ( count( $columns ) > self::MAX_COLUMNS ) { /* translators: %d: maximum number of columns. */ return new WP_Error( 'visualizer_too_many_columns', sprintf( __( 'A maximum of %d columns is accepted.', 'visualizer' ), self::MAX_COLUMNS ) ); } $valid = array(); $names = array(); foreach ( $columns as $column ) { $name = ( is_array( $column ) && isset( $column['name'] ) && is_scalar( $column['name'] ) ) ? sanitize_text_field( wp_strip_all_tags( (string) $column['name'] ) ) : ''; $type = ( is_array( $column ) && isset( $column['type'] ) && is_string( $column['type'] ) ) ? $column['type'] : ''; if ( '' === $name || '0' === $name ) { return new WP_Error( 'visualizer_invalid_columns', __( 'Every column needs a non-empty name.', 'visualizer' ) ); } if ( in_array( $name, $names, true ) ) { /* translators: %s: column label. */ return new WP_Error( 'visualizer_invalid_columns', sprintf( __( 'Duplicate column name "%s".', 'visualizer' ), $name ) ); } if ( ! in_array( $type, Visualizer_Source::getAllowedTypes(), true ) ) { /* translators: 1: column label, 2: list of types. */ return new WP_Error( 'visualizer_invalid_columns', sprintf( __( 'Column "%1$s" has an invalid type. Allowed types: %2$s.', 'visualizer' ), $name, implode( ', ', Visualizer_Source::getAllowedTypes() ) ) ); } $names[] = $name; $valid[] = array( 'name' => $name, 'type' => $type, ); } return $valid; } /** * Validates the chart type against the active plan and resolves the library. * * @param string $type The chart type. * @param string $library The requested library, or empty for the default. * @return string|WP_Error */ private function resolveLibrary( $type, $library ) { $types = Visualizer_Module_Admin::_getChartTypesLocalized(); if ( ! isset( $types[ $type ] ) || ! is_array( $types[ $type ] ) ) { /* translators: %s: list of chart types. */ return new WP_Error( 'visualizer_invalid_type', sprintf( __( 'Unknown chart type. Available types: %s.', 'visualizer' ), implode( ', ', array_keys( $types ) ) ) ); } if ( ! Visualizer_Module_Admin::checkChartStatus( $type ) ) { return $this->planError( 'visualizer_type_unavailable', __( 'This chart type is not available on the current Visualizer plan.', 'visualizer' ), 'pro-chart-types' ); } $supported = isset( $types[ $type ]['supports'] ) ? (array) $types[ $type ]['supports'] : array( 'Google Charts' ); $supported = array_values( array_map( array( $this, 'removeSpaces' ), $supported ) ); if ( '' === $library ) { return $supported[0]; } if ( ! in_array( $library, $supported, true ) ) { /* translators: %s: list of libraries. */ return new WP_Error( 'visualizer_invalid_library', sprintf( __( 'This chart type supports these libraries on the current plan: %s.', 'visualizer' ), implode( ', ', $supported ) ) ); } return $library; } /** * Removes the spaces of a library label ("Google Charts" is stored as "GoogleCharts"). * * @access public * * @param string $label The library label. * @return string */ public function removeSpaces( $label ) { return str_replace( ' ', '', (string) $label ); } /** * Same plan check as the upsell overlays in the chart editor (Visualizer_Module_Sources::addProUpsell()). * * @param string $feature The feature key. * @return bool */ private function isFeatureAvailable( $feature ) { if ( in_array( $feature, (array) self::get_features_for_license( 2 ), true ) ) { return (bool) apply_filters( 'visualizer_is_business', false ); } if ( in_array( $feature, (array) self::get_features_for_license( 1 ), true ) ) { return (bool) self::is_pro(); } return true; } /** * Validates refresh_interval against the intervals the chart editor offers for the active plan. * * @param array $input The ability input. * @param string $type The schedule type (csv, json, db). * @param int $chart_id The chart ID. * @return float|WP_Error */ private function resolveInterval( $input, $type, $chart_id ) { if ( ! isset( $input['refresh_interval'] ) || ! is_numeric( $input['refresh_interval'] ) || (float) $input['refresh_interval'] < 0 ) { return -1.0; } $hours = (float) $input['refresh_interval']; $allowed = apply_filters( 'visualizer_chart_schedules', array( '-1' => __( 'One-time', 'visualizer' ) ), $type, $chart_id ); $allowed = is_array( $allowed ) ? array_keys( $allowed ) : array(); foreach ( $allowed as $option ) { if ( is_numeric( $option ) && abs( (float) $option - $hours ) < 0.00001 ) { return $hours; } } /* translators: %s: list of intervals in hours. */ $message = sprintf( __( 'This refresh interval is not available on the current Visualizer plan. Allowed values (hours): %s.', 'visualizer' ), implode( ', ', $allowed ) ); // The plan is the limit only when it offers no recurring interval; otherwise the value itself is wrong. if ( count( $allowed ) > 1 ) { return new WP_Error( 'visualizer_interval_unavailable', $message ); } return $this->planError( 'visualizer_interval_unavailable', $message, 'refresh-interval' ); } /** * Builds the error for a request the active plan does not cover, with the upgrade link. * * @param string $code The error code. * @param string $message The error message. * @param string $area The gated feature, used as the campaign of the upgrade link. * @return WP_Error */ private function planError( $code, $message, $area ) { $upgrade_url = tsdk_translate_link( tsdk_utmify( Visualizer_Plugin::PRO_TEASER_URL, $area, 'mcp' ) ); return new WP_Error( $code, /* translators: 1: the error message, 2: the upgrade URL. */ sprintf( __( '%1$s Upgrade: %2$s', 'visualizer' ), $message, $upgrade_url ), array( 'upgrade_url' => $upgrade_url ) ); } /** * Validates the url input. * * @param array $input The ability input. * @return string|WP_Error */ private function validateUrl( $input ) { $url = ( isset( $input['url'] ) && is_string( $input['url'] ) ) ? wp_http_validate_url( esc_url_raw( trim( $input['url'] ) ) ) : false; if ( ! $url ) { return new WP_Error( 'visualizer_invalid_url', __( 'The URL you entered is invalid. Please enter a valid URL.', 'visualizer' ) ); } return (string) $url; } // ------------------------------------------------------------------------- // Output helpers // ------------------------------------------------------------------------- /** * Returns the chart name as shown in the chart library. * * @param WP_Post $chart The chart. * @return string */ private function getChartTitle( $chart ) { $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true ); if ( is_array( $settings ) && ! empty( $settings['backend-title'] ) && is_string( $settings['backend-title'] ) ) { return $settings['backend-title']; } return '#' . $chart->ID; } /** * Returns the shortcode of a chart. * * @param int $chart_id The chart ID. * @return string */ private function getShortcode( $chart_id ) { return sprintf( '[visualizer id="%d"]', $chart_id ); } /** * Returns the embed descriptor of a chart. * * @param int $chart_id The chart ID. * @return array{shortcode: string, block: string} */ private function getEmbed( $chart_id ) { return array( 'shortcode' => $this->getShortcode( $chart_id ), 'block' => sprintf( '', $chart_id ), ); } /** * Maps a source class to the source kind used by the abilities. * * @param int $chart_id The chart ID. * @return string */ private function getSourceKind( $chart_id ) { $kinds = array( 'Visualizer_Source_Csv' => 'manual', 'Visualizer_Source_Xlsx' => 'manual', 'Visualizer_Source_Csv_Remote' => 'csv_url', 'Visualizer_Source_Xlsx_Remote' => 'csv_url', 'Visualizer_Source_Json' => 'json', 'Visualizer_Source_Query' => 'db_query', ); $class = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, true ); return isset( $kinds[ $class ] ) ? $kinds[ $class ] : $class; } /** * Describes the saved source of a chart without any credentials. * * @param int $chart_id The chart ID. * @return array */ private function describeSource( $chart_id ) { $kind = $this->getSourceKind( $chart_id ); $source = array( 'kind' => $kind ); switch ( $kind ) { case 'csv_url': $chart = get_post( $chart_id ); $source['url'] = $chart instanceof WP_Post ? $this->getRemoteFileUrl( $chart ) : ''; $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_CHART_SCHEDULE ); break; case 'json': $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true ); $source['url'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL, true ); $source['json_root'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT, true ); $source['json_method'] = ( is_array( $headers ) && ! empty( $headers['method'] ) ) ? (string) $headers['method'] : 'GET'; $source['has_saved_auth'] = is_array( $headers ) && ! empty( $headers['auth'] ); $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE ); break; case 'db_query': $source['is_remote_db'] = ! empty( get_post_meta( $chart_id, Visualizer_Plugin::CF_REMOTE_DB_PARAMS, true ) ); $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_DB_SCHEDULE ); // The SQL is only shown to users who may edit it in the chart editor. if ( current_user_can( 'administrator' ) && is_super_admin() ) { $source['query'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY, true ); } break; } return $source; } /** * Returns a saved refresh interval in hours, -1 when none is saved. * * @param int $chart_id The chart ID. * @param string $meta_key The schedule meta key. * @return float */ private function getInterval( $chart_id, $meta_key ) { $hours = get_post_meta( $chart_id, $meta_key, true ); return is_numeric( $hours ) ? (float) $hours : -1.0; } /** * Returns the URL of a remote CSV/XLSX chart. * * @param WP_Post $chart The chart. * @return string */ private function getRemoteFileUrl( $chart ) { $url = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_URL, true ); if ( '' === $url ) { // Same fallback as Visualizer_Source_Csv_Remote::_repopulate(). $content = self::decode_content( html_entity_decode( $chart->post_content ) ); $url = ( is_array( $content ) && isset( $content['source'] ) && is_string( $content['source'] ) ) ? $content['source'] : ''; } return wp_http_validate_url( $url ) ? $url : ''; } /** * Converts stored series to columns. * * @param mixed $series The series. * @return array */ private function seriesToColumns( $series ) { $columns = array(); if ( is_array( $series ) ) { foreach ( $series as $serie ) { if ( ! is_array( $serie ) ) { continue; } $columns[] = array( 'name' => isset( $serie['label'] ) ? (string) $serie['label'] : '', 'type' => isset( $serie['type'] ) ? (string) $serie['type'] : 'string', ); } } return $columns; } /** * Returns data rows as a list of lists. * * @param mixed $data The chart data. * @return array> */ private function normalizeRows( $data ) { $rows = array(); if ( is_array( $data ) ) { foreach ( $data as $row ) { if ( is_array( $row ) ) { $rows[] = array_values( $row ); } } } return $rows; } /** * Returns the parsed preview of a fetched source. * * @param mixed $series The parsed series. * @param mixed $data The parsed data. * @return array */ private function getPreview( $series, $data ) { $rows = $this->normalizeRows( $data ); return array( 'columns' => $this->seriesToColumns( $series ), 'rows' => array_slice( $rows, 0, self::PREVIEW_ROWS ), 'row_count' => count( $rows ), ); } }