PluginProbe
M Chart / 2.2
M Chart v2.2
2.3.2 2.3.1 2.3 2.2.2 2.2.1 2.2 trunk 1.0 1.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.10 1.10.1 1.11 1.11.1 1.11.2 1.12 1.2 1.2.1 1.3 1.3.1 1.3.2 All 53 releases
m-chart / components / class-m-chart-admin.php

class-m-chart-admin.php in M Chart 2.2, at components/class-m-chart-admin.php

1,257 lines 40.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 class M_Chart_Admin {
8 private $safe_settings = [
9 'performance' => [
10 'default',
11 'no-images',
12 'no-preview',
13 ],
14 'csv_delimiter' => [
15 ',',
16 "\t",
17 ' ',
18 ';',
19 ],
20 'defer_rendering' => [
21 '',
22 'enabled',
23 ],
24 ];
25 private $plugin_url;
26
27 /**
28 * Constructor
29 */
30 public function __construct() {
31 $this->plugin_url = m_chart()->plugin_url();
32
33 add_action( 'admin_init', [ $this, 'admin_init' ] );
34 add_action( 'admin_menu', [ $this, 'admin_menu' ] );
35 // Runs after Freemius adds its own submenu links so we can relabel/reposition the Upgrade link
36 // Freemius hooks its menu at WP_FS__LOWEST_PRIORITY so we go one higher
37 add_action( 'admin_menu', [ $this, 'admin_submenu_links' ], ( defined( 'WP_FS__LOWEST_PRIORITY' ) ? WP_FS__LOWEST_PRIORITY : 999999998 ) + 1 );
38 add_action( 'admin_print_footer_scripts', [ $this, 'admin_print_footer_scripts' ] );
39 add_action( 'admin_head', [ $this, 'admin_head' ] );
40 add_action( 'current_screen', [ $this, 'current_screen' ] );
41 add_action( 'admin_footer', [ $this, 'admin_footer' ] );
42 add_action( 'wp_ajax_m_chart_export_csv', [ $this, 'ajax_export_csv' ] );
43 add_action( 'wp_ajax_m_chart_get_chart_args', [ $this, 'ajax_get_chart_args' ] );
44 add_action( 'wp_ajax_m_chart_import_csv', [ $this, 'ajax_import_csv' ] );
45 add_action( 'edit_form_before_permalink', [ $this, 'edit_form_before_permalink' ] );
46 add_action( 'manage_' . m_chart()->slug . '_posts_custom_column', [ $this, 'manage_posts_custom_column' ], 10, 2 );
47 add_action( 'm_chart_settings_admin', [ $this, 'm_chart_settings_admin' ] );
48
49 add_filter( 'manage_' . m_chart()->slug . '_posts_columns', [ $this, 'manage_posts_columns' ] );
50 }
51
52 /**
53 * Look for save settings submissions
54 */
55 public function admin_init() {
56 $this->save_settings();
57
58 add_action( 'admin_notices', [ $this, 'library_warning' ] );
59 }
60
61 /**
62 * Add settings admin page
63 */
64 public function admin_menu() {
65 add_submenu_page(
66 'edit.php?post_type=' . m_chart()->slug,
67 esc_html__( 'M Chart Settings', 'm-chart' ),
68 esc_html__( 'Settings', 'm-chart' ),
69 'manage_options',
70 m_chart()->slug . '-settings',
71 [ $this, 'm_chart_settings' ]
72 );
73 }
74
75 /**
76 * Arrange the extra Charts submenu links
77 *
78 * Runs at a priority above Freemius so its pricing link already exists when we relabel and reposition it
79 * Handles the Upgrade link, the Docs link, and the per library Add Chart links all in one place so ordering is predictable
80 */
81 public function admin_submenu_links() {
82 global $submenu;
83
84 $menu_slug = 'edit.php?post_type=' . m_chart()->slug;
85
86 // Freemius adds its own pricing/upgrade submenu link for free users
87 // We relabel it to Upgrade and move it just above the Docs link
88 // Parsing the page slug out of the upgrade URL lets us find Freemius's entry without relying on its internals
89 // Repositioning leaves the page route registered so the link still works
90 $pricing_slug = '';
91 $query = wp_parse_url( m_chart()->freemius()->get_upgrade_url(), PHP_URL_QUERY );
92
93 if ( $query ) {
94 parse_str( $query, $query_args );
95 $pricing_slug = $query_args['page'] ?? '';
96 }
97
98 if ( '' !== $pricing_slug && ! empty( $submenu[ $menu_slug ] ) ) {
99 foreach ( $submenu[ $menu_slug ] as $position => $item ) {
100 if ( isset( $item[2] ) && $item[2] === $pricing_slug ) {
101 unset( $submenu[ $menu_slug ][ $position ] );
102
103 $item[0] = esc_html__( 'Upgrade', 'm-chart' );
104 $submenu[ $menu_slug ][99] = $item;
105
106 break;
107 }
108 }
109 }
110
111 // Pin the Freemius Account link to a predictable order just below Settings
112 // Parse the page slug out of the account URL so this survives the menu slug scheme rather than hardcoding it
113 // Freemius's own ordering can drift against our ksort so we set an explicit position
114 $account_slug = '';
115 $query = wp_parse_url( m_chart()->freemius()->get_account_url(), PHP_URL_QUERY );
116
117 if ( $query ) {
118 parse_str( $query, $query_args );
119 $account_slug = $query_args['page'] ?? '';
120 }
121
122 if ( '' !== $account_slug && ! empty( $submenu[ $menu_slug ] ) ) {
123 foreach ( $submenu[ $menu_slug ] as $position => $item ) {
124 if ( isset( $item[2] ) && $item[2] === $account_slug ) {
125 unset( $submenu[ $menu_slug ][ $position ] );
126
127 $submenu[ $menu_slug ][96] = $item;
128
129 break;
130 }
131 }
132 }
133
134 // Docs link — sits at the bottom of the Charts submenu
135 // The third array element is the href; WordPress treats it as a full URL when it includes a scheme
136 // target="_blank" is added by admin_print_footer_scripts() since WP's $submenu API doesn't accept link attributes
137 $submenu[ $menu_slug ][100] = [
138 esc_html__( 'Docs', 'm-chart' ),
139 'edit_posts',
140 'https://docs.mch.art',
141 ];
142
143 // If multiple libraries are active we'll give you the option of using each one
144 // @TODO As written this will break if there's ever more than 10 active libraries... so yeah
145 $libraries = m_chart()->get_libraries();
146
147 if ( 1 < count( $libraries ) ) {
148 // Put the default library into the admin menu first
149 $args = [
150 'post_type' => m_chart()->slug,
151 'library' => m_chart()->get_library(),
152 ];
153
154 $submenu[ $menu_slug ][10] = [
155 'Add ' . $libraries[ m_chart()->get_library() ] . ' Chart',
156 'edit_posts',
157 add_query_arg( $args, admin_url( 'post-new.php' ) ),
158 ];
159
160 unset( $libraries[ m_chart()->get_library() ] );
161
162 // Add a Add Chart option for each active library that isn't the current default
163 $key = 11;
164
165 foreach ( $libraries as $library => $library_name ) {
166 $args = [
167 'post_type' => m_chart()->slug,
168 'library' => $library,
169 ];
170
171 $submenu[ $menu_slug ][ $key ] = [
172 'Add ' . $library_name . ' Chart',
173 'edit_posts',
174 add_query_arg( $args, admin_url( 'post-new.php' ) ),
175 ];
176
177 $key++;
178 }
179 }
180
181 // Gotta sort them so they're in the right order
182 if ( ! empty( $submenu[ $menu_slug ] ) ) {
183 ksort( $submenu[ $menu_slug ] );
184 }
185 }
186
187 /**
188 * Add target="_blank" to the Docs link in the Charts submenu
189 *
190 * WordPress's add_submenu_page() / $submenu API has no concept of link attributes
191 * So we do it via a tiny footer script that runs on every admin page since the menu is global
192 */
193 public function admin_print_footer_scripts() {
194 ?>
195 <script>
196 ( () => {
197 const link = document.querySelector( '#adminmenu a[href="https://docs.mch.art"]' );
198
199 if ( link ) {
200 link.setAttribute( 'target', '_blank' );
201 link.setAttribute( 'rel', 'noopener noreferrer' );
202 }
203 } )();
204 </script>
205 <?php
206 }
207
208 /**
209 * Hook: admin_head — print inline <style> for the Charts admin menu
210 *
211 * Lives inline rather than in the main SCSS bundle because the sidebar renders on every admin page
212 * and that bundle only enqueues on chart screens
213 *
214 * First block hides Freemius's "↳" sub-item arrow on the Account link
215 * Printed for every user since the Account link shows for paying users too
216 *
217 * Second block renders the Upgrade submenu link as a filled pill button with a trailing trendingUp icon
218 * Only printed for free users since that is when the Upgrade link exists
219 * The pill mimics the Pro plugin's menu badge - accent fill via --wp-admin-theme-color, white text, darker accent on hover
220 * The icon is the trendingUp icon from @wordpress/icons masked so background-color: currentColor tints it white to match the text
221 */
222 public function admin_head() {
223 // Freemius prefixes its sub-submenu items with a "↳" arrow via span.fs-submenu-item.fs-sub:before
224 // The #adminmenu prefix beats that selector so we can drop the glyph on our Account link
225 ?>
226 <style id="m-chart-menu-account">
227 #adminmenu .fs-submenu-item.account.fs-sub::before {
228 display: none;
229 }
230 </style>
231 <?php
232
233 // Recolor the top-level menu logo so it adapts to the admin color scheme
234 // WP prints the menu_icon SVG as a non-recolorable background-image and our logo has no fill so it renders black
235 // Masking it with currentColor makes it follow the menu link color - white on the active/hover item, scheme gray otherwise
236 ?>
237 <style id="m-chart-menu-icon">
238 #adminmenu #menu-posts-<?php echo esc_attr( m_chart()->slug ); ?> .wp-menu-image.svg {
239 /* WP sets the background-image via an inline style attribute so this needs !important */
240 background-image: none !important;
241 background-color: currentColor;
242 -webkit-mask: url("data:image/svg+xml;base64,<?php echo esc_attr( m_chart()->logo ); ?>") no-repeat center;
243 mask: url("data:image/svg+xml;base64,<?php echo esc_attr( m_chart()->logo ); ?>") no-repeat center;
244 /* Match WP's .wp-menu-image.svg background-size */
245 -webkit-mask-size: 20px auto;
246 mask-size: 20px auto;
247 }
248 </style>
249 <?php
250
251 if ( ! m_chart()->freemius()->is_free_plan() ) {
252 return;
253 }
254
255 // Parse the pricing page slug from the upgrade URL so the selector tracks the Freemius menu slug scheme
256 // Matching the full page slug avoids also styling the Settings link
257 $pricing_slug = '';
258 $query = wp_parse_url( m_chart()->freemius()->get_upgrade_url(), PHP_URL_QUERY );
259
260 if ( $query ) {
261 parse_str( $query, $query_args );
262 $pricing_slug = $query_args['page'] ?? '';
263 }
264
265 // No pricing slug means there's no Upgrade link to style
266 if ( '' === $pricing_slug ) {
267 return;
268 }
269
270 $pricing_attr = esc_attr( $pricing_slug );
271 ?>
272 <style id="m-chart-menu-upgrade">
273 #adminmenu a[href*="page=<?php echo $pricing_attr; ?>"] {
274 display: inline-block;
275 margin: 4px 0 4px 13px;
276 /*
277 Using !important to beat WordPress's own submenu link padding (5px 12px)
278 the mobile media query overrides this again under 782px so the button can still grow
279 */
280 padding: 2px 10px !important;
281 border-radius: 2px;
282 font-weight: 600;
283 background: var( --wp-admin-theme-color, #3858e9 ) !important;
284 color: #fff !important;
285 }
286
287 #adminmenu a[href*="page=<?php echo $pricing_attr; ?>"]:hover,
288 #adminmenu a[href*="page=<?php echo $pricing_attr; ?>"]:focus {
289 background: color-mix( in srgb, var( --wp-admin-theme-color, #3858e9 ), black 10% ) !important;
290 color: #fff !important;
291 /*
292 WordPress paints an inset 4px left bar on submenu hover/focus
293 We remove it so the button stays clean
294 */
295 box-shadow: none !important;
296 }
297
298 #adminmenu a[href*="page=<?php echo $pricing_attr; ?>"]::after {
299 content: "";
300 display: inline-block;
301 width: 18px;
302 height: 18px;
303 margin-left: 4px;
304 vertical-align: middle;
305 background-color: currentColor;
306 -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M3.445%2016.505a.75.75%200%20001.06.05l5.005-4.55%204.024%203.521%204.716-4.715V14h1.5V8.25H14v1.5h3.19l-3.724%203.723L9.49%209.995l-5.995%205.45a.75.75%200%2000-.05%201.06z'/%3E%3C/svg%3E") no-repeat center / contain;
307 mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M3.445%2016.505a.75.75%200%20001.06.05l5.005-4.55%204.024%203.521%204.716-4.715V14h1.5V8.25H14v1.5h3.19l-3.724%203.723L9.49%209.995l-5.995%205.45a.75.75%200%2000-.05%201.06z'/%3E%3C/svg%3E") no-repeat center / contain;
308 }
309
310 /*
311 Under 782px WordPress enlarges submenu links for touch with an asymmetric 10px 10px 10px 20px padding
312 This keeps the bigger size but matches the left padding to the right so the button looks normal
313 */
314 @media screen and ( max-width: 782px ) {
315 #adminmenu a[href*="page=<?php echo $pricing_attr; ?>"] {
316 padding: 10px !important;
317 }
318
319 #adminmenu a[href*="page=<?php echo $pricing_attr; ?>"]::after {
320 width: 23px;
321 height: 23px;
322 }
323 }
324 </style>
325 <?php
326 }
327
328 /**
329 * Display the M Chart settings admin page
330 */
331 public function m_chart_settings() {
332 $settings = m_chart()->get_settings();
333 require_once __DIR__ . '/templates/m-chart-settings.php';
334 }
335
336 /**
337 * Check for and save M Chart settings
338 */
339 public function save_settings() {
340 if ( ! current_user_can( 'manage_options' ) ) {
341 return;
342 }
343
344 // Check the nonce
345 $nonce = $_POST[ m_chart()->slug ]['nonce'] ?? '';
346
347 if (
348 ! isset( $_POST[ m_chart()->slug ] )
349 || ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-settings' )
350 ) {
351 return;
352 }
353
354 $previous_settings = m_chart()->get_settings();
355 $validated_settings = [];
356 $submitted_settings = $_POST[ m_chart()->slug ];
357
358 $default_settings = apply_filters( 'm_chart_default_settings', m_chart()->settings );
359
360 foreach ( $default_settings as $setting => $default ) {
361 if ( ! isset( $submitted_settings[ $setting ] ) ) {
362 $validated_settings[ $setting ] = $default;
363 continue;
364 }
365
366 // Default chart height is numeric so clamp it to the same range as the per-chart height field
367 if ( 'default_height' === $setting ) {
368 $validated_settings[ $setting ] = min( 1500, max( 300, absint( $submitted_settings[ $setting ] ) ) );
369 continue;
370 }
371
372 if ( isset( $this->safe_settings[ $setting ] ) ) {
373 // If we've got an array of valid values lets check against that
374 $safe_setting = $this->safe_settings[ $setting ];
375
376 if ( in_array( $submitted_settings[ $setting ], $safe_setting, true ) ) {
377 $validated_settings[ $setting ] = $submitted_settings[ $setting ];
378 } else {
379 $validated_settings[ $setting ] = $default;
380 }
381 } else {
382 // Make sure the value is a string and matches the safe pattern before saving it
383 // Non-scalar submissions (e.g. an array from a library-plugin-added setting) fall back o the default
384 // Plugins that need to persist complex shapes should hook 'm_chart_validated_settings' below to inject their own validated value
385 $value = $submitted_settings[ $setting ];
386
387 if ( is_string( $value ) && preg_match( '#^[a-zA-Z0-9-_]+$#', $value ) ) {
388 $validated_settings[ $setting ] = $value;
389 } else {
390 $validated_settings[ $setting ] = $default;
391 }
392 }
393 }
394
395 // Allow third party libraries to further validate the settings
396 $validated_settings = apply_filters( 'm_chart_validated_settings', $validated_settings, $submitted_settings );
397
398 update_option( m_chart()->slug, $validated_settings );
399
400 // Only flush rewrite rules when the embed endpoint is actually being toggled
401 $previous_embeds = $previous_settings['embeds'] ?? '';
402 $current_embeds = $validated_settings['embeds'] ?? '';
403
404 if ( $previous_embeds !== $current_embeds ) {
405 flush_rewrite_rules();
406 }
407
408 add_action( 'admin_notices', [ $this, 'save_success' ] );
409 }
410
411 /**
412 * Display an admin notice that the settings have been saved
413 */
414 public function save_success() {
415 ?>
416 <div class="updated notice notice-success">
417 <p><?php esc_html_e( 'Settings saved', 'm-chart' ); ?></p>
418 </div>
419 <?php
420 }
421
422 /**
423 * Display an admin notice when the site has charts that use Highcharts but M Chart Highcharts Library is not active/installed
424 */
425 public function library_warning() {
426 if ( is_plugin_active( 'm-chart-highcharts-library/m-chart-highcharts-library.php' ) ) {
427 return;
428 }
429
430 $highcharts_check = get_posts(
431 [
432 'post_type' => m_chart()->slug,
433 'posts_per_page' => 1,
434 'post_status' => 'any',
435 'tax_query' => [
436 [
437 'taxonomy' => m_chart()->slug . '-library',
438 'field' => 'slug',
439 'terms' => 'highcharts',
440 ],
441 ],
442 ]
443 );
444
445 if ( ! $highcharts_check ) {
446 return;
447 }
448 ?>
449 <div class="warning notice notice-warning">
450 <p>
451 <?php
452 echo str_replace(
453 esc_html__( 'M Chart Highcharts Library', 'm-chart' ),
454 '<strong>' . esc_html__( 'M Chart Highcharts Library', 'm-chart' ) . '</strong>',
455 esc_html__( 'You have charts that require the M Chart Highcharts Library plugin.', 'm-chart' )
456 );
457 ?>
458 </p>
459 <p><?php esc_html_e( 'These charts will no longer display unless you install the plugin:', 'm-chart' ); ?></p>
460 <p><a href="https://github.com/methnen/m-chart-highcharts-library/"
461 class="button-primary"><?php esc_html_e( 'Learn More', 'm-chart' ); ?></a></p>
462 </div>
463 <?php
464 }
465
466 /**
467 * Load CSS/Javascript necessary for the interface
468 *
469 * @param object the current screen object as passed by the current_screen action hook
470 */
471 public function current_screen( $screen ) {
472 if ( m_chart()->slug !== $screen->post_type ) {
473 return;
474 }
475
476 // Only load these if we are on a post page
477 if ( 'post' === $screen->base ) {
478 // Jspreadsheet CE — needed by both chartjs (React) and other libraries (jQuery)
479 wp_enqueue_style(
480 'jspreadsheet',
481 $this->plugin_url . '/components/external/jspreadsheet/jspreadsheet.css',
482 [],
483 m_chart()->version
484 );
485
486 wp_enqueue_script(
487 'jspreadsheet',
488 $this->plugin_url . '/components/external/jspreadsheet/jspreadsheet.js',
489 [ 'jsuites' ],
490 m_chart()->version
491 );
492
493 // jSuites — required by Jspreadsheet
494 wp_enqueue_style(
495 'jsuites',
496 $this->plugin_url . '/components/external/jsuites/jsuites.css',
497 [],
498 m_chart()->version
499 );
500
501 wp_enqueue_script(
502 'jsuites',
503 $this->plugin_url . '/components/external/jsuites/jsuites.js',
504 [],
505 m_chart()->version
506 );
507
508 // Admin UI React app
509 $admin_app_asset = require __DIR__ . '/admin-ui/index.asset.php';
510
511 wp_enqueue_script(
512 'm-chart-admin-ui',
513 $this->plugin_url . '/components/admin-ui/index.js',
514 array_merge( $admin_app_asset['dependencies'], [ 'wp-hooks' ] ),
515 $admin_app_asset['version'],
516 [ 'strategy' => 'defer' ]
517 );
518
519 wp_set_script_translations(
520 'm-chart-admin-ui',
521 'm-chart',
522 plugin_dir_path( __DIR__ ) . 'components/languages/'
523 );
524
525 // We need the library and post ID for a bunch of stuff below
526 $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : '';
527 $library = m_chart()->get_library();
528
529 if ( ! empty( $post_id ) ) {
530 $library = m_chart()->get_post_meta( absint( $post_id ), 'library' );
531 } elseif (
532 'post' === $screen->base
533 && 'add' === $screen->action
534 && isset( $_GET['library'] )
535 && m_chart()->is_valid_library( $_GET['library'] )
536 ) {
537 $library = $_GET['library'];
538 }
539
540 if ( 'chartjs' === $library ) {
541 // Chart.js libs — enqueued explicitly so the React preview has window.Chart and window
542 // MChartHelper available before m-chart-admin-ui runs its plugin registration
543 // We load every plugin regardless of immediate need when in the edit view since the user can switch chart types from the picker
544 wp_enqueue_script( 'chartjs-helper' );
545 wp_enqueue_script( 'chartjs-datalabels' );
546 wp_enqueue_script( 'chartjs-treemap' );
547 wp_enqueue_script( 'chartjs-boxplot' );
548 }
549
550 $post_meta = m_chart()->get_post_meta( $post_id );
551 $spreadsheet_data = empty( $post_meta['data'] ) ? [ [ '' ] ] : $post_meta['data']['sets'];
552 unset( $post_meta['data'] ); // passed separately as spreadsheet_data
553
554 // Collect library-specific config for the React admin app
555 $type_options = [];
556 $type_option_names = [];
557 $themes = [];
558
559 if ( m_chart()->library( $library ) ) {
560 $library_class = m_chart()->library( $library );
561 $type_options = $library_class->type_options;
562 $type_option_names = $library_class->type_option_names;
563
564 foreach ( $library_class->get_themes() as $theme ) {
565 $themes[] = [
566 'slug' => $theme->slug,
567 'name' => $theme->name,
568 ];
569 }
570 }
571
572 // Format unit terms as an array of {group, units} for easy JS mapping
573 $unit_terms = [];
574
575 foreach ( m_chart()->get_unit_terms() as $group => $units ) {
576 $group_units = [];
577
578 foreach ( $units as $unit ) {
579 $group_units[] = [ 'name' => $unit->name, 'slug' => $unit->slug ];
580 }
581
582 $unit_terms[] = [
583 'group' => $group,
584 'units' => $group_units,
585 ];
586 }
587
588 $chart_image = m_chart()->get_chart_image( $post_id );
589
590 // Compute initial chart args for the React preview (React-enabled libraries, existing posts only)
591 $initial_chart_args = null;
592
593 if ( $post_id && m_chart()->library( $library ) ) {
594 $initial_chart_args = m_chart()->library( $library )->get_chart_args(
595 $post_id,
596 m_chart()->get_chart_default_args,
597 true, // force recompute
598 false // don't store in cache
599 );
600 }
601
602 // Build CSV delimiter map for React's CsvControls component
603 $csv_delimiters = [];
604 foreach ( m_chart()->csv_delimiters as $delimiter => $delimiter_name ) {
605 $csv_delimiters[ $delimiter ] = $delimiter_name;
606 }
607
608 $localize_data = [
609 'slug' => m_chart()->slug,
610 'version' => m_chart()->version,
611 'refresh_counter' => 0,
612 'allow_form_submission' => false,
613 'request' => false,
614 'performance' => m_chart()->get_settings( 'performance' ),
615 'image_support' => apply_filters( 'm_chart_image_support', 'no', $library ),
616 'instant_preview_support' => apply_filters( 'm_chart_instant_preview_support', 'no', $library ),
617 'image_multiplier' => m_chart()->get_settings( 'image_multiplier' ),
618 'image_width' => m_chart()->get_settings( 'image_width' ),
619 'library' => $library,
620 'set_names' => m_chart()->get_post_meta( $post_id, 'set_names' ),
621 'post_id' => $post_id,
622 'nonce' => wp_create_nonce( m_chart()->slug . '-save-post' ),
623 'ajax_url' => admin_url( 'admin-ajax.php' ),
624 'post_meta' => $post_meta,
625 'spreadsheet_data' => $spreadsheet_data,
626 'type_options' => $type_options,
627 'type_option_names' => $type_option_names,
628 'themes' => $themes,
629 'unit_terms' => $unit_terms,
630 'image_url' => $chart_image ? esc_url( $chart_image['url'] ) : '',
631 'chart_args' => $initial_chart_args,
632 'csv_delimiters' => $csv_delimiters,
633 'default_delimiter' => m_chart()->get_settings( 'csv_delimiter' ),
634 'multi_sheet_types' => m_chart()->get_multi_sheet_types(),
635 ];
636
637 wp_localize_script( 'm-chart-admin-ui', 'm_chart_admin', $localize_data );
638
639 do_action( 'm_chart_admin_scripts', $library, $post_id );
640 }
641
642 // Admin panel CSS
643 wp_enqueue_style(
644 'm-chart-admin',
645 $this->plugin_url . '/components/css/m-chart-admin.css',
646 [],
647 m_chart()->version
648 );
649 }
650
651 /**
652 * Add all of the metaboxes needed for the data and chart editing interface
653 */
654 public function meta_boxes() {
655 global $wp_meta_boxes;
656
657 // Remove excerpt from its normal spot in the meta_boxes array so we can put it back in after the spreadsheet
658 // Users can move metaboxes, but this helps put things in a reasonable place on the first visit
659 $excerpt = $wp_meta_boxes[ m_chart()->slug ][ 'normal' ][ 'core' ][ 'postexcerpt' ];
660 unset( $wp_meta_boxes[ m_chart()->slug ][ 'normal' ][ 'core' ][ 'postexcerpt' ] );
661
662 add_meta_box(
663 m_chart()->slug . '-spreadsheet',
664 esc_html__( 'Data', 'm-chart' ),
665 [ $this, 'spreadsheet_meta_box' ],
666 m_chart()->slug,
667 'normal',
668 'high'
669 );
670
671 add_meta_box(
672 m_chart()->slug,
673 esc_html__( 'Chart', 'm-chart' ),
674 [ $this, 'chart_meta_box' ],
675 m_chart()->slug,
676 'normal',
677 'high'
678 );
679
680 $wp_meta_boxes[ m_chart()->slug ][ 'normal' ][ 'high' ][ 'postexcerpt' ] = $excerpt;
681
682 // We are using our own interface for the units so we can remove the units taxonomy metabox
683 remove_meta_box( m_chart()->slug . '-unitsdiv', m_chart()->slug, 'side' );
684 }
685
686 /**
687 * Displays the spreadsheet meta box
688 *
689 * @param object the WP post object as returned by the metabox API
690 */
691 public function spreadsheet_meta_box( $post ) {
692 echo '<div id="m-chart-spreadsheet-root"></div>';
693 echo '<textarea name="' . esc_attr( $this->get_field_name( 'data' ) ) . '" class="data hide"></textarea>';
694 wp_nonce_field( m_chart()->slug . '-save-post', $this->get_field_name( 'nonce' ) );
695 }
696
697 /**
698 * Displays the chart meta box
699 *
700 * @param object the WP post object as returned by the metabox API
701 */
702 public function chart_meta_box( $post ) {
703 // Force an instance of 1 since we NEVER show more than one chart at a time inside the admin panel
704 m_chart()->instance = 1;
705
706 $post_meta = m_chart()->get_post_meta( $post->ID );
707 $image = m_chart()->get_chart_image( $post->ID );
708 $settings = m_chart()->get_settings();
709
710 require_once __DIR__ . '/templates/chart-meta-box.php';
711 }
712
713 /**
714 * Insert CSV Import and Export forms into the footer when editing charts
715 */
716 public function admin_footer() {
717 $screen = get_current_screen();
718
719 if ( 'post' !== $screen->base || m_chart()->slug !== $screen->post_type ) {
720 return;
721 }
722 ?>
723 <script type="text/javascript">
724 <?php do_action( 'm_chart_admin_footer_javascript' ); ?>
725 </script>
726 <?php
727 }
728
729 /**
730 * Inserts a subtitle field under the title field on the chart edit form
731 *
732 * @param object the WP post object as returned by the metabox API
733 */
734 public function edit_form_before_permalink( $post ) {
735 if ( m_chart()->slug !== $post->post_type ) {
736 return;
737 }
738
739 echo '<div id="m-chart-subtitle-root"></div>';
740 }
741
742 /**
743 * Display some additional information about a chart
744 *
745 * @param string the name of the custom column being displayed
746 * @param string the $post_id of the post being displayed in this row
747 */
748 public function manage_posts_custom_column( $column, $post_id ) {
749 if ( m_chart()->slug . '-type' !== $column && m_chart()->slug . '-library' !== $column ) {
750 return;
751 }
752
753 $library = m_chart()->get_post_meta( $post_id, 'library' );
754 $library_instance = m_chart()->library( $library );
755
756 if ( ! $library_instance || $library_instance->library !== $library ) {
757 ?>
758 <span aria-hidden="true"></span>
759 <span class="screen-reader-text"><?php echo esc_html__( 'Library not found', 'm-chart' ); ?></span>
760 <?php
761 return;
762 }
763
764 if ( m_chart()->slug . '-type' === $column ) {
765 $type = m_chart()->get_post_meta( $post_id, 'type' );
766 $type_name = $library_instance->type_option_names[ $type ];
767 ?>
768 <span class="type <?php echo esc_attr( $type ); ?>" title="<?php echo esc_attr( $type_name ); ?>">
769 <?php echo esc_html( $type_name ); ?>
770 </span>
771 <?php
772 }
773
774 if ( m_chart()->slug . '-library' === $column ) {
775 $library_name = $library_instance->library_name;
776 ?>
777 <span class="library <?php echo esc_attr( $library ); ?>" title="<?php echo esc_attr( $library_name ); ?>">
778 <?php echo esc_html( $library_name ); ?>
779 </span>
780 <?php
781 }
782 }
783
784 /**
785 * Add the Chart.js admin settings to the M Chart Settings page
786 */
787 public function m_chart_settings_admin() {
788 $settings = m_chart()->get_settings();
789 require __DIR__ . '/templates/m-chart-settings-chartjs.php';
790 }
791
792 /**
793 * Add our custom column to the array of columns for charts
794 *
795 * @param array the array of columns
796 *
797 * @return array array of columns with the custom column added
798 */
799 public function manage_posts_columns( $columns ) {
800 $new_columns = [];
801
802 foreach ( $columns as $column => $name ) {
803 $new_columns[ $column ] = $name;
804
805 if ( 'author' === $column || 'coauthors' === $column ) {
806 $new_columns[ m_chart()->slug . '-type' ] = 'Type';
807
808 if ( 'yes' === m_chart()->get_settings( 'show_library' ) ) {
809 $new_columns[ m_chart()->slug . '-library' ] = 'Library';
810 }
811 }
812 }
813
814 return $new_columns;
815 }
816
817 /**
818 * Hook to save_post action and save chart related post meta
819 *
820 * @param int the WP post ID of the post being saved
821 */
822 public function save_post( $post_id ) {
823 $post = get_post( $post_id );
824
825 // Check that this isn't an autosave
826 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
827 return;
828 }
829
830 // Check post type
831 if ( ! isset( $post->post_type ) || m_chart()->slug !== $post->post_type ) {
832 return;
833 }
834
835 // Don't run on post revisions (almost always happens just before the real post is saved)
836 if ( wp_is_post_revision( $post->ID ) ) {
837 return;
838 }
839
840 // Make sure we've got some actual M Chart related data in the $_POST array
841 if ( ! isset( $_POST[ m_chart()->slug ] ) ) {
842 return;
843 }
844
845 // Check the nonce
846 $nonce = $_POST[ m_chart()->slug ]['nonce'] ?? '';
847
848 if ( ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) ) {
849 return;
850 }
851
852 unset( $_POST[ m_chart()->slug ]['nonce'] );
853
854 // Check the permissions
855 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
856 return;
857 }
858
859 // If there's an image being passed attach it to the chart post
860 $this->attach_image();
861
862 // Make sure we don't overwrite existing settings in the case someone hits update too quickly
863 if (
864 isset( $_POST[ m_chart()->slug ]['library'] )
865 // Make sure the library value is clean and valid before trying to use it
866 && m_chart()->is_valid_library( $_POST[ m_chart()->slug ]['library'] )
867 ) {
868 $library = sanitize_key( $_POST[ m_chart()->slug ]['library'] );
869
870 // Load the library in question in case there's a filter/action we'll need
871 m_chart()->library( $library );
872
873 // update_post_meta passes the $_POST values directly to validate_post_meta
874 // validate_post_meta returns only valid post meta values and does data validation on each item
875 m_chart()->update_post_meta( $post->ID, $_POST[ m_chart()->slug ] );
876 }
877 }
878
879 /**
880 * Attach a given image to a chart post
881 *
882 * @param int the WP post ID of the post being saved
883 * @param string a base64 encoded string of the image we want to attach
884 */
885 public function attach_image() {
886 $settings = m_chart()->get_settings();
887
888 // If the performance setting isn't turned to default we don't do this
889 if ( 'default' !== $settings['performance'] ) {
890 return false;
891 }
892
893 if ( ! is_numeric( $_POST['post_ID'] ?? '' ) ) {
894 return false;
895 }
896
897 $post_id = absint( $_POST['post_ID'] );
898
899 // Make sure the library used on this post supports images
900 if ( 'no' === apply_filters( 'm_chart_image_support', 'no', m_chart()->get_post_meta( $post_id, 'library' ) ) ) {
901 return false;
902 }
903
904 if ( ! current_user_can( 'edit_post', $post_id ) ) {
905 return false;
906 }
907
908 if ( ! $post = get_post( $post_id ) ) {
909 return false;
910 }
911
912 $img_data = $_POST[ m_chart()->slug ]['img'] ?? '';
913
914 if ( '' === $img_data ) {
915 return false;
916 }
917
918 // Decode the image so we can save it
919 $decoded_img = base64_decode( str_replace( 'data:image/png;base64,', '', $img_data ) );
920
921 // Reject anything that isn't a real PNG before writing it to disk
922 if ( false === $decoded_img || '' === $decoded_img || "\x89PNG\r\n\x1a\n" !== substr( $decoded_img, 0, 8 ) ) {
923 return false;
924 }
925
926 // Cap the image size at 5MB to keep a runaway client from filling disk
927 if ( strlen( $decoded_img ) > 5 * 1024 * 1024 ) {
928 return false;
929 }
930
931 // Check for an existing attached image
932 $attachments = get_posts(
933 [
934 'post_type' => 'attachment',
935 'posts_per_page' => 1,
936 'post_parent' => $post->ID,
937 'meta_key' => m_chart()->slug . '-image',
938 ]
939 );
940
941 // If an existing image was found delete it
942 foreach ( $attachments as $attachment ) {
943 wp_delete_attachment( $attachment->ID, true );
944 }
945
946 // Upload image to WP
947 $file = wp_upload_bits( sanitize_title( $post->post_title . '-' . $post->ID ) . '.png', null, $decoded_img );
948
949 // START acting like media_sideload_image
950 preg_match( '/[^\?]+\.(jpe?g|jpe|gif|png)\b/i', $file['file'], $matches );
951
952 $file_array['name'] = basename( $matches[0] );
953 $file_array['tmp_name'] = $file['file'];
954
955 if ( is_wp_error( $file ) ) {
956 @unlink( $file_array['tmp_name'] );
957 $file_array['tmp_name'] = '';
958 }
959
960 $img_id = media_handle_sideload( $file_array, $post->ID, $post->post_title );
961
962 if ( is_wp_error( $img_id ) ) {
963 @unlink( $file_array['tmp_name'] );
964 return $img_id;
965 }
966 // STOP acting like media_sideload_image
967
968 // Set some meta on the attachment so we know it came from m-chart
969 add_post_meta( $img_id, m_chart()->slug . '-image', $post->ID );
970
971 // Set the attachment as the chart's thumbnail
972 update_post_meta( $post->ID, '_thumbnail_id', $img_id );
973 }
974
975 /**
976 * Parses an incoming CSV file and compiles it into an array
977 *
978 * @return array an array of the data from the imported CSV file ready for use in the chart meta
979 */
980 public function ajax_import_csv() {
981 $post = get_post( absint( $_POST['post_id'] ?? 0 ) );
982
983 // Check post type
984 if ( ! isset( $post->post_type ) || m_chart()->slug !== $post->post_type ) {
985 wp_send_json_error( esc_html__( 'Wrong post type', 'm-chart' ) );
986 }
987
988 // Check the nonce
989 $nonce = $_POST['nonce'] ?? '';
990
991 if ( ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) ) {
992 wp_send_json_error( esc_html__( 'Invalid nonce', 'm-chart' ) );
993 }
994
995 // Check the permissions
996 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
997 wp_send_json_error( esc_html__( 'Wrong post type', 'm-chart' ) );
998 }
999
1000 // Make sure there's a CSV file
1001 if ( empty( $_FILES['import_csv_file']['name'] ) ) {
1002 wp_send_json_error( esc_html__( 'No file to import', 'm-chart' ) );
1003 }
1004
1005 // Check upload-level errors first
1006 if ( UPLOAD_ERR_OK !== ( $_FILES['import_csv_file']['error'] ?? UPLOAD_ERR_NO_FILE ) ) {
1007 wp_send_json_error( esc_html__( 'File upload error', 'm-chart' ) );
1008 }
1009
1010 if ( ! is_uploaded_file( $_FILES['import_csv_file']['tmp_name'] ) ) {
1011 wp_send_json_error( esc_html__( 'Invalid upload', 'm-chart' ) );
1012 }
1013
1014 // Verify both extension AND MIME (some browsers send text/plain for CSV)
1015 $file_check = wp_check_filetype_and_ext(
1016 $_FILES['import_csv_file']['tmp_name'],
1017 $_FILES['import_csv_file']['name'],
1018 [ 'csv' => 'text/csv', 'csv-alt' => 'text/plain' ]
1019 );
1020
1021 if ( 'csv' !== ( $file_check['ext'] ?? '' ) ) {
1022 wp_send_json_error( esc_html__( 'Only CSV files can be imported', 'm-chart' ) );
1023 }
1024
1025 // Do some validation on the CSV file (mirroring what WP does for this sort of thing)
1026 $csv_file = realpath( $_FILES['import_csv_file']['tmp_name'] );
1027
1028 if ( ! $csv_file ) {
1029 wp_send_json_error( esc_html__( 'File path not found', 'm-chart' ) );
1030 }
1031
1032 // Cap file size at 2MB to prevent resource exhaustion
1033 if ( filesize( $csv_file ) > 2 * 1024 * 1024 ) {
1034 wp_send_json_error( esc_html__( 'CSV file too large (max 2MB)', 'm-chart' ) );
1035 }
1036
1037 $csv_data = file_get_contents( $csv_file );
1038
1039 if ( '' === $csv_data ) {
1040 wp_send_json_error( esc_html__( 'CSV file was empty', 'm-chart' ) );
1041 }
1042
1043 // Get parseCSV library so we can use it to convert the CSV to a nice array
1044 // Yes, PHP does this natively now but I've run into trouble with malformed CSV that parsCSV handles fine
1045 require_once __DIR__ . '/external/parsecsv/parsecsv.lib.php';
1046
1047 $parse_csv = new parseCSV();
1048
1049 // The "\n" before and after is to deal with CSV files that don't have line breaks above and below the data
1050 // Which then seems to confuse parseCSV occasionally
1051 $csv_data = "\n" . trim( $csv_data ) . "\n";
1052
1053 // Set delimiter but check to make sure it's safe first
1054 $parse_csv->delimiter = isset( $_POST['csv_delimiter'] ) && in_array( $_POST['csv_delimiter'], $this->safe_settings[ 'csv_delimiter' ] ) ? $_POST['csv_delimiter'] : m_chart()->get_settings( 'csv_delimiter' );
1055
1056 // Parse the CSV
1057 $parse_csv->parse( $csv_data );
1058
1059 // This deals with Google Doc's crappy CSV exports which don't include columns at the end of a row if they are empty
1060 $data_array = $this->fix_csv_data( $parse_csv->data );
1061
1062 wp_send_json_success( $data_array );
1063 }
1064
1065 /**
1066 * Helper function makes sure that the data array has matching numbers of array elements for each row
1067 * CSV from some sources (Google Docs) doesn't include columns that are empty when they are at the end of a row (Why Google? WHY?)
1068 *
1069 * @param array an array of data as returned from the parseCSV class
1070 *
1071 * @return array the array of data with matching array value counts
1072 */
1073 public function fix_csv_data( $data_array ) {
1074 $count = 0;
1075
1076 // Get largest row count
1077 foreach ( $data_array as $data ) {
1078 $temp_count = count( $data );
1079
1080 $count = ( $temp_count > $count ) ? $temp_count : $count;
1081 }
1082
1083 // Fix arrays so value counts match
1084 foreach ( $data_array as $key => $data ) {
1085 $temp_count = count( $data );
1086
1087 if ( $temp_count < $count ) {
1088 $difference = $count - $temp_count;
1089
1090 for ( $i = 0; $i < $difference; $i++ ) {
1091 $data_array[ $key ][] = '';
1092 }
1093 }
1094 }
1095
1096 return $data_array;
1097 }
1098
1099 /**
1100 * Converts data array into CSV and outputs it to the browser
1101 */
1102 public function ajax_export_csv() {
1103 $post_id = $_REQUEST['post_id'] ?? '';
1104 $nonce = $_REQUEST['nonce'] ?? '';
1105
1106 if (
1107 ! is_numeric( $post_id )
1108 || ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' )
1109 || ! current_user_can( 'edit_post', absint( $post_id ) )
1110 ) {
1111 wp_die( esc_html__( 'Unauthorized access', 'm-chart' ), esc_html__( 'You do not have permission to do that', 'm-chart' ), [ 'response' => 401 ] );
1112 }
1113
1114 $post = get_post( absint( $post_id ) );
1115
1116 // If the user passed a data value in their request we'll use it after validation
1117 if ( isset( $_POST['data'] ) && isset( $_POST['title'] ) ) {
1118 $data = m_chart()->validate_data( json_decode( stripslashes( $_POST['data'] ) ) );
1119 $file_name = sanitize_title( $_POST['title'] );
1120 } else {
1121 $data = m_chart()->get_post_meta( $post->ID, 'data' );
1122 $file_name = sanitize_title( get_the_title( $post->ID ) );
1123 }
1124
1125 $set_name = sanitize_title( $_REQUEST['set_name'] ?? '' );
1126
1127 if ( empty( $data ) ) {
1128 return;
1129 }
1130
1131 // Prevent CSV/formula injection by prefixing any cell that begins with a formula trigger so spreadsheet apps see it as a literal string
1132 array_walk_recursive( $data, function ( &$cell ) {
1133 $cell = $this->neutralize_csv_cell( $cell );
1134 } );
1135
1136 require_once __DIR__ . '/external/parsecsv/parsecsv.lib.php';
1137 $parse_csv = new parseCSV();
1138
1139 // Set delimiter
1140 $parse_csv->output_delimiter = m_chart()->get_settings( 'csv_delimiter' );
1141
1142 $parse_csv->output( $file_name . '-' . $set_name . '.csv', $data );
1143 die;
1144 }
1145
1146 /**
1147 * Prefix a cell value with a single quote when it starts with a character that Excel/Sheets/Numbers interpret as a formula trigger
1148 *
1149 * @param mixed $cell The raw cell value
1150 * @return string The cell value, prefixed with ' if it would otherwise execute
1151 */
1152 public function neutralize_csv_cell( $cell ) {
1153 $cell = (string) $cell;
1154
1155 if ( '' !== $cell && in_array( $cell[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) {
1156 return "'" . $cell;
1157 }
1158
1159 return $cell;
1160 }
1161
1162 /**
1163 * Returns JSON encoded chart args from $_POST values sent from the admin panel
1164 *
1165 * @return string a JSON encoded string containing all of the chart args needed to update an active chart
1166 */
1167 public function ajax_get_chart_args() {
1168 // Check the nonce
1169 $nonce = $_POST['nonce'] ?? '';
1170
1171 if ( ! wp_verify_nonce( $nonce, m_chart()->slug . '-save-post' ) ) {
1172 wp_send_json_error( esc_html__( 'Invalid nonce', 'm-chart' ) );
1173 }
1174
1175 // Does the post exist? (post_id is 0 for new charts that haven't been saved yet)
1176 $post_id = absint( $_POST['post_id'] ?? 0 );
1177
1178 if ( $post_id ) {
1179 if ( ! $post = get_post( $post_id ) ) {
1180 wp_send_json_error( esc_html__( 'Invalid post', 'm-chart' ) );
1181 }
1182
1183 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
1184 wp_send_json_error( esc_html__( 'Permission error', 'm-chart' ) );
1185 }
1186 } else {
1187 // New chart — no saved post yet, build a stub so the library can compute chart args
1188 if ( ! current_user_can( 'edit_posts' ) ) {
1189 wp_send_json_error( esc_html__( 'Permission error', 'm-chart' ) );
1190 }
1191
1192 $post = new WP_Post( (object) [
1193 'ID' => 0,
1194 'post_title' => '',
1195 'post_type' => m_chart()->slug,
1196 'post_status' => 'auto-draft',
1197 ] );
1198 }
1199
1200 // Is this a valid library?
1201 $library_slug = $_POST['library'] ?? '';
1202
1203 if ( ! m_chart()->is_valid_library( $library_slug ) ) {
1204 wp_send_json_error( esc_html__( 'Invalid library', 'm-chart' ) );
1205 }
1206
1207 // This does get potentially overwritten later on
1208 // However, it's necessary for initial load on a new chart
1209 if ( 'highcharts' === $library_slug ) {
1210 $library = m_chart()->library( $library_slug );
1211 }
1212
1213 $library = apply_filters( 'm_chart_library_class', m_chart()->library_class, $library_slug );
1214
1215 // Make sure a third-party filter didn't replace the library with something unusable
1216 if ( ! is_object( $library ) || ! method_exists( $library, 'get_chart_args' ) ) {
1217 wp_send_json_error( esc_html__( 'Invalid library', 'm-chart' ) );
1218 }
1219
1220 // Set these values so that get_chart_args has them already available before we call it
1221 $library->args = m_chart()->get_chart_default_args;
1222 $library->post = $post;
1223 $library->post->post_title = sanitize_text_field( $_POST['title'] ?? '' );
1224
1225 // validate_post_meta returns only valid post meta values and does data validation on each item
1226 $library->post_meta = m_chart()->validate_post_meta( $_POST['post_meta'] ?? [] );
1227
1228 wp_send_json_success( $library->get_chart_args( $library->post->ID, $library->args, true, false ) );
1229 }
1230
1231 /**
1232 * Return a name spaced field name
1233 *
1234 * @param string the field name we want to name space
1235 *
1236 * @param string a name spaced field name
1237 */
1238 public function get_field_name( $field_name, $parent_field_name = '' ) {
1239 if ( '' !== $parent_field_name ) {
1240 return m_chart()->slug . '[' . $parent_field_name . ']' . '[' . $field_name . ']';
1241 }
1242
1243 return m_chart()->slug . '[' . $field_name . ']';
1244 }
1245
1246 /**
1247 * Return a name spaced field id
1248 *
1249 * @param string the field id we want to name space
1250 *
1251 * @param string a name spaced field id
1252 */
1253 public function get_field_id( $field_name ) {
1254 return m_chart()->slug . '-' . $field_name;
1255 }
1256 }
1257