PluginProbe
TablePress – Tables in WordPress made easy / 3.0
TablePress – Tables in WordPress made easy v3.0
3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 2.4.4 All 44 releases
← All changes | classes/class-import.php +291 -219 2.0.43.0 View file →
@@ -7,11 +7,15 @@
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
16 20 *
17 21 * @package TablePress
@@ -21,79 +25,68 @@
21 25 */
22 26 class TablePress_Import {
23 27
24 28 /**
25 - * Instance of the TablePress Legacy Importer.
29 + * Instance of the TablePress Legacy or PHPSpreadsheet Importer.
26 30 *
27 31 * @since 1.0.0
28 - * @var TablePress_Import_Legacy
32 + * @var TablePress_Import_Legacy|TablePress_Import_PHPSpreadsheet
29 33 */
30 - protected $importer;
34 + protected object $importer;
31 35
32 36 /**
33 37 * Import configuration (mainly the data from the Import form).
34 38 *
35 39 * @since 2.0.0
36 - * @var array
40 + * @var array<string, mixed>
37 41 */
38 - protected $import_config = array();
42 + protected array $import_config = array();
39 43
40 44 /**
41 - * Whether ZIP archive 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).
42 46 *
43 47 * @since 1.0.0
44 - * @var bool
48 + * @deprecated 2.3.0 ZIP support is now always available, either through `ZipArchive` or through `PclZip`.
45 49 */
46 - public $zip_support_available = false;
50 + public bool $zip_support_available = true;
47 51
48 52 /**
49 53 * List of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
50 54 *
51 55 * @since 2.0.0
52 - * @var array
56 + * @var array<string, string[]>
53 57 */
54 - protected $table_names_ids = array();
58 + protected array $table_names_ids = array();
55 59
56 60 /**
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 61 * Runs the import process for a given import configuration.
70 62 *
71 63 * @since 2.0.0
72 64 *
73 - * @param array $import_config Import configuration.
74 - * @return array|WP_Error List of imported tables on success, WP_Error on failure.
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.
75 67 */
76 - public function run( array $import_config ) {
77 - // Unzip can use a lot of memory and execution time, but not this much hopefully.
78 - /** This filter is documented in the WordPress file wp-admin/admin.php */
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.
79 70 wp_raise_memory_limit( 'admin' );
80 - set_time_limit( 300 );
71 + if ( function_exists( 'set_time_limit' ) ) {
72 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
73 + }
81 74
82 75 $this->import_config = $import_config;
83 76
84 - $import_files = $this->_get_import_files();
77 + $import_files = $this->get_files_to_import();
85 78 if ( is_wp_error( $import_files ) ) {
86 79 return $import_files;
87 80 }
88 81
82 + $import_files = $this->convert_zip_files( $import_files );
83 +
89 84 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) ) {
90 - $this->table_names_ids = $this->_get_list_of_table_names();
85 + $this->table_names_ids = $this->get_list_of_table_names();
91 86 }
92 87
93 - $import_files = $this->_convert_zip_files( $import_files );
94 -
95 - return $this->_import_files( $import_files );
88 + return $this->import_files( $import_files );
96 89 }
97 90
98 91 /**
99 92 * Extracts the files that shall be imported from the import configuration.
@@ -99,23 +92,23 @@
99 92 * Extracts the files that shall be imported from the import configuration.
100 93 *
101 94 * @since 2.0.0
102 95 *
103 - * @return array|WP_Error Files that shall be imported or WP_Error on failure.
96 + * @return File[]|WP_Error Array of files that shall be imported or WP_Error on failure.
104 97 */
105 - protected function _get_import_files() {
98 + protected function get_files_to_import() /* : array|WP_Error */ {
106 99 $import_files = array();
107 100
108 101 switch ( $this->import_config['source'] ) {
109 102 case 'file-upload':
110 103 foreach ( $this->import_config['file-upload']['error'] as $key => $error ) {
111 - $file = array(
104 + $file = new File( array(
112 105 'location' => $this->import_config['file-upload']['tmp_name'][ $key ],
113 106 'name' => $this->import_config['file-upload']['name'][ $key ],
114 - );
107 + ) );
115 108 if ( UPLOAD_ERR_OK !== $error ) {
116 - @unlink( $this->import_config['file-upload']['tmp_name'][ $key ] );
117 - $file['error'] = new WP_Error( 'table_import_file-upload_error', '', $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 );
118 111 }
119 112 $import_files[] = $file;
120 113 }
121 114 break;
@@ -125,31 +118,37 @@
125 118 if ( empty( $host ) ) {
126 119 return new WP_Error( 'table_import_url_host_invalid', '', $this->import_config['url'] );
127 120 }
128 121
129 - // Check the host of the Import URL against a blacklist of hosts, which should not be accessible, e.g. for security considerations.
130 - $blocked_hosts = array(
131 - '169.254.169.254', // AWS Meta-data API.
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.
132 129 );
133 - if ( in_array( $host, $blocked_hosts, true ) ) {
134 - return new WP_Error( 'table_import_url_host_blocked', '', $this->import_config['url'] );
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 ) );
135 132 }
136 133
137 134 /**
138 135 * Load WP file functions to be sure that `download_url()` exists, in particular during Cron requests.
139 136 */
140 - require_once ABSPATH . 'wp-admin/includes/file.php';
137 + require_once ABSPATH . 'wp-admin/includes/file.php'; // @phpstan-ignore requireOnce.fileNotFound (This is a WordPress core file that always exists.)
141 138
142 139 // Download URL to local file.
143 140 $location = download_url( $this->import_config['url'] );
144 141 if ( is_wp_error( $location ) ) {
145 - return new WP_Error( 'table_import_url_download_failed', '', $this->import_config['url'] );
142 + $error = new WP_Error( 'table_import_url_download_failed', '', $this->import_config['url'] );
143 + $error->merge_from( $location );
144 + return $error;
146 145 }
147 146
148 - $import_files[] = array(
147 + $import_files[] = new File( array(
149 148 'location' => $location,
150 149 'name' => $this->import_config['url'],
151 - );
150 + ) );
152 151 break;
153 152 case 'server':
154 153 if ( ABSPATH === $this->import_config['server'] ) {
155 154 return new WP_Error( 'table_import_server_invalid', '', $this->import_config['server'] );
@@ -158,26 +157,26 @@
158 157 if ( ! is_readable( $this->import_config['server'] ) ) {
159 158 return new WP_Error( 'table_import_server_not_readable', '', $this->import_config['server'] );
160 159 }
161 160
162 - $import_files[] = array(
161 + $import_files[] = new File( array(
163 162 'location' => $this->import_config['server'],
164 163 'name' => pathinfo( $this->import_config['server'], PATHINFO_BASENAME ),
165 164 'keep_file' => true, // Files on the server must not be deleted.
166 - );
165 + ) );
167 166 break;
168 167 case 'form-field':
169 168 $location = wp_tempnam();
170 169 $num_written_bytes = file_put_contents( $location, $this->import_config['form-field'] );
171 170 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
172 - @unlink( $location );
171 + @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
173 172 return new WP_Error( 'table_import_form-field_temp_file_not_written' );
174 173 }
175 174
176 - $import_files[] = array(
175 + $import_files[] = new File( array(
177 176 'location' => $location,
178 177 'name' => __( 'Imported from Manual Input', 'tablepress' ),
179 - );
178 + ) );
180 179 break;
181 180 default:
182 181 return new WP_Error( 'table_import_invalid_source', '', $this->import_config['source'] );
183 182 }
@@ -191,77 +190,56 @@
191 190 * ZIP files are removed from the list and their contents are added to the end of the list.
192 191 *
193 192 * @since 2.0.0
194 193 *
195 - * @param array $import_files Files that shall be imported, including ZIP archives.
196 - * @return array Files that shall be imported, with all ZIP archives recursively replaced by their contents.
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.
197 196 */
198 - protected function _convert_zip_files( array $import_files ) {
199 - /*
200 - * Here, a for loop is used over a foreach loop, as the array is modified while being iterated over.
201 - * The foreach approach works in PHP 7+ (via https://www.php.net/manual/en/migration70.incompatible.php#migration70.incompatible.foreach.by-ref), but not in PHP 5.6.
202 - * Once PHP 7.x is required, this can be adjusted again.
203 - */
204 - $num_files = count( $import_files ); // This number is growing inside the loop, if files are extracted from a ZIP file and appended to the list.
205 - for ( $key = 0; $key < $num_files; $key++ ) {
206 - $file = $import_files[ $key ];
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()`.
207 200
208 - if ( isset( $file['error'] ) && is_wp_error( $file['error'] ) ) {
201 + // Skip files that already have an error.
202 + if ( is_wp_error( $file->error ) ) {
209 203 continue;
210 204 }
211 205
212 - $file['extension'] = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );
206 + $file->extension = strtolower( pathinfo( $file->name, PATHINFO_EXTENSION ) );
213 207
214 208 if ( function_exists( 'mime_content_type' ) ) {
215 - $file['mime_type'] = mime_content_type( $file['location'] );
216 - if ( false === $file['mime_type'] ) {
217 - $file['mime_type'] = '';
209 + $mime_type = mime_content_type( $file->location );
210 + if ( false !== $mime_type ) {
211 + $file->mime_type = $mime_type;
218 212 }
219 - } else {
220 - $file['mime_type'] = '';
221 213 }
222 214
223 215 // Detect ZIP files from their file extension or MIME type.
224 - if ( 'zip' === $file['extension'] || 'application/zip' === $file['mime_type'] ) {
225 - if ( ! $this->zip_support_available ) {
226 - $file['error'] = new WP_Error( 'table_import_no_zip_support', '', $file['name'] );
227 - $this->_maybe_unlink_file( $file );
228 - continue;
229 - }
230 -
231 - $extracted_files = $this->_extract_zip_file( $file );
216 + if ( 'zip' === $file->extension || 'application/zip' === $file->mime_type ) {
217 + $extracted_files = $this->extract_zip_file( $file );
232 218 if ( is_wp_error( $extracted_files ) ) {
233 - $file['error'] = $extracted_files->get_error_code();
234 - $this->_maybe_unlink_file( $file );
219 + $file->error = $extracted_files;
220 + $this->maybe_unlink_file( $file );
235 221 continue;
236 222 }
237 223
238 224 if ( empty( $extracted_files ) ) {
239 - $file['error'] = new WP_Error( 'table_import_zip_file_empty', '', $file['name'] );
240 - $this->_maybe_unlink_file( $file );
225 + $file->error = new WP_Error( 'table_import_zip_file_empty', '', $file->name );
226 + $this->maybe_unlink_file( $file );
241 227 continue;
242 228 }
243 229
244 - $this->_maybe_unlink_file( $file );
245 -
246 - // Mark the ZIP file as removed from the list (null), append its contents to the end, and increase the number of files counter.
247 - $file = null;
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 ] );
248 235 array_push( $import_files, ...$extracted_files );
249 - $num_files += count( $extracted_files );
250 236
237 + $this->maybe_unlink_file( $file );
251 238 }
252 -
253 - $import_files[ $key ] = $file;
254 239 }
240 + unset( $file ); // Unset use-by-reference parameter of foreach loop.
255 241
256 - // Actually remove files that are marked as removed (null).
257 - $import_files = array_filter(
258 - $import_files,
259 - static function( $file ) {
260 - return ! is_null( $file );
261 - }
262 - );
263 -
264 242 $import_files = array_merge( $import_files ); // Re-index.
265 243
266 244 return $import_files;
267 245 }
@@ -266,61 +244,95 @@
266 244 return $import_files;
267 245 }
268 246
269 247 /**
270 - * Extracts the files of a ZIP files to a temporary folder and returns a list of files and their location.
248 + * Extracts the files of a ZIP file and returns a list of files and their location.
271 249 *
250 + * Depending on availability, either the PHP's ZipArchive class or WordPress' PclZip class is used.
251 + *
272 252 * @since 2.0.0
273 253 *
274 - * @param array $zip_file File data of a ZIP file (likely in a temporary folder).
275 - * @return array|WP_Error List of files (name and location where they were extracted to) of the ZIP file or WP_Error on failure.
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.
276 256 */
277 - protected function _extract_zip_file( array $zip_file ) {
278 - $zip = new ZipArchive();
279 - $zip_opened = $zip->open( $zip_file['location'], ZIPARCHIVE::CHECKCONS );
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' ) );
265 + }
280 266
281 - // If the ZIP file can't be opened with ZIPARCHIVE::CHECKCONS, try again without.
282 - if ( true !== $zip_opened ) {
283 - $zip_opened = $zip->open( $zip_file['location'] );
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 );
284 272 }
285 273
286 - // If the ZIP file can't even be opened without ZIPARCHIVE::CHECKCONS, bail.
287 - if ( true !== $zip_opened ) {
288 - return new WP_Error( 'table_import_error_zip_open', '', array( 'ziparchive_error' => $zip_opened ) );
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 );
289 294 }
290 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 ) );
299 + }
300 +
291 301 $files = array();
292 302
293 303 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
294 - for ( $file_idx = 0; $file_idx < $zip->numFiles; $file_idx++ ) {
295 - $file_name = $zip->getNameIndex( $file_idx );
304 + for ( $file_idx = 0; $file_idx < $archive->numFiles; $file_idx++ ) {
305 + $file_name = $archive->getNameIndex( $file_idx );
296 306
297 307 if ( false === $file_name ) {
298 - $files[] = array(
299 - 'location' => '',
300 - 'name' => '',
301 - 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
302 - );
308 + $files[] = new File( array(
309 + 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
310 + ) );
303 311 continue;
304 312 }
305 313
306 314 // Skip directories.
307 - if ( '/' === substr( $file_name, -1 ) ) {
315 + if ( str_ends_with( $file_name, '/' ) ) {
308 316 continue;
309 317 }
310 318
311 319 // Skip the __MACOSX directory that macOS adds to archives.
312 - if ( '__MACOSX/' === substr( $file_name, 0, 9 ) ) {
320 + if ( str_starts_with( $file_name, '__MACOSX/' ) ) {
313 321 continue;
314 322 }
315 323
316 - $file_data = $zip->getFromIndex( $file_idx );
324 + // Don't extract invalid files.
325 + if ( 0 !== validate_file( $file_name ) ) {
326 + continue;
327 + }
328 +
329 + $file_data = $archive->getFromIndex( $file_idx );
317 330 if ( false === $file_data ) {
318 - $files[] = array(
319 - 'location' => '',
320 - 'name' => $file_name,
321 - 'error' => new WP_Error( 'table_import_error_zip_get_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
322 - );
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 + ) );
323 335 continue;
324 336 }
325 337
326 338 $location = wp_tempnam();
@@ -325,38 +337,100 @@
325 337
326 338 $location = wp_tempnam();
327 339 $num_written_bytes = file_put_contents( $location, $file_data );
328 340 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
329 - @unlink( $location );
330 - $files[] = array(
331 - 'location' => '',
332 - 'name' => $file_name,
333 - 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
334 - );
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 + ) );
335 346 continue;
336 347 }
337 348
338 - $files[] = array(
349 + $files[] = new File( array(
339 350 'location' => $location,
340 351 'name' => $file_name,
341 - );
352 + ) );
342 353 }
343 354
344 - $zip->close();
355 + $archive->close();
345 356
346 357 return $files;
347 358 }
348 359
349 360 /**
350 - * Deletes a file unless the `keep_file` property is set to false.
361 + * Extracts the files of a ZIP file using WordPress' PclZip class.
351 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 ) ) );
383 + }
384 +
385 + $files = array();
386 +
387 + foreach ( $archive_files as $file ) {
388 + // Skip directories.
389 + if ( $file['folder'] ) {
390 + continue;
391 + }
392 +
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 + ) );
418 + }
419 +
420 + return $files;
421 + }
422 +
423 + /**
424 + * Deletes a file unless the `keep_file` property is set to `true`.
425 + *
352 426 * @since 2.0.0
353 427 *
354 - * @param array $file File that should maybe be deleted.
428 + * @param File $file File that should maybe be deleted.
355 429 */
356 - protected function _maybe_unlink_file( array $file ) {
357 - if ( ! isset( $file['keep_file'] ) || ! $file['keep_file'] ) {
358 - @unlink( $file['location'] );
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
359 433 }
360 434 }
361 435
362 436 /**
@@ -363,11 +437,11 @@
363 437 * Prepares a list of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
364 438 *
365 439 * @since 2.0.0
366 440 *
367 - * @return array List of table names and IDs.
441 + * @return array<string, string[]> List of table names and IDs.
368 442 */
369 - protected function _get_list_of_table_names() {
443 + protected function get_list_of_table_names(): array {
370 444 $existing_tables = array();
371 445 // Load all table IDs and names for a comparison with the file name.
372 446 $table_ids = TablePress::$model_table->load_all( false );
373 447 foreach ( $table_ids as $table_id ) {
@@ -373,9 +447,9 @@
373 447 foreach ( $table_ids as $table_id ) {
374 448 // Load table, without table data, options, and visibility settings.
375 449 $table = TablePress::$model_table->load( $table_id, false, false );
376 450 if ( ! is_wp_error( $table ) ) {
377 - $existing_tables[ $table['name'] ][] = $table['id']; // Attention: The table name is not unique!
451 + $existing_tables[ (string) $table['name'] ][] = $table_id; // Attention: The table name is not unique!
378 452 }
379 453 }
380 454 return $existing_tables;
381 455 }
@@ -386,9 +460,9 @@
386 460 * @since 2.0.0
387 461 *
388 462 * @return bool Whether the legacy import class should be used.
389 463 */
390 - protected function _should_use_legacy_import_class() {
464 + protected function should_use_legacy_import_class(): bool {
391 465 // Allow overriding in the import config (coming e.g. from the import form UI).
392 466 if ( $this->import_config['legacy_import'] ) {
393 467 return true;
394 468 }
@@ -397,9 +471,9 @@
397 471 * Filters whether the Legacy Table Import class shall be used.
398 472 *
399 473 * @since 2.0.0
400 474 *
401 - * @param bool Whether to use the legacy table import class. Default false.
475 + * @param bool $use_legacy_class Whether to use the legacy table import class. Default false.
402 476 */
403 477 if ( apply_filters( 'tablepress_use_legacy_table_import_class', false ) ) {
404 478 return true;
405 479 }
@@ -404,23 +478,17 @@
404 478 return true;
405 479 }
406 480
407 481 // Use the legacy import class, if the requirements for PHPSpreadsheet are not fulfilled.
408 - $phpspreadsheet_requirements_fulfilled = PHP_VERSION_ID >= 70200
409 - && extension_loaded( 'mbstring' )
482 + $phpspreadsheet_requirements_fulfilled = extension_loaded( 'mbstring' )
410 483 && class_exists( 'ZipArchive', false )
411 484 && class_exists( 'DOMDocument', false )
412 485 && function_exists( 'simplexml_load_string' )
413 - && function_exists( 'libxml_disable_entity_loader' );
486 + && ( function_exists( 'libxml_disable_entity_loader' ) || PHP_VERSION_ID >= 80000 ); // This function is only needed for older versions of PHP.
414 487 if ( ! $phpspreadsheet_requirements_fulfilled ) {
415 488 return true;
416 489 }
417 490
418 - // Use the legacy import class, if the PHPSpreadsheet files do not exist (e.g. because `composer install` was not run).
419 - if ( ! file_exists( TABLEPRESS_ABSPATH . 'libraries/autoload.php' ) ) {
420 - return true;
421 - }
422 -
423 491 return false;
424 492 }
425 493
426 494 /**
@@ -427,16 +495,16 @@
427 495 * Imports all found/extracted/configured files into TablePress.
428 496 *
429 497 * @since 2.0.0
430 498 *
431 - * @param array $import_files Files that shall be imported.
432 - * @return array Import tables and import errors.
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.
433 501 */
434 - protected function _import_files( array $import_files ) {
502 + protected function import_files( array $import_files ): array {
435 503 $tables = array();
436 504 $errors = array();
437 505
438 - $use_legacy_import_class = $this->_should_use_legacy_import_class();
506 + $use_legacy_import_class = $this->should_use_legacy_import_class();
439 507
440 508 // Load Import Base Class.
441 509 TablePress::load_file( 'class-import-base.php', 'classes' );
442 510
@@ -441,10 +509,12 @@
441 509 TablePress::load_file( 'class-import-base.php', 'classes' );
442 510
443 511 // Choose the Table Import library based on the PHP version and the filter hook value.
444 512 if ( $use_legacy_import_class ) {
513 + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
445 514 $this->importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
446 515 } else {
516 + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
447 517 $this->importer = TablePress::load_class( 'TablePress_Import_PHPSpreadsheet', 'class-import-phpspreadsheet.php', 'classes' );
448 518 }
449 519
450 520 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
@@ -450,9 +520,9 @@
450 520 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
451 521 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) && '' !== $this->import_config['existing_table'] ) {
452 522 $valid_import_files = 0;
453 523 foreach ( $import_files as $file ) {
454 - if ( ! isset( $file['error'] ) || ! is_wp_error( $file['error'] ) ) {
524 + if ( ! is_wp_error( $file->error ) ) {
455 525 ++$valid_import_files;
456 526 if ( $valid_import_files > 1 ) {
457 527 $this->import_config['existing_table'] = '';
458 528 break;
@@ -462,9 +532,9 @@
462 532 }
463 533
464 534 // Loop through all import files and import them.
465 535 foreach ( $import_files as $file ) {
466 - if ( isset( $file['error'] ) && is_wp_error( $file['error'] ) ) {
536 + if ( is_wp_error( $file->error ) ) {
467 537 $errors[] = $file;
468 538 continue;
469 539 }
470 540
@@ -469,24 +539,24 @@
469 539 }
470 540
471 541 // Use import method depending on chosen import class.
472 542 if ( $use_legacy_import_class ) {
473 - $table = $this->_load_table_from_file_legacy( $file );
543 + $table = $this->load_table_from_file_legacy( $file );
474 544 } else {
475 - $table = $this->_load_table_from_file_phpspreadsheet( $file );
545 + $table = $this->load_table_from_file_phpspreadsheet( $file );
476 546 }
477 547
478 - $this->_maybe_unlink_file( $file );
548 + $this->maybe_unlink_file( $file );
479 549
480 550 if ( is_wp_error( $table ) ) {
481 - $file['error'] = $table;
551 + $file->error = $table;
482 552 $errors[] = $file;
483 553 continue;
484 554 }
485 555
486 - $table = $this->_import_table( $table, $file );
556 + $table = $this->save_imported_table( $table, $file );
487 557 if ( is_wp_error( $table ) ) {
488 - $file['error'] = $table;
558 + $file->error = $table;
489 559 $errors[] = $file;
490 560 continue;
491 561 }
492 562
@@ -503,14 +573,14 @@
503 573 * Loads a table from a file via the legacy import class.
504 574 *
505 575 * @since 2.0.0
506 576 *
507 - * @param array $file File with the table data.
508 - * @return array|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
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.
509 579 */
510 - protected function _load_table_from_file_legacy( array $file ) {
580 + protected function load_table_from_file_legacy( File $file ) /* : array|WP_Error */ {
511 581 // Guess the import format from the file extension.
512 - switch ( $file['extension'] ) {
582 + switch ( $file->extension ) {
513 583 case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet.
514 584 case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded).
515 585 case 'xltx': // Excel (OfficeOpenXML) Template.
516 586 case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded).
@@ -531,27 +601,33 @@
531 601 case 'json':
532 602 $format = 'json';
533 603 break;
534 604 default:
535 - // If no format was found, pass the extension (which will likely result in an error).
536 - $format = $file['extension'];
605 + // If no format was found, try finding the format from the first character below.
606 + $format = '';
537 607 }
538 608
539 - $data = file_get_contents( $file['location'] );
609 + $data = file_get_contents( $file->location );
540 610 if ( false === $data ) {
541 - return new WP_Error( 'table_import_legacy_data_read', '', $file['location'] );
611 + return new WP_Error( 'table_import_legacy_data_read', '', $file->location );
542 612 }
543 613 if ( '' === $data ) {
544 - return new WP_Error( 'table_import_legacy_data_empty', '', $file['location'] );
614 + return new WP_Error( 'table_import_legacy_data_empty', '', $file->location );
545 615 }
546 616
547 617 // If no format could be determined from the file extension, try guessing from the file content.
548 618 if ( '' === $format ) {
619 + $data = trim( $data );
549 620 $first_character = $data[0];
550 - if ( '<' === $first_character ) {
621 + $last_character = $data[-1];
622 +
623 + if ( '<' === $first_character && '>' === $last_character ) {
551 624 $format = 'html';
552 - } elseif ( '{' === $first_character || '[' === $first_character ) {
553 - $format = 'json';
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 + }
554 630 }
555 631 }
556 632
557 633 // Fall back to CSV if no file format could be determined.
@@ -559,15 +635,15 @@
559 635 $format = 'csv';
560 636 }
561 637
562 638 if ( ! isset( $this->importer->import_formats[ $format ] ) ) {
563 - return new WP_Error( 'table_import_legacy_unknown_format', '', $file['name'] );
639 + return new WP_Error( 'table_import_legacy_unknown_format', '', $file->name );
564 640 }
565 641
566 642 $table = $this->importer->import_table( $format, $data );
567 643
568 644 if ( false === $table ) {
569 - return new WP_Error( 'table_import_legacy_importer_failed', '', array( 'file_name' => $file['name'], 'file_format' => $format ) );
645 + return new WP_Error( 'table_import_legacy_importer_failed', '', array( 'file_name' => $file->name, 'file_format' => $format ) );
570 646 }
571 647
572 648 return $table;
573 649 }
@@ -576,19 +652,14 @@
576 652 * Loads a table from a file via the PHPSpreadsheet import class.
577 653 *
578 654 * @since 2.0.0
579 655 *
580 - * @param array $file File with the table data.
581 - * @return array|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
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.
582 658 */
583 - protected function _load_table_from_file_phpspreadsheet( array $file ) {
584 - $table = $this->importer->import_table( $file );
585 -
586 - if ( is_wp_error( $table ) ) {
587 - return $table;
588 - }
589 -
590 - return $table;
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.)
591 662 }
592 663
593 664 /**
594 665 * Imports a loaded table into TablePress.
@@ -594,19 +665,19 @@
594 665 * Imports a loaded table into TablePress.
595 666 *
596 667 * @since 2.0.0
597 668 *
598 - * @param array $table The table to be imported, either with properties or just the $table['data'] property set.
599 - * @param array $file File with the table data.
600 - * @return array|WP_Error Imported table on success, WP_Error on failure.
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.
601 672 */
602 - protected function _import_table( array $table, array $file ) {
673 + protected function save_imported_table( array $table, File $file ) /* : array|WP_Error */ {
603 674 // If name and description are imported from a new table, use those.
604 675 if ( ! isset( $table['name'] ) ) {
605 - $table['name'] = $file['name'];
676 + $table['name'] = $file->name;
606 677 }
607 678 if ( ! isset( $table['description'] ) ) {
608 - $table['description'] = $file['name'];
679 + $table['description'] = $file->name;
609 680 }
610 681
611 682 $import_type = $this->import_config['type'];
612 683 $existing_table_id = $this->import_config['existing_table'];
@@ -615,11 +686,11 @@
615 686 if ( in_array( $import_type, array( 'replace', 'append' ), true ) && '' === $existing_table_id ) {
616 687 if ( isset( $table['id'] ) ) {
617 688 // If the table already contained a table ID (e.g. for the JSON format), use that.
618 689 $existing_table_id = $table['id'];
619 - } elseif ( isset( $this->table_names_ids[ $file['name'] ] ) && 1 === count( $this->table_names_ids[ $file['name'] ] ) ) {
690 + } elseif ( isset( $this->table_names_ids[ $file->name ] ) && 1 === count( $this->table_names_ids[ $file->name ] ) ) {
620 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.
621 - $existing_table_id = $this->table_names_ids[ $file['name'] ][0];
692 + $existing_table_id = $this->table_names_ids[ $file->name ][0];
622 693 }
623 694 }
624 695
625 696 // If the table that is to be replaced or appended to does not exist, add the new table instead.
@@ -627,9 +698,9 @@
627 698 $existing_table_id = '';
628 699 $import_type = 'add';
629 700 }
630 701
631 - $table = $this->_import_tablepress_table( $table, $import_type, $existing_table_id );
702 + $table = $this->import_tablepress_table( $table, $import_type, $existing_table_id );
632 703
633 704 return $table;
634 705 }
635 706
@@ -637,19 +708,20 @@
637 708 * Imports a table by either replacing or appending to an existing table or by adding it as a new table.
638 709 *
639 710 * @since 1.0.0
640 711 *
641 - * @param array $imported_table The table to be imported, either with properties or just the `name`, `description`, and `data` property set.
642 - * @param string $import_type What to do with the imported data: "add", "replace", "append".
643 - * @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.
644 - * @return array|WP_Error Table on success, WP_Error on error.
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.
645 716 */
646 - protected function _import_tablepress_table( array $imported_table, $import_type, $existing_table_id ) {
717 + protected function import_tablepress_table( array $imported_table, string $import_type, string $existing_table_id ) /* : array|WP_Error */ {
647 718 // Full JSON format table can contain a table ID, try to keep that, by later changing the imported table ID to this.
648 - $table_id_in_import = isset( $imported_table['id'] ) ? $imported_table['id'] : '';
719 + $table_id_in_import = $imported_table['id'] ?? '';
649 720
650 - // 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).
651 - if ( in_array( $import_type, array( 'replace', 'append' ), true ) && ! ( current_user_can( 'tablepress_edit_table', $existing_table_id ) || wp_doing_cron() ) ) {
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' ) ) ) {
652 724 return new WP_Error( 'table_import_replace_append_capability_check_failed', '', $existing_table_id );
653 725 }
654 726
655 727 switch ( $import_type ) {
@@ -663,11 +735,11 @@
663 735 case 'replace':
664 736 // Load table, without table data, but with options and visibility settings.
665 737 $existing_table = TablePress::$model_table->load( $existing_table_id, false, true );
666 738 if ( is_wp_error( $existing_table ) ) {
667 - // Add an error code to the existing WP_Error.
668 - $existing_table->add( 'table_import_replace_table_load', '', $existing_table_id );
669 - return $existing_table;
739 + $error = new WP_Error( 'table_import_replace_table_load', '', $existing_table_id );
740 + $error->merge_from( $existing_table );
741 + return $error;
670 742 }
671 743 // Don't change name and description when a table is replaced.
672 744 $imported_table['name'] = $existing_table['name'];
673 745 $imported_table['description'] = $existing_table['description'];
@@ -679,11 +751,11 @@
679 751 case 'append':
680 752 // Load table, with table data, options, and visibility settings.
681 753 $existing_table = TablePress::$model_table->load( $existing_table_id, true, true );
682 754 if ( is_wp_error( $existing_table ) ) {
683 - // Add an error code to the existing WP_Error.
684 - $existing_table->add( 'table_import_append_table_load', '', $existing_table_id );
685 - return $existing_table;
755 + $error = new WP_Error( 'table_import_append_table_load', '', $existing_table_id );
756 + $error->merge_from( $existing_table );
757 + return $error;
686 758 }
687 759 if ( isset( $existing_table['is_corrupted'] ) && $existing_table['is_corrupted'] ) {
688 760 return new WP_Error( 'table_import_append_table_load_corrupted', '', $existing_table_id );
689 761 }
@@ -716,11 +788,11 @@
716 788
717 789 // Check if the new table data is valid and consistent.
718 790 $table = TablePress::$model_table->prepare_table( $existing_table, $imported_table, false );
719 791 if ( is_wp_error( $table ) ) {
720 - // Add an error code to the existing WP_Error.
721 - $table->add( 'table_import_table_prepare', '', $imported_table['id'] );
722 - return $table;
792 + $error = new WP_Error( 'table_import_table_prepare', '', $imported_table['id'] );
793 + $error->merge_from( $table );
794 + return $error;
723 795 }
724 796
725 797 // DataTables Custom Commands can only be edit by trusted users.
726 798 if ( ! current_user_can( 'unfiltered_html' ) ) {
@@ -736,11 +808,11 @@
736 808 $table_id = TablePress::$model_table->add( $table );
737 809 }
738 810
739 811 if ( is_wp_error( $table_id ) ) {
740 - // Add an error code to the existing WP_Error.
741 - $table_id->add( 'table_import_table_save_or_add', '', $table['id'] );
742 - return $table_id;
812 + $error = new WP_Error( 'table_import_table_save_or_add', '', $table['id'] );
813 + $error->merge_from( $table_id );
814 + return $error;
743 815 }
744 816
745 817 // Try to use ID from imported file (e.g. in full JSON format table).
746 818 if ( '' !== $table_id_in_import && $table_id !== $table_id_in_import && current_user_can( 'tablepress_edit_table_id', $table_id ) ) {
@@ -764,11 +836,11 @@
764 836 * @deprecated 2.0.0 Use `run()` instead.
765 837 *
766 838 * @param string $format Import format.
767 839 * @param string $data Data to import.
768 - * @return array|false Table array on success, false on error.
840 + * @return array<string, mixed>|WP_Error|false Table array on success, WP_Error or false on error.
769 841 */
770 - public function import_table( $format, $data ) {
842 + public function import_table( string $format, string $data ) /* : array|false */ {
771 843 TablePress::load_file( 'class-import-base.php', 'classes' );
772 844 $importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
773 845 return $importer->import_table( $format, $data );
774 846 }