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-import.php

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

895 lines 32.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * TablePress Table Import Class
4 *
5 * @package TablePress
6 * @subpackage Export/Import
7 * @author Tobias Bäthge
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 use TablePress\Import\File;
14
15 // Prohibit direct script loading.
16 defined( 'ABSPATH' ) || die( 'No direct script access allowed!' );
17
18 TablePress::load_file( 'class-import-file.php', 'classes' );
19
20 /**
21 * TablePress Table Import Class
22 *
23 * @package TablePress
24 * @subpackage Export/Import
25 * @author Tobias Bäthge
26 * @since 1.0.0
27 */
28 class TablePress_Import {
29
30 /**
31 * Instance of the TablePress Legacy or PHPSpreadsheet Importer.
32 *
33 * @since 1.0.0
34 * @var TablePress_Import_Legacy|TablePress_Import_PHPSpreadsheet
35 */
36 protected object $importer;
37
38 /**
39 * Import configuration (mainly the data from the Import form).
40 *
41 * @since 2.0.0
42 * @var array<string, mixed>
43 */
44 protected array $import_config = array();
45
46 /**
47 * Whether ZIP archive support is available (which it always is, as PclZip is used as a fallback).
48 *
49 * @since 1.0.0
50 * @deprecated 2.3.0 ZIP support is now always available, either through `ZipArchive` or through `PclZip`.
51 */
52 public bool $zip_support_available = true;
53
54 /**
55 * List of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
56 *
57 * @since 2.0.0
58 * @var array<string, string[]>
59 */
60 protected array $table_names_ids = array();
61
62 /**
63 * Runs the import process for a given import configuration.
64 *
65 * @since 2.0.0
66 *
67 * @param array<string, mixed> $import_config Import configuration.
68 * @return array{tables: array<int, array<string, mixed>>, errors: File[]}|WP_Error List of imported tables on success, WP_Error on failure.
69 */
70 public function run( array $import_config ) /* : array|WP_Error */ {
71 // Unziping can use a lot of memory and execution time, but not this much hopefully.
72 wp_raise_memory_limit( 'admin' );
73 if ( function_exists( 'set_time_limit' ) ) {
74 @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
75 }
76
77 $this->import_config = $import_config;
78
79 $import_files = $this->get_files_to_import();
80 if ( is_wp_error( $import_files ) ) {
81 return $import_files;
82 }
83
84 $import_files = $this->convert_zip_files( $import_files );
85
86 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) ) {
87 $this->table_names_ids = $this->get_list_of_table_names();
88 }
89
90 return $this->import_files( $import_files );
91 }
92
93 /**
94 * Extracts the files that shall be imported from the import configuration.
95 *
96 * @since 2.0.0
97 *
98 * @return File[]|WP_Error Array of files that shall be imported or WP_Error on failure.
99 */
100 protected function get_files_to_import() /* : array|WP_Error */ {
101 $import_files = array();
102
103 switch ( $this->import_config['source'] ) {
104 case 'file-upload':
105 foreach ( $this->import_config['file-upload']['error'] as $key => $error ) {
106 $file = new File( array(
107 'location' => $this->import_config['file-upload']['tmp_name'][ $key ],
108 'name' => $this->import_config['file-upload']['name'][ $key ],
109 ) );
110 if ( UPLOAD_ERR_OK !== $error ) {
111 @unlink( $this->import_config['file-upload']['tmp_name'][ $key ] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
112 $file->error = new WP_Error( 'table_import_file-upload_error', '', $error );
113 }
114 $import_files[] = $file;
115 }
116 break;
117 case 'url':
118 $host = wp_parse_url( $this->import_config['url'], PHP_URL_HOST );
119
120 if ( empty( $host ) ) {
121 return new WP_Error( 'table_import_url_host_invalid', '', $this->import_config['url'] );
122 }
123
124 // Check the IP address of the host against a blocklist of hosts which should not be accessible, e.g. for security considerations.
125 $ip = gethostbyname( $host ); // If no IP address can be found, this will return the host name, which will then be checked against the blocklist.
126 $blocked_ips = array(
127 '169.254.169.254', // Meta-data API for various cloud providers.
128 '169.254.170.2', // AWS task metadata endpoint.
129 '192.0.0.192', // Oracle Cloud endpoint.
130 '100.100.100.200', // Alibaba Cloud endpoint.
131 );
132 if ( in_array( $ip, $blocked_ips, true ) ) {
133 return new WP_Error( 'table_import_url_host_blocked', '', array( 'url' => $this->import_config['url'], 'ip' => $ip ) );
134 }
135
136 // Automatically adjust URLs of common services to point to a direct download URL.
137 $this->import_config['url'] = $this->fix_common_url_mistakes( $this->import_config['url'] );
138
139 /**
140 * Load WP file functions to be sure that `download_url()` exists, in particular during Cron requests.
141 */
142 require_once ABSPATH . 'wp-admin/includes/file.php';
143
144 // Download URL to local file.
145 $location = download_url( $this->import_config['url'] );
146 if ( is_wp_error( $location ) ) {
147 $error = new WP_Error( 'table_import_url_download_failed', '', $this->import_config['url'] );
148 $error->merge_from( $location );
149 return $error;
150 }
151
152 $import_files[] = new File( array(
153 'location' => $location,
154 'name' => $this->import_config['url'],
155 ) );
156 break;
157 case 'server':
158 if ( ABSPATH === $this->import_config['server'] ) {
159 return new WP_Error( 'table_import_server_invalid', '', $this->import_config['server'] );
160 }
161
162 if ( ! is_readable( $this->import_config['server'] ) ) {
163 return new WP_Error( 'table_import_server_not_readable', '', $this->import_config['server'] );
164 }
165
166 $import_files[] = new File( array(
167 'location' => $this->import_config['server'],
168 'name' => pathinfo( $this->import_config['server'], PATHINFO_BASENAME ),
169 'keep_file' => true, // Files on the server must not be deleted.
170 ) );
171 break;
172 case 'form-field':
173 $location = wp_tempnam();
174 $num_written_bytes = file_put_contents( $location, $this->import_config['form-field'] );
175 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
176 @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
177 return new WP_Error( 'table_import_form-field_temp_file_not_written' );
178 }
179
180 $import_files[] = new File( array(
181 'location' => $location,
182 'name' => __( 'Imported from Manual Input', 'tablepress' ),
183 ) );
184 break;
185 default:
186 return new WP_Error( 'table_import_invalid_source', '', $this->import_config['source'] );
187 }
188
189 return $import_files;
190 }
191
192 /**
193 * Fixes common mistakes in URLs from popular services to point to a direct download URL.
194 *
195 * Currently supports Google Sheets, Microsoft OneDrive, and Dropbox.
196 * See https://tablepress.org/tutorials/ for more specific instructions on how to get the correct URL.
197 *
198 * @since 3.2.4
199 *
200 * @param string $url URL that shall be fixed.
201 * @return string Fixed URL.
202 */
203 protected function fix_common_url_mistakes( string $url ): string {
204 /**
205 * Filters whether common URL mistakes shall be fixed automatically.
206 *
207 * @since 3.2.4
208 *
209 * @param bool $fix_common_url_mistakes Whether to fix common URL mistakes. Default true.
210 */
211 if ( ! apply_filters( 'tablepress_import_fix_common_url_mistakes', true ) ) {
212 return $url;
213 }
214
215 if ( str_starts_with( $url, 'https://docs.google.com/spreadsheets/' ) && str_ends_with( $url, '/edit?usp=sharing' ) ) {
216 // Google Sheets "Sharing URL" to direct download URL.
217 $url = str_replace( '/edit?usp=sharing', '/export?format=csv', $url );
218 } elseif ( str_starts_with( $url, 'https://1drv.ms/' ) && ! str_ends_with( $url, '&download=1' ) ) {
219 // OneDrive shared link to direct download link.
220 $url .= '&download=1';
221 } elseif ( str_starts_with( $url, 'https://www.dropbox.com/' ) && str_ends_with( $url, '&dl=0' ) ) {
222 // Dropbox shared link to direct download link.
223 $url = str_replace( '&dl=0', '&dl=1', $url );
224 }
225
226 return $url;
227 }
228
229 /**
230 * Replaces ZIP archives in the import files with a list of their contents.
231 *
232 * ZIP files are removed from the list and their contents are added to the end of the list.
233 *
234 * @since 2.0.0
235 *
236 * @param File[] $import_files Files that shall be imported, including ZIP archives.
237 * @return File[] Files that shall be imported, with all ZIP archives recursively replaced by their contents.
238 */
239 protected function convert_zip_files( array $import_files ): array {
240 foreach ( $import_files as $key => &$file ) {
241 // $file has to be used by reference, so that $key points to the correct element, due to array modification with `unset()` and `array_push()`.
242
243 // Skip files that already have an error.
244 if ( is_wp_error( $file->error ) ) {
245 continue;
246 }
247
248 $file->extension = strtolower( pathinfo( $file->name, PATHINFO_EXTENSION ) );
249 if ( '' === $file->extension ) {
250 // If the file name has no extension, try to get it from the location (as WordPress tries adding an extension to that based on the MIME type, e.g. when downloading files).
251 $file->extension = strtolower( pathinfo( $file->location, PATHINFO_EXTENSION ) );
252 }
253
254 if ( function_exists( 'mime_content_type' ) ) {
255 $mime_type = mime_content_type( $file->location );
256 if ( false !== $mime_type ) {
257 $file->mime_type = $mime_type;
258 }
259 }
260
261 // Detect ZIP files from their file extension or MIME type.
262 if ( 'zip' === $file->extension || 'application/zip' === $file->mime_type ) {
263 $extracted_files = $this->extract_zip_file( $file );
264 if ( is_wp_error( $extracted_files ) ) {
265 $file->error = $extracted_files;
266 $this->maybe_unlink_file( $file );
267 continue;
268 }
269
270 if ( empty( $extracted_files ) ) {
271 $file->error = new WP_Error( 'table_import_zip_file_empty', '', $file->name );
272 $this->maybe_unlink_file( $file );
273 continue;
274 }
275
276 /*
277 * Remove the ZIP file from the list and instead append its contents.
278 * Appending ensures recursiveness, as the appended files will be checked again.
279 */
280 unset( $import_files[ $key ] );
281 array_push( $import_files, ...$extracted_files );
282
283 $this->maybe_unlink_file( $file );
284 }
285 }
286 unset( $file ); // Unset use-by-reference parameter of foreach loop.
287
288 $import_files = array_merge( $import_files ); // Re-index.
289
290 return $import_files;
291 }
292
293 /**
294 * Extracts the files of a ZIP file and returns a list of files and their location.
295 *
296 * Depending on availability, either the PHP's ZipArchive class or WordPress' PclZip class is used.
297 *
298 * @since 2.0.0
299 *
300 * @param File $zip_file File data of a ZIP file (likely in a temporary folder).
301 * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure.
302 */
303 protected function extract_zip_file( File $zip_file ) /* : array|WP_Error */ {
304 if ( class_exists( 'ZipArchive', false ) ) {
305 $ziparchive_result = $this->extract_zip_file_ziparchive( $zip_file );
306 if ( is_array( $ziparchive_result ) ) {
307 return $ziparchive_result;
308 }
309 } else {
310 $ziparchive_result = new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => 'Class ZipArchive not available' ) );
311 }
312
313 // Fall through to PclZip if ZipArchive is not available or encountered an error opening the file.
314 $pclzip_result = $this->extract_zip_file_pclzip( $zip_file );
315 if ( is_wp_error( $pclzip_result ) ) {
316 // Append the WP_Error from ZipArchive, to have all error information available.
317 $pclzip_result->merge_from( $ziparchive_result );
318 }
319
320 return $pclzip_result;
321 }
322
323 /**
324 * Extracts the files of a ZIP file using the PHP ZipArchive class.
325 *
326 * The ZIP file is extracted to a temporary folder and a list of files and their location is returned.
327 *
328 * @since 2.3.0
329 *
330 * @param File $zip_file File data of a ZIP file (likely in a temporary folder).
331 * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure.
332 */
333 protected function extract_zip_file_ziparchive( File $zip_file ) /* : array|WP_Error */ {
334 $archive = new ZipArchive();
335 $archive_opened = $archive->open( $zip_file->location, ZipArchive::CHECKCONS );
336
337 // If the ZIP file can't be opened with ZipArchive::CHECKCONS, try again without.
338 if ( true !== $archive_opened ) {
339 $archive_opened = $archive->open( $zip_file->location );
340 }
341
342 // If the ZIP file can't even be opened without ZipArchive::CHECKCONS, bail.
343 if ( true !== $archive_opened ) {
344 return new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => $archive_opened ) );
345 }
346
347 $files = array();
348
349 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
350 for ( $file_idx = 0; $file_idx < $archive->numFiles; $file_idx++ ) {
351 $file_name = $archive->getNameIndex( $file_idx );
352
353 if ( false === $file_name ) {
354 $files[] = new File( array(
355 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
356 ) );
357 continue;
358 }
359
360 // Skip directories.
361 if ( str_ends_with( $file_name, '/' ) ) {
362 continue;
363 }
364
365 // Skip the __MACOSX directory that macOS adds to archives.
366 if ( str_starts_with( $file_name, '__MACOSX/' ) ) {
367 continue;
368 }
369
370 // Don't extract invalid files.
371 if ( 0 !== validate_file( $file_name ) ) {
372 continue;
373 }
374
375 $file_data = $archive->getFromIndex( $file_idx );
376 if ( false === $file_data ) {
377 $files[] = new File( array(
378 'name' => $file_name,
379 'error' => new WP_Error( 'table_import_error_zip_get_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
380 ) );
381 continue;
382 }
383
384 $location = wp_tempnam();
385 $num_written_bytes = file_put_contents( $location, $file_data );
386 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
387 @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
388 $files[] = new File( array(
389 'name' => $file_name,
390 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
391 ) );
392 continue;
393 }
394
395 $files[] = new File( array(
396 'location' => $location,
397 'name' => $file_name,
398 ) );
399 }
400
401 $archive->close();
402
403 return $files;
404 }
405
406 /**
407 * Extracts the files of a ZIP file using WordPress' PclZip class.
408 *
409 * The ZIP file is extracted to a temporary folder and a list of files and their location is returned.
410 *
411 * @since 2.3.0
412 *
413 * @param File $zip_file File data of a ZIP file (likely in a temporary folder).
414 * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure.
415 */
416 protected function extract_zip_file_pclzip( File $zip_file ) /* : array|WP_Error */ {
417 mbstring_binary_safe_encoding();
418
419 require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
420
421 $archive = new PclZip( $zip_file->location );
422 $archive_files = $archive->extract( PCLZIP_OPT_EXTRACT_AS_STRING ); // @phpstan-ignore arguments.count (PclZip::extract() uses `func_get_args()` to handle optional arguments.)
423
424 reset_mbstring_encoding();
425
426 // If the ZIP file can't be opened, bail.
427 if ( ! is_array( $archive_files ) ) {
428 return new WP_Error( 'table_import_error_zip_open', '', array( 'pclzip_error' => $archive->errorInfo( true ) ) );
429 }
430
431 $files = array();
432
433 foreach ( $archive_files as $file ) {
434 // Skip directories.
435 if ( $file['folder'] ) {
436 continue;
437 }
438
439 // Skip the __MACOSX directory that macOS adds to archives.
440 if ( str_starts_with( $file['filename'], '__MACOSX/' ) ) {
441 continue;
442 }
443
444 // Don't extract invalid files.
445 if ( 0 !== validate_file( $file['filename'] ) ) {
446 continue;
447 }
448
449 $location = wp_tempnam();
450 $num_written_bytes = file_put_contents( $location, $file['content'] );
451 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
452 @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
453 $files[] = new File( array(
454 'name' => $file['filename'],
455 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file['index'], 'ziparchive_file_name' => $file['filename'] ) ),
456 ) );
457 continue;
458 }
459
460 $files[] = new File( array(
461 'location' => $location,
462 'name' => $file['filename'],
463 ) );
464 }
465
466 return $files;
467 }
468
469 /**
470 * Deletes a file unless the `keep_file` property is set to `true`.
471 *
472 * @since 2.0.0
473 *
474 * @param File $file File that should maybe be deleted.
475 */
476 protected function maybe_unlink_file( File $file ): void {
477 if ( ! $file->keep_file && file_exists( $file->location ) ) {
478 @unlink( $file->location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
479 }
480 }
481
482 /**
483 * Prepares a list of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
484 *
485 * @since 2.0.0
486 *
487 * @return array<string, string[]> List of table names and IDs.
488 */
489 protected function get_list_of_table_names(): array {
490 $existing_tables = array();
491 // Load all table IDs and names for a comparison with the file name.
492 $table_ids = TablePress::$model_table->load_all( false );
493 foreach ( $table_ids as $table_id ) {
494 // Load table, without table data, options, and visibility settings.
495 $table = TablePress::$model_table->load( $table_id, false, false );
496 if ( ! is_wp_error( $table ) ) {
497 $existing_tables[ (string) $table['name'] ][] = $table_id; // Attention: The table name is not unique!
498 }
499 }
500 return $existing_tables;
501 }
502
503 /**
504 * Checks whether the requirements for the PHPSpreadsheet import class are fulfilled or if the legacy import class should be used.
505 *
506 * @since 2.0.0
507 *
508 * @return bool Whether the legacy import class should be used.
509 */
510 protected function should_use_legacy_import_class(): bool {
511 // Allow overriding in the import config (coming e.g. from the import form UI).
512 if ( $this->import_config['legacy_import'] ) {
513 return true;
514 }
515
516 /**
517 * Filters whether the Legacy Table Import class shall be used.
518 *
519 * @since 2.0.0
520 *
521 * @param bool $use_legacy_class Whether to use the legacy table import class. Default false.
522 */
523 if ( apply_filters( 'tablepress_use_legacy_table_import_class', false ) ) {
524 return true;
525 }
526
527 // Use the legacy import class, if the requirements for PHPSpreadsheet are not fulfilled.
528 $phpspreadsheet_requirements_fulfilled = extension_loaded( 'mbstring' )
529 && class_exists( 'ZipArchive', false )
530 && class_exists( 'DOMDocument', false )
531 && function_exists( 'simplexml_load_string' )
532 && ( function_exists( 'libxml_disable_entity_loader' ) || PHP_VERSION_ID >= 80000 ); // This function is only needed for older versions of PHP.
533 if ( ! $phpspreadsheet_requirements_fulfilled ) {
534 return true;
535 }
536
537 return false;
538 }
539
540 /**
541 * Imports all found/extracted/configured files into TablePress.
542 *
543 * @since 2.0.0
544 *
545 * @param File[] $import_files Files that shall be imported.
546 * @return array{tables: array<int, array<string, mixed>>, errors: File[]} Imported tables and files that caused errors.
547 */
548 protected function import_files( array $import_files ): array {
549 $tables = array();
550 $errors = array();
551
552 $use_legacy_import_class = $this->should_use_legacy_import_class();
553
554 // Load Import Base Class.
555 TablePress::load_file( 'class-import-base.php', 'classes' );
556
557 // Choose the Table Import library based on the PHP version and the filter hook value.
558 if ( $use_legacy_import_class ) {
559 // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
560 $this->importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
561 } else {
562 // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
563 $this->importer = TablePress::load_class( 'TablePress_Import_PHPSpreadsheet', 'class-import-phpspreadsheet.php', 'classes' );
564 }
565
566 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
567 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) && '' !== $this->import_config['existing_table'] ) {
568 $valid_import_files = 0;
569 foreach ( $import_files as $file ) {
570 if ( ! is_wp_error( $file->error ) ) {
571 ++$valid_import_files;
572 if ( $valid_import_files > 1 ) {
573 $this->import_config['existing_table'] = '';
574 break;
575 }
576 }
577 }
578 }
579
580 // Loop through all import files and import them.
581 foreach ( $import_files as $file ) {
582 if ( is_wp_error( $file->error ) ) {
583 $errors[] = $file;
584 continue;
585 }
586
587 // Use import method depending on chosen import class.
588 if ( $use_legacy_import_class ) {
589 $table = $this->load_table_from_file_legacy( $file );
590 } else {
591 $table = $this->load_table_from_file_phpspreadsheet( $file );
592 }
593
594 $this->maybe_unlink_file( $file );
595
596 if ( is_wp_error( $table ) ) {
597 $file->error = $table;
598 $errors[] = $file;
599 continue;
600 }
601
602 $table = $this->save_imported_table( $table, $file );
603 if ( is_wp_error( $table ) ) {
604 $file->error = $table;
605 $errors[] = $file;
606 continue;
607 }
608
609 $tables[] = $table;
610 }
611
612 return array(
613 'tables' => $tables,
614 'errors' => $errors,
615 );
616 }
617
618 /**
619 * Loads a table from a file via the legacy import class.
620 *
621 * @since 2.0.0
622 *
623 * @param File $file File with the table data.
624 * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
625 */
626 protected function load_table_from_file_legacy( File $file ) /* : array|WP_Error */ {
627 // Guess the import format from the file extension.
628 switch ( $file->extension ) {
629 case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet.
630 case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded).
631 case 'xltx': // Excel (OfficeOpenXML) Template.
632 case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded).
633 $format = 'xlsx';
634 break;
635 case 'xls': // Excel (BIFF) Spreadsheet.
636 case 'xlt': // Excel (BIFF) Template.
637 $format = 'xls';
638 break;
639 case 'htm':
640 case 'html':
641 $format = 'html';
642 break;
643 case 'csv':
644 case 'tsv':
645 $format = 'csv';
646 break;
647 case 'json':
648 $format = 'json';
649 break;
650 default:
651 // If no format was found, try finding the format from the first character below.
652 $format = '';
653 }
654
655 $data = file_get_contents( $file->location );
656 if ( false === $data ) {
657 return new WP_Error( 'table_import_legacy_data_read', '', $file->location );
658 }
659 if ( '' === $data ) {
660 return new WP_Error( 'table_import_legacy_data_empty', '', $file->location );
661 }
662
663 // If no format could be determined from the file extension, try guessing from the file content.
664 if ( '' === $format ) {
665 $data = trim( $data );
666 $first_character = $data[0];
667 $last_character = $data[-1];
668
669 if ( '<' === $first_character && '>' === $last_character ) {
670 $format = 'html';
671 } elseif ( ( '[' === $first_character && ']' === $last_character ) || ( '{' === $first_character && '}' === $last_character ) ) {
672 $json_table = json_decode( $data, true );
673 if ( ! is_null( $json_table ) ) {
674 $format = 'json';
675 }
676 }
677 }
678
679 // Fall back to CSV if no file format could be determined.
680 if ( '' === $format ) {
681 $format = 'csv';
682 }
683
684 if ( ! in_array( $format, $this->importer->import_formats, true ) ) { // @phpstan-ignore property.notFound (`$this->importer` is an instance of `TablePress_Import_Legacy` which has the property `import_formats`.)
685 return new WP_Error( 'table_import_legacy_unknown_format', '', $file->name );
686 }
687
688 $table = $this->importer->import_table( $format, $data );
689
690 if ( false === $table ) {
691 return new WP_Error( 'table_import_legacy_importer_failed', '', array( 'file_name' => $file->name, 'file_format' => $format ) );
692 }
693
694 return $table;
695 }
696
697 /**
698 * Loads a table from a file via the PHPSpreadsheet import class.
699 *
700 * @since 2.0.0
701 *
702 * @param File $file File with the table data.
703 * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
704 */
705 protected function load_table_from_file_phpspreadsheet( File $file ) /* : array|WP_Error */ {
706 // Convert File object to array, as those are not yet used outside of this class.
707 return $this->importer->import_table( $file ); // @phpstan-ignore return.type (This is an instance of TablePress_Import_PHPSpreadsheet which does not return false.)
708 }
709
710 /**
711 * Imports a loaded table into TablePress.
712 *
713 * @since 2.0.0
714 *
715 * @param array<string, mixed> $table The table to be imported, either with properties or just the $table['data'] property set.
716 * @param File $file File with the table data.
717 * @return array<string, mixed>|WP_Error Imported table on success, WP_Error on failure.
718 */
719 protected function save_imported_table( array $table, File $file ) /* : array|WP_Error */ {
720 // If name and description are imported from a new table, use those.
721 if ( ! isset( $table['name'] ) ) {
722 $table['name'] = $file->name;
723 }
724 if ( ! isset( $table['description'] ) ) {
725 $table['description'] = $file->name;
726 }
727
728 $import_type = $this->import_config['type'];
729 $existing_table_id = $this->import_config['existing_table'];
730
731 // If no existing table ID has been set (or if we are importing multiple tables), try to find a potential existing table from the table ID in the import data or by comparing the file name with the table name.
732 if ( in_array( $import_type, array( 'replace', 'append' ), true ) && '' === $existing_table_id ) {
733 if ( isset( $table['id'] ) ) {
734 // If the table already contained a table ID (e.g. for the JSON format), use that.
735 $existing_table_id = $table['id'];
736 } elseif ( isset( $this->table_names_ids[ $file->name ] ) && 1 === count( $this->table_names_ids[ $file->name ] ) ) {
737 // Use the replace/append ID of tables where the table name matches the file name, but only if there was exactly one file name match.
738 $existing_table_id = $this->table_names_ids[ $file->name ][0];
739 }
740 }
741
742 // If the table that is to be replaced or appended to does not exist, add the new table instead.
743 if ( ! TablePress::$model_table->table_exists( $existing_table_id ) ) {
744 $existing_table_id = '';
745 $import_type = 'add';
746 }
747
748 $table = $this->import_tablepress_table( $table, $import_type, $existing_table_id );
749
750 return $table;
751 }
752
753 /**
754 * Imports a table by either replacing or appending to an existing table or by adding it as a new table.
755 *
756 * @since 1.0.0
757 *
758 * @param array<string, mixed> $imported_table The table to be imported, either with properties or just the `name`, `description`, and `data` property set.
759 * @param string $import_type What to do with the imported data: "add", "replace", "append".
760 * @param string $existing_table_id Empty string if table shall be added as a new table, ID of the table to be replaced or appended to otherwise.
761 * @return array<string, mixed>|WP_Error Table on success, WP_Error on error.
762 */
763 protected function import_tablepress_table( array $imported_table, string $import_type, string $existing_table_id ) /* : array|WP_Error */ {
764 // Full JSON format table can contain a table ID, try to keep that, by later changing the imported table ID to this.
765 $table_id_in_import = $imported_table['id'] ?? '';
766
767 // To be able to replace or append to a table, the user must be able to edit the table, or it must be a request via the Automatic Periodic Table Import module.
768 if ( in_array( $import_type, array( 'replace', 'append' ), true )
769 && ! ( current_user_can( 'tablepress_edit_table', $existing_table_id ) || doing_action( 'tablepress_automatic_periodic_table_import_action' ) ) ) {
770 return new WP_Error( 'table_import_replace_append_capability_check_failed', '', $existing_table_id );
771 }
772
773 switch ( $import_type ) {
774 case 'add':
775 $existing_table = TablePress::$model_table->get_table_template();
776 // Import visibility information if it exists, usually only for the JSON format.
777 if ( isset( $imported_table['visibility'] ) ) {
778 $existing_table['visibility'] = $imported_table['visibility'];
779 }
780 break;
781 case 'replace':
782 // Load table, without table data, but with options and visibility settings.
783 $existing_table = TablePress::$model_table->load( $existing_table_id, false, true );
784 if ( is_wp_error( $existing_table ) ) {
785 $error = new WP_Error( 'table_import_replace_table_load', '', $existing_table_id );
786 $error->merge_from( $existing_table );
787 return $error;
788 }
789 // Don't change name and description when a table is replaced.
790 $imported_table['name'] = $existing_table['name'];
791 $imported_table['description'] = $existing_table['description'];
792 // Replace visibility information if it exists.
793 if ( isset( $imported_table['visibility'] ) ) {
794 $existing_table['visibility'] = $imported_table['visibility'];
795 }
796 break;
797 case 'append':
798 // Load table, with table data, options, and visibility settings.
799 $existing_table = TablePress::$model_table->load( $existing_table_id, true, true );
800 if ( is_wp_error( $existing_table ) ) {
801 $error = new WP_Error( 'table_import_append_table_load', '', $existing_table_id );
802 $error->merge_from( $existing_table );
803 return $error;
804 }
805 if ( isset( $existing_table['is_corrupted'] ) && $existing_table['is_corrupted'] ) {
806 return new WP_Error( 'table_import_append_table_load_corrupted', '', $existing_table_id );
807 }
808 // Don't change name and description when a table is appended to.
809 $imported_table['name'] = $existing_table['name'];
810 $imported_table['description'] = $existing_table['description'];
811 // Actual appending:.
812 $imported_table['data'] = array_merge( $existing_table['data'], $imported_table['data'] );
813 $this->importer->pad_array_to_max_cols( $imported_table['data'] );
814 // Append visibility information for rows.
815 if ( isset( $imported_table['visibility']['rows'] ) ) {
816 $existing_table['visibility']['rows'] = array_merge( $existing_table['visibility']['rows'], $imported_table['visibility']['rows'] );
817 }
818 // When appending, do not overwrite options, e.g. coming from a JSON file.
819 unset( $imported_table['options'] );
820 break;
821 default:
822 return new WP_Error( 'table_import_import_type_invalid', '', $import_type );
823 }
824
825 // Merge new or existing table with information from the imported table.
826 $imported_table['id'] = $existing_table['id']; // Will be false for new table or the existing table ID.
827 // Cut visibility array (if the imported table is smaller), and pad correctly if imported table is bigger than existing table (or new template).
828 $num_rows = count( $imported_table['data'] );
829 $num_columns = count( $imported_table['data'][0] );
830 $imported_table['visibility'] = array(
831 'rows' => array_pad( array_slice( $existing_table['visibility']['rows'], 0, $num_rows ), $num_rows, 1 ),
832 'columns' => array_pad( array_slice( $existing_table['visibility']['columns'], 0, $num_columns ), $num_columns, 1 ),
833 );
834
835 // Check if the new table data is valid and consistent.
836 $table = TablePress::$model_table->prepare_table( $existing_table, $imported_table, false );
837 if ( is_wp_error( $table ) ) {
838 $error = new WP_Error( 'table_import_table_prepare', '', $imported_table['id'] );
839 $error->merge_from( $table );
840 return $error;
841 }
842
843 // DataTables Custom Commands can only be edit by trusted users.
844 if ( ! current_user_can( 'unfiltered_html' ) ) {
845 $table['options']['datatables_custom_commands'] = $existing_table['options']['datatables_custom_commands'];
846 }
847
848 // Replace existing table or add new table.
849 if ( in_array( $import_type, array( 'replace', 'append' ), true ) ) {
850 // Replace existing table with imported/appended table.
851 $table_id = TablePress::$model_table->save( $table );
852 } else {
853 // Add the imported table (and get its first ID).
854 $table_id = TablePress::$model_table->add( $table );
855 }
856
857 if ( is_wp_error( $table_id ) ) {
858 $error = new WP_Error( 'table_import_table_save_or_add', '', $table['id'] );
859 $error->merge_from( $table_id );
860 return $error;
861 }
862
863 // Try to use ID from imported file (e.g. in full JSON format table).
864 if ( '' !== $table_id_in_import && $table_id !== $table_id_in_import && current_user_can( 'tablepress_edit_table_id', $table_id ) ) {
865 $id_changed = TablePress::$model_table->change_table_id( $table_id, $table_id_in_import );
866 if ( ! is_wp_error( $id_changed ) ) {
867 $table_id = $table_id_in_import;
868 }
869 }
870
871 $table['id'] = $table_id;
872
873 return $table;
874 }
875
876 /**
877 * Imports a table in legacy versions of the Table Auto Update Extension.
878 *
879 * This method is deprecated and is only left for backward compatibility reasons. Do not use this in new code!
880 *
881 * @since 1.0.0
882 * @deprecated 2.0.0 Use `run()` instead.
883 *
884 * @param string $format Import format.
885 * @param string $data Data to import.
886 * @return array<string, mixed>|WP_Error|false Table array on success, WP_Error or false on error.
887 */
888 public function import_table( string $format, string $data ) /* : array|false */ {
889 TablePress::load_file( 'class-import-base.php', 'classes' );
890 $importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
891 return $importer->import_table( $format, $data );
892 }
893
894 } // class TablePress_Import
895