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 +317 -202 2.1.7 → 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,60 +27,48 @@
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 *
35 41 * @since 2.0.0
36 - * @var array
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 *
51 57 * @since 2.0.0
52 - * @var array
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 - * @param array $import_config Import configuration.
74 - * @return array|WP_Error List of imported tables on success, WP_Error on failure.
67 + * @param array<string, mixed> $import_config Import configuration.
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 - public function run( array $import_config ) {
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' );
79 73 if ( function_exists( 'set_time_limit' ) ) {
80 74 @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@@ -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|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() {
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 - @unlink( $this->import_config['file-upload']['tmp_name'][ $key ] );
118 - $file['error'] = new WP_Error( 'table_import_file-upload_error', '', $error );
111 + @unlink( $this->import_config['file-upload']['tmp_name'][ $key ] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
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,26 +162,26 @@
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'] );
174 175 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
175 - @unlink( $location );
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,77 +232,60 @@
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 $import_files Files that shall be imported, including ZIP archives.
199 - * @return array 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 ) {
202 - /*
203 - * Here, a for loop is used over a foreach loop, as the array is modified while being iterated over.
204 - * 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.
205 - * Once PHP 7.x is required, this can be adjusted again.
206 - */
207 - $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.
208 - for ( $key = 0; $key < $num_files; $key++ ) {
209 - $file = $import_files[ $key ];
239 + protected function convert_zip_files( array $import_files ): array {
240 + foreach ( $import_files as $key => &$file ) {
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()`.
210 242
211 - if ( isset( $file['error'] ) && is_wp_error( $file['error'] ) ) {
243 + // Skip files that already have an error.
244 + if ( is_wp_error( $file->error ) ) {
212 245 continue;
213 246 }
214 247
215 - $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 + }
216 253
217 254 if ( function_exists( 'mime_content_type' ) ) {
218 - $file['mime_type'] = mime_content_type( $file['location'] );
219 - if ( false === $file['mime_type'] ) {
220 - $file['mime_type'] = '';
255 + $mime_type = mime_content_type( $file->location );
256 + if ( false !== $mime_type ) {
257 + $file->mime_type = $mime_type;
221 258 }
222 - } else {
223 - $file['mime_type'] = '';
224 259 }
225 260
226 261 // Detect ZIP files from their file extension or MIME type.
227 - if ( 'zip' === $file['extension'] || 'application/zip' === $file['mime_type'] ) {
228 - if ( ! $this->zip_support_available ) {
229 - $file['error'] = new WP_Error( 'table_import_no_zip_support', '', $file['name'] );
230 - $this->_maybe_unlink_file( $file );
231 - continue;
232 - }
233 -
234 - $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 );
235 264 if ( is_wp_error( $extracted_files ) ) {
236 - $file['error'] = $extracted_files->get_error_code();
237 - $this->_maybe_unlink_file( $file );
265 + $file->error = $extracted_files;
266 + $this->maybe_unlink_file( $file );
238 267 continue;
239 268 }
240 269
241 270 if ( empty( $extracted_files ) ) {
242 - $file['error'] = new WP_Error( 'table_import_zip_file_empty', '', $file['name'] );
243 - $this->_maybe_unlink_file( $file );
271 + $file->error = new WP_Error( 'table_import_zip_file_empty', '', $file->name );
272 + $this->maybe_unlink_file( $file );
244 273 continue;
245 274 }
246 275
247 - $this->_maybe_unlink_file( $file );
248 -
249 - // Mark the ZIP file as removed from the list (null), append its contents to the end, and increase the number of files counter.
250 - $file = null;
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 + */
280 + unset( $import_files[ $key ] );
251 281 array_push( $import_files, ...$extracted_files );
252 - $num_files += count( $extracted_files );
253 282
283 + $this->maybe_unlink_file( $file );
254 284 }
255 -
256 - $import_files[ $key ] = $file;
257 285 }
286 + unset( $file ); // Unset use-by-reference parameter of foreach loop.
258 287
259 - // Actually remove files that are marked as removed (null).
260 - $import_files = array_filter(
261 - $import_files,
262 - static function( $file ) {
263 - return ! is_null( $file );
264 - }
265 - );
266 -
267 288 $import_files = array_merge( $import_files ); // Re-index.
268 289
269 290 return $import_files;
270 291 }
@@ -269,61 +290,95 @@
269 290 return $import_files;
270 291 }
271 292
272 293 /**
273 - * 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.
274 295 *
296 + * Depending on availability, either the PHP's ZipArchive class or WordPress' PclZip class is used.
297 + *
275 298 * @since 2.0.0
276 299 *
277 - * @param array $zip_file File data of a ZIP file (likely in a temporary folder).
278 - * @return array|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.
279 302 */
280 - protected function _extract_zip_file( array $zip_file ) {
281 - $zip = new ZipArchive();
282 - $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 + }
283 312
284 - // If the ZIP file can't be opened with ZIPARCHIVE::CHECKCONS, try again without.
285 - if ( true !== $zip_opened ) {
286 - $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 );
287 318 }
288 319
289 - // If the ZIP file can't even be opened without ZIPARCHIVE::CHECKCONS, bail.
290 - if ( true !== $zip_opened ) {
291 - 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 );
292 340 }
293 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 +
294 347 $files = array();
295 348
296 349 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
297 - for ( $file_idx = 0; $file_idx < $zip->numFiles; $file_idx++ ) {
298 - $file_name = $zip->getNameIndex( $file_idx );
350 + for ( $file_idx = 0; $file_idx < $archive->numFiles; $file_idx++ ) {
351 + $file_name = $archive->getNameIndex( $file_idx );
299 352
300 353 if ( false === $file_name ) {
301 - $files[] = array(
302 - 'location' => '',
303 - 'name' => '',
304 - 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
305 - );
354 + $files[] = new File( array(
355 + 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
356 + ) );
306 357 continue;
307 358 }
308 359
309 360 // Skip directories.
310 - if ( '/' === substr( $file_name, -1 ) ) {
361 + if ( str_ends_with( $file_name, '/' ) ) {
311 362 continue;
312 363 }
313 364
314 365 // Skip the __MACOSX directory that macOS adds to archives.
315 - if ( '__MACOSX/' === substr( $file_name, 0, 9 ) ) {
366 + if ( str_starts_with( $file_name, '__MACOSX/' ) ) {
316 367 continue;
317 368 }
318 369
319 - $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 );
320 376 if ( false === $file_data ) {
321 - $files[] = array(
322 - 'location' => '',
323 - 'name' => $file_name,
324 - 'error' => new WP_Error( 'table_import_error_zip_get_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
325 - );
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 + ) );
326 381 continue;
327 382 }
328 383
329 384 $location = wp_tempnam();
@@ -328,38 +383,100 @@
328 383
329 384 $location = wp_tempnam();
330 385 $num_written_bytes = file_put_contents( $location, $file_data );
331 386 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
332 - @unlink( $location );
333 - $files[] = array(
334 - 'location' => '',
335 - 'name' => $file_name,
336 - 'error' => new WP_Error( 'table_import_error_zip_write_temp_data', '', array( 'ziparchive_file_index' => $file_idx, 'ziparchive_file_name' => $file_name ) ),
337 - );
387 + @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
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 + ) );
338 392 continue;
339 393 }
340 394
341 - $files[] = array(
395 + $files[] = new File( array(
342 396 'location' => $location,
343 397 'name' => $file_name,
344 - );
398 + ) );
345 399 }
346 400
347 - $zip->close();
401 + $archive->close();
348 402
349 403 return $files;
350 404 }
351 405
352 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 + /**
353 470 * Deletes a file unless the `keep_file` property is set to `true`.
354 471 *
355 472 * @since 2.0.0
356 473 *
357 - * @param array $file File that should maybe be deleted.
474 + * @param File $file File that should maybe be deleted.
358 475 */
359 - protected function _maybe_unlink_file( array $file ) {
360 - if ( ! ( isset( $file['keep_file'] ) && $file['keep_file'] ) && file_exists( $file['location'] ) ) {
361 - @unlink( $file['location'] );
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
362 479 }
363 480 }
364 481
365 482 /**
@@ -366,11 +483,11 @@
366 483 * Prepares a list of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
367 484 *
368 485 * @since 2.0.0
369 486 *
370 - * @return array List of table names and IDs.
487 + * @return array<string, string[]> List of table names and IDs.
371 488 */
372 - protected function _get_list_of_table_names() {
489 + protected function get_list_of_table_names(): array {
373 490 $existing_tables = array();
374 491 // Load all table IDs and names for a comparison with the file name.
375 492 $table_ids = TablePress::$model_table->load_all( false );
376 493 foreach ( $table_ids as $table_id ) {
@@ -376,9 +493,9 @@
376 493 foreach ( $table_ids as $table_id ) {
377 494 // Load table, without table data, options, and visibility settings.
378 495 $table = TablePress::$model_table->load( $table_id, false, false );
379 496 if ( ! is_wp_error( $table ) ) {
380 - $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!
381 498 }
382 499 }
383 500 return $existing_tables;
384 501 }
@@ -389,9 +506,9 @@
389 506 * @since 2.0.0
390 507 *
391 508 * @return bool Whether the legacy import class should be used.
392 509 */
393 - protected function _should_use_legacy_import_class() {
510 + protected function should_use_legacy_import_class(): bool {
394 511 // Allow overriding in the import config (coming e.g. from the import form UI).
395 512 if ( $this->import_config['legacy_import'] ) {
396 513 return true;
397 514 }
@@ -400,9 +517,9 @@
400 517 * Filters whether the Legacy Table Import class shall be used.
401 518 *
402 519 * @since 2.0.0
403 520 *
404 - * @param bool Whether to use the legacy table import class. Default false.
521 + * @param bool $use_legacy_class Whether to use the legacy table import class. Default false.
405 522 */
406 523 if ( apply_filters( 'tablepress_use_legacy_table_import_class', false ) ) {
407 524 return true;
408 525 }
@@ -407,23 +524,17 @@
407 524 return true;
408 525 }
409 526
410 527 // Use the legacy import class, if the requirements for PHPSpreadsheet are not fulfilled.
411 - $phpspreadsheet_requirements_fulfilled = PHP_VERSION_ID >= 70200
412 - && extension_loaded( 'mbstring' )
528 + $phpspreadsheet_requirements_fulfilled = extension_loaded( 'mbstring' )
413 529 && class_exists( 'ZipArchive', false )
414 530 && class_exists( 'DOMDocument', false )
415 531 && function_exists( 'simplexml_load_string' )
416 - && 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.
417 533 if ( ! $phpspreadsheet_requirements_fulfilled ) {
418 534 return true;
419 535 }
420 536
421 - // Use the legacy import class, if the PHPSpreadsheet files do not exist (e.g. because `composer install` was not run).
422 - if ( ! file_exists( TABLEPRESS_ABSPATH . 'libraries/autoload.php' ) ) {
423 - return true;
424 - }
425 -
426 537 return false;
427 538 }
428 539
429 540 /**
@@ -430,16 +541,16 @@
430 541 * Imports all found/extracted/configured files into TablePress.
431 542 *
432 543 * @since 2.0.0
433 544 *
434 - * @param array $import_files Files that shall be imported.
435 - * @return array 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.
436 547 */
437 - protected function _import_files( array $import_files ) {
548 + protected function import_files( array $import_files ): array {
438 549 $tables = array();
439 550 $errors = array();
440 551
441 - $use_legacy_import_class = $this->_should_use_legacy_import_class();
552 + $use_legacy_import_class = $this->should_use_legacy_import_class();
442 553
443 554 // Load Import Base Class.
444 555 TablePress::load_file( 'class-import-base.php', 'classes' );
445 556
@@ -444,10 +555,12 @@
444 555 TablePress::load_file( 'class-import-base.php', 'classes' );
445 556
446 557 // Choose the Table Import library based on the PHP version and the filter hook value.
447 558 if ( $use_legacy_import_class ) {
559 + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
448 560 $this->importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
449 561 } else {
562 + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
450 563 $this->importer = TablePress::load_class( 'TablePress_Import_PHPSpreadsheet', 'class-import-phpspreadsheet.php', 'classes' );
451 564 }
452 565
453 566 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
@@ -453,9 +566,9 @@
453 566 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
454 567 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) && '' !== $this->import_config['existing_table'] ) {
455 568 $valid_import_files = 0;
456 569 foreach ( $import_files as $file ) {
457 - if ( ! isset( $file['error'] ) || ! is_wp_error( $file['error'] ) ) {
570 + if ( ! is_wp_error( $file->error ) ) {
458 571 ++$valid_import_files;
459 572 if ( $valid_import_files > 1 ) {
460 573 $this->import_config['existing_table'] = '';
461 574 break;
@@ -465,9 +578,9 @@
465 578 }
466 579
467 580 // Loop through all import files and import them.
468 581 foreach ( $import_files as $file ) {
469 - if ( isset( $file['error'] ) && is_wp_error( $file['error'] ) ) {
582 + if ( is_wp_error( $file->error ) ) {
470 583 $errors[] = $file;
471 584 continue;
472 585 }
473 586
@@ -472,24 +585,24 @@
472 585 }
473 586
474 587 // Use import method depending on chosen import class.
475 588 if ( $use_legacy_import_class ) {
476 - $table = $this->_load_table_from_file_legacy( $file );
589 + $table = $this->load_table_from_file_legacy( $file );
477 590 } else {
478 - $table = $this->_load_table_from_file_phpspreadsheet( $file );
591 + $table = $this->load_table_from_file_phpspreadsheet( $file );
479 592 }
480 593
481 - $this->_maybe_unlink_file( $file );
594 + $this->maybe_unlink_file( $file );
482 595
483 596 if ( is_wp_error( $table ) ) {
484 - $file['error'] = $table;
597 + $file->error = $table;
485 598 $errors[] = $file;
486 599 continue;
487 600 }
488 601
489 - $table = $this->_import_table( $table, $file );
602 + $table = $this->save_imported_table( $table, $file );
490 603 if ( is_wp_error( $table ) ) {
491 - $file['error'] = $table;
604 + $file->error = $table;
492 605 $errors[] = $file;
493 606 continue;
494 607 }
495 608
@@ -506,14 +619,14 @@
506 619 * Loads a table from a file via the legacy import class.
507 620 *
508 621 * @since 2.0.0
509 622 *
510 - * @param array $file File with the table data.
511 - * @return array|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
623 + * @param File $file File with the table data.
624 + * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
512 625 */
513 - protected function _load_table_from_file_legacy( array $file ) {
626 + protected function load_table_from_file_legacy( File $file ) /* : array|WP_Error */ {
514 627 // Guess the import format from the file extension.
515 - switch ( $file['extension'] ) {
628 + switch ( $file->extension ) {
516 629 case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet.
517 630 case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded).
518 631 case 'xltx': // Excel (OfficeOpenXML) Template.
519 632 case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded).
@@ -534,27 +647,33 @@
534 647 case 'json':
535 648 $format = 'json';
536 649 break;
537 650 default:
538 - // If no format was found, pass the extension (which will likely result in an error).
539 - $format = $file['extension'];
651 + // If no format was found, try finding the format from the first character below.
652 + $format = '';
540 653 }
541 654
542 - $data = file_get_contents( $file['location'] );
655 + $data = file_get_contents( $file->location );
543 656 if ( false === $data ) {
544 - return new WP_Error( 'table_import_legacy_data_read', '', $file['location'] );
657 + return new WP_Error( 'table_import_legacy_data_read', '', $file->location );
545 658 }
546 659 if ( '' === $data ) {
547 - return new WP_Error( 'table_import_legacy_data_empty', '', $file['location'] );
660 + return new WP_Error( 'table_import_legacy_data_empty', '', $file->location );
548 661 }
549 662
550 663 // If no format could be determined from the file extension, try guessing from the file content.
551 664 if ( '' === $format ) {
665 + $data = trim( $data );
552 666 $first_character = $data[0];
553 - if ( '<' === $first_character ) {
667 + $last_character = $data[-1];
668 +
669 + if ( '<' === $first_character && '>' === $last_character ) {
554 670 $format = 'html';
555 - } elseif ( '{' === $first_character || '[' === $first_character ) {
556 - $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 + }
557 676 }
558 677 }
559 678
560 679 // Fall back to CSV if no file format could be determined.
@@ -561,16 +680,16 @@
561 680 if ( '' === $format ) {
562 681 $format = 'csv';
563 682 }
564 683
565 - if ( ! isset( $this->importer->import_formats[ $format ] ) ) {
566 - 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 );
567 686 }
568 687
569 688 $table = $this->importer->import_table( $format, $data );
570 689
571 690 if ( false === $table ) {
572 - 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 ) );
573 692 }
574 693
575 694 return $table;
576 695 }
@@ -579,19 +698,14 @@
579 698 * Loads a table from a file via the PHPSpreadsheet import class.
580 699 *
581 700 * @since 2.0.0
582 701 *
583 - * @param array $file File with the table data.
584 - * @return array|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
702 + * @param File $file File with the table data.
703 + * @return array<string, mixed>|WP_Error Loaded table on success (either with all properties or just 'data'), WP_Error on failure.
585 704 */
586 - protected function _load_table_from_file_phpspreadsheet( array $file ) {
587 - $table = $this->importer->import_table( $file );
588 -
589 - if ( is_wp_error( $table ) ) {
590 - return $table;
591 - }
592 -
593 - return $table;
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.)
594 708 }
595 709
596 710 /**
597 711 * Imports a loaded table into TablePress.
@@ -597,19 +711,19 @@
597 711 * Imports a loaded table into TablePress.
598 712 *
599 713 * @since 2.0.0
600 714 *
601 - * @param array $table The table to be imported, either with properties or just the $table['data'] property set.
602 - * @param array $file File with the table data.
603 - * @return array|WP_Error Imported table on success, WP_Error on failure.
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.
717 + * @return array<string, mixed>|WP_Error Imported table on success, WP_Error on failure.
604 718 */
605 - protected function _import_table( array $table, array $file ) {
719 + protected function save_imported_table( array $table, File $file ) /* : array|WP_Error */ {
606 720 // If name and description are imported from a new table, use those.
607 721 if ( ! isset( $table['name'] ) ) {
608 - $table['name'] = $file['name'];
722 + $table['name'] = $file->name;
609 723 }
610 724 if ( ! isset( $table['description'] ) ) {
611 - $table['description'] = $file['name'];
725 + $table['description'] = $file->name;
612 726 }
613 727
614 728 $import_type = $this->import_config['type'];
615 729 $existing_table_id = $this->import_config['existing_table'];
@@ -618,11 +732,11 @@
618 732 if ( in_array( $import_type, array( 'replace', 'append' ), true ) && '' === $existing_table_id ) {
619 733 if ( isset( $table['id'] ) ) {
620 734 // If the table already contained a table ID (e.g. for the JSON format), use that.
621 735 $existing_table_id = $table['id'];
622 - } elseif ( isset( $this->table_names_ids[ $file['name'] ] ) && 1 === count( $this->table_names_ids[ $file['name'] ] ) ) {
736 + } elseif ( isset( $this->table_names_ids[ $file->name ] ) && 1 === count( $this->table_names_ids[ $file->name ] ) ) {
623 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.
624 - $existing_table_id = $this->table_names_ids[ $file['name'] ][0];
738 + $existing_table_id = $this->table_names_ids[ $file->name ][0];
625 739 }
626 740 }
627 741
628 742 // If the table that is to be replaced or appended to does not exist, add the new table instead.
@@ -630,9 +744,9 @@
630 744 $existing_table_id = '';
631 745 $import_type = 'add';
632 746 }
633 747
634 - $table = $this->_import_tablepress_table( $table, $import_type, $existing_table_id );
748 + $table = $this->import_tablepress_table( $table, $import_type, $existing_table_id );
635 749
636 750 return $table;
637 751 }
638 752
@@ -640,19 +754,20 @@
640 754 * Imports a table by either replacing or appending to an existing table or by adding it as a new table.
641 755 *
642 756 * @since 1.0.0
643 757 *
644 - * @param array $imported_table The table to be imported, either with properties or just the `name`, `description`, and `data` property set.
645 - * @param string $import_type What to do with the imported data: "add", "replace", "append".
646 - * @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.
647 - * @return array|WP_Error Table on success, WP_Error on error.
758 + * @param array<string, mixed> $imported_table The table to be imported, either with properties or just the `name`, `description`, and `data` property set.
759 + * @param string $import_type What to do with the imported data: "add", "replace", "append".
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.
761 + * @return array<string, mixed>|WP_Error Table on success, WP_Error on error.
648 762 */
649 - protected function _import_tablepress_table( array $imported_table, $import_type, $existing_table_id ) {
763 + protected function import_tablepress_table( array $imported_table, string $import_type, string $existing_table_id ) /* : array|WP_Error */ {
650 764 // Full JSON format table can contain a table ID, try to keep that, by later changing the imported table ID to this.
651 - $table_id_in_import = isset( $imported_table['id'] ) ? $imported_table['id'] : '';
765 + $table_id_in_import = $imported_table['id'] ?? '';
652 766
653 - // 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).
654 - 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' ) ) ) {
655 770 return new WP_Error( 'table_import_replace_append_capability_check_failed', '', $existing_table_id );
656 771 }
657 772
658 773 switch ( $import_type ) {
@@ -767,11 +882,11 @@
767 882 * @deprecated 2.0.0 Use `run()` instead.
768 883 *
769 884 * @param string $format Import format.
770 885 * @param string $data Data to import.
771 - * @return array|false Table array on success, false on error.
886 + * @return array<string, mixed>|WP_Error|false Table array on success, WP_Error or false on error.
772 887 */
773 - public function import_table( $format, $data ) {
888 + public function import_table( string $format, string $data ) /* : array|false */ {
774 889 TablePress::load_file( 'class-import-base.php', 'classes' );
775 890 $importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
776 891 return $importer->import_table( $format, $data );
777 892 }