| 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 |
|