PluginProbe
TablePress – Tables in WordPress made easy / trunk
TablePress – Tables in WordPress made easy vtrunk
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 / classes / class-import.php

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

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