PluginProbe
TablePress – Tables in WordPress made easy / 3.0.1
TablePress – Tables in WordPress made easy v3.0.1
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 2.4.4 All 44 releases
tablepress / models / model-table.php

model-table.php in TablePress – Tables in WordPress made easy 3.0.1, at models/model-table.php

1,268 lines 43.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Table Model
4 *
5 * @package TablePress
6 * @subpackage Models
7 * @author Tobias Bäthge
8 * @since 1.0.0
9 */
10
11 // Prohibit direct script loading.
12 defined( 'ABSPATH' ) || die( 'No direct script access allowed!' );
13
14 /**
15 * Table Model class
16 *
17 * @package TablePress
18 * @subpackage Models
19 * @author Tobias Bäthge
20 * @since 1.0.0
21 */
22 class TablePress_Table_Model extends TablePress_Model {
23
24 /**
25 * Instance of the Post Type Model.
26 *
27 * @since 1.0.0
28 */
29 protected \TablePress_Post_Model $model_post;
30
31 /**
32 * Name of the Post Meta Field for table options.
33 *
34 * @since 1.0.0
35 */
36 protected string $table_options_field_name = '_tablepress_table_options';
37
38 /**
39 * Name of the Post Meta Field for table visibility.
40 *
41 * @since 1.0.0
42 */
43 protected string $table_visibility_field_name = '_tablepress_table_visibility';
44
45 /**
46 * Default set of tables.
47 *
48 * @since 1.0.0
49 * @var array{last_id: int, table_post: array<string, int>} {
50 * @type int $last_id Last table ID that was given to a new table.
51 * @type array<string, int> $table_post Connections between table ID and post ID (key: table ID, value: post ID).
52 * }
53 */
54 protected array $default_tables = array(
55 'last_id' => 0,
56 'table_post' => array(),
57 );
58
59 /**
60 * Instance of WP_Option class for the list of tables.
61 *
62 * @since 1.0.0
63 */
64 protected \TablePress_WP_Option $tables;
65
66 /**
67 * Init the Table model by instantiating a Post model and loading the list of tables option.
68 *
69 * @since 1.0.0
70 */
71 public function __construct() {
72 parent::__construct();
73 $this->model_post = TablePress::load_model( 'post' );
74
75 $params = array(
76 'option_name' => 'tablepress_tables',
77 'default_value' => $this->default_tables,
78 );
79 $this->tables = TablePress::load_class( 'TablePress_WP_Option', 'class-wp_option.php', 'classes', $params );
80 }
81
82 /**
83 * Get the tables option, which holds the connection between table ID and post ID.
84 *
85 * @since 1.0.0
86 *
87 * @return mixed[] Current set of tables.
88 */
89 public function _debug_get_tables(): array {
90 return $this->tables->get();
91 }
92
93 /**
94 * Update the tables option, which holds the connection between table ID and post ID.
95 *
96 * @since 1.0.0
97 *
98 * @param mixed[] $tables New set of tables.
99 */
100 public function _debug_update_tables( array $tables ): void {
101 $this->tables->update( $tables );
102 }
103
104 /**
105 * Convert a table to a post, which can be stored in the database.
106 *
107 * @since 1.0.0
108 *
109 * @param array<string, mixed> $table Table.
110 * @param int $post_id Post ID of an existing table, or -1 for a new table.
111 * @return array<string, mixed> Post.
112 */
113 protected function _table_to_post( array $table, int $post_id ): array {
114 // Sanitize each cell, table name, and table description, if the user is not allowed to work with unfiltered HTML.
115 if ( ! current_user_can( 'unfiltered_html' ) ) {
116 $table = $this->sanitize( $table );
117 }
118
119 // New posts have a post ID of false in WordPress.
120 if ( -1 === $post_id ) {
121 $post_id = false;
122 }
123
124 $post = array(
125 'ID' => $post_id,
126 'post_title' => $table['name'],
127 'post_excerpt' => $table['description'],
128 'post_content' => wp_json_encode( $table['data'], TABLEPRESS_JSON_OPTIONS ),
129 'post_mime_type' => 'application/json',
130 );
131
132 return $post;
133 }
134
135 /**
136 * Convert a post (from the database) to a table.
137 *
138 * @since 1.0.0
139 *
140 * @param WP_Post $post Post.
141 * @param string $table_id Table ID.
142 * @param bool $load_data Whether the table data shall be loaded.
143 * @return array<string, mixed> Table.
144 */
145 protected function _post_to_table( WP_Post $post, string $table_id, bool $load_data ): array {
146 $table = array(
147 'id' => $table_id,
148 'name' => $post->post_title,
149 'description' => $post->post_excerpt,
150 'author' => $post->post_author,
151 // 'created' => $post->post_date,
152 'last_modified' => $post->post_modified,
153 );
154
155 if ( ! $load_data ) {
156 return $table;
157 }
158
159 $table['data'] = json_decode( $post->post_content, true );
160
161 // Check if JSON could be decoded.
162 if ( is_null( $table['data'] ) ) {
163 $table['data'] = array( array( "The internal data of table {$table_id} is corrupted." ) ); // Set a single cell as the cell content.
164 $table['is_corrupted'] = true;
165 $table['json_error'] = json_last_error_msg();
166 $table['description'] = "[ERROR] TABLE IS CORRUPTED (JSON error: {$table['json_error']})! DO NOT EDIT THIS TABLE NOW!\nInstead, please see https://tablepress.org/faq/corrupted-tables/ for instructions.\n-\n{$table['description']}";
167 } else {
168 // Specifically cast to an array again.
169 $table['data'] = (array) $table['data'];
170 }
171
172 return $table;
173 }
174
175 /**
176 * Load a table.
177 *
178 * @since 1.0.0
179 *
180 * @param string $table_id Table ID.
181 * @param bool $load_data Whether the table data shall be loaded.
182 * @param bool $load_options_visibility Whether the table options and table visibility shall be loaded.
183 * @return array<string, mixed>|WP_Error Table as an array on success, WP_Error on error.
184 */
185 public function load( string $table_id, bool $load_data = true, bool $load_options_visibility = true ) /* : array|WP_Error */ {
186 if ( empty( $table_id ) ) {
187 return new WP_Error( 'table_load_empty_table_id' );
188 }
189
190 $post_id = $this->_get_post_id( $table_id );
191 if ( false === $post_id ) {
192 return new WP_Error( 'table_load_no_post_id_for_table_id', '', $table_id );
193 }
194
195 $post = $this->model_post->get( $post_id );
196 if ( false === $post ) {
197 return new WP_Error( 'table_load_no_post_for_post_id', '', $post_id );
198 }
199
200 $table = $this->_post_to_table( $post, $table_id, $load_data );
201 if ( $load_options_visibility ) {
202 $table['options'] = $this->_get_table_options( $post_id );
203 $table['visibility'] = $this->_get_table_visibility( $post_id );
204 }
205 return $table;
206 }
207
208 /**
209 * Load the IDs of all tables that can be loaded from the database.
210 *
211 * @since 1.0.0
212 *
213 * @param bool $prime_meta_cache Optional. Whether the prime the post meta cache when loading the posts.
214 * @param bool $run_filter Optional. Whether to run a filter on the list of table IDs.
215 * @return string[] Array of table IDs.
216 */
217 public function load_all( bool $prime_meta_cache = true, bool $run_filter = true ): array {
218 $table_post = $this->tables->get( 'table_post' );
219 if ( empty( $table_post ) ) {
220 return array();
221 }
222
223 // Load all table posts with one query, to prime the cache.
224 $this->model_post->load_posts( array_values( $table_post ), $prime_meta_cache );
225
226 // This loop now uses the WP cache.
227 $table_ids = array();
228 foreach ( $table_post as $table_id => $post_id ) {
229 $table_id = (string) $table_id; // Ensure that the table ID is a string, as it comes from an array key where numeric strings are converted to integers.
230
231 // Load table without data and options to save memory.
232 $table = $this->load( $table_id, false, false );
233
234 // Skip tables that could not be loaded properly.
235 if ( ! is_wp_error( $table ) ) {
236 $table_ids[] = $table_id;
237 }
238 }
239
240 if ( $run_filter ) {
241 /**
242 * Filters all table IDs that are loaded.
243 *
244 * @since 1.4.0
245 *
246 * @param string[] $table_ids The table IDs that are loaded.
247 */
248 $table_ids = apply_filters( 'tablepress_load_all_tables', $table_ids );
249 }
250
251 return $table_ids;
252 }
253
254 /**
255 * Sanitize the table to remove undesired HTML code using KSES.
256 *
257 * @since 1.8.0
258 *
259 * @param array<string, mixed> $table Table.
260 * @return array<string, mixed> Sanitized table.
261 */
262 public function sanitize( array $table ): array {
263 // Sanitize the table name and description.
264 $fields = array( 'name', 'description' );
265 foreach ( $fields as $field ) {
266 $table[ $field ] = wp_kses_post( $table[ $field ] );
267 }
268
269 // Sanitize each cell.
270 foreach ( $table['data'] as $row_idx => $row ) {
271 foreach ( $row as $column_idx => $cell_content ) {
272 $table['data'][ $row_idx ][ $column_idx ] = wp_kses_post( $cell_content ); // Equals wp_filter_post_kses(), but without the unnecessary slashes handling.
273 }
274 }
275
276 return $table;
277 }
278
279 /**
280 * Save a table.
281 *
282 * @since 1.0.0
283 *
284 * @param array<string, mixed> $table Table (needs to have $table['id']!).
285 * @return string|WP_Error WP_Error on error, string table ID on success.
286 */
287 public function save( array $table ) /* : string|WP_Error */ {
288 if ( empty( $table['id'] ) ) {
289 return new WP_Error( 'table_save_empty_table_id' );
290 }
291
292 $post_id = $this->_get_post_id( $table['id'] );
293 if ( false === $post_id ) {
294 return new WP_Error( 'table_save_no_post_id_for_table_id', '', $table['id'] );
295 }
296
297 $post = $this->_table_to_post( $table, $post_id );
298 $new_post_id = $this->model_post->update( $post );
299 if ( is_wp_error( $new_post_id ) ) {
300 $error = new WP_Error( 'table_save_post_update', '', $post_id );
301 $error->merge_from( $new_post_id );
302 return $error;
303 }
304 if ( $post_id !== $new_post_id ) {
305 return new WP_Error( 'table_save_new_post_id_does_not_match', '', $new_post_id );
306 }
307
308 $options_saved = $this->_update_table_options( $new_post_id, $table['options'] );
309 if ( ! $options_saved ) {
310 return new WP_Error( 'table_save_update_table_options_failed', '', $new_post_id );
311 }
312
313 $visibility_saved = $this->_update_table_visibility( $new_post_id, $table['visibility'] );
314 if ( ! $visibility_saved ) {
315 return new WP_Error( 'table_save_update_table_visibility_failed', '', $new_post_id );
316 }
317
318 // At this point, post was successfully added.
319
320 // Invalidate table output caches that belong to this table.
321 $this->invalidate_table_output_cache( $table['id'] );
322 // Flush caching plugins' caches.
323 $this->_flush_caching_plugins_caches();
324
325 /**
326 * Fires after a table has been saved.
327 *
328 * @since 1.5.0
329 *
330 * @param string $table_id ID of the added table.
331 */
332 do_action( 'tablepress_event_saved_table', $table['id'] );
333
334 return $table['id'];
335 }
336
337 /**
338 * Add a new table.
339 *
340 * @since 1.0.0
341 *
342 * @param array<string, mixed> $table Table ($table['id'] is not necessary).
343 * @param string $copy_or_add Optional. 'copy' if the table is copied, 'add' if it is a new table. Default 'add'.
344 * @return string|WP_Error WP_Error on error, string table ID of the new table on success.
345 */
346 public function add( array $table, string $copy_or_add = 'add' ) /* : string|WP_Error */ {
347 $post_id = -1; // To insert table.
348 $post = $this->_table_to_post( $table, $post_id );
349 $new_post_id = $this->model_post->insert( $post );
350 if ( is_wp_error( $new_post_id ) ) {
351 $error = new WP_Error( 'table_add_post_insert', '' );
352 $error->merge_from( $new_post_id );
353 return $error;
354 }
355
356 $options_saved = $this->_add_table_options( $new_post_id, $table['options'] );
357 if ( ! $options_saved ) {
358 return new WP_Error( 'table_add_update_table_options_failed', '', $new_post_id );
359 }
360
361 $visibility_saved = $this->_add_table_visibility( $new_post_id, $table['visibility'] );
362 if ( ! $visibility_saved ) {
363 return new WP_Error( 'table_add_update_table_visibility_failed', '', $new_post_id );
364 }
365
366 // At this point, post was successfully added, now get an unused table ID.
367 $table_id = $this->_get_new_table_id();
368 $this->_update_post_id( $table_id, $new_post_id );
369
370 if ( 'add' === $copy_or_add ) {
371 /**
372 * Fires after a new table has been added.
373 *
374 * @since 1.1.0
375 *
376 * @param string $table_id ID of the added table.
377 */
378 do_action( 'tablepress_event_added_table', $table_id );
379 }
380
381 return $table_id;
382 }
383
384 /**
385 * Create a copy of a table and add it.
386 *
387 * @since 1.0.0
388 *
389 * @param string $table_id ID of the table to be copied.
390 * @return string|WP_Error WP_Error on error, string table ID of the new table on success.
391 */
392 public function copy( string $table_id ) /* : string|WP_Error */ {
393 $table = $this->load( $table_id, true, true );
394 if ( is_wp_error( $table ) ) {
395 $error = new WP_Error( 'table_copy_table_load', '', $table_id );
396 $error->merge_from( $table );
397 return $error;
398 }
399
400 // Adjust name of copied table.
401 if ( '' === trim( $table['name'] ) ) {
402 $table['name'] = __( '(no name)', 'tablepress' );
403 }
404 $table['name'] = sprintf( __( 'Copy of %s', 'tablepress' ), $table['name'] );
405
406 // Merge this data into an empty table template.
407 $table = $this->prepare_table( $this->get_table_template(), $table, false );
408 if ( is_wp_error( $table ) ) {
409 $error = new WP_Error( 'table_copy_table_prepare', '', $table_id );
410 $error->merge_from( $table );
411 return $error;
412 }
413
414 // Add the copied table.
415 $new_table_id = $this->add( $table, 'copy' );
416 if ( is_wp_error( $new_table_id ) ) {
417 $error = new WP_Error( 'table_copy_table_add', '', $table_id );
418 $error->merge_from( $new_table_id );
419 return $error;
420 }
421
422 /**
423 * Fires after an existing table has been copied.
424 *
425 * @since 1.1.0
426 *
427 * @param string $new_table_id ID of the copy of the table.
428 * @param string $table_id ID of the existing table that is copied.
429 */
430 do_action( 'tablepress_event_copied_table', $new_table_id, $table_id );
431
432 return $new_table_id;
433 }
434
435 /**
436 * Delete a table (and its options).
437 *
438 * @since 1.0.0
439 *
440 * @param string $table_id ID of the table to be deleted.
441 * @return bool|WP_Error WP_Error on error, true on success.
442 */
443 public function delete( string $table_id ) /* : true|WP_Error */ {
444 if ( ! $this->table_exists( $table_id ) ) {
445 return new WP_Error( 'table_delete_table_does_not_exist', '', $table_id );
446 }
447
448 $post_id = $this->_get_post_id( $table_id ); // No ! false check necessary, as this is covered by table_exists() check above.
449
450 // @phpstan-ignore argument.type
451 $deleted = $this->model_post->delete( $post_id ); // Post Meta fields will be deleted automatically by that function.
452 if ( false === $deleted ) {
453 return new WP_Error( 'table_delete_post_could_not_be_deleted', '', $post_id );
454 }
455
456 // If post was deleted successfully, remove the table ID from the list of tables.
457 $this->_remove_post_id( $table_id );
458
459 // Invalidate table output caches that belong to this table.
460 $this->invalidate_table_output_cache( $table_id );
461 // Flush caching plugins' caches.
462 $this->_flush_caching_plugins_caches();
463
464 /**
465 * Fires after a table has been deleted.
466 *
467 * @since 1.1.0
468 *
469 * @param string $table_id ID of the deleted table.
470 */
471 do_action( 'tablepress_event_deleted_table', $table_id );
472
473 return true;
474 }
475
476 /**
477 * Delete all tables.
478 *
479 * @since 1.0.0
480 */
481 public function delete_all(): void {
482 $tables = $this->tables->get();
483 if ( empty( $tables['table_post'] ) ) {
484 return;
485 }
486
487 foreach ( $tables['table_post'] as $table_id => $post_id ) {
488 $table_id = (string) $table_id; // Ensure that the table ID is a string, as it comes from an array key where numeric strings are converted to integers.
489
490 $this->model_post->delete( $post_id ); // Post Meta fields will be deleted automatically by that function.
491 unset( $tables['table_post'][ $table_id ] );
492
493 // Invalidate table output caches that belong to this table.
494 $this->invalidate_table_output_cache( $table_id );
495 }
496
497 $this->tables->update( $tables );
498 // Flush caching plugins' caches.
499 $this->_flush_caching_plugins_caches();
500
501 /**
502 * Fires after all tables have been deleted.
503 *
504 * @since 1.1.0
505 */
506 do_action( 'tablepress_event_deleted_all_tables' );
507 }
508
509 /**
510 * Check if a table ID exists in the list of tables (this does not guarantee that the post with the table data exists!).
511 *
512 * @since 1.0.0
513 *
514 * @param string $table_id Table ID.
515 * @return bool Whether the table ID exists.
516 */
517 public function table_exists( string $table_id ): bool {
518 $table_post = $this->tables->get( 'table_post' );
519 return isset( $table_post[ $table_id ] );
520 }
521
522 /**
523 * Count the number of tables from either just the list, or by also counting the posts in the database.
524 *
525 * @since 1.0.0
526 *
527 * @param bool $single_value Optional. Whether to return just the number of tables from the list, or also count in the database.
528 * @return int|array{list: int, db: int} Number of Tables (if $single_value), or array of Numbers from list/DB (if ! $single_value).
529 */
530 public function count_tables( bool $single_value = true ) /* : int|array */ {
531 $count_list = count( $this->tables->get( 'table_post' ) );
532 if ( $single_value ) {
533 return $count_list;
534 }
535
536 $count_db = $this->model_post->count_posts();
537 return array(
538 'list' => $count_list,
539 'db' => $count_db,
540 );
541 }
542
543 /**
544 * Delete all transients used for output caching of a table (e.g. when the table is updated or deleted).
545 *
546 * @since 1.0.0
547 * @since 1.8.0 Renamed from _invalidate_table_output_cache to invalidate_table_output_cache and made public.
548 *
549 * @param string $table_id Table ID.
550 */
551 public function invalidate_table_output_cache( string $table_id ): void {
552 $caches_list_transient_name = 'tablepress_c_' . md5( $table_id );
553 $caches_list = get_transient( $caches_list_transient_name );
554 if ( false !== $caches_list ) {
555 $caches_list = (array) json_decode( $caches_list, true );
556 foreach ( $caches_list as $cache_transient_name ) {
557 delete_transient( $cache_transient_name );
558 }
559 }
560 delete_transient( $caches_list_transient_name );
561 }
562
563 /**
564 * Flush the caches of common caching plugins.
565 *
566 * @since 1.0.0
567 */
568 public function _flush_caching_plugins_caches(): void {
569 /**
570 * Filters whether the caches of common caching plugins shall be flushed.
571 *
572 * @since 1.0.0
573 *
574 * @param bool $flush Whether caches of caching plugins shall be flushed. Default true.
575 */
576 if ( ! apply_filters( 'tablepress_flush_caching_plugins_caches', true ) ) {
577 return;
578 }
579
580 // Common cache flush callback.
581 $cache_flush_callbacks = array(
582 array( 'Breeze_PurgeCache', 'breeze_cache_flush' ), // Breeze.
583 array( 'comet_cache', 'clear' ), // Comet Cache.
584 'pantheon_wp_clear_edge_all', // Pantheon.
585 'sg_cachepress_purge_cache', // SG Optimizer.
586 array( 'Swift_Performance_Cache', 'clear_all_cache' ), // Swift Performance.
587 'w3tc_pgcache_flush', // W3 Total Cache.
588 array( 'WpeCommon', 'purge_memcached' ), // WP Engine.
589 array( 'WpeCommon', 'clear_maxcdn_cache' ), // WP Engine.
590 array( 'WpeCommon', 'purge_varnish_cache' ), // WP Engine.
591 'wpfc_clear_all_cache', // WP Fastest Cache.
592 'rocket_clean_domain', // WP Rocket.
593 'wp_cache_clear_cache', // WP Super Cache.
594 array( 'zencache', 'clear' ), // Zen Cache.
595 );
596 foreach ( $cache_flush_callbacks as $cache_flush_callback ) {
597 if ( is_callable( $cache_flush_callback ) ) {
598 call_user_func( $cache_flush_callback );
599 }
600 }
601
602 // Common cache flush hooks.
603 $cache_flush_hooks = array(
604 'ce_clear_cache', // Cache Enabler.
605 'cachify_flush_cache', // Cachify.
606 'autoptimize_action_cachepurged', // Hyper Cache.
607 );
608 foreach ( $cache_flush_hooks as $cache_flush_hook ) {
609 do_action( $cache_flush_hook );
610 }
611
612 // Kinsta.
613 if ( isset( $GLOBALS['kinsta_cache'] ) && ! empty( $GLOBALS['kinsta_cache']->kinsta_cache_purge ) && is_callable( array( $GLOBALS['kinsta_cache']->kinsta_cache_purge, 'purge_complete_caches' ) ) ) {
614 $GLOBALS['kinsta_cache']->kinsta_cache_purge->purge_complete_caches(); // @phpstan-ignore method.nonObject
615 }
616 // LiteSpeed Cache.
617 if ( is_callable( array( 'LiteSpeed_Cache_Tags', 'add_purge_tag' ) ) ) {
618 LiteSpeed_Cache_Tags::add_purge_tag( '*' ); // @phpstan-ignore class.notFound
619 }
620 // Pagely.
621 if ( class_exists( 'PagelyCachePurge' ) ) {
622 $_pagely = new PagelyCachePurge();
623 if ( is_callable( array( $_pagely, 'purgeAll' ) ) ) {
624 $_pagely->purgeAll();
625 }
626 }
627 // Pressidum.
628 if ( is_callable( array( 'Ninukis_Plugin', 'get_instance' ) ) ) {
629 $_pressidum = Ninukis_Plugin::get_instance(); // @phpstan-ignore class.notFound
630 if ( is_callable( array( $_pressidum, 'purgeAllCaches' ) ) ) {
631 $_pressidum->purgeAllCaches(); // @phpstan-ignore method.nonObject
632 }
633 }
634 // Savvii.
635 if ( defined( '\Savvii\CacheFlusherPlugin::NAME_DOMAINFLUSH_NOW' ) ) {
636 $_savvii = new \Savvii\CacheFlusherPlugin(); // @phpstan-ignore class.notFound
637 if ( is_callable( array( $_savvii, 'domainflush' ) ) ) {
638 $_savvii->domainflush(); // @phpstan-ignore class.notFound
639 }
640 }
641 // WP Fastest Cache.
642 if ( isset( $GLOBALS['wp_fastest_cache'] ) && is_callable( array( $GLOBALS['wp_fastest_cache'], 'deleteCache' ) ) ) {
643 $GLOBALS['wp_fastest_cache']->deleteCache( true ); // @phpstan-ignore method.nonObject
644 }
645 // WP-Optimize.
646 if ( function_exists( 'WP_Optimize' ) ) {
647 WP_Optimize()->get_page_cache()->purge();
648 }
649 }
650
651 /**
652 * Get the post ID of a given table ID (if the table ID exists).
653 *
654 * @since 1.0.0
655 *
656 * @param string $table_id Table ID.
657 * @return int|false Post ID on success, false on error.
658 */
659 protected function _get_post_id( string $table_id ) /* : int|false */ {
660 $table_post = $this->tables->get( 'table_post' );
661 if ( ! isset( $table_post[ $table_id ] ) ) {
662 return false;
663 }
664 return $table_post[ $table_id ];
665 }
666
667 /**
668 * Update/Add a post ID for a given table ID, and sort the list of tables by their key in natural sort order.
669 *
670 * @since 1.0.0
671 *
672 * @param string $table_id Table ID.
673 * @param int $post_id Post ID.
674 */
675 protected function _update_post_id( string $table_id, int $post_id ): void {
676 $tables = $this->tables->get();
677 $tables['table_post'][ $table_id ] = $post_id;
678 uksort( $tables['table_post'], 'strnatcasecmp' );
679 $this->tables->update( $tables );
680 }
681
682 /**
683 * Remove a table ID/post ID connection from the list of tables.
684 *
685 * @since 1.0.0
686 *
687 * @param string $table_id Table ID.
688 */
689 protected function _remove_post_id( string $table_id ): void {
690 $tables = $this->tables->get();
691 unset( $tables['table_post'][ $table_id ] );
692 $this->tables->update( $tables );
693 }
694
695 /**
696 * Change the table ID of a table.
697 *
698 * @since 1.0.0
699 *
700 * @param string $old_id Old table ID.
701 * @param string $new_id New table ID.
702 * @return bool|WP_Error True on success, WP_Error on error.
703 */
704 public function change_table_id( string $old_id, string $new_id ) /* : true|WP_Error */ {
705 $post_id = $this->_get_post_id( $old_id );
706 if ( false === $post_id ) {
707 return new WP_Error( 'table_change_id_no_post_id_for_table_id', '', $old_id );
708 }
709
710 // Check new ID for correct format (string from letters, numbers, -, and _ only, except the '0' string).
711 if ( empty( $new_id ) || 0 !== preg_match( '/[^a-zA-Z0-9_-]/', $new_id ) ) {
712 return new WP_Error( 'table_change_id_new_id_is_invalid', '', $new_id );
713 }
714
715 if ( $this->table_exists( $new_id ) ) {
716 return new WP_Error( 'table_change_table_new_id_exists', '', $new_id );
717 }
718
719 $this->_update_post_id( $new_id, $post_id );
720 $this->_remove_post_id( $old_id );
721
722 /**
723 * Fires after the ID of a table has been changed.
724 *
725 * @since 1.1.0
726 *
727 * @param string $new_id New ID of the table.
728 * @param string $old_id Old ID of the table.
729 */
730 do_action( 'tablepress_event_changed_table_id', $new_id, $old_id );
731
732 return true;
733 }
734
735 /**
736 * Get an unused table ID (e.g. for a new table).
737 *
738 * @since 1.0.0
739 *
740 * @return string Unused table ID (e.g. for a new table).
741 */
742 protected function _get_new_table_id(): string {
743 $tables = $this->tables->get();
744 // Need to check new ID candidate in a loop, because a higher ID might already be in use, if a table ID was changed manually.
745 do {
746 ++$tables['last_id'];
747 $last_id_string = (string) $tables['last_id'];
748 } while ( $this->table_exists( $last_id_string ) );
749 $this->tables->update( $tables );
750 return $last_id_string;
751 }
752
753 /**
754 * Get the template for an empty table.
755 *
756 * Important: This scheme is versioned via TablePress::table_scheme_version; changes likely need a version update!
757 *
758 * @since 1.0.0
759 *
760 * @return array<string, mixed> Empty table.
761 */
762 public function get_table_template(): array {
763 // Attention: Array keys have to be lowercase, to make it possible to match them with Shortcode attributes!
764 $table = array(
765 'id' => false,
766 'name' => '',
767 'description' => '',
768 'data' => array( array( '' ) ), // One empty cell.
769 'last_modified' => wp_date( 'Y-m-d H:i:s' ),
770 'author' => get_current_user_id(),
771 'options' => array(
772 'last_editor' => get_current_user_id(),
773 'table_head' => 1,
774 'table_foot' => 0,
775 'alternating_row_colors' => true,
776 'row_hover' => true,
777 'print_name' => false,
778 'print_name_position' => 'above',
779 'print_description' => false,
780 'print_description_position' => 'below',
781 'extra_css_classes' => '',
782 // DataTables JavaScript library.
783 'use_datatables' => true,
784 'datatables_sort' => true,
785 'datatables_filter' => true,
786 'datatables_paginate' => true,
787 'datatables_lengthchange' => true,
788 'datatables_paginate_entries' => 10,
789 'datatables_info' => true,
790 'datatables_scrollx' => false,
791 'datatables_custom_commands' => '',
792 ),
793 'visibility' => array(
794 'rows' => array( 1 ), // One visbile row.
795 'columns' => array( 1 ), // One visible column.
796 ),
797 );
798 /**
799 * Filters the default template/structure of an empty table.
800 *
801 * @since 1.0.0
802 *
803 * @param array<string, mixed> $table Default template/structure of an empty table.
804 */
805 return apply_filters( 'tablepress_table_template', $table );
806 }
807
808 /**
809 * Combine two tables (e.g. an existing one with the updated data, or an empty one with new data).
810 *
811 * Performs consistency checks on data and visibility settings.
812 *
813 * @since 1.0.0
814 *
815 * @param array<string, mixed> $table Table to merge into.
816 * @param array<string, mixed> $new_table Table to merge.
817 * @param bool $table_size_check Optional. Whether to check the number of rows and columns (e.g. not necessary for added or copied tables).
818 * @return array<string, mixed>|WP_Error Merged table on success, WP_Error on error.
819 */
820 public function prepare_table( array $table, array $new_table, bool $table_size_check = true ) /* : array|WP_Error */ {
821 // Table ID must be the same (if there was an ID already).
822 if ( false !== $table['id'] && $table['id'] !== $new_table['id'] ) {
823 return new WP_Error( 'table_prepare_no_id_match', '', $new_table['id'] );
824 }
825
826 // Name, description, and data array need to exist, data must not be empty, the others could be ''.
827 if ( ! isset( $new_table['name'], $new_table['description'] )
828 || empty( $new_table['data'] ) || empty( $new_table['data'][0] ) ) {
829 return new WP_Error( 'table_prepare_name_description_or_data_not_set' );
830 }
831
832 // Visibility needs to exist.
833 if ( ! isset( $new_table['visibility']['rows'], $new_table['visibility']['columns'] ) ) {
834 return new WP_Error( 'table_prepare_visibility_not_set' );
835 }
836 $new_table['visibility']['rows'] = array_map( 'intval', $new_table['visibility']['rows'] );
837 $new_table['visibility']['columns'] = array_map( 'intval', $new_table['visibility']['columns'] );
838
839 // Check dimensions of table data array (not done for newly added, copied, or imported tables).
840 if ( $table_size_check ) {
841 if ( ! isset( $new_table['number']['rows'], $new_table['number']['columns'] ) ) {
842 return new WP_Error( 'table_prepare_size_check_numbers_not_set' );
843 }
844 // Table data needs to be ok, and have the correct number of rows and columns.
845 $new_table['number']['rows'] = (int) $new_table['number']['rows'];
846 $new_table['number']['columns'] = (int) $new_table['number']['columns'];
847 if ( 0 === $new_table['number']['rows']
848 || 0 === $new_table['number']['columns']
849 || count( $new_table['data'] ) !== $new_table['number']['rows']
850 || count( $new_table['data'][0] ) !== $new_table['number']['columns'] ) {
851 return new WP_Error( 'table_prepare_size_check_numbers_dont_match' );
852 }
853 // Visibility also needs to have correct dimensions.
854 if ( count( $new_table['visibility']['rows'] ) !== $new_table['number']['rows']
855 || count( $new_table['visibility']['columns'] ) !== $new_table['number']['columns'] ) {
856 return new WP_Error( 'table_prepare_size_check_visibility_doesnt_match' );
857 }
858 }
859
860 // All checks were successful, replace original values with new ones.
861
862 // $table['id'] is either false (and remains false) or already equal to $new_table['id'].
863 $table['new_id'] = $new_table['new_id'] ?? $table['id'];
864 $table['name'] = $new_table['name'];
865 $table['description'] = $new_table['description'];
866 $table['data'] = $new_table['data'];
867 // Make sure that cells are stored as strings.
868 array_walk_recursive(
869 $table['data'],
870 static function ( /* string|int|float|bool|null */ &$cell_content, int $col_idx ): void {
871 $cell_content = (string) $cell_content;
872 },
873 );
874 // Table Options.
875 if ( isset( $new_table['options'] ) ) { // Options are for example not set for newly added tables.
876 // Specials check for certain options.
877 if ( isset( $new_table['options']['extra_css_classes'] ) ) {
878 $new_table['options']['extra_css_classes'] = explode( ' ', $new_table['options']['extra_css_classes'] );
879 $new_table['options']['extra_css_classes'] = array_map( array( 'TablePress', 'sanitize_css_class' ), $new_table['options']['extra_css_classes'] );
880 $new_table['options']['extra_css_classes'] = array_unique( $new_table['options']['extra_css_classes'] );
881 $new_table['options']['extra_css_classes'] = trim( implode( ' ', $new_table['options']['extra_css_classes'] ) );
882 }
883 if ( isset( $new_table['options']['datatables_paginate_entries'] ) ) {
884 $new_table['options']['datatables_paginate_entries'] = (int) $new_table['options']['datatables_paginate_entries'];
885 if ( $new_table['options']['datatables_paginate_entries'] < 1 ) {
886 $new_table['options']['datatables_paginate_entries'] = 10; // Default value.
887 }
888 }
889
890 // Backward compatibility: Convert boolean or numeric string "table_head" and "table_foot" options to integer.
891 if ( isset( $new_table['options']['table_head'] ) ) {
892 $new_table['options']['table_head'] = absint( $new_table['options']['table_head'] );
893 }
894 if ( isset( $new_table['options']['table_foot'] ) ) {
895 $new_table['options']['table_foot'] = absint( $new_table['options']['table_foot'] );
896 }
897
898 // Merge new options.
899 $default_table = $this->get_table_template();
900 $table['options'] = array_intersect_key( $table['options'], $default_table['options'] );
901 $new_table['options'] = array_intersect_key( $new_table['options'], $default_table['options'] );
902 $table['options'] = array_merge( $table['options'], $new_table['options'] );
903 }
904 // Table Visibility.
905 $table['visibility']['rows'] = $new_table['visibility']['rows'];
906 $table['visibility']['columns'] = $new_table['visibility']['columns'];
907
908 // $table['author'] = get_current_user_id(); // We don't want this, as it would override the original author.
909 // $table['created'] = wp_date( 'Y-m-d H:i:s' ); // We don't want this, as it would override the original datetime.
910 $table['last_modified'] = wp_date( 'Y-m-d H:i:s' );
911 $table['options']['last_editor'] = get_current_user_id();
912
913 // Prevent issues if the "Custom Commands" field is not set, e.g. when non-admins have previously edited the table.
914 if ( ! isset( $table['options']['datatables_custom_commands'] ) ) {
915 $table['options']['datatables_custom_commands'] = '';
916 }
917
918 // Convert CSS classes and some DataTables 1.x parameters to the DataTables 2 variants.
919 if ( '' !== $table['options']['datatables_custom_commands'] ) {
920 $table['options']['datatables_custom_commands'] = TablePress::convert_datatables_api_data( $table['options']['datatables_custom_commands'] );
921 }
922
923 return $table;
924 }
925
926 /**
927 * Save the table options of a table (in a post meta field of the table's post).
928 *
929 * @since 1.0.0
930 *
931 * @param int $post_id Post ID.
932 * @param array<string, mixed> $options Table options.
933 * @return bool True on success, false on error.
934 */
935 protected function _add_table_options( int $post_id, array $options ): bool {
936 $options = wp_json_encode( $options, TABLEPRESS_JSON_OPTIONS );
937 return $this->model_post->add_meta_field( $post_id, $this->table_options_field_name, $options ); // @phpstan-ignore argument.type
938 }
939
940 /**
941 * Update the table options of a table (in a post meta field in the table's post).
942 *
943 * @since 1.0.0
944 *
945 * @param int $post_id Post ID.
946 * @param array<string, mixed> $options Table options.
947 * @return bool True on success, false on error.
948 */
949 protected function _update_table_options( int $post_id, array $options ): bool {
950 $options = wp_json_encode( $options, TABLEPRESS_JSON_OPTIONS );
951 return $this->model_post->update_meta_field( $post_id, $this->table_options_field_name, $options ); // @phpstan-ignore argument.type
952 }
953
954 /**
955 * Get the table options of a table (from a post meta field of the table's post).
956 *
957 * @since 1.0.0
958 *
959 * @param int $post_id Post ID.
960 * @return array<string, mixed> Table options on success, empty array on error.
961 */
962 protected function _get_table_options( int $post_id ): array {
963 $options = $this->model_post->get_meta_field( $post_id, $this->table_options_field_name );
964 if ( empty( $options ) ) {
965 return array();
966 }
967 $options = (array) json_decode( $options, true );
968
969 // Backward compatibility: Convert boolean "table_head" and "table_foot" options to integer.
970 $options['table_head'] = absint( $options['table_head'] );
971 $options['table_foot'] = absint( $options['table_foot'] );
972
973 return $options;
974 }
975
976 /**
977 * Save the table visibility of a table (in a post meta field of the table's post).
978 *
979 * @since 1.0.0
980 *
981 * @param int $post_id Post ID.
982 * @param array{rows: int[], columns: int[]} $visibility Table visibility.
983 * @return bool True on success, false on error.
984 */
985 protected function _add_table_visibility( int $post_id, array $visibility ): bool {
986 $visibility = wp_json_encode( $visibility, TABLEPRESS_JSON_OPTIONS );
987 return $this->model_post->add_meta_field( $post_id, $this->table_visibility_field_name, $visibility ); // @phpstan-ignore argument.type
988 }
989
990 /**
991 * Update the table visibility of a table (in a post meta field in the table's post).
992 *
993 * @since 1.0.0
994 *
995 * @param int $post_id Post ID.
996 * @param array{rows: int[], columns: int[]} $visibility Table visibility.
997 * @return bool True on success, false on error.
998 */
999 protected function _update_table_visibility( int $post_id, array $visibility ): bool {
1000 $visibility = wp_json_encode( $visibility, TABLEPRESS_JSON_OPTIONS );
1001 return $this->model_post->update_meta_field( $post_id, $this->table_visibility_field_name, $visibility ); // @phpstan-ignore argument.type
1002 }
1003
1004 /**
1005 * Get the table visibility of a table (from a post meta field of the table's post).
1006 *
1007 * @since 1.0.0
1008 *
1009 * @param int $post_id Post ID.
1010 * @return array{rows: int[], columns: int[]} Table visibility on success, empty array on error.
1011 */
1012 protected function _get_table_visibility( int $post_id ): array {
1013 $visibility = $this->model_post->get_meta_field( $post_id, $this->table_visibility_field_name );
1014 if ( empty( $visibility ) ) {
1015 return array(
1016 'rows' => array(),
1017 'columns' => array(),
1018 );
1019 }
1020 return json_decode( $visibility, true );
1021 }
1022
1023 /**
1024 * Merge existing Table Options with default Table Options,
1025 * remove (no longer) existing options, after a table scheme change,
1026 * for all tables.
1027 *
1028 * @since 1.0.0
1029 * @since 2.0.0 Add optional $remove_old_options parameter.
1030 *
1031 * @param bool $remove_old_options Optional. Whether old table options should be removed from the database. Default true.
1032 */
1033 public function merge_table_options_defaults( bool $remove_old_options = true ): void {
1034 $table_post = $this->tables->get( 'table_post' );
1035 if ( empty( $table_post ) ) {
1036 return;
1037 }
1038
1039 // Prime the meta cache with the table options of all tables.
1040 update_meta_cache( 'post', array_values( $table_post ) );
1041
1042 // Get default Table with default Table Options.
1043 $default_table = $this->get_table_template();
1044
1045 // Go through all tables (this loop now uses the WP cache).
1046 foreach ( $table_post as $post_id ) {
1047 $table_options = $this->_get_table_options( $post_id );
1048
1049 /**
1050 * Filters the Table Options before they are merged with the default Table Options.
1051 *
1052 * @since 3.0.0
1053 *
1054 * @param array<string, mixed> $table_options Table Options.
1055 * @param array<string, mixed> $default_table_options Default Table Options.
1056 * @param bool $remove_old_options Whether old table options should be removed from the database.
1057 * @param int $post_id Post ID of the table.
1058 */
1059 $table_options = apply_filters( 'tablepress_table_options_before_merge', $table_options, $default_table['options'], $remove_old_options, $post_id );
1060
1061 if ( $remove_old_options ) {
1062 // Remove old (i.e. no longer existing) Table Options.
1063 $table_options = array_intersect_key( $table_options, $default_table['options'] );
1064 }
1065 // Merge current into new Table Options.
1066 $table_options = array_merge( $default_table['options'], $table_options );
1067 $this->_update_table_options( $post_id, $table_options );
1068 }
1069 }
1070
1071 /**
1072 * Updates all tables' "Custom Commands" to use DataTables 2 variants instead of old DataTables 1.x CSS classes and parameters
1073 *
1074 * @since 3.0.0
1075 */
1076 public function update_custom_commands_datatables_tp30(): void {
1077 $table_post = $this->tables->get( 'table_post' );
1078 if ( empty( $table_post ) ) {
1079 return;
1080 }
1081
1082 // Prime the meta cache with the table options of all tables.
1083 update_meta_cache( 'post', array_values( $table_post ) );
1084
1085 foreach ( $table_post as $table_id => $post_id ) {
1086 $table_options = $this->_get_table_options( $post_id );
1087
1088 // Fix tables where the "Custom Commands" entry is missing entirely.
1089 if ( ! isset( $table_options['datatables_custom_commands'] ) ) {
1090 $table_options['datatables_custom_commands'] = '';
1091 $this->_update_table_options( $post_id, $table_options );
1092 continue;
1093 }
1094
1095 // Nothing to do if there are no "Custom Commands".
1096 if ( '' === $table_options['datatables_custom_commands'] ) {
1097 continue;
1098 }
1099 // Run search/replace.
1100 $old_custom_commands = $table_options['datatables_custom_commands'];
1101 $table_options['datatables_custom_commands'] = TablePress::convert_datatables_api_data( $table_options['datatables_custom_commands'] );
1102 // No need to save (which runs a DB query) if nothing was replaced in the "Custom Commands".
1103 if ( $old_custom_commands === $table_options['datatables_custom_commands'] ) {
1104 continue;
1105 }
1106
1107 $this->_update_table_options( $post_id, $table_options );
1108 }
1109 }
1110
1111 /**
1112 * Invalidate all table output caches, e.g. after a plugin update.
1113 *
1114 * @since 1.0.0
1115 */
1116 public function invalidate_table_output_caches(): void {
1117 $table_post = $this->tables->get( 'table_post' );
1118 if ( empty( $table_post ) ) {
1119 return;
1120 }
1121
1122 foreach ( $table_post as $table_id => $post_id ) {
1123 $table_id = (string) $table_id; // Ensure that the table ID is a string, as it comes from an array key where numeric strings are converted to integers.
1124 $this->invalidate_table_output_cache( $table_id );
1125 }
1126 }
1127
1128 /**
1129 * Add mime type field to existing posts with the TablePress Custom Post Type,
1130 * so that other plugins know that they are not dealing with plain text.
1131 *
1132 * @since 1.5.0
1133 *
1134 * @global wpdb $wpdb WordPress database abstraction object.
1135 */
1136 public function add_mime_type_to_posts(): void {
1137 global $wpdb;
1138 $wpdb->update( $wpdb->posts, array( 'post_mime_type' => 'application/json' ), array( 'post_type' => $this->model_post->get_post_type() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1139 }
1140
1141 /**
1142 * Add a table ID for a post (table) that was imported through the WP WXR importer to the table ID to post ID map.
1143 *
1144 * @since 1.5.0
1145 *
1146 * @param int|WP_Error $post_id Post ID of the imported post on success. 0 or WP_Error on failure.
1147 * @param int $original_post_id Original post ID that the post had on the site where it was exported from.
1148 * @param array<string, mixed> $postdata Post data that was imported into the database.
1149 * @param array<string, mixed> $post Original post data as it was exported.
1150 */
1151 public function add_table_id_on_wp_import( /* int|WP_Error */ $post_id, int $original_post_id, array $postdata, array $post ): void {
1152 // Bail if the post could not be imported or if the post is not a TablePress table.
1153 if ( is_wp_error( $post_id ) || $this->model_post->get_post_type() !== $postdata['post_type'] ) {
1154 return;
1155 }
1156
1157 // Extract the table IDs from the `_tablepress_export_table_id` post meta field.
1158 $table_ids = array();
1159 if ( isset( $post['postmeta'] ) && is_array( $post['postmeta'] ) ) {
1160 foreach ( $post['postmeta'] as $postmeta ) {
1161 if ( '_tablepress_export_table_id' === $postmeta['key'] ) {
1162 $table_ids = $postmeta['value'];
1163 $table_ids = explode( ',', $table_ids );
1164 break;
1165 }
1166 }
1167 }
1168
1169 // Save the post ID for each of the table IDs.
1170 $post_id_saved = false;
1171 foreach ( $table_ids as $table_id ) {
1172 $table_id = (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', $table_id );
1173 if ( '' === $table_id || $this->table_exists( $table_id ) ) {
1174 continue;
1175 }
1176 $this->_update_post_id( $table_id, $post_id );
1177 $post_id_saved = true;
1178 }
1179
1180 // Save the post ID for a new table ID if it could not be saved for any of the imported table IDs.
1181 if ( ! $post_id_saved ) {
1182 $table_id = $this->_get_new_table_id();
1183 $this->_update_post_id( $table_id, $post_id );
1184 }
1185 }
1186
1187 /**
1188 * Remove the `_tablepress_export_table_id` post meta field from the fields that are imported during a WP WXR import.
1189 *
1190 * @since 1.5.0
1191 *
1192 * @param array<string, mixed> $postmeta Post meta fields for the post.
1193 * @param int $post_id Post ID.
1194 * @param array<string, mixed> $post Post.
1195 * @return array<string, mixed> Modified post meta fields.
1196 */
1197 public function prevent_table_id_post_meta_import_on_wp_import( array $postmeta, int $post_id, array $post ): array {
1198 // Bail if the post is not a TablePress table.
1199 if ( $this->model_post->get_post_type() !== $post['post_type'] ) {
1200 return $postmeta;
1201 }
1202
1203 // Remove the `_tablepress_export_table_id` post meta field from the post meta fields.
1204 foreach ( $postmeta as $index => $meta ) {
1205 if ( '_tablepress_export_table_id' === $meta['key'] ) {
1206 unset( $postmeta[ $index ] );
1207 }
1208 }
1209
1210 return $postmeta;
1211 }
1212
1213 /**
1214 * Add the table IDs for an exported post (table) to the WP WXR export file.
1215 *
1216 * The table IDs for a table are exported in a faked post meta field.
1217 * As there's no action for adding extra data to the WXR export file, we hijack the `wxr_export_skip_postmeta` filter hook.
1218 *
1219 * @since 1.5.0
1220 *
1221 * @param bool $skip Whether to skip the current post meta. Default false.
1222 * @param string $meta_key Current meta key.
1223 * @param stdClass $meta Current meta object.
1224 * @return bool Whether to skip the current post meta (unchanged $skip parameter).
1225 */
1226 public function add_table_id_to_wp_export( bool $skip, string $meta_key, stdClass $meta ): bool {
1227 // Bail if the exporter doesn't process a TablePress table right now.
1228 if ( $this->table_options_field_name !== $meta_key ) {
1229 return $skip;
1230 }
1231
1232 // Find all table IDs that map to the post ID of the table that is currently being exported.
1233 $table_post = $this->tables->get( 'table_post' );
1234 $table_ids = array_keys( $table_post, (int) $meta->post_id, true );
1235
1236 // Bail if no table IDs are mapped to this post ID.
1237 if ( empty( $table_ids ) ) {
1238 return $skip;
1239 }
1240
1241 // Pretend that there is a `_tablepress_export_table_id` post meta field with the list of table IDs.
1242 $key = '_tablepress_export_table_id';
1243 $value = wxr_cdata( implode( ',', $table_ids ) ); // @phpstan-ignore function.notFound
1244
1245 // Hijack the filter and print extra XML code for our faked post meta field.
1246 // phpcs:disable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped
1247 echo <<<WXR
1248 <wp:postmeta>
1249 <wp:meta_key>{$key}</wp:meta_key>
1250 <wp:meta_value>{$value}</wp:meta_value>
1251 </wp:postmeta>\n
1252 WXR;
1253 // phpcs:enable
1254
1255 return $skip;
1256 }
1257
1258 /**
1259 * Delete the WP_Option of the model.
1260 *
1261 * @since 1.0.0
1262 */
1263 public function destroy(): void {
1264 $this->tables->delete();
1265 }
1266
1267 } // class TablePress_Table_Model
1268