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

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