PluginProbe
Visualizer – Tables & Charts Manager with Built-in AI Generator / 4.0.9
Visualizer – Tables & Charts Manager with Built-in AI Generator v4.0.9
4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.10.1 3.10.10 3.10.11 3.10.12 3.10.13 3.10.14 3.10.15 3.10.2 All 150 releases
visualizer / classes / Visualizer / Module / Abilities.php

Abilities.php in Visualizer – Tables & Charts Manager with Built-in AI Generator 4.0.9, at classes/Visualizer/Module/Abilities.php

1,708 lines 59.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registers the Visualizer abilities with the WordPress Abilities API.
4 *
5 * @package Visualizer
6 */
7
8 /**
9 * Abilities module class.
10 *
11 * Every ability is a thin wrapper over the chart storage, source classes and
12 * hooks the editors already use. It is a no-op when the Abilities API is not
13 * available (WordPress < 6.9).
14 *
15 * @category Visualizer
16 * @package Module
17 */
18 class Visualizer_Module_Abilities extends Visualizer_Module {
19
20 const NAME = __CLASS__;
21
22 const CATEGORY = 'visualizer';
23
24 const MAX_ROWS = 5000;
25 const MAX_COLUMNS = 100;
26 const PREVIEW_ROWS = 20;
27 const MAX_PER_PAGE = 100;
28
29 /**
30 * Constructor.
31 *
32 * @access public
33 *
34 * @param Visualizer_Plugin $plugin The instance of the plugin.
35 */
36 public function __construct( Visualizer_Plugin $plugin ) {
37 parent::__construct( $plugin );
38
39 // These hooks only fire when the Abilities API is available (WordPress 6.9+).
40 $this->_addAction( 'wp_abilities_api_categories_init', 'registerCategory' );
41 $this->_addAction( 'wp_abilities_api_init', 'registerAbilities' );
42 }
43
44 /**
45 * Registers the ability category.
46 *
47 * @access public
48 * @return void
49 */
50 public function registerCategory() {
51 if ( ! function_exists( 'wp_register_ability_category' ) ) {
52 return;
53 }
54
55 wp_register_ability_category(
56 self::CATEGORY,
57 array(
58 'label' => __( 'Visualizer', 'visualizer' ),
59 'description' => __( 'Create, read and refresh Visualizer charts and tables.', 'visualizer' ),
60 )
61 );
62 }
63
64 /**
65 * Registers the abilities.
66 *
67 * @access public
68 * @return void
69 */
70 public function registerAbilities() {
71 if ( ! function_exists( 'wp_register_ability' ) ) {
72 return;
73 }
74
75 $column_schema = array(
76 'type' => 'object',
77 'properties' => array(
78 'name' => array(
79 'type' => 'string',
80 'description' => __( 'Column label.', 'visualizer' ),
81 ),
82 'type' => array(
83 'type' => 'string',
84 'description' => __( 'Column data type.', 'visualizer' ),
85 'enum' => Visualizer_Source::getAllowedTypes(),
86 ),
87 ),
88 'required' => array( 'name', 'type' ),
89 );
90
91 $embed_schema = array(
92 'type' => 'object',
93 'properties' => array(
94 'shortcode' => array( 'type' => 'string' ),
95 'block' => array( 'type' => 'string' ),
96 ),
97 );
98
99 $rows_schema = array(
100 'type' => 'array',
101 'description' => __( 'Data rows. Each row is an array of cell values in column order.', 'visualizer' ),
102 'items' => array(
103 'type' => 'array',
104 ),
105 );
106
107 $preview_schema = array(
108 'type' => 'object',
109 'properties' => array(
110 'columns' => array(
111 'type' => 'array',
112 'items' => $column_schema,
113 ),
114 'rows' => $rows_schema,
115 'row_count' => array( 'type' => 'integer' ),
116 ),
117 );
118
119 wp_register_ability(
120 'visualizer/list-charts',
121 array(
122 'label' => __( 'List charts', 'visualizer' ),
123 'description' => __( 'Lists the Visualizer charts the current user can manage, with their type, library, data source and shortcode.', 'visualizer' ),
124 'category' => self::CATEGORY,
125 'input_schema' => array(
126 'type' => 'object',
127 'default' => array(),
128 'properties' => array(
129 'page' => array(
130 'type' => 'integer',
131 'description' => __( 'Page of results.', 'visualizer' ),
132 'default' => 1,
133 'minimum' => 1,
134 ),
135 'per_page' => array(
136 'type' => 'integer',
137 'description' => __( 'Charts per page.', 'visualizer' ),
138 'default' => 20,
139 'minimum' => 1,
140 'maximum' => self::MAX_PER_PAGE,
141 ),
142 'type' => array(
143 'type' => 'string',
144 'description' => __( 'Only return charts of this type (for example pie, line, bar, tabular).', 'visualizer' ),
145 ),
146 'search' => array(
147 'type' => 'string',
148 'description' => __( 'Search term matched against the chart settings (title), like the chart library search.', 'visualizer' ),
149 ),
150 ),
151 ),
152 'output_schema' => array(
153 'type' => 'object',
154 'properties' => array(
155 'charts' => array(
156 'type' => 'array',
157 'items' => array(
158 'type' => 'object',
159 'properties' => array(
160 'id' => array( 'type' => 'integer' ),
161 'title' => array( 'type' => 'string' ),
162 'type' => array( 'type' => 'string' ),
163 'library' => array( 'type' => 'string' ),
164 'source' => array( 'type' => 'string' ),
165 'modified' => array( 'type' => 'string' ),
166 'shortcode' => array( 'type' => 'string' ),
167 ),
168 ),
169 ),
170 'total' => array( 'type' => 'integer' ),
171 'total_pages' => array( 'type' => 'integer' ),
172 ),
173 ),
174 'execute_callback' => array( $this, 'executeListCharts' ),
175 'permission_callback' => array( $this, 'canManageCharts' ),
176 'meta' => array(
177 'annotations' => array(
178 'readonly' => true,
179 'destructive' => false,
180 'idempotent' => true,
181 ),
182 'show_in_rest' => true,
183 ),
184 )
185 );
186
187 wp_register_ability(
188 'visualizer/get-chart',
189 array(
190 'label' => __( 'Get chart', 'visualizer' ),
191 'description' => __( 'Returns the type, columns, rows, options, data source and embed markup (shortcode and block) of a chart. Source credentials are never returned.', 'visualizer' ),
192 'category' => self::CATEGORY,
193 'input_schema' => array(
194 'type' => 'object',
195 'properties' => array(
196 'chart_id' => array(
197 'type' => 'integer',
198 'description' => __( 'The chart ID.', 'visualizer' ),
199 'minimum' => 1,
200 ),
201 ),
202 'required' => array( 'chart_id' ),
203 ),
204 'output_schema' => array(
205 'type' => 'object',
206 'properties' => array(
207 'id' => array( 'type' => 'integer' ),
208 'title' => array( 'type' => 'string' ),
209 'type' => array( 'type' => 'string' ),
210 'library' => array( 'type' => 'string' ),
211 'columns' => array(
212 'type' => 'array',
213 'items' => $column_schema,
214 ),
215 'rows' => $rows_schema,
216 'options' => array(
217 'type' => 'object',
218 'additionalProperties' => true,
219 ),
220 'source' => array(
221 'type' => 'object',
222 'additionalProperties' => true,
223 ),
224 'embed' => $embed_schema,
225 ),
226 ),
227 'execute_callback' => array( $this, 'executeGetChart' ),
228 'permission_callback' => array( $this, 'canEditChartInput' ),
229 'meta' => array(
230 'annotations' => array(
231 'readonly' => true,
232 'destructive' => false,
233 'idempotent' => true,
234 ),
235 'show_in_rest' => true,
236 ),
237 )
238 );
239
240 wp_register_ability(
241 'visualizer/upsert-chart',
242 array(
243 'label' => __( 'Create or update chart', 'visualizer' ),
244 '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' ),
245 'category' => self::CATEGORY,
246 'input_schema' => array(
247 'type' => 'object',
248 'properties' => array(
249 'chart_id' => array(
250 'type' => 'integer',
251 'description' => __( 'The chart to update. Omit to create a new chart.', 'visualizer' ),
252 'minimum' => 1,
253 ),
254 'title' => array(
255 'type' => 'string',
256 'description' => __( 'Chart name shown in the chart library.', 'visualizer' ),
257 ),
258 'type' => array(
259 'type' => 'string',
260 'description' => __( 'Chart type, for example pie, line, bar, column, area, tabular. Required when creating.', 'visualizer' ),
261 ),
262 'library' => array(
263 'type' => 'string',
264 'description' => __( 'Rendering library. Defaults to the first library the chart type supports.', 'visualizer' ),
265 'enum' => array( 'GoogleCharts', 'ChartJS', 'DataTable' ),
266 ),
267 'columns' => array(
268 'type' => 'array',
269 'description' => __( 'Typed columns. Required when creating; when updating, send together with rows to replace the chart data.', 'visualizer' ),
270 'items' => $column_schema,
271 ),
272 'rows' => $rows_schema,
273 'options' => array(
274 'type' => 'object',
275 'description' => __( 'Chart settings merged over the existing ones (for example title, legend, colors).', 'visualizer' ),
276 'additionalProperties' => true,
277 ),
278 'dry_run' => array(
279 'type' => 'boolean',
280 'description' => __( 'Validate and return the parsed data preview without saving.', 'visualizer' ),
281 'default' => false,
282 ),
283 ),
284 ),
285 'output_schema' => array(
286 'type' => 'object',
287 'properties' => array(
288 'id' => array( 'type' => 'integer' ),
289 'created' => array( 'type' => 'boolean' ),
290 'embed' => $embed_schema,
291 'dry_run' => array( 'type' => 'boolean' ),
292 'valid' => array( 'type' => 'boolean' ),
293 'parsed_preview' => $preview_schema,
294 'errors' => array(
295 'type' => 'array',
296 'items' => array( 'type' => 'string' ),
297 ),
298 ),
299 ),
300 'execute_callback' => array( $this, 'executeUpsertChart' ),
301 'permission_callback' => array( $this, 'canUpsertChart' ),
302 'meta' => array(
303 'annotations' => array(
304 'readonly' => false,
305 'destructive' => false,
306 'idempotent' => true,
307 ),
308 'show_in_rest' => true,
309 ),
310 )
311 );
312
313 wp_register_ability(
314 'visualizer/set-data-source',
315 array(
316 'label' => __( 'Set chart data source', 'visualizer' ),
317 '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' ),
318 'category' => self::CATEGORY,
319 'input_schema' => array(
320 'type' => 'object',
321 'properties' => array(
322 'chart_id' => array(
323 'type' => 'integer',
324 'description' => __( 'The chart ID.', 'visualizer' ),
325 'minimum' => 1,
326 ),
327 'source' => array(
328 'type' => 'string',
329 'description' => __( 'Source kind.', 'visualizer' ),
330 'enum' => array( 'csv_url', 'json', 'db_query' ),
331 ),
332 'url' => array(
333 'type' => 'string',
334 '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' ),
335 ),
336 'json_root' => array(
337 'type' => 'string',
338 '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' ),
339 ),
340 'json_method' => array(
341 'type' => 'string',
342 'enum' => array( 'GET', 'POST' ),
343 'default' => 'GET',
344 ),
345 'json_paging' => array(
346 'type' => 'string',
347 'description' => __( 'Node that holds the next page URL, same format as json_root.', 'visualizer' ),
348 ),
349 'mapping' => array(
350 'type' => 'array',
351 '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' ),
352 'items' => $column_schema,
353 ),
354 'query' => array(
355 'type' => 'string',
356 'description' => __( 'db_query sources only: a single SELECT statement run against the WordPress database.', 'visualizer' ),
357 ),
358 'refresh_interval' => array(
359 'type' => 'number',
360 'description' => __( 'Hours between automatic refreshes. -1 imports once. Only the intervals offered by the chart editor for the active plan are accepted.', 'visualizer' ),
361 'default' => -1,
362 ),
363 'dry_run' => array(
364 'type' => 'boolean',
365 'default' => false,
366 ),
367 ),
368 'required' => array( 'chart_id', 'source' ),
369 ),
370 'output_schema' => array(
371 'type' => 'object',
372 'properties' => array(
373 'id' => array( 'type' => 'integer' ),
374 'source' => array( 'type' => 'string' ),
375 'refresh_interval' => array( 'type' => 'number' ),
376 'dry_run' => array( 'type' => 'boolean' ),
377 'available_roots' => array(
378 'type' => 'array',
379 'items' => array( 'type' => 'string' ),
380 ),
381 'available_keys' => array(
382 'type' => 'array',
383 'items' => array( 'type' => 'string' ),
384 ),
385 'parsed_preview' => $preview_schema,
386 'embed' => $embed_schema,
387 ),
388 ),
389 'execute_callback' => array( $this, 'executeSetDataSource' ),
390 'permission_callback' => array( $this, 'canSetDataSource' ),
391 'meta' => array(
392 'annotations' => array(
393 'readonly' => false,
394 'destructive' => false,
395 'idempotent' => true,
396 ),
397 'show_in_rest' => true,
398 ),
399 )
400 );
401
402 wp_register_ability(
403 'visualizer/refresh-chart-data',
404 array(
405 'label' => __( 'Refresh chart data', 'visualizer' ),
406 'description' => __( 'Fetches the current data for a chart from its saved remote CSV/XLSX, JSON or database source and stores it.', 'visualizer' ),
407 'category' => self::CATEGORY,
408 'input_schema' => array(
409 'type' => 'object',
410 'properties' => array(
411 'chart_id' => array(
412 'type' => 'integer',
413 'description' => __( 'The chart ID.', 'visualizer' ),
414 'minimum' => 1,
415 ),
416 ),
417 'required' => array( 'chart_id' ),
418 ),
419 'output_schema' => array(
420 'type' => 'object',
421 'properties' => array(
422 'id' => array( 'type' => 'integer' ),
423 'source' => array( 'type' => 'string' ),
424 'refreshed' => array( 'type' => 'boolean' ),
425 'row_count' => array( 'type' => 'integer' ),
426 ),
427 ),
428 'execute_callback' => array( $this, 'executeRefreshChartData' ),
429 'permission_callback' => array( $this, 'canEditChartInput' ),
430 'meta' => array(
431 'annotations' => array(
432 'readonly' => false,
433 'destructive' => false,
434 'idempotent' => true,
435 ),
436 'show_in_rest' => true,
437 ),
438 )
439 );
440 }
441
442 // -------------------------------------------------------------------------
443 // Permissions
444 // -------------------------------------------------------------------------
445
446 /**
447 * Same capability as the chart library page and the charts list endpoint.
448 *
449 * @access public
450 * @return bool
451 */
452 public function canManageCharts() {
453 return current_user_can( 'edit_posts' );
454 }
455
456 /**
457 * Same checks as the chart editor: edit_posts plus Visualizer_Module::can_edit_chart().
458 *
459 * @access public
460 *
461 * @param mixed $input The ability input.
462 * @return bool
463 */
464 public function canEditChartInput( $input = array() ) {
465 if ( ! current_user_can( 'edit_posts' ) ) {
466 return false;
467 }
468
469 $chart_id = $this->getChartId( $input );
470
471 // A missing or invalid ID is reported by the execute callback.
472 return ! $chart_id || ! $this->getChart( $chart_id ) || self::can_edit_chart( $chart_id );
473 }
474
475 /**
476 * Creating needs edit_posts; updating additionally needs can_edit_chart().
477 *
478 * @access public
479 *
480 * @param mixed $input The ability input.
481 * @return bool
482 */
483 public function canUpsertChart( $input = array() ) {
484 return $this->canEditChartInput( $input );
485 }
486
487 /**
488 * Chart editor checks, plus the database import checks for SQL sources.
489 *
490 * @access public
491 *
492 * @param mixed $input The ability input.
493 * @return bool
494 */
495 public function canSetDataSource( $input = array() ) {
496 if ( ! $this->canEditChartInput( $input ) ) {
497 return false;
498 }
499
500 if ( is_array( $input ) && isset( $input['source'] ) && 'db_query' === $input['source'] ) {
501 // Same as Visualizer_Module_Chart::saveQuery().
502 return current_user_can( 'administrator' ) && is_super_admin();
503 }
504
505 return true;
506 }
507
508 // -------------------------------------------------------------------------
509 // Execute callbacks
510 // -------------------------------------------------------------------------
511
512 /**
513 * Lists charts.
514 *
515 * @access public
516 *
517 * @param mixed $input The ability input.
518 * @return array<string, mixed>|WP_Error
519 */
520 public function executeListCharts( $input = array() ) {
521 $input = is_array( $input ) ? $input : array();
522 $page = isset( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1;
523 $per_page = isset( $input['per_page'] ) ? absint( $input['per_page'] ) : 20;
524 $per_page = min( self::MAX_PER_PAGE, max( 1, $per_page ) );
525
526 $query_args = array(
527 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
528 'post_status' => 'publish',
529 'posts_per_page' => $per_page,
530 'paged' => $page,
531 );
532
533 // Same restriction as Visualizer_Module_Chart::getCharts().
534 if ( ! current_user_can( 'edit_others_posts' ) ) {
535 $query_args['author'] = get_current_user_id();
536 }
537
538 $meta_query = array();
539 if ( ! empty( $input['type'] ) ) {
540 $type = sanitize_text_field( $input['type'] );
541 if ( ! in_array( $type, Visualizer_Plugin::getChartTypes(), true ) ) {
542 return new WP_Error( 'visualizer_invalid_type', __( 'Unknown chart type.', 'visualizer' ) );
543 }
544 $meta_query[] = array(
545 'key' => Visualizer_Plugin::CF_CHART_TYPE,
546 'value' => $type,
547 'compare' => '=',
548 );
549 }
550 if ( ! empty( $input['search'] ) ) {
551 $meta_query[] = array(
552 'key' => Visualizer_Plugin::CF_SETTINGS,
553 'value' => sanitize_text_field( $input['search'] ),
554 'compare' => 'LIKE',
555 );
556 }
557 if ( $meta_query ) {
558 $query_args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
559 }
560
561 $query = new WP_Query( apply_filters( 'visualizer_query_args', $query_args ) );
562 $charts = array();
563 foreach ( $query->posts as $chart ) {
564 if ( ! $chart instanceof WP_Post ) {
565 continue;
566 }
567 $charts[] = array(
568 'id' => $chart->ID,
569 'title' => $this->getChartTitle( $chart ),
570 'type' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ),
571 'library' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ),
572 'source' => $this->getSourceKind( $chart->ID ),
573 'modified' => (string) $chart->post_modified_gmt,
574 'shortcode' => $this->getShortcode( $chart->ID ),
575 );
576 }
577
578 return array(
579 'charts' => $charts,
580 'total' => (int) $query->found_posts,
581 'total_pages' => (int) $query->max_num_pages,
582 );
583 }
584
585 /**
586 * Returns one chart.
587 *
588 * @access public
589 *
590 * @param mixed $input The ability input.
591 * @return array<string, mixed>|WP_Error
592 */
593 public function executeGetChart( $input = array() ) {
594 $chart = $this->getEditableChart( $input );
595 if ( is_wp_error( $chart ) ) {
596 return $chart;
597 }
598
599 $type = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true );
600 $series = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SERIES, get_post_meta( $chart->ID, Visualizer_Plugin::CF_SERIES, true ), $chart->ID, $type );
601 $data = self::get_chart_data( $chart, $type );
602 $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true );
603 $settings = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $settings, $chart->ID, $type );
604 if ( ! is_array( $settings ) ) {
605 $settings = array();
606 }
607 // The chart preview image is a large data URI, not an option.
608 unset( $settings['chart-img'] );
609
610 return array(
611 'id' => $chart->ID,
612 'title' => $this->getChartTitle( $chart ),
613 'type' => $type,
614 'library' => (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ),
615 'columns' => $this->seriesToColumns( $series ),
616 'rows' => $this->normalizeRows( $data ),
617 'options' => $settings,
618 'source' => $this->describeSource( $chart->ID ),
619 'embed' => $this->getEmbed( $chart->ID ),
620 );
621 }
622
623 /**
624 * Creates or updates a chart from typed columns and rows.
625 *
626 * @access public
627 *
628 * @param mixed $input The ability input.
629 * @return array<string, mixed>|WP_Error
630 */
631 public function executeUpsertChart( $input = array() ) {
632 $input = is_array( $input ) ? $input : array();
633 $dry_run = ! empty( $input['dry_run'] );
634 $chart = null;
635
636 if ( ! empty( $input['chart_id'] ) ) {
637 $chart = $this->getEditableChart( $input );
638 if ( is_wp_error( $chart ) ) {
639 return $chart;
640 }
641 }
642
643 $has_data = isset( $input['columns'] ) || isset( $input['rows'] );
644 if ( ! $chart && ! $has_data ) {
645 return new WP_Error( 'visualizer_missing_data', __( 'columns and rows are required to create a chart.', 'visualizer' ) );
646 }
647
648 // Chart type and library, gated like the chart type page.
649 $type = $chart ? (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true ) : '';
650 if ( isset( $input['type'] ) && '' !== $input['type'] ) {
651 $type = sanitize_text_field( $input['type'] );
652 }
653 if ( '' === $type ) {
654 return new WP_Error( 'visualizer_missing_type', __( 'type is required to create a chart.', 'visualizer' ) );
655 }
656
657 $type_changed = ! $chart || isset( $input['type'] ) || isset( $input['library'] );
658 $library = $chart ? (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true ) : '';
659 if ( $type_changed ) {
660 $library = $this->resolveLibrary( $type, isset( $input['library'] ) ? (string) $input['library'] : ( isset( $input['type'] ) ? '' : $library ) );
661 if ( is_wp_error( $library ) ) {
662 return $library;
663 }
664 }
665
666 $source = null;
667 if ( $has_data ) {
668 $source = $this->buildManualSource( $input );
669 if ( is_wp_error( $source ) ) {
670 if ( $dry_run ) {
671 return array(
672 'dry_run' => true,
673 'valid' => false,
674 'errors' => $source->get_error_messages(),
675 );
676 }
677 return $source;
678 }
679 }
680
681 $options = array();
682 if ( isset( $input['options'] ) ) {
683 if ( ! is_array( $input['options'] ) ) {
684 return new WP_Error( 'visualizer_invalid_options', __( 'options must be an object.', 'visualizer' ) );
685 }
686 // Same sanitization as the settings form (Visualizer_Module_Chart::sanitizeSettings()).
687 $options = map_deep( $input['options'], 'sanitize_textarea_field' );
688 unset( $options['chart-img'] );
689 }
690
691 if ( $dry_run ) {
692 $result = array(
693 'dry_run' => true,
694 'valid' => true,
695 'errors' => array(),
696 );
697 if ( $source ) {
698 $result['parsed_preview'] = $this->getPreview( $source->getSeries(), $source->getRawData() );
699 }
700 return $result;
701 }
702
703 $created = false;
704 $this->disableRevisionsTemporarily();
705
706 if ( ! $chart ) {
707 // Same defaults as the chart creation flow in Visualizer_Module_Chart::renderChartPages().
708 $chart_id = wp_insert_post(
709 array(
710 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
711 'post_title' => 'Visualization',
712 'post_author' => get_current_user_id(),
713 'post_status' => 'auto-draft',
714 'post_content' => $source->getData(),
715 ),
716 true
717 );
718 if ( is_wp_error( $chart_id ) ) {
719 return $chart_id;
720 }
721
722 add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, $type );
723 add_post_meta( $chart_id, Visualizer_Plugin::CF_DEFAULT_DATA, 0 );
724 add_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, $source->getSourceName() );
725 add_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $source->getSeries() );
726 add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, $library );
727 add_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, array( 'focusTarget' => 'datum' ) );
728
729 do_action( 'visualizer_pro_new_chart_defaults', $chart_id );
730
731 $chart = get_post( $chart_id );
732 if ( ! $chart instanceof WP_Post ) {
733 return new WP_Error( 'visualizer_create_failed', __( 'The chart could not be created.', 'visualizer' ) );
734 }
735 Visualizer_Module_Utility::set_defaults( $chart );
736 $created = true;
737 } else {
738 if ( $type_changed ) {
739 update_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_TYPE, $type );
740 update_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, $library );
741 }
742 if ( $source ) {
743 $this->clearSourceMeta( $chart->ID, true );
744 $this->persistSource( $chart->ID, $source );
745 }
746 }
747
748 // Settings, stored the way the settings form stores them.
749 $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true );
750 if ( ! is_array( $settings ) ) {
751 $settings = array();
752 }
753 $settings = array_merge( $settings, $options );
754
755 $post_update = array( 'ID' => $chart->ID );
756 if ( isset( $input['title'] ) && '' !== trim( (string) $input['title'] ) ) {
757 $title = sanitize_text_field( $input['title'] );
758 $settings['backend-title'] = $title;
759 $post_update['post_title'] = $title;
760 }
761
762 $internal_title = '';
763 if ( ! empty( $settings['title'] ) ) {
764 $internal_title = is_array( $settings['title'] ) ? ( isset( $settings['title']['text'] ) ? $settings['title']['text'] : '' ) : $settings['title'];
765 }
766 $settings['internal_title'] = '' !== (string) $internal_title ? $internal_title : $chart->ID;
767 if ( empty( $settings['pieResidueSliceLabel'] ) ) {
768 $settings['pieResidueSliceLabel'] = esc_html__( 'Other', 'visualizer' );
769 }
770 update_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, $settings );
771
772 if ( $created ) {
773 $post_update['post_status'] = 'publish';
774 }
775 if ( count( $post_update ) > 1 ) {
776 wp_update_post( $post_update );
777 }
778
779 $this->clearChartCache( $chart->ID );
780
781 return array(
782 'id' => $chart->ID,
783 'created' => $created,
784 'embed' => $this->getEmbed( $chart->ID ),
785 );
786 }
787
788 /**
789 * Configures a remote CSV/XLSX, JSON or database source for a chart.
790 *
791 * @access public
792 *
793 * @param mixed $input The ability input.
794 * @return array<string, mixed>|WP_Error
795 */
796 public function executeSetDataSource( $input = array() ) {
797 $input = is_array( $input ) ? $input : array();
798 $chart = $this->getEditableChart( $input );
799 if ( is_wp_error( $chart ) ) {
800 return $chart;
801 }
802
803 $kind = isset( $input['source'] ) ? sanitize_key( $input['source'] ) : '';
804 $dry_run = ! empty( $input['dry_run'] );
805
806 $features = array(
807 'csv_url' => 'import-url',
808 'json' => 'import-url',
809 'db_query' => 'db-query',
810 );
811 if ( ! isset( $features[ $kind ] ) ) {
812 return new WP_Error( 'visualizer_invalid_source', __( 'source must be one of csv_url, json or db_query.', 'visualizer' ) );
813 }
814 if ( ! $this->isFeatureAvailable( $features[ $kind ] ) ) {
815 return $this->planError( 'visualizer_feature_unavailable', __( 'This data source is not available on the current Visualizer plan.', 'visualizer' ), $features[ $kind ] );
816 }
817
818 $schedule_types = array(
819 'csv_url' => 'csv',
820 'json' => 'json',
821 'db_query' => 'db',
822 );
823 $hours = $this->resolveInterval( $input, $schedule_types[ $kind ], $chart->ID );
824 if ( is_wp_error( $hours ) ) {
825 return $hours;
826 }
827
828 switch ( $kind ) {
829 case 'csv_url':
830 return $this->setCsvUrlSource( $chart, $input, $hours, $dry_run );
831 case 'json':
832 return $this->setJsonSource( $chart, $input, $hours, $dry_run );
833 default:
834 return $this->setQuerySource( $chart, $input, $hours, $dry_run );
835 }
836 }
837
838 /**
839 * Re-fetches the data of a chart from its saved source.
840 *
841 * @access public
842 *
843 * @param mixed $input The ability input.
844 * @return array<string, mixed>|WP_Error
845 */
846 public function executeRefreshChartData( $input = array() ) {
847 $chart = $this->getEditableChart( $input );
848 if ( is_wp_error( $chart ) ) {
849 return $chart;
850 }
851
852 $source_class = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_SOURCE, true );
853
854 switch ( $source_class ) {
855 case 'Visualizer_Source_Query':
856 case 'Visualizer_Source_Json':
857 // Handled by Visualizer_Module_Setup::refresh_db_for_chart().
858 delete_post_meta( $chart->ID, Visualizer_Plugin::CF_ERROR );
859 apply_filters( 'visualizer_schedule_refresh_chart', $chart, $chart->ID, true );
860 $error = get_post_meta( $chart->ID, Visualizer_Plugin::CF_ERROR, true );
861 if ( ! empty( $error ) ) {
862 return new WP_Error( 'visualizer_refresh_failed', sanitize_text_field( $error ) );
863 }
864 break;
865 case 'Visualizer_Source_Csv_Remote':
866 case 'Visualizer_Source_Xlsx_Remote':
867 $url = $this->getRemoteFileUrl( $chart );
868 if ( '' === $url ) {
869 return new WP_Error( 'visualizer_no_source', __( 'The chart has no saved source URL.', 'visualizer' ) );
870 }
871 $source = $this->fetchSource( new $source_class( $url ) );
872 if ( is_wp_error( $source ) ) {
873 return $source;
874 }
875 $this->disableRevisionsTemporarily();
876 $this->persistSource( $chart->ID, $source );
877 break;
878 default:
879 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' ) );
880 }
881
882 $this->clearChartCache( $chart->ID );
883
884 $chart = get_post( $chart->ID );
885 $rows = $chart instanceof WP_Post ? self::get_chart_data( $chart, '' ) : array();
886
887 return array(
888 'id' => (int) $input['chart_id'],
889 'source' => $this->getSourceKind( (int) $input['chart_id'] ),
890 'refreshed' => true,
891 'row_count' => is_array( $rows ) ? count( $rows ) : 0,
892 );
893 }
894
895 // -------------------------------------------------------------------------
896 // Source helpers
897 // -------------------------------------------------------------------------
898
899 /**
900 * Remote CSV/XLSX source, same flow as the "Import from URL" form in Visualizer_Module_Chart::uploadData().
901 *
902 * @param WP_Post $chart The chart.
903 * @param array<string, mixed> $input The ability input.
904 * @param float $hours The refresh interval.
905 * @param bool $dry_run Whether to skip saving.
906 * @return array<string, mixed>|WP_Error
907 */
908 private function setCsvUrlSource( $chart, $input, $hours, $dry_run ) {
909 $url = $this->validateUrl( $input );
910 if ( is_wp_error( $url ) ) {
911 return $url;
912 }
913
914 $extension = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
915 $source = 'xlsx' === $extension ? new Visualizer_Source_Xlsx_Remote( $url ) : new Visualizer_Source_Csv_Remote( $url );
916 $source = $this->fetchSource( $source );
917 if ( is_wp_error( $source ) ) {
918 return $source;
919 }
920
921 if ( $dry_run ) {
922 return array(
923 'id' => $chart->ID,
924 'source' => 'csv_url',
925 'dry_run' => true,
926 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ),
927 );
928 }
929
930 $this->disableRevisionsTemporarily();
931 $this->clearSourceMeta( $chart->ID, $hours < 0 );
932 $this->persistSource( $chart->ID, $source );
933 if ( $hours >= 0 ) {
934 apply_filters( 'visualizer_pro_chart_schedule', $chart->ID, $url, $hours );
935 }
936 $this->clearChartCache( $chart->ID );
937
938 return array(
939 'id' => $chart->ID,
940 'source' => 'csv_url',
941 'refresh_interval' => $hours,
942 'dry_run' => false,
943 'embed' => $this->getEmbed( $chart->ID ),
944 );
945 }
946
947 /**
948 * JSON source, same flow as Visualizer_Module_Chart::setJsonData().
949 *
950 * @param WP_Post $chart The chart.
951 * @param array<string, mixed> $input The ability input.
952 * @param float $hours The refresh interval.
953 * @param bool $dry_run Whether to skip saving.
954 * @return array<string, mixed>|WP_Error
955 */
956 private function setJsonSource( $chart, $input, $hours, $dry_run ) {
957 $url = $this->validateUrl( $input );
958 if ( is_wp_error( $url ) ) {
959 return $url;
960 }
961
962 $params = array(
963 'url' => $url,
964 'root' => ( isset( $input['json_root'] ) && is_string( $input['json_root'] ) && '' !== trim( $input['json_root'] ) ) ? sanitize_text_field( $input['json_root'] ) : 'root',
965 'method' => ( isset( $input['json_method'] ) && 'POST' === strtoupper( (string) $input['json_method'] ) ) ? 'POST' : 'GET',
966 );
967 if ( ! empty( $input['json_paging'] ) && is_string( $input['json_paging'] ) ) {
968 $params['paging'] = sanitize_text_field( $input['json_paging'] );
969 }
970
971 // Credentials saved from the chart editor are reused for the same host only; they are never accepted or returned here.
972 $saved_headers = get_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_HEADERS, true );
973 $saved_url = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_URL, true );
974 $auth = array();
975 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 ) ) {
976 $auth = $saved_headers['auth'];
977 if ( is_array( $auth ) && isset( $auth['username'], $auth['password'] ) ) {
978 $params['username'] = $auth['username'];
979 $params['password'] = $auth['password'];
980 } elseif ( is_string( $auth ) ) {
981 $params['auth'] = $auth;
982 }
983 }
984
985 // First pass: discover the keys available under the chosen root.
986 $probe = $this->fetchSource( new Visualizer_Source_Json( $params ) );
987 if ( is_wp_error( $probe ) ) {
988 return $probe;
989 }
990 $raw = $probe->getRawData();
991 $first = $raw ? reset( $raw ) : null;
992 $keys = is_array( $first ) ? array_map( 'strval', array_keys( $first ) ) : array();
993 $mapping = isset( $input['mapping'] ) ? $input['mapping'] : array();
994 if ( ! $keys && ( $mapping || ! $dry_run ) ) {
995 return new WP_Error( 'visualizer_fetch_failed', __( 'Unable to fetch data from the endpoint. Check the URL and the root.', 'visualizer' ) );
996 }
997
998 if ( ! $mapping ) {
999 if ( $dry_run ) {
1000 $roots = ( new Visualizer_Source_Json( $params ) )->fetchRoots();
1001 return array(
1002 'id' => $chart->ID,
1003 'source' => 'json',
1004 'dry_run' => true,
1005 'available_roots' => is_array( $roots ) ? array_values( array_map( 'strval', $roots ) ) : array(),
1006 'available_keys' => $keys,
1007 );
1008 }
1009 return new WP_Error( 'visualizer_missing_mapping', __( 'mapping is required for JSON sources. Run with dry_run=true to list the available keys.', 'visualizer' ) );
1010 }
1011
1012 $columns = $this->validateColumns( $mapping );
1013 if ( is_wp_error( $columns ) ) {
1014 return $columns;
1015 }
1016 $params['header'] = array();
1017 $params['type'] = array();
1018 foreach ( $columns as $column ) {
1019 if ( ! in_array( $column['name'], $keys, true ) ) {
1020 /* translators: %s: JSON key. */
1021 return new WP_Error( 'visualizer_invalid_mapping', sprintf( __( 'The key "%s" does not exist in the JSON data.', 'visualizer' ), $column['name'] ) );
1022 }
1023 $params['header'][] = $column['name'];
1024 $params['type'][ $column['name'] ] = $column['type'];
1025 }
1026
1027 $source = new Visualizer_Source_Json( $params );
1028 try {
1029 $source->fetchFromEditableTable();
1030 } catch ( Exception $e ) {
1031 return new WP_Error( 'visualizer_fetch_failed', __( 'The data could not be parsed with the given column types.', 'visualizer' ) );
1032 }
1033 if ( ! $source->getSeries() ) {
1034 return new WP_Error( 'visualizer_invalid_mapping', __( 'No columns could be mapped.', 'visualizer' ) );
1035 }
1036
1037 if ( $dry_run ) {
1038 return array(
1039 'id' => $chart->ID,
1040 'source' => 'json',
1041 'dry_run' => true,
1042 'available_keys' => $keys,
1043 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ),
1044 );
1045 }
1046
1047 $this->disableRevisionsTemporarily();
1048 $this->clearSourceMeta( $chart->ID, true );
1049 update_post_meta( $chart->ID, Visualizer_Plugin::CF_EDITABLE_TABLE, true );
1050 $this->persistSource( $chart->ID, $source );
1051 update_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_URL, $params['url'] );
1052 update_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_ROOT, $params['root'] );
1053
1054 $headers = array( 'method' => $params['method'] );
1055 if ( $auth ) {
1056 $headers['auth'] = $auth;
1057 }
1058 add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_HEADERS, $headers );
1059 if ( ! empty( $params['paging'] ) ) {
1060 add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_PAGING, $params['paging'] );
1061 }
1062
1063 // Same as Visualizer_Module_Chart::setJsonSchedule().
1064 $schedules = get_option( Visualizer_Plugin::CF_JSON_SCHEDULE, array() );
1065 $schedules = is_array( $schedules ) ? $schedules : array();
1066 if ( $hours >= 0 ) {
1067 add_post_meta( $chart->ID, Visualizer_Plugin::CF_JSON_SCHEDULE, $hours );
1068 $schedules[ $chart->ID ] = time() + $hours * HOUR_IN_SECONDS;
1069 } else {
1070 unset( $schedules[ $chart->ID ] );
1071 }
1072 update_option( Visualizer_Plugin::CF_JSON_SCHEDULE, $schedules );
1073
1074 $this->clearChartCache( $chart->ID );
1075
1076 return array(
1077 'id' => $chart->ID,
1078 'source' => 'json',
1079 'refresh_interval' => $hours,
1080 'dry_run' => false,
1081 'embed' => $this->getEmbed( $chart->ID ),
1082 );
1083 }
1084
1085 /**
1086 * WordPress database query source, same flow as Visualizer_Module_Chart::saveQuery().
1087 *
1088 * @param WP_Post $chart The chart.
1089 * @param array<string, mixed> $input The ability input.
1090 * @param float $hours The refresh interval.
1091 * @param bool $dry_run Whether to skip saving.
1092 * @return array<string, mixed>|WP_Error
1093 */
1094 private function setQuerySource( $chart, $input, $hours, $dry_run ) {
1095 // Repeated here because the permission callback only sees the raw input.
1096 if ( ! current_user_can( 'administrator' ) || ! is_super_admin() ) {
1097 return new WP_Error( 'visualizer_forbidden', __( 'Action not allowed for this user.', 'visualizer' ) );
1098 }
1099
1100 $query = isset( $input['query'] ) && is_string( $input['query'] ) ? trim( trim( $input['query'] ), ';' ) : '';
1101 if ( '' === $query ) {
1102 return new WP_Error( 'visualizer_missing_query', __( 'query is required for db_query sources.', 'visualizer' ) );
1103 }
1104
1105 // No connection parameters: the query always runs against the WordPress database.
1106 $source = new Visualizer_Source_Query( $query, $chart->ID, array( 'db_type' => Visualizer_Plugin::WP_DB_NAME ) );
1107 $source->fetch( false );
1108 $error = $source->get_error();
1109 if ( ! empty( $error ) ) {
1110 return new WP_Error( 'visualizer_query_failed', sanitize_text_field( $error ) );
1111 }
1112 if ( ! $source->getSeries() ) {
1113 return new WP_Error( 'visualizer_query_failed', __( 'The query returned no rows.', 'visualizer' ) );
1114 }
1115
1116 if ( $dry_run ) {
1117 return array(
1118 'id' => $chart->ID,
1119 'source' => 'db_query',
1120 'dry_run' => true,
1121 'parsed_preview' => $this->getPreview( $source->getSeries(), $source->getRawData() ),
1122 );
1123 }
1124
1125 $this->disableRevisionsTemporarily();
1126 $this->clearSourceMeta( $chart->ID, true );
1127 update_post_meta( $chart->ID, Visualizer_Plugin::CF_DB_QUERY, $query );
1128 update_post_meta( $chart->ID, Visualizer_Plugin::CF_DB_SCHEDULE, $hours );
1129 $this->persistSource( $chart->ID, $source );
1130
1131 $schedules = get_option( Visualizer_Plugin::CF_DB_SCHEDULE, array() );
1132 $schedules = is_array( $schedules ) ? $schedules : array();
1133 $schedules[ $chart->ID ] = time() + $hours * HOUR_IN_SECONDS;
1134 update_option( Visualizer_Plugin::CF_DB_SCHEDULE, $schedules );
1135
1136 $this->clearChartCache( $chart->ID );
1137
1138 return array(
1139 'id' => $chart->ID,
1140 'source' => 'db_query',
1141 'refresh_interval' => $hours,
1142 'dry_run' => false,
1143 'embed' => $this->getEmbed( $chart->ID ),
1144 );
1145 }
1146
1147 /**
1148 * Builds a CSV source from typed columns and rows, the format the manual data editor submits.
1149 *
1150 * @param array<string, mixed> $input The ability input.
1151 * @return Visualizer_Source|WP_Error
1152 */
1153 private function buildManualSource( $input ) {
1154 $columns = $this->validateColumns( isset( $input['columns'] ) ? $input['columns'] : null );
1155 if ( is_wp_error( $columns ) ) {
1156 return $columns;
1157 }
1158
1159 $rows = isset( $input['rows'] ) ? $input['rows'] : null;
1160 if ( ! is_array( $rows ) || ! $rows ) {
1161 return new WP_Error( 'visualizer_invalid_rows', __( 'rows must be a non-empty array of arrays.', 'visualizer' ) );
1162 }
1163 if ( count( $rows ) > self::MAX_ROWS ) {
1164 /* translators: %d: maximum number of rows. */
1165 return new WP_Error( 'visualizer_too_many_rows', sprintf( __( 'A maximum of %d rows is accepted.', 'visualizer' ), self::MAX_ROWS ) );
1166 }
1167
1168 $errors = new WP_Error();
1169 $lines = array( wp_list_pluck( $columns, 'name' ), wp_list_pluck( $columns, 'type' ) );
1170 foreach ( array_values( $rows ) as $index => $row ) {
1171 if ( ! is_array( $row ) || count( $row ) > count( $columns ) ) {
1172 /* translators: %d: row number. */
1173 $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %d is not an array or has more cells than columns.', 'visualizer' ), $index + 1 ) );
1174 continue;
1175 }
1176 $line = array();
1177 foreach ( array_values( $row ) as $position => $cell ) {
1178 if ( is_bool( $cell ) ) {
1179 $cell = $cell ? 'true' : 'false';
1180 } elseif ( null === $cell ) {
1181 $cell = '';
1182 } elseif ( ! is_scalar( $cell ) ) {
1183 /* translators: %d: row number. */
1184 $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %d contains a value that is not a string, number or boolean.', 'visualizer' ), $index + 1 ) );
1185 continue 2;
1186 }
1187 $cell = sanitize_text_field( (string) $cell );
1188 if ( 'number' === $columns[ $position ]['type'] && '' !== $cell && ! is_numeric( str_replace( ',', '', $cell ) ) ) {
1189 /* translators: 1: row number, 2: column label. */
1190 $errors->add( 'visualizer_invalid_rows', sprintf( __( 'Row %1$d: "%2$s" expects a number.', 'visualizer' ), $index + 1, $columns[ $position ]['name'] ) );
1191 continue 2;
1192 }
1193 $line[] = $cell;
1194 }
1195 $lines[] = array_pad( $line, count( $columns ), '' );
1196
1197 if ( count( $errors->get_error_messages() ) >= 20 ) {
1198 break;
1199 }
1200 }
1201 if ( $errors->has_errors() ) {
1202 return $errors;
1203 }
1204
1205 $tmpfile = wp_tempnam( Visualizer_Plugin::NAME );
1206 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
1207 $handle = $tmpfile ? fopen( $tmpfile, 'w' ) : false;
1208 if ( ! $handle ) {
1209 return new WP_Error( 'visualizer_tmp_file', __( 'A temporary file could not be created.', 'visualizer' ) );
1210 }
1211 foreach ( $lines as $line ) {
1212 fputcsv( $handle, $line, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE );
1213 }
1214 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
1215
1216 $source = $this->fetchSource( new Visualizer_Source_Csv( $tmpfile ) );
1217 wp_delete_file( $tmpfile );
1218
1219 if ( ! is_wp_error( $source ) && count( $source->getSeries() ) !== count( $columns ) ) {
1220 return new WP_Error( 'visualizer_invalid_columns', __( 'The columns could not be parsed. Check that every column has a label and a type.', 'visualizer' ) );
1221 }
1222
1223 return $source;
1224 }
1225
1226 /**
1227 * Runs fetch() on a source and converts failures to WP_Error.
1228 *
1229 * @param Visualizer_Source $source The source.
1230 * @return Visualizer_Source|WP_Error
1231 */
1232 private function fetchSource( $source ) {
1233 try {
1234 $fetched = $source->fetch();
1235 } catch ( Exception $e ) {
1236 $fetched = false;
1237 }
1238
1239 $error = $source->get_error();
1240 if ( ! $fetched || ! empty( $error ) ) {
1241 return new WP_Error( 'visualizer_fetch_failed', ! empty( $error ) ? sanitize_text_field( $error ) : __( 'Could not parse data. Check the format and try again.', 'visualizer' ) );
1242 }
1243
1244 return $source;
1245 }
1246
1247 /**
1248 * Stores the series and data of a fetched source, as the editors do after an import.
1249 *
1250 * @param int $chart_id The chart ID.
1251 * @param Visualizer_Source $source The fetched source.
1252 * @return void
1253 */
1254 private function persistSource( $chart_id, $source ) {
1255 wp_update_post(
1256 array(
1257 'ID' => $chart_id,
1258 'post_content' => $source->getData(),
1259 )
1260 );
1261 update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $source->getSeries() );
1262 update_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, $source->getSourceName() );
1263 update_post_meta( $chart_id, Visualizer_Plugin::CF_DEFAULT_DATA, 0 );
1264 delete_post_meta( $chart_id, Visualizer_Plugin::CF_ERROR );
1265 }
1266
1267 /**
1268 * Removes the parameters of the previous source, as Visualizer_Module_Chart::uploadData() does before an import.
1269 *
1270 * @param int $chart_id The chart ID.
1271 * @param bool $remove_schedule Whether to remove the remote file schedule too.
1272 * @return void
1273 */
1274 private function clearSourceMeta( $chart_id, $remove_schedule ) {
1275 if ( $remove_schedule ) {
1276 apply_filters( 'visualizer_pro_remove_schedule', $chart_id );
1277 delete_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_URL );
1278 delete_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_SCHEDULE );
1279 }
1280
1281 delete_post_meta( $chart_id, Visualizer_Plugin::CF_FILTER_CONFIG );
1282 delete_post_meta( $chart_id, '__transient-' . Visualizer_Plugin::CF_FILTER_CONFIG );
1283 delete_post_meta( $chart_id, '__transient-' . Visualizer_Plugin::CF_DB_QUERY );
1284 delete_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY );
1285 delete_post_meta( $chart_id, Visualizer_Plugin::CF_DB_SCHEDULE );
1286 delete_post_meta( $chart_id, Visualizer_Plugin::CF_REMOTE_DB_PARAMS );
1287
1288 delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL );
1289 delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT );
1290 delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_PAGING );
1291 delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS );
1292 delete_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE );
1293
1294 delete_post_meta( $chart_id, Visualizer_Plugin::CF_ERROR );
1295 delete_post_meta( $chart_id, Visualizer_Plugin::CF_EDITOR );
1296 delete_post_meta( $chart_id, Visualizer_Plugin::CF_EDITABLE_TABLE );
1297 }
1298
1299 /**
1300 * Clears the cached chart output.
1301 *
1302 * @param int $chart_id The chart ID.
1303 * @return void
1304 */
1305 private function clearChartCache( $chart_id ) {
1306 delete_transient( Visualizer_Plugin::CF_CHART_CACHE . '_' . $chart_id );
1307 }
1308
1309 // -------------------------------------------------------------------------
1310 // Validation helpers
1311 // -------------------------------------------------------------------------
1312
1313 /**
1314 * Extracts the chart ID from the input.
1315 *
1316 * @param mixed $input The ability input.
1317 * @return int
1318 */
1319 private function getChartId( $input ) {
1320 return ( is_array( $input ) && isset( $input['chart_id'] ) ) ? absint( $input['chart_id'] ) : 0;
1321 }
1322
1323 /**
1324 * Returns the chart post, or null when the ID is not a chart.
1325 *
1326 * @param int $chart_id The chart ID.
1327 * @return WP_Post|null
1328 */
1329 private function getChart( $chart_id ) {
1330 $chart = $chart_id ? get_post( $chart_id ) : null;
1331
1332 return ( $chart instanceof WP_Post && Visualizer_Plugin::CPT_VISUALIZER === $chart->post_type ) ? $chart : null;
1333 }
1334
1335 /**
1336 * Returns the chart from the input when the current user can edit it.
1337 *
1338 * @param mixed $input The ability input.
1339 * @return WP_Post|WP_Error
1340 */
1341 private function getEditableChart( $input ) {
1342 $chart = $this->getChart( $this->getChartId( $input ) );
1343 if ( ! $chart ) {
1344 return new WP_Error( 'visualizer_chart_not_found', __( 'Chart not found.', 'visualizer' ) );
1345 }
1346 if ( ! self::can_edit_chart( $chart->ID ) ) {
1347 return new WP_Error( 'visualizer_forbidden', __( 'You do not have permission to perform this action.', 'visualizer' ) );
1348 }
1349
1350 return $chart;
1351 }
1352
1353 /**
1354 * Validates a list of {name, type} columns.
1355 *
1356 * @param mixed $columns The columns input.
1357 * @return array<int, array{name: string, type: string}>|WP_Error
1358 */
1359 private function validateColumns( $columns ) {
1360 if ( ! is_array( $columns ) || ! $columns ) {
1361 return new WP_Error( 'visualizer_invalid_columns', __( 'columns must be a non-empty array of {name, type} objects.', 'visualizer' ) );
1362 }
1363 if ( count( $columns ) > self::MAX_COLUMNS ) {
1364 /* translators: %d: maximum number of columns. */
1365 return new WP_Error( 'visualizer_too_many_columns', sprintf( __( 'A maximum of %d columns is accepted.', 'visualizer' ), self::MAX_COLUMNS ) );
1366 }
1367
1368 $valid = array();
1369 $names = array();
1370 foreach ( $columns as $column ) {
1371 $name = ( is_array( $column ) && isset( $column['name'] ) && is_scalar( $column['name'] ) ) ? sanitize_text_field( wp_strip_all_tags( (string) $column['name'] ) ) : '';
1372 $type = ( is_array( $column ) && isset( $column['type'] ) && is_string( $column['type'] ) ) ? $column['type'] : '';
1373 if ( '' === $name || '0' === $name ) {
1374 return new WP_Error( 'visualizer_invalid_columns', __( 'Every column needs a non-empty name.', 'visualizer' ) );
1375 }
1376 if ( in_array( $name, $names, true ) ) {
1377 /* translators: %s: column label. */
1378 return new WP_Error( 'visualizer_invalid_columns', sprintf( __( 'Duplicate column name "%s".', 'visualizer' ), $name ) );
1379 }
1380 if ( ! in_array( $type, Visualizer_Source::getAllowedTypes(), true ) ) {
1381 /* translators: 1: column label, 2: list of types. */
1382 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() ) ) );
1383 }
1384 $names[] = $name;
1385 $valid[] = array(
1386 'name' => $name,
1387 'type' => $type,
1388 );
1389 }
1390
1391 return $valid;
1392 }
1393
1394 /**
1395 * Validates the chart type against the active plan and resolves the library.
1396 *
1397 * @param string $type The chart type.
1398 * @param string $library The requested library, or empty for the default.
1399 * @return string|WP_Error
1400 */
1401 private function resolveLibrary( $type, $library ) {
1402 $types = Visualizer_Module_Admin::_getChartTypesLocalized();
1403 if ( ! isset( $types[ $type ] ) || ! is_array( $types[ $type ] ) ) {
1404 /* translators: %s: list of chart types. */
1405 return new WP_Error( 'visualizer_invalid_type', sprintf( __( 'Unknown chart type. Available types: %s.', 'visualizer' ), implode( ', ', array_keys( $types ) ) ) );
1406 }
1407 if ( ! Visualizer_Module_Admin::checkChartStatus( $type ) ) {
1408 return $this->planError( 'visualizer_type_unavailable', __( 'This chart type is not available on the current Visualizer plan.', 'visualizer' ), 'pro-chart-types' );
1409 }
1410
1411 $supported = isset( $types[ $type ]['supports'] ) ? (array) $types[ $type ]['supports'] : array( 'Google Charts' );
1412 $supported = array_values( array_map( array( $this, 'removeSpaces' ), $supported ) );
1413 if ( '' === $library ) {
1414 return $supported[0];
1415 }
1416 if ( ! in_array( $library, $supported, true ) ) {
1417 /* translators: %s: list of libraries. */
1418 return new WP_Error( 'visualizer_invalid_library', sprintf( __( 'This chart type supports these libraries on the current plan: %s.', 'visualizer' ), implode( ', ', $supported ) ) );
1419 }
1420
1421 return $library;
1422 }
1423
1424 /**
1425 * Removes the spaces of a library label ("Google Charts" is stored as "GoogleCharts").
1426 *
1427 * @access public
1428 *
1429 * @param string $label The library label.
1430 * @return string
1431 */
1432 public function removeSpaces( $label ) {
1433 return str_replace( ' ', '', (string) $label );
1434 }
1435
1436 /**
1437 * Same plan check as the upsell overlays in the chart editor (Visualizer_Module_Sources::addProUpsell()).
1438 *
1439 * @param string $feature The feature key.
1440 * @return bool
1441 */
1442 private function isFeatureAvailable( $feature ) {
1443 if ( in_array( $feature, (array) self::get_features_for_license( 2 ), true ) ) {
1444 return (bool) apply_filters( 'visualizer_is_business', false );
1445 }
1446 if ( in_array( $feature, (array) self::get_features_for_license( 1 ), true ) ) {
1447 return (bool) self::is_pro();
1448 }
1449
1450 return true;
1451 }
1452
1453 /**
1454 * Validates refresh_interval against the intervals the chart editor offers for the active plan.
1455 *
1456 * @param array<string, mixed> $input The ability input.
1457 * @param string $type The schedule type (csv, json, db).
1458 * @param int $chart_id The chart ID.
1459 * @return float|WP_Error
1460 */
1461 private function resolveInterval( $input, $type, $chart_id ) {
1462 if ( ! isset( $input['refresh_interval'] ) || ! is_numeric( $input['refresh_interval'] ) || (float) $input['refresh_interval'] < 0 ) {
1463 return -1.0;
1464 }
1465
1466 $hours = (float) $input['refresh_interval'];
1467 $allowed = apply_filters( 'visualizer_chart_schedules', array( '-1' => __( 'One-time', 'visualizer' ) ), $type, $chart_id );
1468 $allowed = is_array( $allowed ) ? array_keys( $allowed ) : array();
1469 foreach ( $allowed as $option ) {
1470 if ( is_numeric( $option ) && abs( (float) $option - $hours ) < 0.00001 ) {
1471 return $hours;
1472 }
1473 }
1474
1475 /* translators: %s: list of intervals in hours. */
1476 $message = sprintf( __( 'This refresh interval is not available on the current Visualizer plan. Allowed values (hours): %s.', 'visualizer' ), implode( ', ', $allowed ) );
1477
1478 // The plan is the limit only when it offers no recurring interval; otherwise the value itself is wrong.
1479 if ( count( $allowed ) > 1 ) {
1480 return new WP_Error( 'visualizer_interval_unavailable', $message );
1481 }
1482
1483 return $this->planError( 'visualizer_interval_unavailable', $message, 'refresh-interval' );
1484 }
1485
1486 /**
1487 * Builds the error for a request the active plan does not cover, with the upgrade link.
1488 *
1489 * @param string $code The error code.
1490 * @param string $message The error message.
1491 * @param string $area The gated feature, used as the campaign of the upgrade link.
1492 * @return WP_Error
1493 */
1494 private function planError( $code, $message, $area ) {
1495 $upgrade_url = tsdk_translate_link( tsdk_utmify( Visualizer_Plugin::PRO_TEASER_URL, $area, 'mcp' ) );
1496
1497 return new WP_Error(
1498 $code,
1499 /* translators: 1: the error message, 2: the upgrade URL. */
1500 sprintf( __( '%1$s Upgrade: %2$s', 'visualizer' ), $message, $upgrade_url ),
1501 array( 'upgrade_url' => $upgrade_url )
1502 );
1503 }
1504
1505 /**
1506 * Validates the url input.
1507 *
1508 * @param array<string, mixed> $input The ability input.
1509 * @return string|WP_Error
1510 */
1511 private function validateUrl( $input ) {
1512 $url = ( isset( $input['url'] ) && is_string( $input['url'] ) ) ? wp_http_validate_url( esc_url_raw( trim( $input['url'] ) ) ) : false;
1513 if ( ! $url ) {
1514 return new WP_Error( 'visualizer_invalid_url', __( 'The URL you entered is invalid. Please enter a valid URL.', 'visualizer' ) );
1515 }
1516
1517 return (string) $url;
1518 }
1519
1520 // -------------------------------------------------------------------------
1521 // Output helpers
1522 // -------------------------------------------------------------------------
1523
1524 /**
1525 * Returns the chart name as shown in the chart library.
1526 *
1527 * @param WP_Post $chart The chart.
1528 * @return string
1529 */
1530 private function getChartTitle( $chart ) {
1531 $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS, true );
1532 if ( is_array( $settings ) && ! empty( $settings['backend-title'] ) && is_string( $settings['backend-title'] ) ) {
1533 return $settings['backend-title'];
1534 }
1535
1536 return '#' . $chart->ID;
1537 }
1538
1539 /**
1540 * Returns the shortcode of a chart.
1541 *
1542 * @param int $chart_id The chart ID.
1543 * @return string
1544 */
1545 private function getShortcode( $chart_id ) {
1546 return sprintf( '[visualizer id="%d"]', $chart_id );
1547 }
1548
1549 /**
1550 * Returns the embed descriptor of a chart.
1551 *
1552 * @param int $chart_id The chart ID.
1553 * @return array{shortcode: string, block: string}
1554 */
1555 private function getEmbed( $chart_id ) {
1556 return array(
1557 'shortcode' => $this->getShortcode( $chart_id ),
1558 'block' => sprintf( '<!-- wp:visualizer/chart {"id":%d} /-->', $chart_id ),
1559 );
1560 }
1561
1562 /**
1563 * Maps a source class to the source kind used by the abilities.
1564 *
1565 * @param int $chart_id The chart ID.
1566 * @return string
1567 */
1568 private function getSourceKind( $chart_id ) {
1569 $kinds = array(
1570 'Visualizer_Source_Csv' => 'manual',
1571 'Visualizer_Source_Xlsx' => 'manual',
1572 'Visualizer_Source_Csv_Remote' => 'csv_url',
1573 'Visualizer_Source_Xlsx_Remote' => 'csv_url',
1574 'Visualizer_Source_Json' => 'json',
1575 'Visualizer_Source_Query' => 'db_query',
1576 );
1577 $class = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_SOURCE, true );
1578
1579 return isset( $kinds[ $class ] ) ? $kinds[ $class ] : $class;
1580 }
1581
1582 /**
1583 * Describes the saved source of a chart without any credentials.
1584 *
1585 * @param int $chart_id The chart ID.
1586 * @return array<string, mixed>
1587 */
1588 private function describeSource( $chart_id ) {
1589 $kind = $this->getSourceKind( $chart_id );
1590 $source = array( 'kind' => $kind );
1591
1592 switch ( $kind ) {
1593 case 'csv_url':
1594 $chart = get_post( $chart_id );
1595 $source['url'] = $chart instanceof WP_Post ? $this->getRemoteFileUrl( $chart ) : '';
1596 $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_CHART_SCHEDULE );
1597 break;
1598 case 'json':
1599 $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true );
1600 $source['url'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL, true );
1601 $source['json_root'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT, true );
1602 $source['json_method'] = ( is_array( $headers ) && ! empty( $headers['method'] ) ) ? (string) $headers['method'] : 'GET';
1603 $source['has_saved_auth'] = is_array( $headers ) && ! empty( $headers['auth'] );
1604 $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE );
1605 break;
1606 case 'db_query':
1607 $source['is_remote_db'] = ! empty( get_post_meta( $chart_id, Visualizer_Plugin::CF_REMOTE_DB_PARAMS, true ) );
1608 $source['refresh_interval'] = $this->getInterval( $chart_id, Visualizer_Plugin::CF_DB_SCHEDULE );
1609 // The SQL is only shown to users who may edit it in the chart editor.
1610 if ( current_user_can( 'administrator' ) && is_super_admin() ) {
1611 $source['query'] = (string) get_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY, true );
1612 }
1613 break;
1614 }
1615
1616 return $source;
1617 }
1618
1619 /**
1620 * Returns a saved refresh interval in hours, -1 when none is saved.
1621 *
1622 * @param int $chart_id The chart ID.
1623 * @param string $meta_key The schedule meta key.
1624 * @return float
1625 */
1626 private function getInterval( $chart_id, $meta_key ) {
1627 $hours = get_post_meta( $chart_id, $meta_key, true );
1628
1629 return is_numeric( $hours ) ? (float) $hours : -1.0;
1630 }
1631
1632 /**
1633 * Returns the URL of a remote CSV/XLSX chart.
1634 *
1635 * @param WP_Post $chart The chart.
1636 * @return string
1637 */
1638 private function getRemoteFileUrl( $chart ) {
1639 $url = (string) get_post_meta( $chart->ID, Visualizer_Plugin::CF_CHART_URL, true );
1640 if ( '' === $url ) {
1641 // Same fallback as Visualizer_Source_Csv_Remote::_repopulate().
1642 $content = self::decode_content( html_entity_decode( $chart->post_content ) );
1643 $url = ( is_array( $content ) && isset( $content['source'] ) && is_string( $content['source'] ) ) ? $content['source'] : '';
1644 }
1645
1646 return wp_http_validate_url( $url ) ? $url : '';
1647 }
1648
1649 /**
1650 * Converts stored series to columns.
1651 *
1652 * @param mixed $series The series.
1653 * @return array<int, array{name: string, type: string}>
1654 */
1655 private function seriesToColumns( $series ) {
1656 $columns = array();
1657 if ( is_array( $series ) ) {
1658 foreach ( $series as $serie ) {
1659 if ( ! is_array( $serie ) ) {
1660 continue;
1661 }
1662 $columns[] = array(
1663 'name' => isset( $serie['label'] ) ? (string) $serie['label'] : '',
1664 'type' => isset( $serie['type'] ) ? (string) $serie['type'] : 'string',
1665 );
1666 }
1667 }
1668
1669 return $columns;
1670 }
1671
1672 /**
1673 * Returns data rows as a list of lists.
1674 *
1675 * @param mixed $data The chart data.
1676 * @return array<int, array<int, mixed>>
1677 */
1678 private function normalizeRows( $data ) {
1679 $rows = array();
1680 if ( is_array( $data ) ) {
1681 foreach ( $data as $row ) {
1682 if ( is_array( $row ) ) {
1683 $rows[] = array_values( $row );
1684 }
1685 }
1686 }
1687
1688 return $rows;
1689 }
1690
1691 /**
1692 * Returns the parsed preview of a fetched source.
1693 *
1694 * @param mixed $series The parsed series.
1695 * @param mixed $data The parsed data.
1696 * @return array<string, mixed>
1697 */
1698 private function getPreview( $series, $data ) {
1699 $rows = $this->normalizeRows( $data );
1700
1701 return array(
1702 'columns' => $this->seriesToColumns( $series ),
1703 'rows' => array_slice( $rows, 0, self::PREVIEW_ROWS ),
1704 'row_count' => count( $rows ),
1705 );
1706 }
1707 }
1708