PluginProbe
M Chart / 2.3.1
M Chart v2.3.1
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.3.1, at components/class-m-chart-admin.php

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