PluginProbe
TablePress – Tables in WordPress made easy / 3.3
TablePress – Tables in WordPress made easy v3.3
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 3.3, at classes/class-import.php

889 lines 32.4 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
248 if ( function_exists( 'mime_content_type' ) ) {
249 $mime_type = mime_content_type( $file->location );
250 if ( false !== $mime_type ) {
251 $file->mime_type = $mime_type;
252 }
253 }
254
255 // Detect ZIP files from their file extension or MIME type.
256 if ( 'zip' === $file->extension || 'application/zip' === $file->mime_type ) {
257 $extracted_files = $this->extract_zip_file( $file );
258 if ( is_wp_error( $extracted_files ) ) {
259 $file->error = $extracted_files;
260 $this->maybe_unlink_file( $file );
261 continue;
262 }
263
264 if ( empty( $extracted_files ) ) {
265 $file->error = new WP_Error( 'table_import_zip_file_empty', '', $file->name );
266 $this->maybe_unlink_file( $file );
267 continue;
268 }
269
270 /*
271 * Remove the ZIP file from the list and instead append its contents.
272 * Appending ensures recursiveness, as the appended files will be checked again.
273 */
274 unset( $import_files[ $key ] );
275 array_push( $import_files, ...$extracted_files );
276
277 $this->maybe_unlink_file( $file );
278 }
279 }
280 unset( $file ); // Unset use-by-reference parameter of foreach loop.
281
282 $import_files = array_merge( $import_files ); // Re-index.
283
284 return $import_files;
285 }
286
287 /**
288 * Extracts the files of a ZIP file and returns a list of files and their location.
289 *
290 * Depending on availability, either the PHP's ZipArchive class or WordPress' PclZip class is used.
291 *
292 * @since 2.0.0
293 *
294 * @param File $zip_file File data of a ZIP file (likely in a temporary folder).
295 * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure.
296 */
297 protected function extract_zip_file( File $zip_file ) /* : array|WP_Error */ {
298 if ( class_exists( 'ZipArchive', false ) ) {
299 $ziparchive_result = $this->extract_zip_file_ziparchive( $zip_file );
300 if ( is_array( $ziparchive_result ) ) {
301 return $ziparchive_result;
302 }
303 } else {
304 $ziparchive_result = new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => 'Class ZipArchive not available' ) );
305 }
306
307 // Fall through to PclZip if ZipArchive is not available or encountered an error opening the file.
308 $pclzip_result = $this->extract_zip_file_pclzip( $zip_file );
309 if ( is_wp_error( $pclzip_result ) ) {
310 // Append the WP_Error from ZipArchive, to have all error information available.
311 $pclzip_result->merge_from( $ziparchive_result );
312 }
313
314 return $pclzip_result;
315 }
316
317 /**
318 * Extracts the files of a ZIP file using the PHP ZipArchive class.
319 *
320 * The ZIP file is extracted to a temporary folder and a list of files and their location is returned.
321 *
322 * @since 2.3.0
323 *
324 * @param File $zip_file File data of a ZIP file (likely in a temporary folder).
325 * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure.
326 */
327 protected function extract_zip_file_ziparchive( File $zip_file ) /* : array|WP_Error */ {
328 $archive = new ZipArchive();
329 $archive_opened = $archive->open( $zip_file->location, ZipArchive::CHECKCONS );
330
331 // If the ZIP file can't be opened with ZipArchive::CHECKCONS, try again without.
332 if ( true !== $archive_opened ) {
333 $archive_opened = $archive->open( $zip_file->location );
334 }
335
336 // If the ZIP file can't even be opened without ZipArchive::CHECKCONS, bail.
337 if ( true !== $archive_opened ) {
338 return new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => $archive_opened ) );
339 }
340
341 $files = array();
342
343 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
344 for ( $file_idx = 0; $file_idx < $archive->numFiles; $file_idx++ ) {
345 $file_name = $archive->getNameIndex( $file_idx );
346
347 if ( false === $file_name ) {
348 $files[] = new File( array(
349 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
350 ) );
351 continue;
352 }
353
354 // Skip directories.
355 if ( str_ends_with( $file_name, '/' ) ) {
356 continue;
357 }
358
359 // Skip the __MACOSX directory that macOS adds to archives.
360 if ( str_starts_with( $file_name, '__MACOSX/' ) ) {
361 continue;
362 }
363
364 // Don't extract invalid files.
365 if ( 0 !== validate_file( $file_name ) ) {
366 continue;
367 }
368
369 $file_data = $archive->getFromIndex( $file_idx );
370 if ( false === $file_data ) {
371 $files[] = new File( array(
372 'name' => $file_name,
373 'error' => new WP_Error( 'table_import_error_zip_get_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
374 ) );
375 continue;
376 }
377
378 $location = wp_tempnam();
379 $num_written_bytes = file_put_contents( $location, $file_data );
380 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
381 @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
382 $files[] = new File( array(
383 'name' => $file_name,
384 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
385 ) );
386 continue;
387 }
388
389 $files[] = new File( array(
390 'location' => $location,
391 'name' => $file_name,
392 ) );
393 }
394
395 $archive->close();
396
397 return $files;
398 }
399
400 /**
401 * Extracts the files of a ZIP file using WordPress' PclZip class.
402 *
403 * The ZIP file is extracted to a temporary folder and a list of files and their location is returned.
404 *
405 * @since 2.3.0
406 *
407 * @param File $zip_file File data of a ZIP file (likely in a temporary folder).
408 * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure.
409 */
410 protected function extract_zip_file_pclzip( File $zip_file ) /* : array|WP_Error */ {
411 mbstring_binary_safe_encoding();
412
413 require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
414
415 $archive = new PclZip( $zip_file->location );
416 $archive_files = $archive->extract( PCLZIP_OPT_EXTRACT_AS_STRING ); // @phpstan-ignore arguments.count (PclZip::extract() uses `func_get_args()` to handle optional arguments.)
417
418 reset_mbstring_encoding();
419
420 // If the ZIP file can't be opened, bail.
421 if ( ! is_array( $archive_files ) ) {
422 return new WP_Error( 'table_import_error_zip_open', '', array( 'pclzip_error' => $archive->errorInfo( true ) ) );
423 }
424
425 $files = array();
426
427 foreach ( $archive_files as $file ) {
428 // Skip directories.
429 if ( $file['folder'] ) {
430 continue;
431 }
432
433 // Skip the __MACOSX directory that macOS adds to archives.
434 if ( str_starts_with( $file['filename'], '__MACOSX/' ) ) {
435 continue;
436 }
437
438 // Don't extract invalid files.
439 if ( 0 !== validate_file( $file['filename'] ) ) {
440 continue;
441 }
442
443 $location = wp_tempnam();
444 $num_written_bytes = file_put_contents( $location, $file['content'] );
445 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
446 @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
447 $files[] = new File( array(
448 'name' => $file['filename'],
449 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file['index'], 'ziparchive_file_name' => $file['filename'] ) ),
450 ) );
451 continue;
452 }
453
454 $files[] = new File( array(
455 'location' => $location,
456 'name' => $file['filename'],
457 ) );
458 }
459
460 return $files;
461 }
462
463 /**
464 * Deletes a file unless the `keep_file` property is set to `true`.
465 *
466 * @since 2.0.0
467 *
468 * @param File $file File that should maybe be deleted.
469 */
470 protected function maybe_unlink_file( File $file ): void {
471 if ( ! $file->keep_file && file_exists( $file->location ) ) {
472 @unlink( $file->location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
473 }
474 }
475
476 /**
477 * Prepares a list of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
478 *
479 * @since 2.0.0
480 *
481 * @return array<string, string[]> List of table names and IDs.
482 */
483 protected function get_list_of_table_names(): array {
484 $existing_tables = array();
485 // Load all table IDs and names for a comparison with the file name.
486 $table_ids = TablePress::$model_table->load_all( false );
487 foreach ( $table_ids as $table_id ) {
488 // Load table, without table data, options, and visibility settings.
489 $table = TablePress::$model_table->load( $table_id, false, false );
490 if ( ! is_wp_error( $table ) ) {
491 $existing_tables[ (string) $table['name'] ][] = $table_id; // Attention: The table name is not unique!
492 }
493 }
494 return $existing_tables;
495 }
496
497 /**
498 * Checks whether the requirements for the PHPSpreadsheet import class are fulfilled or if the legacy import class should be used.
499 *
500 * @since 2.0.0
501 *
502 * @return bool Whether the legacy import class should be used.
503 */
504 protected function should_use_legacy_import_class(): bool {
505 // Allow overriding in the import config (coming e.g. from the import form UI).
506 if ( $this->import_config['legacy_import'] ) {
507 return true;
508 }
509
510 /**
511 * Filters whether the Legacy Table Import class shall be used.
512 *
513 * @since 2.0.0
514 *
515 * @param bool $use_legacy_class Whether to use the legacy table import class. Default false.
516 */
517 if ( apply_filters( 'tablepress_use_legacy_table_import_class', false ) ) {
518 return true;
519 }
520
521 // Use the legacy import class, if the requirements for PHPSpreadsheet are not fulfilled.
522 $phpspreadsheet_requirements_fulfilled = extension_loaded( 'mbstring' )
523 && class_exists( 'ZipArchive', false )
524 && class_exists( 'DOMDocument', false )
525 && function_exists( 'simplexml_load_string' )
526 && ( function_exists( 'libxml_disable_entity_loader' ) || PHP_VERSION_ID >= 80000 ); // This function is only needed for older versions of PHP.
527 if ( ! $phpspreadsheet_requirements_fulfilled ) {
528 return true;
529 }
530
531 return false;
532 }
533
534 /**
535 * Imports all found/extracted/configured files into TablePress.
536 *
537 * @since 2.0.0
538 *
539 * @param File[] $import_files Files that shall be imported.
540 * @return array{tables: array<int, array<string, mixed>>, errors: File[]} Imported tables and files that caused errors.
541 */
542 protected function import_files( array $import_files ): array {
543 $tables = array();
544 $errors = array();
545
546 $use_legacy_import_class = $this->should_use_legacy_import_class();
547
548 // Load Import Base Class.
549 TablePress::load_file( 'class-import-base.php', 'classes' );
550
551 // Choose the Table Import library based on the PHP version and the filter hook value.
552 if ( $use_legacy_import_class ) {
553 // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
554 $this->importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
555 } else {
556 // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
557 $this->importer = TablePress::load_class( 'TablePress_Import_PHPSpreadsheet', 'class-import-phpspreadsheet.php', 'classes' );
558 }
559
560 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
561 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) && '' !== $this->import_config['existing_table'] ) {
562 $valid_import_files = 0;
563 foreach ( $import_files as $file ) {
564 if ( ! is_wp_error( $file->error ) ) {
565 ++$valid_import_files;
566 if ( $valid_import_files > 1 ) {
567 $this->import_config['existing_table'] = '';
568 break;
569 }
570 }
571 }
572 }
573
574 // Loop through all import files and import them.
575 foreach ( $import_files as $file ) {
576 if ( is_wp_error( $file->error ) ) {
577 $errors[] = $file;
578 continue;
579 }
580
581 // Use import method depending on chosen import class.
582 if ( $use_legacy_import_class ) {
583 $table = $this->load_table_from_file_legacy( $file );
584 } else {
585 $table = $this->load_table_from_file_phpspreadsheet( $file );
586 }
587
588 $this->maybe_unlink_file( $file );
589
590 if ( is_wp_error( $table ) ) {
591 $file->error = $table;
592 $errors[] = $file;
593 continue;
594 }
595
596 $table = $this->save_imported_table( $table, $file );
597 if ( is_wp_error( $table ) ) {
598 $file->error = $table;
599 $errors[] = $file;
600 continue;
601 }
602
603 $tables[] = $table;
604 }
605
606 return array(
607 'tables' => $tables,
608 'errors' => $errors,
609 );
610 }
611
612 /**
613 * Loads a table from a file via the legacy import class.
614 *
615 * @since 2.0.0
616 *
617 * @param File $file File with the table data.
618 * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
619 */
620 protected function load_table_from_file_legacy( File $file ) /* : array|WP_Error */ {
621 // Guess the import format from the file extension.
622 switch ( $file->extension ) {
623 case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet.
624 case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded).
625 case 'xltx': // Excel (OfficeOpenXML) Template.
626 case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded).
627 $format = 'xlsx';
628 break;
629 case 'xls': // Excel (BIFF) Spreadsheet.
630 case 'xlt': // Excel (BIFF) Template.
631 $format = 'xls';
632 break;
633 case 'htm':
634 case 'html':
635 $format = 'html';
636 break;
637 case 'csv':
638 case 'tsv':
639 $format = 'csv';
640 break;
641 case 'json':
642 $format = 'json';
643 break;
644 default:
645 // If no format was found, try finding the format from the first character below.
646 $format = '';
647 }
648
649 $data = file_get_contents( $file->location );
650 if ( false === $data ) {
651 return new WP_Error( 'table_import_legacy_data_read', '', $file->location );
652 }
653 if ( '' === $data ) {
654 return new WP_Error( 'table_import_legacy_data_empty', '', $file->location );
655 }
656
657 // If no format could be determined from the file extension, try guessing from the file content.
658 if ( '' === $format ) {
659 $data = trim( $data );
660 $first_character = $data[0];
661 $last_character = $data[-1];
662
663 if ( '<' === $first_character && '>' === $last_character ) {
664 $format = 'html';
665 } elseif ( ( '[' === $first_character && ']' === $last_character ) || ( '{' === $first_character && '}' === $last_character ) ) {
666 $json_table = json_decode( $data, true );
667 if ( ! is_null( $json_table ) ) {
668 $format = 'json';
669 }
670 }
671 }
672
673 // Fall back to CSV if no file format could be determined.
674 if ( '' === $format ) {
675 $format = 'csv';
676 }
677
678 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`.)
679 return new WP_Error( 'table_import_legacy_unknown_format', '', $file->name );
680 }
681
682 $table = $this->importer->import_table( $format, $data );
683
684 if ( false === $table ) {
685 return new WP_Error( 'table_import_legacy_importer_failed', '', array( 'file_name' => $file->name, 'file_format' => $format ) );
686 }
687
688 return $table;
689 }
690
691 /**
692 * Loads a table from a file via the PHPSpreadsheet import class.
693 *
694 * @since 2.0.0
695 *
696 * @param File $file File with the table data.
697 * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
698 */
699 protected function load_table_from_file_phpspreadsheet( File $file ) /* : array|WP_Error */ {
700 // Convert File object to array, as those are not yet used outside of this class.
701 return $this->importer->import_table( $file ); // @phpstan-ignore return.type (This is an instance of TablePress_Import_PHPSpreadsheet which does not return false.)
702 }
703
704 /**
705 * Imports a loaded table into TablePress.
706 *
707 * @since 2.0.0
708 *
709 * @param array<string, mixed> $table The table to be imported, either with properties or just the $table['data'] property set.
710 * @param File $file File with the table data.
711 * @return array<string, mixed>|WP_Error Imported table on success, WP_Error on failure.
712 */
713 protected function save_imported_table( array $table, File $file ) /* : array|WP_Error */ {
714 // If name and description are imported from a new table, use those.
715 if ( ! isset( $table['name'] ) ) {
716 $table['name'] = $file->name;
717 }
718 if ( ! isset( $table['description'] ) ) {
719 $table['description'] = $file->name;
720 }
721
722 $import_type = $this->import_config['type'];
723 $existing_table_id = $this->import_config['existing_table'];
724
725 // 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.
726 if ( in_array( $import_type, array( 'replace', 'append' ), true ) && '' === $existing_table_id ) {
727 if ( isset( $table['id'] ) ) {
728 // If the table already contained a table ID (e.g. for the JSON format), use that.
729 $existing_table_id = $table['id'];
730 } elseif ( isset( $this->table_names_ids[ $file->name ] ) && 1 === count( $this->table_names_ids[ $file->name ] ) ) {
731 // 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.
732 $existing_table_id = $this->table_names_ids[ $file->name ][0];
733 }
734 }
735
736 // If the table that is to be replaced or appended to does not exist, add the new table instead.
737 if ( ! TablePress::$model_table->table_exists( $existing_table_id ) ) {
738 $existing_table_id = '';
739 $import_type = 'add';
740 }
741
742 $table = $this->import_tablepress_table( $table, $import_type, $existing_table_id );
743
744 return $table;
745 }
746
747 /**
748 * Imports a table by either replacing or appending to an existing table or by adding it as a new table.
749 *
750 * @since 1.0.0
751 *
752 * @param array<string, mixed> $imported_table The table to be imported, either with properties or just the `name`, `description`, and `data` property set.
753 * @param string $import_type What to do with the imported data: "add", "replace", "append".
754 * @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.
755 * @return array<string, mixed>|WP_Error Table on success, WP_Error on error.
756 */
757 protected function import_tablepress_table( array $imported_table, string $import_type, string $existing_table_id ) /* : array|WP_Error */ {
758 // Full JSON format table can contain a table ID, try to keep that, by later changing the imported table ID to this.
759 $table_id_in_import = $imported_table['id'] ?? '';
760
761 // 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.
762 if ( in_array( $import_type, array( 'replace', 'append' ), true )
763 && ! ( current_user_can( 'tablepress_edit_table', $existing_table_id ) || doing_action( 'tablepress_automatic_periodic_table_import_action' ) ) ) {
764 return new WP_Error( 'table_import_replace_append_capability_check_failed', '', $existing_table_id );
765 }
766
767 switch ( $import_type ) {
768 case 'add':
769 $existing_table = TablePress::$model_table->get_table_template();
770 // Import visibility information if it exists, usually only for the JSON format.
771 if ( isset( $imported_table['visibility'] ) ) {
772 $existing_table['visibility'] = $imported_table['visibility'];
773 }
774 break;
775 case 'replace':
776 // Load table, without table data, but with options and visibility settings.
777 $existing_table = TablePress::$model_table->load( $existing_table_id, false, true );
778 if ( is_wp_error( $existing_table ) ) {
779 $error = new WP_Error( 'table_import_replace_table_load', '', $existing_table_id );
780 $error->merge_from( $existing_table );
781 return $error;
782 }
783 // Don't change name and description when a table is replaced.
784 $imported_table['name'] = $existing_table['name'];
785 $imported_table['description'] = $existing_table['description'];
786 // Replace visibility information if it exists.
787 if ( isset( $imported_table['visibility'] ) ) {
788 $existing_table['visibility'] = $imported_table['visibility'];
789 }
790 break;
791 case 'append':
792 // Load table, with table data, options, and visibility settings.
793 $existing_table = TablePress::$model_table->load( $existing_table_id, true, true );
794 if ( is_wp_error( $existing_table ) ) {
795 $error = new WP_Error( 'table_import_append_table_load', '', $existing_table_id );
796 $error->merge_from( $existing_table );
797 return $error;
798 }
799 if ( isset( $existing_table['is_corrupted'] ) && $existing_table['is_corrupted'] ) {
800 return new WP_Error( 'table_import_append_table_load_corrupted', '', $existing_table_id );
801 }
802 // Don't change name and description when a table is appended to.
803 $imported_table['name'] = $existing_table['name'];
804 $imported_table['description'] = $existing_table['description'];
805 // Actual appending:.
806 $imported_table['data'] = array_merge( $existing_table['data'], $imported_table['data'] );
807 $this->importer->pad_array_to_max_cols( $imported_table['data'] );
808 // Append visibility information for rows.
809 if ( isset( $imported_table['visibility']['rows'] ) ) {
810 $existing_table['visibility']['rows'] = array_merge( $existing_table['visibility']['rows'], $imported_table['visibility']['rows'] );
811 }
812 // When appending, do not overwrite options, e.g. coming from a JSON file.
813 unset( $imported_table['options'] );
814 break;
815 default:
816 return new WP_Error( 'table_import_import_type_invalid', '', $import_type );
817 }
818
819 // Merge new or existing table with information from the imported table.
820 $imported_table['id'] = $existing_table['id']; // Will be false for new table or the existing table ID.
821 // Cut visibility array (if the imported table is smaller), and pad correctly if imported table is bigger than existing table (or new template).
822 $num_rows = count( $imported_table['data'] );
823 $num_columns = count( $imported_table['data'][0] );
824 $imported_table['visibility'] = array(
825 'rows' => array_pad( array_slice( $existing_table['visibility']['rows'], 0, $num_rows ), $num_rows, 1 ),
826 'columns' => array_pad( array_slice( $existing_table['visibility']['columns'], 0, $num_columns ), $num_columns, 1 ),
827 );
828
829 // Check if the new table data is valid and consistent.
830 $table = TablePress::$model_table->prepare_table( $existing_table, $imported_table, false );
831 if ( is_wp_error( $table ) ) {
832 $error = new WP_Error( 'table_import_table_prepare', '', $imported_table['id'] );
833 $error->merge_from( $table );
834 return $error;
835 }
836
837 // DataTables Custom Commands can only be edit by trusted users.
838 if ( ! current_user_can( 'unfiltered_html' ) ) {
839 $table['options']['datatables_custom_commands'] = $existing_table['options']['datatables_custom_commands'];
840 }
841
842 // Replace existing table or add new table.
843 if ( in_array( $import_type, array( 'replace', 'append' ), true ) ) {
844 // Replace existing table with imported/appended table.
845 $table_id = TablePress::$model_table->save( $table );
846 } else {
847 // Add the imported table (and get its first ID).
848 $table_id = TablePress::$model_table->add( $table );
849 }
850
851 if ( is_wp_error( $table_id ) ) {
852 $error = new WP_Error( 'table_import_table_save_or_add', '', $table['id'] );
853 $error->merge_from( $table_id );
854 return $error;
855 }
856
857 // Try to use ID from imported file (e.g. in full JSON format table).
858 if ( '' !== $table_id_in_import && $table_id !== $table_id_in_import && current_user_can( 'tablepress_edit_table_id', $table_id ) ) {
859 $id_changed = TablePress::$model_table->change_table_id( $table_id, $table_id_in_import );
860 if ( ! is_wp_error( $id_changed ) ) {
861 $table_id = $table_id_in_import;
862 }
863 }
864
865 $table['id'] = $table_id;
866
867 return $table;
868 }
869
870 /**
871 * Imports a table in legacy versions of the Table Auto Update Extension.
872 *
873 * This method is deprecated and is only left for backward compatibility reasons. Do not use this in new code!
874 *
875 * @since 1.0.0
876 * @deprecated 2.0.0 Use `run()` instead.
877 *
878 * @param string $format Import format.
879 * @param string $data Data to import.
880 * @return array<string, mixed>|WP_Error|false Table array on success, WP_Error or false on error.
881 */
882 public function import_table( string $format, string $data ) /* : array|false */ {
883 TablePress::load_file( 'class-import-base.php', 'classes' );
884 $importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
885 return $importer->import_table( $format, $data );
886 }
887
888 } // class TablePress_Import
889