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