PluginProbe
TablePress – Tables in WordPress made easy / 3.4
TablePress – Tables in WordPress made easy v3.4
3.4 3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 All 45 releases
tablepress / classes / class-tablepress.php

class-tablepress.php in TablePress – Tables in WordPress made easy 3.4, at classes/class-tablepress.php

1,130 lines 42.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * TablePress Class
4 *
5 * @package TablePress
6 * @author Tobias Bäthge
7 * @since 1.0.0
8 */
9
10 declare(strict_types=1);
11
12 // Prohibit direct script loading.
13 defined( 'ABSPATH' ) || die( 'No direct script access allowed!' );
14
15 /**
16 * TablePress class
17 *
18 * @package TablePress
19 * @author Tobias Bäthge
20 * @since 1.0.0
21 */
22 abstract class TablePress {
23
24 /**
25 * TablePress version.
26 *
27 * Increases whenever a new plugin version is released.
28 *
29 * @since 1.0.0
30 * @const string
31 */
32 public const version = '3.4'; // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
33
34 /**
35 * TablePress internal plugin version ("options scheme" version).
36 *
37 * Increases whenever the scheme for the plugin options changes, or on a plugin update.
38 *
39 * @since 1.0.0
40 * @const int
41 */
42 public const db_version = 133; // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
43
44 /**
45 * TablePress "table scheme" (data format structure) version.
46 *
47 * Increases whenever the scheme for a $table changes,
48 * used to be able to update plugin options and table scheme independently.
49 *
50 * @since 1.0.0
51 * @const int
52 */
53 public const table_scheme_version = 3; // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
54
55 /**
56 * Instance of the Options Model.
57 *
58 * @since 1.3.0
59 */
60 public static \TablePress_Options_Model $model_options;
61
62 /**
63 * Instance of the Table Model.
64 *
65 * @since 1.3.0
66 */
67 public static \TablePress_Table_Model $model_table;
68
69 /**
70 * Instance of the controller.
71 *
72 * @since 1.0.0
73 */
74 public static \TablePress_Frontend_Controller $controller;
75
76 /**
77 * Name of the Shortcode to show a TablePress table.
78 *
79 * Should only be modified through the filter hook 'tablepress_table_shortcode'.
80 *
81 * @since 1.0.0
82 * @var non-empty-string
83 */
84 public static string $shortcode = 'table';
85
86 /**
87 * Name of the Shortcode to show extra information of a TablePress table.
88 *
89 * Should only be modified through the filter hook 'tablepress_table_info_shortcode'.
90 *
91 * @since 1.0.0
92 * @var non-empty-string
93 */
94 public static string $shortcode_info = 'table-info';
95
96 /**
97 * List of TablePress premium modules.
98 *
99 * @since 2.1.0
100 * @var array<string, array<string, mixed>> $modules Array with module slugs as keys and module data as values.
101 */
102 public static array $modules = array();
103
104 /**
105 * Start-up TablePress (run on WordPress "init") and load the controller for the current state.
106 *
107 * @since 1.0.0
108 */
109 public static function run(): void {
110 /**
111 * Fires before TablePress is loaded.
112 *
113 * The `tablepress_loaded` action hook might be a better choice in most situations, as TablePress options will then be available.
114 *
115 * @since 1.0.0
116 */
117 do_action( 'tablepress_run' );
118
119 /**
120 * Filters the string that is used as the [table] Shortcode.
121 *
122 * @since 1.0.0
123 *
124 * @param non-empty-string $shortcode The [table] Shortcode string.
125 */
126 self::$shortcode = apply_filters( 'tablepress_table_shortcode', self::$shortcode );
127 /**
128 * Filters the string that is used as the [table-info] Shortcode.
129 *
130 * @since 1.0.0
131 *
132 * @param non-empty-string $shortcode_info The [table-info] Shortcode string.
133 */
134 self::$shortcode_info = apply_filters( 'tablepress_table_info_shortcode', self::$shortcode_info );
135
136 // Load modals for table and options, to be accessible from everywhere via `TablePress::$model_options` and `TablePress::$model_table`.
137 self::$model_options = self::load_model( 'options' );
138 self::$model_table = self::load_model( 'table' );
139
140 // Exit early, i.e. before a controller is loaded, if TablePress functionality is likely not needed.
141 $exit_early = false;
142 if ( ( isset( $_SERVER['SCRIPT_FILENAME'] ) && 'wp-login.php' === basename( $_SERVER['SCRIPT_FILENAME'] ) ) // Detect the WordPress Login screen.
143 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
144 || wp_doing_cron() ) {
145 $exit_early = true;
146 }
147 /**
148 * Filters whether TablePress should exit early, e.g. during wp-login.php, XML-RPC, and WP-Cron requests.
149 *
150 * @since 2.0.0
151 *
152 * @param bool $exit_early Whether TablePress should exit early.
153 */
154 if ( apply_filters( 'tablepress_exit_early', $exit_early ) ) {
155 return;
156 }
157
158 if ( is_admin() ) {
159 $controller = 'admin';
160 if ( wp_doing_ajax() ) {
161 $controller = 'admin_ajax';
162 }
163 self::load_controller( $controller );
164 }
165 // Load the frontend controller in all scenarios, so that Shortcode render functions are always available.
166 self::$controller = self::load_controller( 'frontend' );
167
168 // Add filters and actions for the integration into the WP WXR exporter and importer.
169 add_action( 'wp_import_insert_post', array( TablePress::$model_table, 'add_table_id_on_wp_import' ), 10, 4 ); // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
170 add_filter( 'wp_import_post_meta', array( TablePress::$model_table, 'prevent_table_id_post_meta_import_on_wp_import' ), 10, 3 ); // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
171 add_filter( 'wxr_export_skip_postmeta', array( TablePress::$model_table, 'add_table_id_to_wp_export' ), 10, 3 ); // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed
172
173 /**
174 * Fires after TablePress is loaded.
175 *
176 * The `tablepress_run` action hook can be used if code has to run before TablePress is loaded.
177 *
178 * @since 2.0.0
179 */
180 do_action( 'tablepress_loaded' );
181 }
182
183 /**
184 * Load a file with require_once(), after running it through a filter.
185 *
186 * @since 1.0.0
187 *
188 * @param string $file Name of the PHP file.
189 * @param string $folder Name of the folder with the file.
190 */
191 public static function load_file( string $file, string $folder ): void {
192 $full_path = TABLEPRESS_ABSPATH . $folder . '/' . $file;
193 /**
194 * Filters the full path of a file that shall be loaded.
195 *
196 * @since 1.0.0
197 *
198 * @param string $full_path Full path of the file that shall be loaded.
199 * @param string $file File name of the file that shall be loaded.
200 * @param string $folder Folder name of the file that shall be loaded.
201 */
202 $full_path = apply_filters( 'tablepress_load_file_full_path', $full_path, $file, $folder );
203 if ( $full_path ) {
204 require_once $full_path;
205 }
206 }
207
208 /**
209 * Create a new instance of the $class_name, which is stored in $file in the $folder subfolder
210 * of the plugin's directory.
211 *
212 * @since 1.0.0
213 *
214 * @param string $class_name Name of the class.
215 * @param string $file Name of the PHP file with the class.
216 * @param string $folder Name of the folder with $class_name's $file.
217 * @param mixed[]|string|null $params Optional. Parameters that are passed to the constructor of $class_name.
218 * @return object Initialized instance of the class.
219 */
220 public static function load_class( string $class_name, string $file, string $folder, /* ?array|string */ $params = null ): object {
221 /**
222 * Filters name of the class that shall be loaded.
223 *
224 * @since 1.0.0
225 *
226 * @param string $class_name Name of the class that shall be loaded.
227 */
228 $class_name = apply_filters( 'tablepress_load_class_name', $class_name );
229 if ( ! class_exists( $class_name, false ) ) {
230 self::load_file( $file, $folder );
231 }
232 $the_class = new $class_name( $params );
233 return $the_class;
234 }
235
236 /**
237 * Create a new instance of the $model, which is stored in the "models" subfolder.
238 *
239 * @since 1.0.0
240 *
241 * @param string $model Name of the model.
242 * @return object Instance of the initialized model.
243 */
244 public static function load_model( string $model ): object {
245 // Model Base Class.
246 self::load_file( 'class-model.php', 'classes' );
247 // Make first letter uppercase for a better looking naming pattern.
248 $ucmodel = ucfirst( $model );
249 $the_model = self::load_class( "TablePress_{$ucmodel}_Model", "model-{$model}.php", 'models' );
250 return $the_model;
251 }
252
253 /**
254 * Create a new instance of the $view, which is stored in the "views" subfolder, and set it up with $data.
255 *
256 * @since 1.0.0
257 *
258 * @param string $view Name of the view to load.
259 * @param array<string, mixed> $data Optional. Parameters/PHP variables that shall be available to the view.
260 * @return object Instance of the initialized view, already set up, just needs to be rendered.
261 */
262 public static function load_view( string $view, array $data = array() ): object {
263 // View Base Class.
264 self::load_file( 'class-view.php', 'classes' );
265 // Make first letter uppercase for a better looking naming pattern.
266 $ucview = ucfirst( $view );
267 $the_view = self::load_class( "TablePress_{$ucview}_View", "view-{$view}.php", 'views' );
268 $the_view->setup( $view, $data );
269 return $the_view;
270 }
271
272 /**
273 * Create a new instance of the $controller, which is stored in the "controllers" subfolder.
274 *
275 * @since 1.0.0
276 *
277 * @param string $controller Name of the controller.
278 * @return object Instance of the initialized controller.
279 */
280 public static function load_controller( string $controller ): object {
281 // Controller Base Class.
282 self::load_file( 'class-controller.php', 'classes' );
283 // Make first letter uppercase for a better looking naming pattern.
284 $uccontroller = ucfirst( $controller );
285 $the_controller = self::load_class( "TablePress_{$uccontroller}_Controller", "controller-{$controller}.php", 'controllers' );
286 return $the_controller;
287 }
288
289 /**
290 * Generate the complete nonce string, from the nonce base, the action and an item, e.g. tablepress_delete_table_3.
291 *
292 * @since 1.0.0
293 *
294 * @param string $action Action for which the nonce is needed.
295 * @param string|false $item Optional. Item for which the action will be performed, like "table". false if no item should be used in the nonce.
296 * @return string The resulting nonce string.
297 */
298 public static function nonce( string $action, /* string|false */ $item = false ): string {
299 $nonce = "tablepress_{$action}";
300 if ( $item ) {
301 $nonce .= "_{$item}";
302 }
303 return $nonce;
304 }
305
306 /**
307 * Check whether a nonce string is valid.
308 *
309 * @since 1.0.0
310 *
311 * @param string $action Action for which the nonce should be checked.
312 * @param string|false $item Optional. Item for which the action should be performed, like "table". false if no item should be used in the nonce.
313 * @param string $query_arg Optional. Name of the nonce query string argument in $_POST.
314 * @param bool $ajax Whether the nonce comes from an AJAX request.
315 */
316 public static function check_nonce( string $action, /* string|false */ $item = false, string $query_arg = '_wpnonce', bool $ajax = false ): void {
317 $nonce_action = self::nonce( $action, $item );
318 if ( $ajax ) {
319 check_ajax_referer( $nonce_action, $query_arg );
320 } else {
321 check_admin_referer( $nonce_action, $query_arg );
322 }
323 }
324
325 /**
326 * Calculate the column index (number) of a column header string (example: A is 1, AA is 27, ...).
327 *
328 * For the opposite, @see number_to_letter().
329 *
330 * @since 1.0.0
331 *
332 * @param string $column Column string.
333 * @return int Column number, 1-based.
334 */
335 public static function letter_to_number( string $column ): int {
336 $column = (string) preg_replace( '/[^A-Za-z]/', '', $column );
337 $column = strtoupper( $column );
338 $count = strlen( $column );
339 $number = 0;
340 for ( $i = 0; $i < $count; $i++ ) {
341 $number += ( ord( $column[ $count - 1 - $i ] ) - 64 ) * 26 ** $i;
342 }
343 return $number;
344 }
345
346 /**
347 * "Calculate" the column header string of a column index (example: 2 is B, AB is 28, ...).
348 *
349 * For the opposite, @see letter_to_number().
350 *
351 * @since 1.0.0
352 *
353 * @param int $number Column number, 1-based.
354 * @return string Column string.
355 */
356 public static function number_to_letter( int $number ): string {
357 $column = '';
358 while ( $number > 0 ) {
359 $column = chr( 65 + ( ( $number - 1 ) % 26 ) ) . $column;
360 $number = intdiv( $number - 1, 26 );
361 }
362 return $column;
363 }
364
365 /**
366 * Converts a range string (e.g., "1-5" or "A-E") to an array of numbers.
367 *
368 * @since 3.4.0
369 *
370 * @param string $value The range string.
371 * @param int $min_number The minimum allowed number for the range, usually 0 or 1.
372 * @param int $max_number The maximum allowed number for the range, usually the number of rows or columns in the table.
373 * @return int[] The array of numbers in the range.
374 */
375 public static function convert_range_to_array( string $value, int $min_number, int $max_number ): array {
376 $parts = explode( '-', $value );
377
378 // Ignore invalid ranges, e.g., "1-5-10" or "A-".
379 if ( count( $parts ) !== 2 || '' === $parts[0] || '' === $parts[1] ) {
380 return array();
381 }
382
383 $start = trim( $parts[0] );
384 if ( ! is_numeric( $start ) ) {
385 $start = self::letter_to_number( $start );
386 }
387
388 $end = trim( $parts[1] );
389 if ( ! is_numeric( $end ) ) {
390 $end = self::letter_to_number( $end );
391 }
392
393 // Catch completely out-of-bound ranges, taking into account that the order can be reversed.
394 if ( ( $start < $min_number && $end < $min_number ) || ( $start > $max_number && $end > $max_number ) ) {
395 return array();
396 }
397
398 // Clamp the start and end values to the min and max numbers.
399 $start = max( $min_number, min( (int) $start, $max_number ) );
400 $end = max( $min_number, min( (int) $end, $max_number ) );
401
402 return range( $start, $end );
403 }
404
405 /**
406 * Converts a comma-separated list of row/column numbers/letters/ranges to a unique and sorted array of numbers.
407 *
408 * @since 3.4.0
409 *
410 * @param string $item_list A comma-separated list of row/column numbers/letters/ranges.
411 * @param int $min_number The minimum allowed number for ranges, usually 0 or 1.
412 * @param int $max_number The maximum allowed number for ranges, usually the number of rows or columns in the table.
413 * @param array{ unique?: bool, sort?: bool, zero-based?: bool } $post_processing An array of post-processing operations to apply to the resulting array.
414 * @return int[] The array of numbers in the list, unique and sorted.
415 */
416 public static function convert_row_column_list_to_array( string $item_list, int $min_number, int $max_number, array $post_processing = array() ): array {
417 $original_item_list = explode( ',', $item_list );
418
419 $item_list = array();
420 foreach ( $original_item_list as $value ) {
421 if ( str_contains( $value, '-' ) ) {
422 // Convert ranges.
423 $range = self::convert_range_to_array( $value, $min_number, $max_number );
424 $item_list = array_merge( $item_list, $range );
425 } else {
426 // Handle single values.
427 $value = trim( $value );
428 if ( ! is_numeric( $value ) ) {
429 $value = self::letter_to_number( $value );
430 }
431 if ( $value >= $min_number && $value <= $max_number ) {
432 $item_list[] = (int) $value;
433 }
434 }
435 }
436
437 // Maybe remove duplicate entries, sort the array, or convert to zero-based numbering.
438 $post_processing['unique'] ??= true;
439 if ( $post_processing['unique'] ) {
440 $item_list = array_unique( $item_list, SORT_NUMERIC );
441 }
442
443 $post_processing['sort'] ??= true;
444 if ( $post_processing['sort'] ) {
445 sort( $item_list, SORT_NUMERIC );
446 }
447
448 $post_processing['zero-based'] ??= false;
449 if ( $post_processing['zero-based'] ) {
450 foreach ( $item_list as &$value ) {
451 --$value;
452 }
453 unset( $value ); // Unset use-by-reference parameter of foreach loop.
454 }
455
456 return $item_list;
457 }
458
459 /**
460 * Get a nice looking date and time string from the mySQL format of datetime strings for output.
461 *
462 * @since 1.0.0
463 *
464 * @param string $datetime_string DateTime string, often in mySQL format..
465 * @param string $separator_or_format Optional. Separator between date and time, or format string.
466 * @return string Nice looking string with the date and time.
467 */
468 public static function format_datetime( string $datetime_string, string $separator_or_format = ' ' ): string {
469 $timezone = wp_timezone();
470 $datetime = date_create( $datetime_string, $timezone );
471 if ( false === $datetime ) {
472 return $datetime_string;
473 }
474 $timestamp = $datetime->getTimestamp();
475
476 switch ( $separator_or_format ) {
477 case ' ':
478 case '<br />':
479 case '<br/>':
480 case '<br>':
481 $date = wp_date( get_option( 'date_format' ), $timestamp, $timezone );
482 $time = wp_date( get_option( 'time_format' ), $timestamp, $timezone );
483 $output = "{$date}{$separator_or_format}{$time}";
484 break;
485 default:
486 $output = (string) wp_date( $separator_or_format, $timestamp, $timezone );
487 break;
488 }
489
490 return $output;
491 }
492
493 /**
494 * Get the name from a WP user ID (used to store information on last editor of a table).
495 *
496 * @since 1.0.0
497 *
498 * @param int $user_id WP user ID.
499 * @return string Nickname of the WP user with the $user_id.
500 */
501 public static function get_user_display_name( int $user_id ): string {
502 $user = get_userdata( $user_id );
503 /* translators: %s: Label for unknown user */
504 return $user->display_name ?? sprintf( '<em>%s</em>', __( 'unknown', 'tablepress' ) );
505 }
506
507 /**
508 * Sanitizes a CSS class to ensure it only contains valid characters.
509 *
510 * Strips the string down to A-Z, a-z, 0-9, :, _, -.
511 * This is an extension to WP's `sanitize_html_class()`, to also allow `:` which are used in some CSS frameworks.
512 *
513 * @since 1.11.0
514 *
515 * @param string $css_class The CSS class name to be sanitized.
516 * @return string The sanitized CSS class.
517 */
518 public static function sanitize_css_class( string $css_class ): string {
519 // Strip out any %-encoded octets.
520 $sanitized_css_class = (string) preg_replace( '|%[a-fA-F0-9][a-fA-F0-9]|', '', $css_class );
521 // Limit to A-Z, a-z, 0-9, ':', '_', and '-'.
522 $sanitized_css_class = (string) preg_replace( '/[^A-Za-z0-9:_-]/', '', $sanitized_css_class );
523 return $sanitized_css_class;
524 }
525
526 /**
527 * Extracts the top-level keys from a JavaScript object string.
528 *
529 * This function is used to extract the keys of the "Custom Commands" JavaScript object string, to check for overrides.
530 * It covers most cases, like normal object properties with and without quotes, shorthand properties, and shorthand methods,
531 * and also ignores single-line and multi-line comments.
532 * It does not cover all possible JavaScript syntax (like template literals, special characters, ...),
533 * but should be sufficient for the use case.
534 *
535 * @since 3.0.0
536 *
537 * @param string $js_object_string A JavaScript object as a string.
538 * @return string[] Array of top-level keys of the object.
539 */
540 public static function extract_keys_from_js_object_string( string $js_object_string ): array {
541 $object_keys = array();
542 $length = strlen( $js_object_string );
543 $depth = 0;
544 $key_expected = true;
545 $in_quotes = false;
546 $quote_char = '';
547 $in_function_declaration = false;
548 $in_single_line_comment = false;
549 $in_multi_line_comment = false;
550 $object_key = '';
551
552 for ( $i = 0; $i < $length; $i++ ) {
553 $char = $js_object_string[ $i ];
554
555 // Skip parsing single-line comments.
556 if ( $in_single_line_comment ) {
557 if ( "\n" === $char ) {
558 $in_single_line_comment = false;
559 }
560 continue;
561 } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
562 if ( '/' === $char && $i + 1 < $length && '/' === $js_object_string[ $i + 1 ] ) {
563 $in_single_line_comment = true;
564 ++$i; // Skip the second '/'.
565 continue;
566 }
567 }
568
569 // Skip parsing multi-line comments.
570 if ( $in_multi_line_comment ) {
571 if ( '*' === $char && $i + 1 < $length && '/' === $js_object_string[ $i + 1 ] ) {
572 $in_multi_line_comment = false;
573 ++$i; // Skip the '/' that ends the multi-line comment.
574 }
575 continue;
576 } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
577 if ( '/' === $char && $i + 1 < $length && '*' === $js_object_string[ $i + 1 ] ) {
578 $in_multi_line_comment = true;
579 ++$i; // Skip the '*'.
580 continue;
581 }
582 }
583
584 // Skip parsing while inside a quoted string.
585 if ( $in_quotes ) {
586 if ( $quote_char === $char ) {
587 $in_quotes = false;
588 }
589 continue;
590 } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
591 if ( '"' === $char || "'" === $char ) {
592 $in_quotes = true;
593 $quote_char = $char;
594 continue;
595 }
596 }
597
598 /*
599 * Skip parsing while inside a `function abc( ... )` declaration string.
600 * The `$key_expected` check limits search the "function" string to object values.
601 * The check for the plain `f` reduces expensive `substr()` calls.
602 */
603 if ( ! $key_expected ) {
604 if ( $in_function_declaration ) {
605 if ( ')' === $char ) {
606 $in_function_declaration = false;
607 }
608 continue;
609 } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
610 if ( 'f' === $char && 'function' === substr( $js_object_string, $i, 8 ) ) {
611 $in_function_declaration = true;
612 $i += 7; // Skip the rest of the "function" string.
613 continue;
614 }
615 }
616 }
617
618 // Handle object depth, so that most parsing can be limited to the top level.
619 if ( '{' === $char || '[' === $char ) {
620 ++$depth;
621 }
622
623 // Extract only keys at the top level.
624 if ( 1 === $depth ) {
625 if ( $key_expected ) {
626 if ( ':' === $char ) {
627 // Check for normal keys, with value after :.
628
629 // Go backwards to find the start of the key.
630 $j = $i - 1;
631 while ( $j >= 0 && preg_match( '/\s/', $js_object_string[ $j ] ) ) {
632 --$j;
633 }
634 $key_end = $j; // Position of the last character of the key (potentially with quote).
635 if ( '"' === $js_object_string[ $j ] || "'" === $js_object_string[ $j ] ) {
636 // Quoted key.
637 $quote_char = $js_object_string[ $j ];
638 --$j;
639 while ( $j >= 0 && $quote_char !== $js_object_string[ $j ] ) {
640 --$j;
641 }
642 $key_start = $j + 1;
643 } else {
644 // Unquoted key.
645 while ( $j >= 0 && preg_match( '/[\w]/', $js_object_string[ $j ] ) ) {
646 --$j;
647 }
648 $key_start = $j + 1;
649 }
650 $object_key = substr( $js_object_string, $key_start, $key_end - $key_start + 1 );
651 $object_key = trim( $object_key, "\"'" );
652 if ( '' !== $object_key && ! in_array( $object_key, $object_keys, true ) ) {
653 $object_keys[] = $object_key;
654 }
655 $key_expected = false;
656 } elseif ( ( ',' === $char || '}' === $char ) ) { // The `}` case is for the last key.
657 // Check for shorthand properties (which must be unquoted).
658
659 // Go backwards to find the start of the shorthand key.
660 $j = $i - 1;
661 while ( $j >= 0 && preg_match( '/\s/', $js_object_string[ $j ] ) ) {
662 --$j;
663 }
664 $key_end = $j; // Position of the last character of the key (without a quote).
665 while ( $j >= 0 && preg_match( '/[\w]/', $js_object_string[ $j ] ) ) {
666 --$j;
667 }
668 $key_start = $j + 1;
669 $object_key = substr( $js_object_string, $key_start, $key_end - $key_start + 1 );
670 if ( '' !== $object_key && ! in_array( $object_key, $object_keys, true ) ) {
671 $object_keys[] = $object_key;
672 }
673 } elseif ( '(' === $char ) {
674 // Detect shorthand method definitions.
675
676 // Go back to find the start of the method name.
677 $j = $i - 1;
678 while ( $j >= 0 && preg_match( '/\s/', $js_object_string[ $j ] ) ) {
679 --$j;
680 }
681 $key_end = $j;
682 while ( $j >= 0 && preg_match( '/[\w]/', $js_object_string[ $j ] ) ) {
683 --$j;
684 }
685 $key_start = $j + 1;
686 $object_key = substr( $js_object_string, $key_start, $key_end - $key_start + 1 );
687 if ( '' !== $object_key && ! in_array( $object_key, $object_keys, true ) ) {
688 $object_keys[] = $object_key;
689 }
690 }
691 }
692
693 // Reset the "key expected" flag after a comma or closing brace.
694 if ( ',' === $char || '}' === $char ) {
695 $key_expected = true;
696 }
697 }
698
699 // Handle object depth.
700 if ( '}' === $char || ']' === $char ) {
701 --$depth;
702 }
703 }
704
705 return $object_keys;
706 }
707
708 /**
709 * Converts old DataTables 1.x CSS classes and parameters to the DataTables 2 variants.
710 *
711 * This function is used to modernize "Custom CSS" and "Custom Commands" for compatibility with DataTables 2.x.
712 * It probably does not catch all possible cases.
713 *
714 * @since 3.0.0
715 *
716 * @param string $code Code that contains DataTables 1.x CSS classes and parameters.
717 * @return string Updated code with DataTables 2.x CSS classes and parameters.
718 */
719 public static function convert_datatables_api_data( string $code ): string {
720 /**
721 * Mappings for DataTables 1.x CSS class or parameter to DataTables 2 variants.
722 * As this array is used in `strtr()`, it's pre-sorted for descending string length of the array keys.
723 */
724 static $datatables_api_data_mappings = array(
725 // CSS classes.
726 '.tablepress thead .sorting:hover' => '.tablepress thead .dt-orderable-asc:hover,.tablepress thead .dt-orderable-desc:hover',
727 '.tablepress thead .sorting_desc' => '.tablepress thead .dt-ordering-desc',
728 '.dataTables_filter label input' => '.dt-container .dt-search input',
729 '.tablepress thead .sorting_asc' => '.tablepress thead .dt-ordering-asc',
730 '.dataTables_scrollFootInner' => '.dt-scroll-footInner',
731 '.dataTables_scrollHeadInner' => '.dt-scroll-headInner',
732 '.tablepress thead .sorting' => '.tablepress thead .dt-orderable-asc,.tablepress thead .dt-orderable-desc',
733 '.dataTables_processing' => '.dt-processing',
734 '.dataTables_scrollBody' => '.dt-scroll-body',
735 '.dataTables_scrollFoot' => '.dt-scroll-foot',
736 '.dataTables_scrollHead' => '.dt-scroll-head',
737 '.dataTables_paginate' => '.dt-paging',
738 '.tablepress .even td' => '.tablepress>:where(tbody.row-striping)>:nth-child(odd)>*',
739 '.dataTables_wrapper' => '.dt-container',
740 '.tablepress .odd td' => '.tablepress>:where(tbody.row-striping)>:nth-child(even)>*',
741 '.dataTables_filter' => '.dt-search',
742 '.dataTables_length' => '.dt-length',
743 '.dataTables_scroll' => '.dt-scroll',
744 '.dataTables_empty' => '.dt-empty',
745 '.dataTables_info' => '.dt-info',
746 '.paginate_button' => '.dt-paging-button',
747 // DataTables API functions.
748 '$.fn.dataTable.' => 'DataTable.',
749 );
750 $code = strtr( $code, $datatables_api_data_mappings );
751
752 // HTML ID mappings, which were removed.
753 if ( str_contains( $code, '#tablepress-' ) ) {
754 $code = (string) preg_replace(
755 array(
756 '/#tablepress-([A-Za-z1-9_-]|[A-Za-z0-9_-]{2,})_paginate/',
757 '/#tablepress-([A-Za-z1-9_-]|[A-Za-z0-9_-]{2,})_filter/',
758 '/#tablepress-([A-Za-z1-9_-]|[A-Za-z0-9_-]{2,})_length/',
759 '/#tablepress-([A-Za-z1-9_-]|[A-Za-z0-9_-]{2,})_info/',
760 ),
761 array(
762 '#tablepress-$1_wrapper .dt-paging',
763 '#tablepress-$1_wrapper .dt-search',
764 '#tablepress-$1_wrapper .dt-length',
765 '#tablepress-$1_wrapper .dt-info',
766 ),
767 $code,
768 );
769 }
770
771 return $code;
772 }
773
774 /**
775 * Retrieves all information of a WP_Error object as a string.
776 *
777 * @since 1.4.0
778 *
779 * @param WP_Error $wp_error A WP_Error object.
780 * @return string All error codes, messages, and data of the WP_Error.
781 */
782 public static function get_wp_error_string( WP_Error $wp_error ): string {
783 $error_strings = array();
784 $error_codes = $wp_error->get_error_codes();
785 // Reverse order to get latest errors first.
786 $error_codes = array_reverse( $error_codes );
787 foreach ( $error_codes as $error_code ) {
788 $error_strings[ $error_code ] = $error_code;
789 $error_messages = $wp_error->get_error_messages( $error_code );
790 $error_messages = implode( ', ', $error_messages );
791 if ( ! empty( $error_messages ) ) {
792 $error_strings[ $error_code ] .= " ({$error_messages})";
793 }
794 $error_data = $wp_error->get_error_data( $error_code );
795 if ( is_string( $error_data ) ) {
796 $error_strings[ $error_code ] .= " [{$error_data}]";
797 } elseif ( is_array( $error_data ) ) {
798 foreach ( $error_data as $key => $value ) {
799 $error_data[ $key ] = "{$key}: {$value}";
800 }
801 $error_data = implode( ', ', $error_data );
802 $error_strings[ $error_code ] .= " [{$error_data}]";
803 }
804 }
805 return implode( ";\n", $error_strings );
806 }
807
808 /**
809 * Generate the action URL, to be used as a link within the plugin (e.g. in the submenu navigation or List of Tables).
810 *
811 * @since 1.0.0
812 *
813 * @param array<string, mixed> $params Optional. Parameters to form the query string of the URL.
814 * @param bool $add_nonce Optional. Whether the URL shall be nonced by WordPress.
815 * @param string $target Optional. Target File, e.g. "admin-post.php" for POST requests.
816 * @return string The URL for the given parameters (already run through esc_url() with $add_nonce === true!).
817 */
818 public static function url( array $params = array(), bool $add_nonce = false, string $target = '' ): string {
819 // Default action is "list", if no action given.
820 if ( ! isset( $params['action'] ) ) {
821 $params['action'] = 'list';
822 }
823 $nonce_action = $params['action'];
824
825 if ( '' !== $target ) {
826 $params['action'] = "tablepress_{$params['action']}";
827 } else {
828 $params['page'] = 'tablepress';
829 // Top-level parent page needs special treatment for better action strings.
830 if ( self::$controller->is_top_level_page ) {
831 $target = 'admin.php';
832 if ( ! in_array( $params['action'], array( 'list', 'edit' ), true ) ) {
833 $params['page'] = "tablepress_{$params['action']}";
834 }
835 if ( ! in_array( $params['action'], array( 'edit' ), true ) ) {
836 $params['action'] = false;
837 }
838 } else {
839 $target = self::$controller->parent_page;
840 }
841 }
842
843 // $default_params also determines the order of the values in the query string.
844 $default_params = array(
845 'page' => false,
846 'action' => false,
847 'item' => false,
848 );
849 $params = array_merge( $default_params, $params );
850
851 if ( isset( $params['error_details'] ) ) {
852 $params['error_details'] = rawurlencode( $params['error_details'] );
853 }
854
855 $url = add_query_arg( $params, admin_url( $target ) );
856 if ( $add_nonce ) {
857 $url = wp_nonce_url( $url, self::nonce( $nonce_action, $params['item'] ) ); // wp_nonce_url() does esc_html().
858 }
859 return $url;
860 }
861
862 /**
863 * Create a redirect URL from the $target_parameters and redirect the user.
864 *
865 * @since 1.0.0
866 *
867 * @param array<string, mixed> $params Optional. Parameters from which the target URL is constructed.
868 * @param bool $add_nonce Optional. Whether the URL shall be nonced by WordPress.
869 */
870 public static function redirect( array $params = array(), bool $add_nonce = false ): void {
871 $redirect = self::url( $params );
872 if ( $add_nonce ) {
873 if ( ! isset( $params['item'] ) ) {
874 $params['item'] = false;
875 }
876 // Don't use wp_nonce_url(), as that uses esc_html().
877 $redirect = add_query_arg( '_wpnonce', wp_create_nonce( self::nonce( $params['action'], $params['item'] ) ), $redirect );
878 }
879 wp_redirect( $redirect );
880 exit;
881 }
882
883 /**
884 * Determines the editor that the site uses, so that certain text and input fields referring to Shortcodes can be displayed or not.
885 *
886 * @since 3.1.0
887 *
888 * @return string The editor that the site uses, either "block", "elementor", or "other".
889 */
890 public static function site_used_editor(): string {
891 if ( is_plugin_active( 'elementor/elementor.php' ) ) {
892 return 'elementor';
893 }
894
895 // Checking for Elementor is not needed anymore in this condition.
896 $site_uses_block_editor = use_block_editor_for_post_type( 'post' )
897 && ! is_plugin_active( 'classic-editor/classic-editor.php' )
898 && ! is_plugin_active( 'classic-editor-addon/classic-editor-addon.php' )
899 && ! is_plugin_active( 'siteorigin-panels/siteorigin-panels.php' )
900 && ! is_plugin_active( 'beaver-builder-lite-version/fl-builder.php' );
901 /**
902 * Filters the outcome of the check whether the site uses the block editor.
903 *
904 * This can be used when certain conditions (e.g. new site builders) are not (yet) accounted for.
905 *
906 * @since 2.0.1
907 *
908 * @param bool $site_uses_block_editor True if the site uses the block editor, false otherwise.
909 */
910 $site_uses_block_editor = (bool) apply_filters( 'tablepress_site_uses_block_editor', $site_uses_block_editor );
911 if ( $site_uses_block_editor ) {
912 return 'block';
913 }
914
915 return 'other';
916 }
917
918 /**
919 * Adds the (translated) names and descriptions to the list of feature modules.
920 *
921 * @since 3.3.0
922 */
923 public static function load_modules_data(): void {
924 // Prevent repeated execution of expensive translation functions via a static variable.
925 static $modules_initialized = false;
926 if ( $modules_initialized ) {
927 return;
928 }
929 $modules_initialized = true;
930
931 $modules = array(
932 'advanced-access-rights' => array(
933 'name' => __( 'Advanced Access Rights', 'tablepress' ),
934 'description' => __( 'Restrict access to individual tables for individual users.', 'tablepress' ),
935 ),
936 'automatic-periodic-table-import' => array(
937 'name' => __( 'Automatic Periodic Table Import', 'tablepress' ),
938 'description' => __( 'Periodically update tables from a configured import source.', 'tablepress' ),
939 ),
940 'automatic-table-export' => array(
941 'name' => __( 'Automatic Table Export', 'tablepress' ),
942 'description' => __( 'Export and save tables to files on the server after they were modified.', 'tablepress' ),
943 ),
944 'cell-highlighting' => array(
945 'name' => __( 'Cell Highlighting', 'tablepress' ),
946 'description' => __( 'Add CSS classes to cells for highlighting based on their content.', 'tablepress' ),
947 ),
948 'column-order' => array(
949 'name' => __( 'Column Order', 'tablepress' ),
950 'description' => __( 'Order the columns in different ways when a table is shown.', 'tablepress' ),
951 ),
952 'datatables-advanced-loading' => array(
953 'name' => __( 'Advanced Loading', 'tablepress' ),
954 'description' => __( 'Load the table data from a JSON array for faster loading.', 'tablepress' ),
955 ),
956 'datatables-alphabetsearch' => array(
957 'name' => __( 'Alphabet Search', 'tablepress' ),
958 'description' => __( 'Show Alphabet buttons above the table to filter rows by their first letter.', 'tablepress' ),
959 ),
960 'datatables-auto-filter' => array(
961 'name' => __( 'Automatic Filter', 'tablepress' ),
962 'description' => __( 'Pre-filter a table when it is shown.', 'tablepress' ),
963 ),
964 'datatables-buttons' => array(
965 'name' => __( 'User Action Buttons', 'tablepress' ),
966 'description' => __( 'Add buttons for downloading, copying, printing, and changing column visibility of tables.', 'tablepress' ),
967 ),
968 'datatables-columnfilterwidgets' => array(
969 'name' => __( 'Column Filter Dropdowns', 'tablepress' ),
970 'description' => __( 'Add a search dropdown for each column above the table.', 'tablepress' ),
971 ),
972 'datatables-column-filter' => array(
973 'name' => __( 'Individual Column Filtering', 'tablepress' ),
974 'description' => __( 'Add a search field or filter dropdown for each column to a table head or foot row.', 'tablepress' ),
975 ),
976 'datatables-counter-column' => array(
977 'name' => __( 'Index Column', 'tablepress' ),
978 'description' => __( 'Make the first column an index or counter column with the row position.', 'tablepress' ),
979 ),
980 'datatables-fixedheader-fixedcolumns' => array(
981 'name' => __( 'Fixed Rows and Columns', 'tablepress' ),
982 'description' => __( 'Fix the header and footer row and the first and last column when scrolling the table.', 'tablepress' ),
983 ),
984 'datatables-layout' => array(
985 'name' => __( 'Table Layout', 'tablepress' ),
986 'description' => __( 'Customize the layout and position of features around a table.', 'tablepress' ),
987 ),
988 'datatables-fuzzysearch' => array(
989 'name' => __( 'Fuzzy Search', 'tablepress' ),
990 'description' => __( 'Let the search account for spelling mistakes and typos and find similar matches.', 'tablepress' ),
991 ),
992 'datatables-inverted-filter' => array(
993 'name' => __( 'Inverted Filtering', 'tablepress' ),
994 'description' => __( 'Turn the filtering into a search and hide the table if no search term is entered.', 'tablepress' ),
995 ),
996 'datatables-pagination' => array(
997 'name' => __( 'Advanced Pagination Settings', 'tablepress' ),
998 'description' => __( 'Customize the pagination settings of the table.', 'tablepress' ),
999 ),
1000 'datatables-rowgroup' => array(
1001 'name' => __( 'Row Grouping', 'tablepress' ),
1002 'description' => __( 'Group table rows by a common keyword, category, or title.', 'tablepress' ),
1003 ),
1004 'datatables-searchbuilder' => array(
1005 'name' => __( 'Custom Search Builder', 'tablepress' ),
1006 'description' => __( 'Show a search builder interface for filtering from groups and using conditions.', 'tablepress' ),
1007 ),
1008 'datatables-searchhighlight' => array(
1009 'name' => __( 'Search Highlighting', 'tablepress' ),
1010 'description' => __( 'Highlight found search terms in the table.', 'tablepress' ),
1011 ),
1012 'datatables-searchpanes' => array(
1013 'name' => __( 'Search Panes', 'tablepress' ),
1014 'description' => __( 'Show panes for filtering the columns.', 'tablepress' ),
1015 ),
1016 'datatables-serverside-processing' => array(
1017 'name' => __( 'Server-side Processing', 'tablepress' ),
1018 'description' => __( 'Process sorting, filtering, and pagination on the server for faster loading of large tables.', 'tablepress' ),
1019 ),
1020 'default-style-customizer' => array(
1021 'name' => __( 'Default Style Customizer', 'tablepress' ),
1022 'description' => __( 'Change the default styling of your tables in the visual style customizer.', 'tablepress' ),
1023 ),
1024 'email-notifications' => array(
1025 'name' => __( 'Email Notifications', 'tablepress' ),
1026 'description' => __( 'Get email notifications when certain actions are performed on tables.', 'tablepress' ),
1027 ),
1028 'responsive-tables' => array(
1029 'name' => __( 'Responsive Tables', 'tablepress' ),
1030 'description' => __( 'Make your tables look good on different screen sizes.', 'tablepress' ),
1031 ),
1032 'rest-api' => array(
1033 'name' => __( 'REST API', 'tablepress' ),
1034 'description' => __( 'Read table data via the WordPress REST API, e.g. in external apps.', 'tablepress' ),
1035 ),
1036 'row-filtering' => array(
1037 'name' => __( 'Row Filtering', 'tablepress' ),
1038 'description' => __( 'Show only table rows that contain defined keywords.', 'tablepress' ),
1039 ),
1040 'row-highlighting' => array(
1041 'name' => __( 'Row Highlighting', 'tablepress' ),
1042 'description' => __( 'Add CSS classes to rows for highlighting based on their content.', 'tablepress' ),
1043 ),
1044 'row-order' => array(
1045 'name' => __( 'Row Order', 'tablepress' ),
1046 'description' => __( 'Order the rows in different ways when a table is shown.', 'tablepress' ),
1047 ),
1048 );
1049
1050 // Append translated module names and descriptions to potentially existing module meta data.
1051 self::$modules = array_merge_recursive( self::$modules, $modules );
1052 }
1053
1054 /**
1055 * Enqueues a CSS file, possibly with dependencies.
1056 *
1057 * @since 3.3.0
1058 *
1059 * @param string $name Name of the CSS file, without extension.
1060 * @param string[] $dependencies Optional. List of names of CSS stylesheets that this stylesheet depends on, and which need to be included before this one.
1061 * @param string $path Optional. Path to the CSS file.
1062 */
1063 public static function enqueue_style( string $name, array $dependencies = array(), string $path = 'admin/css/build/' ): void {
1064 $css_file = "{$path}{$name}.css";
1065 $css_url = plugins_url( $css_file, TABLEPRESS__FILE__ );
1066 wp_enqueue_style( "tablepress-{$name}", $css_url, $dependencies, self::version );
1067 }
1068
1069 /**
1070 * Enqueues a JavaScript file, possibly with dependencies and extra information.
1071 *
1072 * @since 3.3.0
1073 *
1074 * @param string $name Name of the JS file, without extension.
1075 * @param string[] $dependencies Optional. List of names of JS scripts that this script depends on, and which need to be included before this one.
1076 * @param array<string, mixed> $script_data Optional. JS data that is printed to the page before the script is included. The array key will be used as the name, the value will be JSON encoded.
1077 * @param string $path Optional. Path to the JS file.
1078 *
1079 * @phpstan-param non-empty-string $name
1080 * @phpstan-param non-empty-string[] $dependencies
1081 */
1082 public static function enqueue_script( string $name, array $dependencies = array(), array $script_data = array(), string $path = 'admin/js/build/' ): void {
1083 $js_file = "{$path}{$name}.js";
1084 $js_url = plugins_url( $js_file, TABLEPRESS__FILE__ );
1085
1086 $version = self::version;
1087
1088 // Load dependencies and version from the auto-generated asset PHP file.
1089 $script_asset_path = TABLEPRESS_ABSPATH . "{$path}{$name}.asset.php";
1090 if ( file_exists( $script_asset_path ) ) {
1091 $script_asset = require $script_asset_path;
1092 if ( isset( $script_asset['dependencies'] ) ) {
1093 $dependencies = array_merge( $dependencies, $script_asset['dependencies'] );
1094 }
1095 if ( isset( $script_asset['version'] ) ) {
1096 $version = $script_asset['version'];
1097 }
1098 }
1099
1100 /**
1101 * Filters the dependencies of a TablePress script file.
1102 *
1103 * @since 2.0.0
1104 *
1105 * @param string[] $dependencies List of the dependencies that the $name script relies on.
1106 * @param string $name Name of the JS script, without extension.
1107 *
1108 * @phpstan-param non-empty-string[] $dependencies
1109 */
1110 $dependencies = apply_filters( 'tablepress_admin_page_script_dependencies', $dependencies, $name );
1111
1112 $script_name = "tablepress-{$name}";
1113
1114 wp_enqueue_script( $script_name, $js_url, $dependencies, $version, array( 'in_footer' => true ) );
1115
1116 // Load JavaScript translation files, for all scripts that rely on `wp-i18n`.
1117 if ( in_array( 'wp-i18n', $dependencies, true ) ) {
1118 wp_set_script_translations( $script_name, 'tablepress' );
1119 }
1120
1121 if ( ! empty( $script_data ) ) {
1122 foreach ( $script_data as $var_name => $var_data ) {
1123 $var_data = wp_json_encode( $var_data, JSON_FORCE_OBJECT | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
1124 wp_add_inline_script( $script_name, "const tablepress_{$var_name} = {$var_data};", 'before' );
1125 }
1126 }
1127 }
1128
1129 } // class TablePress
1130