| @@ -7,13 +7,18 @@ | ||
| 7 | 7 | * @author Tobias Bäthge |
| 8 | 8 | * @since 1.0.0 |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | +use TablePress\Import\File; | |
| 12 | + | |
| 11 | 13 | // Prohibit direct script loading. |
| 12 | 14 | defined( 'ABSPATH' ) || die( 'No direct script access allowed!' ); |
| 13 | 15 | |
| 16 | +TablePress::load_file( 'class-import-file.php', 'classes' ); | |
| 17 | + | |
| 14 | 18 | /** |
| 15 | 19 | * TablePress Table Import Class |
| 20 | + * | |
| 16 | 21 | * @package TablePress |
| 17 | 22 | * @subpackage Export/Import |
| 18 | 23 | * @author Tobias Bäthge |
| 19 | 24 | * @since 1.0.0 |
| @@ -20,448 +25,824 @@ | ||
| 20 | 25 | */ |
| 21 | 26 | class TablePress_Import { |
| 22 | 27 | |
| 23 | 28 | /** |
| 24 | - * File/Data Formats that are available for import. | |
| 29 | + * Instance of the TablePress Legacy or PHPSpreadsheet Importer. | |
| 25 | 30 | * |
| 26 | 31 | * @since 1.0.0 |
| 27 | - * @var array | |
| 32 | + * @var TablePress_Import_Legacy|TablePress_Import_PHPSpreadsheet | |
| 28 | 33 | */ |
| 29 | - public $import_formats = array(); | |
| 34 | + protected object $importer; | |
| 30 | 35 | |
| 31 | 36 | /** |
| 32 | - * Whether ZIP archive support is available in the PHP installation on the server. | |
| 37 | + * Import configuration (mainly the data from the Import form). | |
| 33 | 38 | * |
| 34 | - * @since 1.0.0 | |
| 35 | - * @var bool | |
| 39 | + * @since 2.0.0 | |
| 40 | + * @var array<string, mixed> | |
| 36 | 41 | */ |
| 37 | - public $zip_support_available = false; | |
| 42 | + protected array $import_config = array(); | |
| 38 | 43 | |
| 39 | 44 | /** |
| 40 | - * Whether HTML import support is available in the PHP installation on the server. | |
| 45 | + * Whether ZIP archive support is available (which it always is, as PclZip is used as a fallback). | |
| 41 | 46 | * |
| 42 | 47 | * @since 1.0.0 |
| 43 | - * @var bool | |
| 48 | + * @deprecated 2.3.0 ZIP support is now always available, either through `ZipArchive` or through `PclZip`. | |
| 44 | 49 | */ |
| 45 | - public $html_import_support_available = false; | |
| 50 | + public bool $zip_support_available = true; | |
| 46 | 51 | |
| 47 | 52 | /** |
| 48 | - * Data to be imported. | |
| 53 | + * List of table names/IDs for use when replacing/appending existing tables (except for the JSON format). | |
| 49 | 54 | * |
| 50 | - * @since 1.0.0 | |
| 51 | - * @var string | |
| 55 | + * @since 2.0.0 | |
| 56 | + * @var array<string, string[]> | |
| 52 | 57 | */ |
| 53 | - protected $import_data; | |
| 58 | + protected array $table_names_ids = array(); | |
| 54 | 59 | |
| 55 | 60 | /** |
| 56 | - * Imported table. | |
| 61 | + * Runs the import process for a given import configuration. | |
| 57 | 62 | * |
| 58 | - * @since 1.0.0 | |
| 59 | - * @var array|false | |
| 60 | - */ | |
| 61 | - protected $imported_table = false; | |
| 62 | - | |
| 63 | - /** | |
| 64 | - * Initialize the Import class. | |
| 63 | + * @since 2.0.0 | |
| 65 | 64 | * |
| 66 | - * @since 1.0.0 | |
| 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 | 67 | */ |
| 68 | - public function __construct() { | |
| 69 | - /** This filter is documented in the WordPress function unzip_file() in wp-admin/includes/file.php */ | |
| 70 | - if ( class_exists( 'ZipArchive', false ) && apply_filters( 'unzip_file_use_ziparchive', true ) ) { | |
| 71 | - $this->zip_support_available = true; | |
| 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 | |
| 72 | 73 | } |
| 73 | 74 | |
| 74 | - if ( class_exists( 'DOMDocument', false ) && function_exists( 'simplexml_import_dom' ) && function_exists( 'libxml_use_internal_errors' ) ) { | |
| 75 | - $this->html_import_support_available = true; | |
| 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; | |
| 76 | 80 | } |
| 77 | 81 | |
| 78 | - // Initiate here, because function call not possible outside a class method. | |
| 79 | - $this->import_formats = array(); | |
| 80 | - $this->import_formats['csv'] = __( 'CSV - Character-Separated Values', 'tablepress' ); | |
| 81 | - if ( $this->html_import_support_available ) { | |
| 82 | - $this->import_formats['html'] = __( 'HTML - Hypertext Markup Language', 'tablepress' ); | |
| 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(); | |
| 83 | 86 | } |
| 84 | - $this->import_formats['json'] = __( 'JSON - JavaScript Object Notation', 'tablepress' ); | |
| 85 | - $this->import_formats['xls'] = __( 'XLS - Microsoft Excel 97-2003 (experimental)', 'tablepress' ); | |
| 86 | - $this->import_formats['xlsx'] = __( 'XLSX - Microsoft Excel 2007-2019 (experimental)', 'tablepress' ); | |
| 87 | + | |
| 88 | + return $this->import_files( $import_files ); | |
| 87 | 89 | } |
| 88 | 90 | |
| 89 | 91 | /** |
| 90 | - * Import a table. | |
| 92 | + * Extracts the files that shall be imported from the import configuration. | |
| 91 | 93 | * |
| 92 | - * @since 1.0.0 | |
| 94 | + * @since 2.0.0 | |
| 93 | 95 | * |
| 94 | - * @param string $format Import format. | |
| 95 | - * @param string $data Data to import. | |
| 96 | - * @return array|false Table array on success, false on error. | |
| 96 | + * @return File[]|WP_Error Array of files that shall be imported or WP_Error on failure. | |
| 97 | 97 | */ |
| 98 | - public function import_table( $format, $data ) { | |
| 99 | - $this->import_data = apply_filters( 'tablepress_import_table_data', $data, $format ); | |
| 98 | + protected function get_files_to_import() /* : array|WP_Error */ { | |
| 99 | + $import_files = array(); | |
| 100 | 100 | |
| 101 | - if ( ! in_array( $format, array( 'xlsx', 'xls' ), true ) ) { | |
| 102 | - $this->fix_table_encoding(); | |
| 103 | - } | |
| 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 ); | |
| 104 | 117 | |
| 105 | - switch ( $format ) { | |
| 106 | - case 'csv': | |
| 107 | - $this->import_csv(); | |
| 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 | + /** | |
| 135 | + * Load WP file functions to be sure that `download_url()` exists, in particular during Cron requests. | |
| 136 | + */ | |
| 137 | + require_once ABSPATH . 'wp-admin/includes/file.php'; // @phpstan-ignore requireOnce.fileNotFound (This is a WordPress core file that always exists.) | |
| 138 | + | |
| 139 | + // Download URL to local file. | |
| 140 | + $location = download_url( $this->import_config['url'] ); | |
| 141 | + if ( is_wp_error( $location ) ) { | |
| 142 | + $error = new WP_Error( 'table_import_url_download_failed', '', $this->import_config['url'] ); | |
| 143 | + $error->merge_from( $location ); | |
| 144 | + return $error; | |
| 145 | + } | |
| 146 | + | |
| 147 | + $import_files[] = new File( array( | |
| 148 | + 'location' => $location, | |
| 149 | + 'name' => $this->import_config['url'], | |
| 150 | + ) ); | |
| 108 | 151 | break; |
| 109 | - case 'html': | |
| 110 | - $this->import_html(); | |
| 152 | + case 'server': | |
| 153 | + if ( ABSPATH === $this->import_config['server'] ) { | |
| 154 | + return new WP_Error( 'table_import_server_invalid', '', $this->import_config['server'] ); | |
| 155 | + } | |
| 156 | + | |
| 157 | + if ( ! is_readable( $this->import_config['server'] ) ) { | |
| 158 | + return new WP_Error( 'table_import_server_not_readable', '', $this->import_config['server'] ); | |
| 159 | + } | |
| 160 | + | |
| 161 | + $import_files[] = new File( array( | |
| 162 | + 'location' => $this->import_config['server'], | |
| 163 | + 'name' => pathinfo( $this->import_config['server'], PATHINFO_BASENAME ), | |
| 164 | + 'keep_file' => true, // Files on the server must not be deleted. | |
| 165 | + ) ); | |
| 111 | 166 | break; |
| 112 | - case 'json': | |
| 113 | - $this->import_json(); | |
| 167 | + case 'form-field': | |
| 168 | + $location = wp_tempnam(); | |
| 169 | + $num_written_bytes = file_put_contents( $location, $this->import_config['form-field'] ); | |
| 170 | + if ( false === $num_written_bytes || 0 === $num_written_bytes ) { | |
| 171 | + @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 172 | + return new WP_Error( 'table_import_form-field_temp_file_not_written' ); | |
| 173 | + } | |
| 174 | + | |
| 175 | + $import_files[] = new File( array( | |
| 176 | + 'location' => $location, | |
| 177 | + 'name' => __( 'Imported from Manual Input', 'tablepress' ), | |
| 178 | + ) ); | |
| 114 | 179 | break; |
| 115 | - case 'xlsx': | |
| 116 | - $this->import_xlsx(); | |
| 117 | - break; | |
| 118 | - case 'xls': | |
| 119 | - $this->import_xls(); | |
| 120 | - break; | |
| 121 | 180 | default: |
| 122 | - return false; | |
| 181 | + return new WP_Error( 'table_import_invalid_source', '', $this->import_config['source'] ); | |
| 123 | 182 | } |
| 124 | 183 | |
| 125 | - return $this->imported_table; | |
| 184 | + return $import_files; | |
| 126 | 185 | } |
| 127 | 186 | |
| 128 | 187 | /** |
| 129 | - * Import CSV data. | |
| 188 | + * Replaces ZIP archives in the import files with a list of their contents. | |
| 130 | 189 | * |
| 131 | - * @since 1.0.0 | |
| 190 | + * ZIP files are removed from the list and their contents are added to the end of the list. | |
| 191 | + * | |
| 192 | + * @since 2.0.0 | |
| 193 | + * | |
| 194 | + * @param File[] $import_files Files that shall be imported, including ZIP archives. | |
| 195 | + * @return File[] Files that shall be imported, with all ZIP archives recursively replaced by their contents. | |
| 132 | 196 | */ |
| 133 | - protected function import_csv() { | |
| 134 | - $csv_parser = TablePress::load_class( 'CSV_Parser', 'csv-parser.class.php', 'libraries' ); | |
| 135 | - $csv_parser->load_data( $this->import_data ); | |
| 136 | - $delimiter = $csv_parser->find_delimiter(); | |
| 137 | - $data = $csv_parser->parse( $delimiter ); | |
| 138 | - $this->imported_table = array( 'data' => $this->pad_array_to_max_cols( $data ) ); | |
| 197 | + protected function convert_zip_files( array $import_files ): array { | |
| 198 | + foreach ( $import_files as $key => &$file ) { | |
| 199 | + // $file has to be used by reference, so that $key points to the correct element, due to array modification with `unset()` and `array_push()`. | |
| 200 | + | |
| 201 | + // Skip files that already have an error. | |
| 202 | + if ( is_wp_error( $file->error ) ) { | |
| 203 | + continue; | |
| 204 | + } | |
| 205 | + | |
| 206 | + $file->extension = strtolower( pathinfo( $file->name, PATHINFO_EXTENSION ) ); | |
| 207 | + | |
| 208 | + if ( function_exists( 'mime_content_type' ) ) { | |
| 209 | + $mime_type = mime_content_type( $file->location ); | |
| 210 | + if ( false !== $mime_type ) { | |
| 211 | + $file->mime_type = $mime_type; | |
| 212 | + } | |
| 213 | + } | |
| 214 | + | |
| 215 | + // Detect ZIP files from their file extension or MIME type. | |
| 216 | + if ( 'zip' === $file->extension || 'application/zip' === $file->mime_type ) { | |
| 217 | + $extracted_files = $this->extract_zip_file( $file ); | |
| 218 | + if ( is_wp_error( $extracted_files ) ) { | |
| 219 | + $file->error = $extracted_files; | |
| 220 | + $this->maybe_unlink_file( $file ); | |
| 221 | + continue; | |
| 222 | + } | |
| 223 | + | |
| 224 | + if ( empty( $extracted_files ) ) { | |
| 225 | + $file->error = new WP_Error( 'table_import_zip_file_empty', '', $file->name ); | |
| 226 | + $this->maybe_unlink_file( $file ); | |
| 227 | + continue; | |
| 228 | + } | |
| 229 | + | |
| 230 | + /* | |
| 231 | + * Remove the ZIP file from the list and instead append its contents. | |
| 232 | + * Appending ensures recursiveness, as the appended files will be checked again. | |
| 233 | + */ | |
| 234 | + unset( $import_files[ $key ] ); | |
| 235 | + array_push( $import_files, ...$extracted_files ); | |
| 236 | + | |
| 237 | + $this->maybe_unlink_file( $file ); | |
| 238 | + } | |
| 239 | + } | |
| 240 | + unset( $file ); // Unset use-by-reference parameter of foreach loop. | |
| 241 | + | |
| 242 | + $import_files = array_merge( $import_files ); // Re-index. | |
| 243 | + | |
| 244 | + return $import_files; | |
| 139 | 245 | } |
| 140 | 246 | |
| 141 | 247 | /** |
| 142 | - * Import HTML data. | |
| 248 | + * Extracts the files of a ZIP file and returns a list of files and their location. | |
| 143 | 249 | * |
| 144 | - * @since 1.0.0 | |
| 250 | + * Depending on availability, either the PHP's ZipArchive class or WordPress' PclZip class is used. | |
| 251 | + * | |
| 252 | + * @since 2.0.0 | |
| 253 | + * | |
| 254 | + * @param File $zip_file File data of a ZIP file (likely in a temporary folder). | |
| 255 | + * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure. | |
| 145 | 256 | */ |
| 146 | - protected function import_html() { | |
| 147 | - if ( ! $this->html_import_support_available ) { | |
| 148 | - return false; | |
| 257 | + protected function extract_zip_file( File $zip_file ) /* : array|WP_Error */ { | |
| 258 | + if ( class_exists( 'ZipArchive', false ) ) { | |
| 259 | + $ziparchive_result = $this->extract_zip_file_ziparchive( $zip_file ); | |
| 260 | + if ( is_array( $ziparchive_result ) ) { | |
| 261 | + return $ziparchive_result; | |
| 262 | + } | |
| 263 | + } else { | |
| 264 | + $ziparchive_result = new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => 'Class ZipArchive not available' ) ); | |
| 149 | 265 | } |
| 150 | 266 | |
| 151 | - if ( false === stripos( $this->import_data, '<table' ) || false === stripos( $this->import_data, '</table>' ) ) { | |
| 152 | - $this->imported_table = false; | |
| 153 | - return; | |
| 267 | + // Fall through to PclZip if ZipArchive is not available or encountered an error opening the file. | |
| 268 | + $pclzip_result = $this->extract_zip_file_pclzip( $zip_file ); | |
| 269 | + if ( is_wp_error( $pclzip_result ) ) { | |
| 270 | + // Append the WP_Error from ZipArchive, to have all error information available. | |
| 271 | + $pclzip_result->merge_from( $ziparchive_result ); | |
| 154 | 272 | } |
| 155 | 273 | |
| 156 | - // Prepend XML declaration, for better encoding support. | |
| 157 | - $full_html = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . $this->import_data; | |
| 158 | - if ( function_exists( 'libxml_disable_entity_loader' ) ) { | |
| 159 | - /* | |
| 160 | - * Don't expand external entities, see https://websec.io/2012/08/27/Preventing-XXE-in-PHP.html. | |
| 161 | - * Silence warnings as the function is deprecated in PHP 8, but can be necessary with LIBXML_NOENT being defined, see https://core.trac.wordpress.org/changeset/50714. | |
| 162 | - */ | |
| 163 | - @libxml_disable_entity_loader( true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 274 | + return $pclzip_result; | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * Extracts the files of a ZIP file using the PHP ZipArchive class. | |
| 279 | + * | |
| 280 | + * The ZIP file is extracted to a temporary folder and a list of files and their location is returned. | |
| 281 | + * | |
| 282 | + * @since 2.3.0 | |
| 283 | + * | |
| 284 | + * @param File $zip_file File data of a ZIP file (likely in a temporary folder). | |
| 285 | + * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure. | |
| 286 | + */ | |
| 287 | + protected function extract_zip_file_ziparchive( File $zip_file ) /* : array|WP_Error */ { | |
| 288 | + $archive = new ZipArchive(); | |
| 289 | + $archive_opened = $archive->open( $zip_file->location, ZipArchive::CHECKCONS ); | |
| 290 | + | |
| 291 | + // If the ZIP file can't be opened with ZipArchive::CHECKCONS, try again without. | |
| 292 | + if ( true !== $archive_opened ) { | |
| 293 | + $archive_opened = $archive->open( $zip_file->location ); | |
| 164 | 294 | } |
| 165 | - // No warnings/errors raised, but stored internally. | |
| 166 | - libxml_use_internal_errors( true ); | |
| 167 | - $dom = new DOMDocument( '1.0', 'UTF-8' ); | |
| 168 | - // No strict checking for invalid HTML. | |
| 169 | - $dom->strictErrorChecking = false; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase | |
| 170 | - $dom->loadHTML( $full_html ); | |
| 171 | - if ( false === $dom ) { | |
| 172 | - $this->imported_table = false; | |
| 173 | - return; | |
| 295 | + | |
| 296 | + // If the ZIP file can't even be opened without ZipArchive::CHECKCONS, bail. | |
| 297 | + if ( true !== $archive_opened ) { | |
| 298 | + return new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => $archive_opened ) ); | |
| 174 | 299 | } |
| 175 | - $dom_tables = $dom->getElementsByTagName( 'table' ); | |
| 176 | - if ( 0 === count( $dom_tables ) ) { | |
| 177 | - $this->imported_table = false; | |
| 178 | - return; | |
| 300 | + | |
| 301 | + $files = array(); | |
| 302 | + | |
| 303 | + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase | |
| 304 | + for ( $file_idx = 0; $file_idx < $archive->numFiles; $file_idx++ ) { | |
| 305 | + $file_name = $archive->getNameIndex( $file_idx ); | |
| 306 | + | |
| 307 | + if ( false === $file_name ) { | |
| 308 | + $files[] = new File( array( | |
| 309 | + 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ), | |
| 310 | + ) ); | |
| 311 | + continue; | |
| 312 | + } | |
| 313 | + | |
| 314 | + // Skip directories. | |
| 315 | + if ( str_ends_with( $file_name, '/' ) ) { | |
| 316 | + continue; | |
| 317 | + } | |
| 318 | + | |
| 319 | + // Skip the __MACOSX directory that macOS adds to archives. | |
| 320 | + if ( str_starts_with( $file_name, '__MACOSX/' ) ) { | |
| 321 | + continue; | |
| 322 | + } | |
| 323 | + | |
| 324 | + // Don't extract invalid files. | |
| 325 | + if ( 0 !== validate_file( $file_name ) ) { | |
| 326 | + continue; | |
| 327 | + } | |
| 328 | + | |
| 329 | + $file_data = $archive->getFromIndex( $file_idx ); | |
| 330 | + if ( false === $file_data ) { | |
| 331 | + $files[] = new File( array( | |
| 332 | + 'name' => $file_name, | |
| 333 | + 'error' => new WP_Error( 'table_import_error_zip_get_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ), | |
| 334 | + ) ); | |
| 335 | + continue; | |
| 336 | + } | |
| 337 | + | |
| 338 | + $location = wp_tempnam(); | |
| 339 | + $num_written_bytes = file_put_contents( $location, $file_data ); | |
| 340 | + if ( false === $num_written_bytes || 0 === $num_written_bytes ) { | |
| 341 | + @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 342 | + $files[] = new File( array( | |
| 343 | + 'name' => $file_name, | |
| 344 | + 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ), | |
| 345 | + ) ); | |
| 346 | + continue; | |
| 347 | + } | |
| 348 | + | |
| 349 | + $files[] = new File( array( | |
| 350 | + 'location' => $location, | |
| 351 | + 'name' => $file_name, | |
| 352 | + ) ); | |
| 179 | 353 | } |
| 180 | - libxml_clear_errors(); // Clear errors so that we only catch those inside the table in the next line. | |
| 181 | - $table = simplexml_import_dom( $dom_tables->item( 0 ) ); | |
| 182 | - if ( false === $table ) { | |
| 183 | - $this->imported_table = false; | |
| 184 | - return; | |
| 354 | + | |
| 355 | + $archive->close(); | |
| 356 | + | |
| 357 | + return $files; | |
| 358 | + } | |
| 359 | + | |
| 360 | + /** | |
| 361 | + * Extracts the files of a ZIP file using WordPress' PclZip class. | |
| 362 | + * | |
| 363 | + * The ZIP file is extracted to a temporary folder and a list of files and their location is returned. | |
| 364 | + * | |
| 365 | + * @since 2.3.0 | |
| 366 | + * | |
| 367 | + * @param File $zip_file File data of a ZIP file (likely in a temporary folder). | |
| 368 | + * @return File[]|WP_Error List of files to import that were extracted from the ZIP file or WP_Error on failure. | |
| 369 | + */ | |
| 370 | + protected function extract_zip_file_pclzip( File $zip_file ) /* : array|WP_Error */ { | |
| 371 | + mbstring_binary_safe_encoding(); | |
| 372 | + | |
| 373 | + require_once ABSPATH . 'wp-admin/includes/class-pclzip.php'; // @phpstan-ignore requireOnce.fileNotFound (This is a WordPress core file that always exists.) | |
| 374 | + | |
| 375 | + $archive = new PclZip( $zip_file->location ); | |
| 376 | + $archive_files = $archive->extract( PCLZIP_OPT_EXTRACT_AS_STRING ); // @phpstan-ignore arguments.count (PclZip::extract() uses `func_get_args()` to handle optional arguments.) | |
| 377 | + | |
| 378 | + reset_mbstring_encoding(); | |
| 379 | + | |
| 380 | + // If the ZIP file can't be opened, bail. | |
| 381 | + if ( ! is_array( $archive_files ) ) { | |
| 382 | + return new WP_Error( 'table_import_error_zip_open', '', array( 'pclzip_error' => $archive->errorInfo( true ) ) ); | |
| 185 | 383 | } |
| 186 | 384 | |
| 187 | - $errors = libxml_get_errors(); | |
| 188 | - libxml_clear_errors(); | |
| 189 | - if ( ! empty( $errors ) ) { | |
| 190 | - $output = '<strong>' . __( 'The imported file contains errors:', 'tablepress' ) . '</strong><br /><br />'; | |
| 191 | - foreach ( $errors as $error ) { | |
| 192 | - switch ( $error->level ) { | |
| 193 | - case LIBXML_ERR_WARNING: | |
| 194 | - $output .= "Warning {$error->code}: {$error->message} in line {$error->line}, column {$error->column}<br />"; | |
| 195 | - break; | |
| 196 | - case LIBXML_ERR_ERROR: | |
| 197 | - $output .= "Error {$error->code}: {$error->message} in line {$error->line}, column {$error->column}<br />"; | |
| 198 | - break; | |
| 199 | - case LIBXML_ERR_FATAL: | |
| 200 | - $output .= "Fatal Error {$error->code}: {$error->message} in line {$error->line}, column {$error->column}<br />"; | |
| 201 | - break; | |
| 202 | - } | |
| 385 | + $files = array(); | |
| 386 | + | |
| 387 | + foreach ( $archive_files as $file ) { | |
| 388 | + // Skip directories. | |
| 389 | + if ( $file['folder'] ) { | |
| 390 | + continue; | |
| 203 | 391 | } |
| 204 | - wp_die( $output, 'Import Error', array( 'response' => 200, 'back_link' => true ) ); | |
| 205 | - } | |
| 206 | 392 | |
| 207 | - $html_table = array( | |
| 208 | - 'data' => array(), | |
| 209 | - 'options' => array(), | |
| 210 | - ); | |
| 211 | - if ( isset( $table->thead ) ) { | |
| 212 | - $html_table['data'] = array_merge( $html_table['data'], $this->_import_html_rows( $table->thead[0]->tr ) ); | |
| 213 | - $html_table['options']['table_head'] = true; | |
| 393 | + // Skip the __MACOSX directory that macOS adds to archives. | |
| 394 | + if ( str_starts_with( $file['filename'], '__MACOSX/' ) ) { | |
| 395 | + continue; | |
| 396 | + } | |
| 397 | + | |
| 398 | + // Don't extract invalid files. | |
| 399 | + if ( 0 !== validate_file( $file['filename'] ) ) { | |
| 400 | + continue; | |
| 401 | + } | |
| 402 | + | |
| 403 | + $location = wp_tempnam(); | |
| 404 | + $num_written_bytes = file_put_contents( $location, $file['content'] ); | |
| 405 | + if ( false === $num_written_bytes || 0 === $num_written_bytes ) { | |
| 406 | + @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 407 | + $files[] = new File( array( | |
| 408 | + 'name' => $file['filename'], | |
| 409 | + 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file['index'], 'ziparchive_file_name' => $file['filename'] ) ), | |
| 410 | + ) ); | |
| 411 | + continue; | |
| 412 | + } | |
| 413 | + | |
| 414 | + $files[] = new File( array( | |
| 415 | + 'location' => $location, | |
| 416 | + 'name' => $file['filename'], | |
| 417 | + ) ); | |
| 214 | 418 | } |
| 215 | - if ( isset( $table->tbody ) ) { | |
| 216 | - $html_table['data'] = array_merge( $html_table['data'], $this->_import_html_rows( $table->tbody[0]->tr ) ); | |
| 217 | - } | |
| 218 | - if ( isset( $table->tr ) ) { | |
| 219 | - $html_table['data'] = array_merge( $html_table['data'], $this->_import_html_rows( $table->tr ) ); | |
| 220 | - } | |
| 221 | - if ( isset( $table->tfoot ) ) { | |
| 222 | - $html_table['data'] = array_merge( $html_table['data'], $this->_import_html_rows( $table->tfoot[0]->tr ) ); | |
| 223 | - $html_table['options']['table_foot'] = true; | |
| 224 | - } | |
| 225 | 419 | |
| 226 | - $html_table['data'] = $this->pad_array_to_max_cols( $html_table['data'] ); | |
| 227 | - $this->imported_table = $html_table; | |
| 420 | + return $files; | |
| 228 | 421 | } |
| 229 | 422 | |
| 230 | 423 | /** |
| 231 | - * Helper for HTML import. | |
| 424 | + * Deletes a file unless the `keep_file` property is set to `true`. | |
| 232 | 425 | * |
| 233 | - * @since 1.0.0 | |
| 426 | + * @since 2.0.0 | |
| 234 | 427 | * |
| 235 | - * @param SimpleXMLElement $element XMLElement. | |
| 236 | - * @return array SimpleXMLElement exported to an array. | |
| 428 | + * @param File $file File that should maybe be deleted. | |
| 237 | 429 | */ |
| 238 | - protected function _import_html_rows( $element ) { | |
| 239 | - $rows = array(); | |
| 240 | - foreach ( $element as $row ) { | |
| 241 | - $new_row = array(); | |
| 242 | - foreach ( $row as $cell ) { | |
| 243 | - // Get text between <td>...</td>, or <th>...</th>, possibly with attributes. | |
| 244 | - if ( 1 === preg_match( '#<t(?:d|h).*?>(.*)</t(?:d|h)>#is', $cell->asXML(), $matches ) ) { | |
| 245 | - /* | |
| 246 | - * Decode HTML entities again, as there might be some left especially in attributes of HTML tags in the cells, | |
| 247 | - * see https://secure.php.net/manual/en/simplexmlelement.asxml.php#107137. | |
| 248 | - */ | |
| 249 | - $matches[1] = html_entity_decode( $matches[1], ENT_NOQUOTES, 'UTF-8' ); | |
| 250 | - $new_row[] = $matches[1]; | |
| 430 | + protected function maybe_unlink_file( File $file ): void { | |
| 431 | + if ( ! $file->keep_file && file_exists( $file->location ) ) { | |
| 432 | + @unlink( $file->location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 433 | + } | |
| 434 | + } | |
| 251 | 435 | |
| 252 | - // Look for colspan and add correct number of cells. | |
| 253 | - if ( 1 === preg_match( '#<t(?:d|h).*colspan="(\d+)".*?>#is', $cell->asXml(), $matches ) ) { | |
| 254 | - for ( $i = 1; $i < (int) $matches[1]; $i++ ) { | |
| 255 | - $new_row[] = '#colspan#'; | |
| 256 | - } | |
| 257 | - } | |
| 258 | - } else { | |
| 259 | - $new_row[] = ''; | |
| 260 | - } | |
| 436 | + /** | |
| 437 | + * Prepares a list of table names/IDs for use when replacing/appending existing tables (except for the JSON format). | |
| 438 | + * | |
| 439 | + * @since 2.0.0 | |
| 440 | + * | |
| 441 | + * @return array<string, string[]> List of table names and IDs. | |
| 442 | + */ | |
| 443 | + protected function get_list_of_table_names(): array { | |
| 444 | + $existing_tables = array(); | |
| 445 | + // Load all table IDs and names for a comparison with the file name. | |
| 446 | + $table_ids = TablePress::$model_table->load_all( false ); | |
| 447 | + foreach ( $table_ids as $table_id ) { | |
| 448 | + // Load table, without table data, options, and visibility settings. | |
| 449 | + $table = TablePress::$model_table->load( $table_id, false, false ); | |
| 450 | + if ( ! is_wp_error( $table ) ) { | |
| 451 | + $existing_tables[ (string) $table['name'] ][] = $table_id; // Attention: The table name is not unique! | |
| 261 | 452 | } |
| 262 | - $rows[] = $new_row; | |
| 263 | 453 | } |
| 264 | - return $rows; | |
| 454 | + return $existing_tables; | |
| 265 | 455 | } |
| 266 | 456 | |
| 267 | 457 | /** |
| 268 | - * Import JSON data. | |
| 458 | + * Checks whether the requirements for the PHPSpreadsheet import class are fulfilled or if the legacy import class should be used. | |
| 269 | 459 | * |
| 270 | - * @since 1.0.0 | |
| 460 | + * @since 2.0.0 | |
| 461 | + * | |
| 462 | + * @return bool Whether the legacy import class should be used. | |
| 271 | 463 | */ |
| 272 | - protected function import_json() { | |
| 273 | - $json_table = json_decode( $this->import_data, true ); | |
| 464 | + protected function should_use_legacy_import_class(): bool { | |
| 465 | + // Allow overriding in the import config (coming e.g. from the import form UI). | |
| 466 | + if ( $this->import_config['legacy_import'] ) { | |
| 467 | + return true; | |
| 468 | + } | |
| 274 | 469 | |
| 275 | - // Check if JSON could be decoded. | |
| 276 | - if ( is_null( $json_table ) ) { | |
| 277 | - // If possible, try to find out what error prevented the JSON from being decoded. | |
| 278 | - $json_error = 'The error could not be determined.'; | |
| 279 | - $json_error_msg = json_last_error_msg(); | |
| 280 | - if ( false !== $json_error_msg ) { | |
| 281 | - $json_error = $json_error_msg; | |
| 282 | - } | |
| 283 | - $output = '<strong>' . __( 'The imported file contains errors:', 'tablepress' ) . "</strong><br /><br />JSON error: {$json_error}<br />"; | |
| 284 | - wp_die( $output, 'Import Error', array( 'response' => 200, 'back_link' => true ) ); | |
| 470 | + /** | |
| 471 | + * Filters whether the Legacy Table Import class shall be used. | |
| 472 | + * | |
| 473 | + * @since 2.0.0 | |
| 474 | + * | |
| 475 | + * @param bool $use_legacy_class Whether to use the legacy table import class. Default false. | |
| 476 | + */ | |
| 477 | + if ( apply_filters( 'tablepress_use_legacy_table_import_class', false ) ) { | |
| 478 | + return true; | |
| 285 | 479 | } |
| 286 | 480 | |
| 287 | - // Specifically cast to an array again. | |
| 288 | - $json_table = (array) $json_table; | |
| 289 | - | |
| 290 | - if ( isset( $json_table['data'] ) ) { | |
| 291 | - // JSON data contained a full export. | |
| 292 | - $table = $json_table; | |
| 293 | - } else { | |
| 294 | - // JSON data contained only the data of a table, but no options. | |
| 295 | - $table = array( 'data' => array() ); | |
| 296 | - foreach ( $json_table as $row ) { | |
| 297 | - $table['data'][] = array_values( (array) $row ); | |
| 298 | - } | |
| 481 | + // Use the legacy import class, if the requirements for PHPSpreadsheet are not fulfilled. | |
| 482 | + $phpspreadsheet_requirements_fulfilled = extension_loaded( 'mbstring' ) | |
| 483 | + && class_exists( 'ZipArchive', false ) | |
| 484 | + && class_exists( 'DOMDocument', false ) | |
| 485 | + && function_exists( 'simplexml_load_string' ) | |
| 486 | + && ( function_exists( 'libxml_disable_entity_loader' ) || PHP_VERSION_ID >= 80000 ); // This function is only needed for older versions of PHP. | |
| 487 | + if ( ! $phpspreadsheet_requirements_fulfilled ) { | |
| 488 | + return true; | |
| 299 | 489 | } |
| 300 | 490 | |
| 301 | - $table['data'] = $this->pad_array_to_max_cols( $table['data'] ); | |
| 302 | - $this->imported_table = $table; | |
| 491 | + return false; | |
| 303 | 492 | } |
| 304 | 493 | |
| 305 | 494 | /** |
| 306 | - * Import Microsoft Excel 97-2003 data. | |
| 495 | + * Imports all found/extracted/configured files into TablePress. | |
| 307 | 496 | * |
| 308 | - * @since 1.1.0 | |
| 497 | + * @since 2.0.0 | |
| 498 | + * | |
| 499 | + * @param File[] $import_files Files that shall be imported. | |
| 500 | + * @return array{tables: array<int, array<string, mixed>>, errors: File[]} Imported tables and files that caused errors. | |
| 309 | 501 | */ |
| 310 | - protected function import_xls() { | |
| 311 | - $excel_reader = TablePress::load_class( 'Spreadsheet_Excel_Reader', 'excel-reader.class.php', 'libraries', $this->import_data ); | |
| 502 | + protected function import_files( array $import_files ): array { | |
| 503 | + $tables = array(); | |
| 504 | + $errors = array(); | |
| 312 | 505 | |
| 313 | - // Loop through Excel file and retrieve value and colspan/rowspan properties for each cell. | |
| 314 | - $sheet = 0; // 0 means first sheet of the Workbook | |
| 315 | - $table = array(); | |
| 316 | - for ( $row = 1; $row <= $excel_reader->rowcount( $sheet ); $row++ ) { | |
| 317 | - $table_row = array(); | |
| 318 | - for ( $col = 1; $col <= $excel_reader->colcount( $sheet ); $col++ ) { | |
| 319 | - $cell = array(); | |
| 320 | - $cell['rowspan'] = $excel_reader->rowspan( $row, $col, $sheet ); | |
| 321 | - $cell['colspan'] = $excel_reader->colspan( $row, $col, $sheet ); | |
| 322 | - $cell['val'] = $excel_reader->val( $row, $col, $sheet ); | |
| 323 | - $table_row[] = $cell; | |
| 324 | - } | |
| 325 | - $table[] = $table_row; | |
| 326 | - } | |
| 506 | + $use_legacy_import_class = $this->should_use_legacy_import_class(); | |
| 327 | 507 | |
| 328 | - // Transform colspan/rowspan properties to TablePress equivalent (cell content). | |
| 329 | - foreach ( $table as $row_idx => $row ) { | |
| 330 | - foreach ( $row as $col_idx => $cell ) { | |
| 331 | - if ( 1 === $cell['rowspan'] && 1 === $cell['colspan'] ) { | |
| 332 | - continue; | |
| 333 | - } | |
| 508 | + // Load Import Base Class. | |
| 509 | + TablePress::load_file( 'class-import-base.php', 'classes' ); | |
| 334 | 510 | |
| 335 | - if ( 1 < $cell['colspan'] ) { | |
| 336 | - for ( $i = 1; $i < $cell['colspan']; $i++ ) { | |
| 337 | - $table[ $row_idx ][ $col_idx + $i ]['val'] = '#colspan#'; | |
| 338 | - } | |
| 339 | - } | |
| 340 | - if ( 1 < $cell['rowspan'] ) { | |
| 341 | - for ( $i = 1; $i < $cell['rowspan']; $i++ ) { | |
| 342 | - $table[ $row_idx + $i ][ $col_idx ]['val'] = '#rowspan#'; | |
| 343 | - } | |
| 344 | - } | |
| 511 | + // Choose the Table Import library based on the PHP version and the filter hook value. | |
| 512 | + if ( $use_legacy_import_class ) { | |
| 513 | + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.) | |
| 514 | + $this->importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' ); | |
| 515 | + } else { | |
| 516 | + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.) | |
| 517 | + $this->importer = TablePress::load_class( 'TablePress_Import_PHPSpreadsheet', 'class-import-phpspreadsheet.php', 'classes' ); | |
| 518 | + } | |
| 345 | 519 | |
| 346 | - if ( 1 < $cell['rowspan'] && 1 < $cell['colspan'] ) { | |
| 347 | - for ( $i = 1; $i < $cell['rowspan']; $i++ ) { | |
| 348 | - for ( $j = 1; $j < $cell['colspan']; $j++ ) { | |
| 349 | - $table[ $row_idx + $i ][ $col_idx + $j ]['val'] = '#span#'; | |
| 350 | - } | |
| 520 | + // If there is more than one valid import file, ignore the chosen existing table for replacing/appending. | |
| 521 | + if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) && '' !== $this->import_config['existing_table'] ) { | |
| 522 | + $valid_import_files = 0; | |
| 523 | + foreach ( $import_files as $file ) { | |
| 524 | + if ( ! is_wp_error( $file->error ) ) { | |
| 525 | + ++$valid_import_files; | |
| 526 | + if ( $valid_import_files > 1 ) { | |
| 527 | + $this->import_config['existing_table'] = ''; | |
| 528 | + break; | |
| 351 | 529 | } |
| 352 | 530 | } |
| 353 | 531 | } |
| 354 | 532 | } |
| 355 | 533 | |
| 356 | - // Flatten value property to two-dimensional array. | |
| 357 | - $result_table = array(); | |
| 358 | - foreach ( $table as $row_idx => $row ) { | |
| 359 | - $table_row = array(); | |
| 360 | - foreach ( $row as $col_idx => $cell ) { | |
| 361 | - $table_row[] = (string) $cell['val']; | |
| 534 | + // Loop through all import files and import them. | |
| 535 | + foreach ( $import_files as $file ) { | |
| 536 | + if ( is_wp_error( $file->error ) ) { | |
| 537 | + $errors[] = $file; | |
| 538 | + continue; | |
| 362 | 539 | } |
| 363 | - $result_table[] = $table_row; | |
| 540 | + | |
| 541 | + // Use import method depending on chosen import class. | |
| 542 | + if ( $use_legacy_import_class ) { | |
| 543 | + $table = $this->load_table_from_file_legacy( $file ); | |
| 544 | + } else { | |
| 545 | + $table = $this->load_table_from_file_phpspreadsheet( $file ); | |
| 546 | + } | |
| 547 | + | |
| 548 | + $this->maybe_unlink_file( $file ); | |
| 549 | + | |
| 550 | + if ( is_wp_error( $table ) ) { | |
| 551 | + $file->error = $table; | |
| 552 | + $errors[] = $file; | |
| 553 | + continue; | |
| 554 | + } | |
| 555 | + | |
| 556 | + $table = $this->save_imported_table( $table, $file ); | |
| 557 | + if ( is_wp_error( $table ) ) { | |
| 558 | + $file->error = $table; | |
| 559 | + $errors[] = $file; | |
| 560 | + continue; | |
| 561 | + } | |
| 562 | + | |
| 563 | + $tables[] = $table; | |
| 364 | 564 | } |
| 365 | 565 | |
| 366 | - $this->imported_table = array( 'data' => $this->pad_array_to_max_cols( $result_table ) ); | |
| 566 | + return array( | |
| 567 | + 'tables' => $tables, | |
| 568 | + 'errors' => $errors, | |
| 569 | + ); | |
| 367 | 570 | } |
| 368 | 571 | |
| 369 | 572 | /** |
| 370 | - * Import Microsoft Excel 2007-2019 data. | |
| 573 | + * Loads a table from a file via the legacy import class. | |
| 371 | 574 | * |
| 372 | - * @since 1.1.0 | |
| 575 | + * @since 2.0.0 | |
| 576 | + * | |
| 577 | + * @param File $file File with the table data. | |
| 578 | + * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure. | |
| 373 | 579 | */ |
| 374 | - protected function import_xlsx() { | |
| 375 | - TablePress::load_file( 'simplexlsx.class.php', 'libraries' ); | |
| 376 | - $xlsx_file = SimpleXLSX::parse( $this->import_data, true ); | |
| 580 | + protected function load_table_from_file_legacy( File $file ) /* : array|WP_Error */ { | |
| 581 | + // Guess the import format from the file extension. | |
| 582 | + switch ( $file->extension ) { | |
| 583 | + case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet. | |
| 584 | + case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded). | |
| 585 | + case 'xltx': // Excel (OfficeOpenXML) Template. | |
| 586 | + case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded). | |
| 587 | + $format = 'xlsx'; | |
| 588 | + break; | |
| 589 | + case 'xls': // Excel (BIFF) Spreadsheet. | |
| 590 | + case 'xlt': // Excel (BIFF) Template. | |
| 591 | + $format = 'xls'; | |
| 592 | + break; | |
| 593 | + case 'htm': | |
| 594 | + case 'html': | |
| 595 | + $format = 'html'; | |
| 596 | + break; | |
| 597 | + case 'csv': | |
| 598 | + case 'tsv': | |
| 599 | + $format = 'csv'; | |
| 600 | + break; | |
| 601 | + case 'json': | |
| 602 | + $format = 'json'; | |
| 603 | + break; | |
| 604 | + default: | |
| 605 | + // If no format was found, try finding the format from the first character below. | |
| 606 | + $format = ''; | |
| 607 | + } | |
| 377 | 608 | |
| 378 | - if ( ! $xlsx_file ) { | |
| 379 | - $output = '<strong>' . __( 'The imported file contains errors:', 'tablepress' ) . '</strong><br /><br />' . SimpleXLSX::parseError() . '<br />'; | |
| 380 | - wp_die( $output, 'Import Error', array( 'response' => 200, 'back_link' => true ) ); | |
| 609 | + $data = file_get_contents( $file->location ); | |
| 610 | + if ( false === $data ) { | |
| 611 | + return new WP_Error( 'table_import_legacy_data_read', '', $file->location ); | |
| 381 | 612 | } |
| 613 | + if ( '' === $data ) { | |
| 614 | + return new WP_Error( 'table_import_legacy_data_empty', '', $file->location ); | |
| 615 | + } | |
| 382 | 616 | |
| 383 | - $this->imported_table = array( 'data' => $xlsx_file->rows() ); | |
| 617 | + // If no format could be determined from the file extension, try guessing from the file content. | |
| 618 | + if ( '' === $format ) { | |
| 619 | + $data = trim( $data ); | |
| 620 | + $first_character = $data[0]; | |
| 621 | + $last_character = $data[-1]; | |
| 622 | + | |
| 623 | + if ( '<' === $first_character && '>' === $last_character ) { | |
| 624 | + $format = 'html'; | |
| 625 | + } elseif ( ( '[' === $first_character && ']' === $last_character ) || ( '{' === $first_character && '}' === $last_character ) ) { | |
| 626 | + $json_table = json_decode( $data, true ); | |
| 627 | + if ( ! is_null( $json_table ) ) { | |
| 628 | + $format = 'json'; | |
| 629 | + } | |
| 630 | + } | |
| 631 | + } | |
| 632 | + | |
| 633 | + // Fall back to CSV if no file format could be determined. | |
| 634 | + if ( '' === $format ) { | |
| 635 | + $format = 'csv'; | |
| 636 | + } | |
| 637 | + | |
| 638 | + if ( ! isset( $this->importer->import_formats[ $format ] ) ) { | |
| 639 | + return new WP_Error( 'table_import_legacy_unknown_format', '', $file->name ); | |
| 640 | + } | |
| 641 | + | |
| 642 | + $table = $this->importer->import_table( $format, $data ); | |
| 643 | + | |
| 644 | + if ( false === $table ) { | |
| 645 | + return new WP_Error( 'table_import_legacy_importer_failed', '', array( 'file_name' => $file->name, 'file_format' => $format ) ); | |
| 646 | + } | |
| 647 | + | |
| 648 | + return $table; | |
| 384 | 649 | } |
| 385 | 650 | |
| 386 | 651 | /** |
| 387 | - * Make sure array is rectangular with $max_cols columns in every row. | |
| 652 | + * Loads a table from a file via the PHPSpreadsheet import class. | |
| 388 | 653 | * |
| 389 | - * @since 1.0.0 | |
| 654 | + * @since 2.0.0 | |
| 390 | 655 | * |
| 391 | - * @param array $array Two-dimensional array to be padded. | |
| 392 | - * @return array Padded array. | |
| 656 | + * @param File $file File with the table data. | |
| 657 | + * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure. | |
| 393 | 658 | */ |
| 394 | - public function pad_array_to_max_cols( array $array ) { | |
| 395 | - $rows = count( $array ); | |
| 396 | - $rows = ( $rows > 0 ) ? $rows : 1; | |
| 397 | - $max_columns = $this->count_max_columns( $array ); | |
| 398 | - $max_columns = ( $max_columns > 0 ) ? $max_columns : 1; | |
| 399 | - // array_map wants arrays as additional parameters, so we create one with the max_columns to pad to and one with the value to use (empty string). | |
| 400 | - $max_columns_array = array_fill( 1, $rows, $max_columns ); | |
| 401 | - $pad_values_array = array_fill( 1, $rows, '' ); | |
| 402 | - return array_map( 'array_pad', $array, $max_columns_array, $pad_values_array ); | |
| 659 | + protected function load_table_from_file_phpspreadsheet( File $file ) /* : array|WP_Error */ { | |
| 660 | + // Convert File object to array, as those are not yet used outside of this class. | |
| 661 | + return $this->importer->import_table( $file ); // @phpstan-ignore return.type (This is an instance of TablePress_Import_PHPSpreadsheet which does not return false.) | |
| 403 | 662 | } |
| 404 | 663 | |
| 405 | 664 | /** |
| 406 | - * Get the highest number of columns in the rows. | |
| 665 | + * Imports a loaded table into TablePress. | |
| 407 | 666 | * |
| 408 | - * @since 1.0.0 | |
| 667 | + * @since 2.0.0 | |
| 409 | 668 | * |
| 410 | - * @param array $array Two-dimensional array. | |
| 411 | - * @return int Highest number of columns in the rows of the array. | |
| 669 | + * @param array<string, mixed> $table The table to be imported, either with properties or just the $table['data'] property set. | |
| 670 | + * @param File $file File with the table data. | |
| 671 | + * @return array<string, mixed>|WP_Error Imported table on success, WP_Error on failure. | |
| 412 | 672 | */ |
| 413 | - protected function count_max_columns( array $array ) { | |
| 414 | - $max_columns = 0; | |
| 415 | - foreach ( $array as $row_idx => $row ) { | |
| 416 | - $num_columns = count( $row ); | |
| 417 | - $max_columns = max( $num_columns, $max_columns ); | |
| 673 | + protected function save_imported_table( array $table, File $file ) /* : array|WP_Error */ { | |
| 674 | + // If name and description are imported from a new table, use those. | |
| 675 | + if ( ! isset( $table['name'] ) ) { | |
| 676 | + $table['name'] = $file->name; | |
| 418 | 677 | } |
| 419 | - return $max_columns; | |
| 678 | + if ( ! isset( $table['description'] ) ) { | |
| 679 | + $table['description'] = $file->name; | |
| 680 | + } | |
| 681 | + | |
| 682 | + $import_type = $this->import_config['type']; | |
| 683 | + $existing_table_id = $this->import_config['existing_table']; | |
| 684 | + | |
| 685 | + // 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. | |
| 686 | + if ( in_array( $import_type, array( 'replace', 'append' ), true ) && '' === $existing_table_id ) { | |
| 687 | + if ( isset( $table['id'] ) ) { | |
| 688 | + // If the table already contained a table ID (e.g. for the JSON format), use that. | |
| 689 | + $existing_table_id = $table['id']; | |
| 690 | + } elseif ( isset( $this->table_names_ids[ $file->name ] ) && 1 === count( $this->table_names_ids[ $file->name ] ) ) { | |
| 691 | + // 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. | |
| 692 | + $existing_table_id = $this->table_names_ids[ $file->name ][0]; | |
| 693 | + } | |
| 694 | + } | |
| 695 | + | |
| 696 | + // If the table that is to be replaced or appended to does not exist, add the new table instead. | |
| 697 | + if ( ! TablePress::$model_table->table_exists( $existing_table_id ) ) { | |
| 698 | + $existing_table_id = ''; | |
| 699 | + $import_type = 'add'; | |
| 700 | + } | |
| 701 | + | |
| 702 | + $table = $this->import_tablepress_table( $table, $import_type, $existing_table_id ); | |
| 703 | + | |
| 704 | + return $table; | |
| 420 | 705 | } |
| 421 | 706 | |
| 422 | 707 | /** |
| 423 | - * Fixes the encoding to UTF-8 for the entire string that is to be imported. | |
| 708 | + * Imports a table by either replacing or appending to an existing table or by adding it as a new table. | |
| 424 | 709 | * |
| 425 | 710 | * @since 1.0.0 |
| 426 | 711 | * |
| 427 | - * @link http://stevephillips.me/blog/dealing-php-and-character-encoding | |
| 712 | + * @param array<string, mixed> $imported_table The table to be imported, either with properties or just the `name`, `description`, and `data` property set. | |
| 713 | + * @param string $import_type What to do with the imported data: "add", "replace", "append". | |
| 714 | + * @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. | |
| 715 | + * @return array<string, mixed>|WP_Error Table on success, WP_Error on error. | |
| 428 | 716 | */ |
| 429 | - protected function fix_table_encoding() { | |
| 430 | - // Check and remove possible UTF-8 Byte-Order Mark (BOM). | |
| 431 | - $bom = pack( 'CCC', 0xef, 0xbb, 0xbf ); | |
| 432 | - if ( 0 === strncmp( $this->import_data, $bom, 3 ) ) { | |
| 433 | - $this->import_data = substr( $this->import_data, 3 ); | |
| 434 | - // If data has a BOM, it's UTF-8, so further checks unnecessary. | |
| 435 | - return; | |
| 717 | + protected function import_tablepress_table( array $imported_table, string $import_type, string $existing_table_id ) /* : array|WP_Error */ { | |
| 718 | + // Full JSON format table can contain a table ID, try to keep that, by later changing the imported table ID to this. | |
| 719 | + $table_id_in_import = $imported_table['id'] ?? ''; | |
| 720 | + | |
| 721 | + // 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. | |
| 722 | + if ( in_array( $import_type, array( 'replace', 'append' ), true ) | |
| 723 | + && ! ( current_user_can( 'tablepress_edit_table', $existing_table_id ) || doing_action( 'tablepress_automatic_periodic_table_import_action' ) ) ) { | |
| 724 | + return new WP_Error( 'table_import_replace_append_capability_check_failed', '', $existing_table_id ); | |
| 436 | 725 | } |
| 437 | 726 | |
| 438 | - // Require the iconv() function for the following checks. | |
| 439 | - if ( ! function_exists( 'iconv' ) ) { | |
| 440 | - return; | |
| 727 | + switch ( $import_type ) { | |
| 728 | + case 'add': | |
| 729 | + $existing_table = TablePress::$model_table->get_table_template(); | |
| 730 | + // Import visibility information if it exists, usually only for the JSON format. | |
| 731 | + if ( isset( $imported_table['visibility'] ) ) { | |
| 732 | + $existing_table['visibility'] = $imported_table['visibility']; | |
| 733 | + } | |
| 734 | + break; | |
| 735 | + case 'replace': | |
| 736 | + // Load table, without table data, but with options and visibility settings. | |
| 737 | + $existing_table = TablePress::$model_table->load( $existing_table_id, false, true ); | |
| 738 | + if ( is_wp_error( $existing_table ) ) { | |
| 739 | + $error = new WP_Error( 'table_import_replace_table_load', '', $existing_table_id ); | |
| 740 | + $error->merge_from( $existing_table ); | |
| 741 | + return $error; | |
| 742 | + } | |
| 743 | + // Don't change name and description when a table is replaced. | |
| 744 | + $imported_table['name'] = $existing_table['name']; | |
| 745 | + $imported_table['description'] = $existing_table['description']; | |
| 746 | + // Replace visibility information if it exists. | |
| 747 | + if ( isset( $imported_table['visibility'] ) ) { | |
| 748 | + $existing_table['visibility'] = $imported_table['visibility']; | |
| 749 | + } | |
| 750 | + break; | |
| 751 | + case 'append': | |
| 752 | + // Load table, with table data, options, and visibility settings. | |
| 753 | + $existing_table = TablePress::$model_table->load( $existing_table_id, true, true ); | |
| 754 | + if ( is_wp_error( $existing_table ) ) { | |
| 755 | + $error = new WP_Error( 'table_import_append_table_load', '', $existing_table_id ); | |
| 756 | + $error->merge_from( $existing_table ); | |
| 757 | + return $error; | |
| 758 | + } | |
| 759 | + if ( isset( $existing_table['is_corrupted'] ) && $existing_table['is_corrupted'] ) { | |
| 760 | + return new WP_Error( 'table_import_append_table_load_corrupted', '', $existing_table_id ); | |
| 761 | + } | |
| 762 | + // Don't change name and description when a table is appended to. | |
| 763 | + $imported_table['name'] = $existing_table['name']; | |
| 764 | + $imported_table['description'] = $existing_table['description']; | |
| 765 | + // Actual appending:. | |
| 766 | + $imported_table['data'] = array_merge( $existing_table['data'], $imported_table['data'] ); | |
| 767 | + $this->importer->pad_array_to_max_cols( $imported_table['data'] ); | |
| 768 | + // Append visibility information for rows. | |
| 769 | + if ( isset( $imported_table['visibility']['rows'] ) ) { | |
| 770 | + $existing_table['visibility']['rows'] = array_merge( $existing_table['visibility']['rows'], $imported_table['visibility']['rows'] ); | |
| 771 | + } | |
| 772 | + // When appending, do not overwrite options, e.g. coming from a JSON file. | |
| 773 | + unset( $imported_table['options'] ); | |
| 774 | + break; | |
| 775 | + default: | |
| 776 | + return new WP_Error( 'table_import_import_type_invalid', '', $import_type ); | |
| 441 | 777 | } |
| 442 | 778 | |
| 443 | - // Check for possible UTF-16 BOMs ("little endian" and "big endian") and try to convert the data to UTF-8. | |
| 444 | - if ( "\xFF\xFE" === substr( $this->import_data, 0, 2 ) || "\xFE\xFF" === substr( $this->import_data, 0, 2 ) ) { | |
| 445 | - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 446 | - $data = @iconv( 'UTF-16', 'UTF-8', $this->import_data ); | |
| 447 | - if ( false !== $data ) { | |
| 448 | - $this->import_data = $data; | |
| 449 | - return; | |
| 450 | - } | |
| 779 | + // Merge new or existing table with information from the imported table. | |
| 780 | + $imported_table['id'] = $existing_table['id']; // Will be false for new table or the existing table ID. | |
| 781 | + // Cut visibility array (if the imported table is smaller), and pad correctly if imported table is bigger than existing table (or new template). | |
| 782 | + $num_rows = count( $imported_table['data'] ); | |
| 783 | + $num_columns = count( $imported_table['data'][0] ); | |
| 784 | + $imported_table['visibility'] = array( | |
| 785 | + 'rows' => array_pad( array_slice( $existing_table['visibility']['rows'], 0, $num_rows ), $num_rows, 1 ), | |
| 786 | + 'columns' => array_pad( array_slice( $existing_table['visibility']['columns'], 0, $num_columns ), $num_columns, 1 ), | |
| 787 | + ); | |
| 788 | + | |
| 789 | + // Check if the new table data is valid and consistent. | |
| 790 | + $table = TablePress::$model_table->prepare_table( $existing_table, $imported_table, false ); | |
| 791 | + if ( is_wp_error( $table ) ) { | |
| 792 | + $error = new WP_Error( 'table_import_table_prepare', '', $imported_table['id'] ); | |
| 793 | + $error->merge_from( $table ); | |
| 794 | + return $error; | |
| 451 | 795 | } |
| 452 | 796 | |
| 453 | - // Detect the character encoding and convert to UTF-8, if it's different. | |
| 454 | - if ( function_exists( 'mb_detect_encoding' ) ) { | |
| 455 | - $current_encoding = mb_detect_encoding( $this->import_data, 'ASCII, UTF-8, ISO-8859-1' ); | |
| 456 | - if ( 'UTF-8' !== $current_encoding ) { | |
| 457 | - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | |
| 458 | - $data = @iconv( $current_encoding, 'UTF-8', $this->import_data ); | |
| 459 | - if ( false !== $data ) { | |
| 460 | - $this->import_data = $data; | |
| 461 | - return; | |
| 462 | - } | |
| 797 | + // DataTables Custom Commands can only be edit by trusted users. | |
| 798 | + if ( ! current_user_can( 'unfiltered_html' ) ) { | |
| 799 | + $table['options']['datatables_custom_commands'] = $existing_table['options']['datatables_custom_commands']; | |
| 800 | + } | |
| 801 | + | |
| 802 | + // Replace existing table or add new table. | |
| 803 | + if ( in_array( $import_type, array( 'replace', 'append' ), true ) ) { | |
| 804 | + // Replace existing table with imported/appended table. | |
| 805 | + $table_id = TablePress::$model_table->save( $table ); | |
| 806 | + } else { | |
| 807 | + // Add the imported table (and get its first ID). | |
| 808 | + $table_id = TablePress::$model_table->add( $table ); | |
| 809 | + } | |
| 810 | + | |
| 811 | + if ( is_wp_error( $table_id ) ) { | |
| 812 | + $error = new WP_Error( 'table_import_table_save_or_add', '', $table['id'] ); | |
| 813 | + $error->merge_from( $table_id ); | |
| 814 | + return $error; | |
| 815 | + } | |
| 816 | + | |
| 817 | + // Try to use ID from imported file (e.g. in full JSON format table). | |
| 818 | + if ( '' !== $table_id_in_import && $table_id !== $table_id_in_import && current_user_can( 'tablepress_edit_table_id', $table_id ) ) { | |
| 819 | + $id_changed = TablePress::$model_table->change_table_id( $table_id, $table_id_in_import ); | |
| 820 | + if ( ! is_wp_error( $id_changed ) ) { | |
| 821 | + $table_id = $table_id_in_import; | |
| 463 | 822 | } |
| 464 | 823 | } |
| 824 | + | |
| 825 | + $table['id'] = $table_id; | |
| 826 | + | |
| 827 | + return $table; | |
| 828 | + } | |
| 829 | + | |
| 830 | + /** | |
| 831 | + * Imports a table in legacy versions of the Table Auto Update Extension. | |
| 832 | + * | |
| 833 | + * This method is deprecated and is only left for backward compatibility reasons. Do not use this in new code! | |
| 834 | + * | |
| 835 | + * @since 1.0.0 | |
| 836 | + * @deprecated 2.0.0 Use `run()` instead. | |
| 837 | + * | |
| 838 | + * @param string $format Import format. | |
| 839 | + * @param string $data Data to import. | |
| 840 | + * @return array<string, mixed>|WP_Error|false Table array on success, WP_Error or false on error. | |
| 841 | + */ | |
| 842 | + public function import_table( string $format, string $data ) /* : array|false */ { | |
| 843 | + TablePress::load_file( 'class-import-base.php', 'classes' ); | |
| 844 | + $importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' ); | |
| 845 | + return $importer->import_table( $format, $data ); | |
| 465 | 846 | } |
| 466 | 847 | |
| 467 | 848 | } // class TablePress_Import |