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 +337 -219 2.0.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,79 +27,68 @@
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 ) {
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 */
70 + public function run( array $import_config ) /* : array|WP_Error */ {
71 + // Unziping can use a lot of memory and execution time, but not this much hopefully.
79 72 wp_raise_memory_limit( 'admin' );
80 - set_time_limit( 300 );
73 + if ( function_exists( 'set_time_limit' ) ) {
74 + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
75 + }
81 76
82 77 $this->import_config = $import_config;
83 78
84 - $import_files = $this->_get_import_files();
79 + $import_files = $this->get_files_to_import();
85 80 if ( is_wp_error( $import_files ) ) {
86 81 return $import_files;
87 82 }
88 83
84 + $import_files = $this->convert_zip_files( $import_files );
85 +
89 86 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) ) {
90 - $this->table_names_ids = $this->_get_list_of_table_names();
87 + $this->table_names_ids = $this->get_list_of_table_names();
91 88 }
92 89
93 - $import_files = $this->_convert_zip_files( $import_files );
94 -
95 - return $this->_import_files( $import_files );
90 + return $this->import_files( $import_files );
96 91 }
97 92
98 93 /**
99 94 * Extracts the files that shall be imported from the import configuration.
@@ -99,23 +94,23 @@
99 94 * Extracts the files that shall be imported from the import configuration.
100 95 *
101 96 * @since 2.0.0
102 97 *
103 - * @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.
104 99 */
105 - protected function _get_import_files() {
100 + protected function get_files_to_import() /* : array|WP_Error */ {
106 101 $import_files = array();
107 102
108 103 switch ( $this->import_config['source'] ) {
109 104 case 'file-upload':
110 105 foreach ( $this->import_config['file-upload']['error'] as $key => $error ) {
111 - $file = array(
106 + $file = new File( array(
112 107 'location' => $this->import_config['file-upload']['tmp_name'][ $key ],
113 108 'name' => $this->import_config['file-upload']['name'][ $key ],
114 - );
109 + ) );
115 110 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 );
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 );
118 113 }
119 114 $import_files[] = $file;
120 115 }
121 116 break;
@@ -125,16 +120,23 @@
125 120 if ( empty( $host ) ) {
126 121 return new WP_Error( 'table_import_url_host_invalid', '', $this->import_config['url'] );
127 122 }
128 123
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.
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.
132 131 );
133 - if ( in_array( $host, $blocked_hosts, true ) ) {
134 - 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 ) );
135 134 }
136 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 +
137 139 /**
138 140 * Load WP file functions to be sure that `download_url()` exists, in particular during Cron requests.
139 141 */
140 142 require_once ABSPATH . 'wp-admin/includes/file.php';
@@ -141,15 +143,17 @@
141 143
142 144 // Download URL to local file.
143 145 $location = download_url( $this->import_config['url'] );
144 146 if ( is_wp_error( $location ) ) {
145 - return new WP_Error( 'table_import_url_download_failed', '', $this->import_config['url'] );
147 + $error = new WP_Error( 'table_import_url_download_failed', '', $this->import_config['url'] );
148 + $error->merge_from( $location );
149 + return $error;
146 150 }
147 151
148 - $import_files[] = array(
152 + $import_files[] = new File( array(
149 153 'location' => $location,
150 154 'name' => $this->import_config['url'],
151 - );
155 + ) );
152 156 break;
153 157 case 'server':
154 158 if ( ABSPATH === $this->import_config['server'] ) {
155 159 return new WP_Error( 'table_import_server_invalid', '', $this->import_config['server'] );
@@ -158,26 +162,26 @@
158 162 if ( ! is_readable( $this->import_config['server'] ) ) {
159 163 return new WP_Error( 'table_import_server_not_readable', '', $this->import_config['server'] );
160 164 }
161 165
162 - $import_files[] = array(
166 + $import_files[] = new File( array(
163 167 'location' => $this->import_config['server'],
164 168 'name' => pathinfo( $this->import_config['server'], PATHINFO_BASENAME ),
165 169 'keep_file' => true, // Files on the server must not be deleted.
166 - );
170 + ) );
167 171 break;
168 172 case 'form-field':
169 173 $location = wp_tempnam();
170 174 $num_written_bytes = file_put_contents( $location, $this->import_config['form-field'] );
171 175 if ( false === $num_written_bytes || 0 === $num_written_bytes ) {
172 - @unlink( $location );
176 + @unlink( $location ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
173 177 return new WP_Error( 'table_import_form-field_temp_file_not_written' );
174 178 }
175 179
176 - $import_files[] = array(
180 + $import_files[] = new File( array(
177 181 'location' => $location,
178 182 'name' => __( 'Imported from Manual Input', 'tablepress' ),
179 - );
183 + ) );
180 184 break;
181 185 default:
182 186 return new WP_Error( 'table_import_invalid_source', '', $this->import_config['source'] );
183 187 }
@@ -185,8 +189,45 @@
185 189 return $import_files;
186 190 }
187 191
188 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 + /**
189 230 * Replaces ZIP archives in the import files with a list of their contents.
190 231 *
191 232 * ZIP files are removed from the list and their contents are added to the end of the list.
192 233 *
@@ -191,77 +232,60 @@
191 232 * ZIP files are removed from the list and their contents are added to the end of the list.
192 233 *
193 234 * @since 2.0.0
194 235 *
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.
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.
197 238 */
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 ];
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()`.
207 242
208 - if ( isset( $file['error'] ) && is_wp_error( $file['error'] ) ) {
243 + // Skip files that already have an error.
244 + if ( is_wp_error( $file->error ) ) {
209 245 continue;
210 246 }
211 247
212 - $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 + }
213 253
214 254 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'] = '';
255 + $mime_type = mime_content_type( $file->location );
256 + if ( false !== $mime_type ) {
257 + $file->mime_type = $mime_type;
218 258 }
219 - } else {
220 - $file['mime_type'] = '';
221 259 }
222 260
223 261 // 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 );
262 + if ( 'zip' === $file->extension || 'application/zip' === $file->mime_type ) {
263 + $extracted_files = $this->extract_zip_file( $file );
232 264 if ( is_wp_error( $extracted_files ) ) {
233 - $file['error'] = $extracted_files->get_error_code();
234 - $this->_maybe_unlink_file( $file );
265 + $file->error = $extracted_files;
266 + $this->maybe_unlink_file( $file );
235 267 continue;
236 268 }
237 269
238 270 if ( empty( $extracted_files ) ) {
239 - $file['error'] = new WP_Error( 'table_import_zip_file_empty', '', $file['name'] );
240 - $this->_maybe_unlink_file( $file );
271 + $file->error = new WP_Error( 'table_import_zip_file_empty', '', $file->name );
272 + $this->maybe_unlink_file( $file );
241 273 continue;
242 274 }
243 275
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;
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 ] );
248 281 array_push( $import_files, ...$extracted_files );
249 - $num_files += count( $extracted_files );
250 282
283 + $this->maybe_unlink_file( $file );
251 284 }
252 -
253 - $import_files[ $key ] = $file;
254 285 }
286 + unset( $file ); // Unset use-by-reference parameter of foreach loop.
255 287
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 288 $import_files = array_merge( $import_files ); // Re-index.
265 289
266 290 return $import_files;
267 291 }
@@ -266,61 +290,95 @@
266 290 return $import_files;
267 291 }
268 292
269 293 /**
270 - * 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.
271 295 *
296 + * Depending on availability, either the PHP's ZipArchive class or WordPress' PclZip class is used.
297 + *
272 298 * @since 2.0.0
273 299 *
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.
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.
276 302 */
277 - protected function _extract_zip_file( array $zip_file ) {
278 - $zip = new ZipArchive();
279 - $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 + }
280 312
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'] );
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 );
284 318 }
285 319
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 ) );
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 );
289 340 }
290 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 +
291 347 $files = array();
292 348
293 349 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
294 - for ( $file_idx = 0; $file_idx < $zip->numFiles; $file_idx++ ) {
295 - $file_name = $zip->getNameIndex( $file_idx );
350 + for ( $file_idx = 0; $file_idx < $archive->numFiles; $file_idx++ ) {
351 + $file_name = $archive->getNameIndex( $file_idx );
296 352
297 353 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 - );
354 + $files[] = new File( array(
355 + 'error' => new WP_Error( 'table_import_error_zip_stat', '', array( 'ziparchive_file_index' => $file_idx ) ),
356 + ) );
303 357 continue;
304 358 }
305 359
306 360 // Skip directories.
307 - if ( '/' === substr( $file_name, -1 ) ) {
361 + if ( str_ends_with( $file_name, '/' ) ) {
308 362 continue;
309 363 }
310 364
311 365 // Skip the __MACOSX directory that macOS adds to archives.
312 - if ( '__MACOSX/' === substr( $file_name, 0, 9 ) ) {
366 + if ( str_starts_with( $file_name, '__MACOSX/' ) ) {
313 367 continue;
314 368 }
315 369
316 - $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 );
317 376 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 - );
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 + ) );
323 381 continue;
324 382 }
325 383
326 384 $location = wp_tempnam();
@@ -325,38 +383,100 @@
325 383
326 384 $location = wp_tempnam();
327 385 $num_written_bytes = file_put_contents( $location, $file_data );
328 386 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 - );
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 + ) );
335 392 continue;
336 393 }
337 394
338 - $files[] = array(
395 + $files[] = new File( array(
339 396 'location' => $location,
340 397 'name' => $file_name,
341 - );
398 + ) );
342 399 }
343 400
344 - $zip->close();
401 + $archive->close();
345 402
346 403 return $files;
347 404 }
348 405
349 406 /**
350 - * Deletes a file unless the `keep_file` property is set to false.
407 + * Extracts the files of a ZIP file using WordPress' PclZip class.
351 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 + /**
470 + * Deletes a file unless the `keep_file` property is set to `true`.
471 + *
352 472 * @since 2.0.0
353 473 *
354 - * @param array $file File that should maybe be deleted.
474 + * @param File $file File that should maybe be deleted.
355 475 */
356 - protected function _maybe_unlink_file( array $file ) {
357 - if ( ! isset( $file['keep_file'] ) || ! $file['keep_file'] ) {
358 - @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
359 479 }
360 480 }
361 481
362 482 /**
@@ -363,11 +483,11 @@
363 483 * Prepares a list of table names/IDs for use when replacing/appending existing tables (except for the JSON format).
364 484 *
365 485 * @since 2.0.0
366 486 *
367 - * @return array List of table names and IDs.
487 + * @return array<string, string[]> List of table names and IDs.
368 488 */
369 - protected function _get_list_of_table_names() {
489 + protected function get_list_of_table_names(): array {
370 490 $existing_tables = array();
371 491 // Load all table IDs and names for a comparison with the file name.
372 492 $table_ids = TablePress::$model_table->load_all( false );
373 493 foreach ( $table_ids as $table_id ) {
@@ -373,9 +493,9 @@
373 493 foreach ( $table_ids as $table_id ) {
374 494 // Load table, without table data, options, and visibility settings.
375 495 $table = TablePress::$model_table->load( $table_id, false, false );
376 496 if ( ! is_wp_error( $table ) ) {
377 - $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!
378 498 }
379 499 }
380 500 return $existing_tables;
381 501 }
@@ -386,9 +506,9 @@
386 506 * @since 2.0.0
387 507 *
388 508 * @return bool Whether the legacy import class should be used.
389 509 */
390 - protected function _should_use_legacy_import_class() {
510 + protected function should_use_legacy_import_class(): bool {
391 511 // Allow overriding in the import config (coming e.g. from the import form UI).
392 512 if ( $this->import_config['legacy_import'] ) {
393 513 return true;
394 514 }
@@ -397,9 +517,9 @@
397 517 * Filters whether the Legacy Table Import class shall be used.
398 518 *
399 519 * @since 2.0.0
400 520 *
401 - * @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.
402 522 */
403 523 if ( apply_filters( 'tablepress_use_legacy_table_import_class', false ) ) {
404 524 return true;
405 525 }
@@ -404,23 +524,17 @@
404 524 return true;
405 525 }
406 526
407 527 // 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' )
528 + $phpspreadsheet_requirements_fulfilled = extension_loaded( 'mbstring' )
410 529 && class_exists( 'ZipArchive', false )
411 530 && class_exists( 'DOMDocument', false )
412 531 && function_exists( 'simplexml_load_string' )
413 - && 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.
414 533 if ( ! $phpspreadsheet_requirements_fulfilled ) {
415 534 return true;
416 535 }
417 536
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 537 return false;
424 538 }
425 539
426 540 /**
@@ -427,16 +541,16 @@
427 541 * Imports all found/extracted/configured files into TablePress.
428 542 *
429 543 * @since 2.0.0
430 544 *
431 - * @param array $import_files Files that shall be imported.
432 - * @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.
433 547 */
434 - protected function _import_files( array $import_files ) {
548 + protected function import_files( array $import_files ): array {
435 549 $tables = array();
436 550 $errors = array();
437 551
438 - $use_legacy_import_class = $this->_should_use_legacy_import_class();
552 + $use_legacy_import_class = $this->should_use_legacy_import_class();
439 553
440 554 // Load Import Base Class.
441 555 TablePress::load_file( 'class-import-base.php', 'classes' );
442 556
@@ -441,10 +555,12 @@
441 555 TablePress::load_file( 'class-import-base.php', 'classes' );
442 556
443 557 // Choose the Table Import library based on the PHP version and the filter hook value.
444 558 if ( $use_legacy_import_class ) {
559 + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
445 560 $this->importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
446 561 } else {
562 + // @phpstan-ignore assign.propertyType (The `load_class()` method returns `object` and not a specific type.)
447 563 $this->importer = TablePress::load_class( 'TablePress_Import_PHPSpreadsheet', 'class-import-phpspreadsheet.php', 'classes' );
448 564 }
449 565
450 566 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
@@ -450,9 +566,9 @@
450 566 // If there is more than one valid import file, ignore the chosen existing table for replacing/appending.
451 567 if ( in_array( $this->import_config['type'], array( 'replace', 'append' ), true ) && '' !== $this->import_config['existing_table'] ) {
452 568 $valid_import_files = 0;
453 569 foreach ( $import_files as $file ) {
454 - if ( ! isset( $file['error'] ) || ! is_wp_error( $file['error'] ) ) {
570 + if ( ! is_wp_error( $file->error ) ) {
455 571 ++$valid_import_files;
456 572 if ( $valid_import_files > 1 ) {
457 573 $this->import_config['existing_table'] = '';
458 574 break;
@@ -462,9 +578,9 @@
462 578 }
463 579
464 580 // Loop through all import files and import them.
465 581 foreach ( $import_files as $file ) {
466 - if ( isset( $file['error'] ) && is_wp_error( $file['error'] ) ) {
582 + if ( is_wp_error( $file->error ) ) {
467 583 $errors[] = $file;
468 584 continue;
469 585 }
470 586
@@ -469,24 +585,24 @@
469 585 }
470 586
471 587 // Use import method depending on chosen import class.
472 588 if ( $use_legacy_import_class ) {
473 - $table = $this->_load_table_from_file_legacy( $file );
589 + $table = $this->load_table_from_file_legacy( $file );
474 590 } else {
475 - $table = $this->_load_table_from_file_phpspreadsheet( $file );
591 + $table = $this->load_table_from_file_phpspreadsheet( $file );
476 592 }
477 593
478 - $this->_maybe_unlink_file( $file );
594 + $this->maybe_unlink_file( $file );
479 595
480 596 if ( is_wp_error( $table ) ) {
481 - $file['error'] = $table;
597 + $file->error = $table;
482 598 $errors[] = $file;
483 599 continue;
484 600 }
485 601
486 - $table = $this->_import_table( $table, $file );
602 + $table = $this->save_imported_table( $table, $file );
487 603 if ( is_wp_error( $table ) ) {
488 - $file['error'] = $table;
604 + $file->error = $table;
489 605 $errors[] = $file;
490 606 continue;
491 607 }
492 608
@@ -503,14 +619,14 @@
503 619 * Loads a table from a file via the legacy import class.
504 620 *
505 621 * @since 2.0.0
506 622 *
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.
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.
509 625 */
510 - protected function _load_table_from_file_legacy( array $file ) {
626 + protected function load_table_from_file_legacy( File $file ) /* : array|WP_Error */ {
511 627 // Guess the import format from the file extension.
512 - switch ( $file['extension'] ) {
628 + switch ( $file->extension ) {
513 629 case 'xlsx': // Excel (OfficeOpenXML) Spreadsheet.
514 630 case 'xlsm': // Excel (OfficeOpenXML) Macro Spreadsheet (macros will be discarded).
515 631 case 'xltx': // Excel (OfficeOpenXML) Template.
516 632 case 'xltm': // Excel (OfficeOpenXML) Macro Template (macros will be discarded).
@@ -531,27 +647,33 @@
531 647 case 'json':
532 648 $format = 'json';
533 649 break;
534 650 default:
535 - // If no format was found, pass the extension (which will likely result in an error).
536 - $format = $file['extension'];
651 + // If no format was found, try finding the format from the first character below.
652 + $format = '';
537 653 }
538 654
539 - $data = file_get_contents( $file['location'] );
655 + $data = file_get_contents( $file->location );
540 656 if ( false === $data ) {
541 - return new WP_Error( 'table_import_legacy_data_read', '', $file['location'] );
657 + return new WP_Error( 'table_import_legacy_data_read', '', $file->location );
542 658 }
543 659 if ( '' === $data ) {
544 - return new WP_Error( 'table_import_legacy_data_empty', '', $file['location'] );
660 + return new WP_Error( 'table_import_legacy_data_empty', '', $file->location );
545 661 }
546 662
547 663 // If no format could be determined from the file extension, try guessing from the file content.
548 664 if ( '' === $format ) {
665 + $data = trim( $data );
549 666 $first_character = $data[0];
550 - if ( '<' === $first_character ) {
667 + $last_character = $data[-1];
668 +
669 + if ( '<' === $first_character && '>' === $last_character ) {
551 670 $format = 'html';
552 - } elseif ( '{' === $first_character || '[' === $first_character ) {
553 - $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 + }
554 676 }
555 677 }
556 678
557 679 // Fall back to CSV if no file format could be determined.
@@ -558,16 +680,16 @@
558 680 if ( '' === $format ) {
559 681 $format = 'csv';
560 682 }
561 683
562 - if ( ! isset( $this->importer->import_formats[ $format ] ) ) {
563 - 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 );
564 686 }
565 687
566 688 $table = $this->importer->import_table( $format, $data );
567 689
568 690 if ( false === $table ) {
569 - 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 ) );
570 692 }
571 693
572 694 return $table;
573 695 }
@@ -576,19 +698,14 @@
576 698 * Loads a table from a file via the PHPSpreadsheet import class.
577 699 *
578 700 * @since 2.0.0
579 701 *
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.
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.
582 704 */
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;
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.)
591 708 }
592 709
593 710 /**
594 711 * Imports a loaded table into TablePress.
@@ -594,19 +711,19 @@
594 711 * Imports a loaded table into TablePress.
595 712 *
596 713 * @since 2.0.0
597 714 *
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.
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.
601 718 */
602 - protected function _import_table( array $table, array $file ) {
719 + protected function save_imported_table( array $table, File $file ) /* : array|WP_Error */ {
603 720 // If name and description are imported from a new table, use those.
604 721 if ( ! isset( $table['name'] ) ) {
605 - $table['name'] = $file['name'];
722 + $table['name'] = $file->name;
606 723 }
607 724 if ( ! isset( $table['description'] ) ) {
608 - $table['description'] = $file['name'];
725 + $table['description'] = $file->name;
609 726 }
610 727
611 728 $import_type = $this->import_config['type'];
612 729 $existing_table_id = $this->import_config['existing_table'];
@@ -615,11 +732,11 @@
615 732 if ( in_array( $import_type, array( 'replace', 'append' ), true ) && '' === $existing_table_id ) {
616 733 if ( isset( $table['id'] ) ) {
617 734 // If the table already contained a table ID (e.g. for the JSON format), use that.
618 735 $existing_table_id = $table['id'];
619 - } 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 ] ) ) {
620 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.
621 - $existing_table_id = $this->table_names_ids[ $file['name'] ][0];
738 + $existing_table_id = $this->table_names_ids[ $file->name ][0];
622 739 }
623 740 }
624 741
625 742 // If the table that is to be replaced or appended to does not exist, add the new table instead.
@@ -627,9 +744,9 @@
627 744 $existing_table_id = '';
628 745 $import_type = 'add';
629 746 }
630 747
631 - $table = $this->_import_tablepress_table( $table, $import_type, $existing_table_id );
748 + $table = $this->import_tablepress_table( $table, $import_type, $existing_table_id );
632 749
633 750 return $table;
634 751 }
635 752
@@ -637,19 +754,20 @@
637 754 * Imports a table by either replacing or appending to an existing table or by adding it as a new table.
638 755 *
639 756 * @since 1.0.0
640 757 *
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.
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.
645 762 */
646 - 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 */ {
647 764 // 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'] : '';
765 + $table_id_in_import = $imported_table['id'] ?? '';
649 766
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() ) ) {
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' ) ) ) {
652 770 return new WP_Error( 'table_import_replace_append_capability_check_failed', '', $existing_table_id );
653 771 }
654 772
655 773 switch ( $import_type ) {
@@ -663,11 +781,11 @@
663 781 case 'replace':
664 782 // Load table, without table data, but with options and visibility settings.
665 783 $existing_table = TablePress::$model_table->load( $existing_table_id, false, true );
666 784 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;
785 + $error = new WP_Error( 'table_import_replace_table_load', '', $existing_table_id );
786 + $error->merge_from( $existing_table );
787 + return $error;
670 788 }
671 789 // Don't change name and description when a table is replaced.
672 790 $imported_table['name'] = $existing_table['name'];
673 791 $imported_table['description'] = $existing_table['description'];
@@ -679,11 +797,11 @@
679 797 case 'append':
680 798 // Load table, with table data, options, and visibility settings.
681 799 $existing_table = TablePress::$model_table->load( $existing_table_id, true, true );
682 800 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;
801 + $error = new WP_Error( 'table_import_append_table_load', '', $existing_table_id );
802 + $error->merge_from( $existing_table );
803 + return $error;
686 804 }
687 805 if ( isset( $existing_table['is_corrupted'] ) && $existing_table['is_corrupted'] ) {
688 806 return new WP_Error( 'table_import_append_table_load_corrupted', '', $existing_table_id );
689 807 }
@@ -716,11 +834,11 @@
716 834
717 835 // Check if the new table data is valid and consistent.
718 836 $table = TablePress::$model_table->prepare_table( $existing_table, $imported_table, false );
719 837 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;
838 + $error = new WP_Error( 'table_import_table_prepare', '', $imported_table['id'] );
839 + $error->merge_from( $table );
840 + return $error;
723 841 }
724 842
725 843 // DataTables Custom Commands can only be edit by trusted users.
726 844 if ( ! current_user_can( 'unfiltered_html' ) ) {
@@ -736,11 +854,11 @@
736 854 $table_id = TablePress::$model_table->add( $table );
737 855 }
738 856
739 857 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;
858 + $error = new WP_Error( 'table_import_table_save_or_add', '', $table['id'] );
859 + $error->merge_from( $table_id );
860 + return $error;
743 861 }
744 862
745 863 // Try to use ID from imported file (e.g. in full JSON format table).
746 864 if ( '' !== $table_id_in_import && $table_id !== $table_id_in_import && current_user_can( 'tablepress_edit_table_id', $table_id ) ) {
@@ -764,11 +882,11 @@
764 882 * @deprecated 2.0.0 Use `run()` instead.
765 883 *
766 884 * @param string $format Import format.
767 885 * @param string $data Data to import.
768 - * @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.
769 887 */
770 - public function import_table( $format, $data ) {
888 + public function import_table( string $format, string $data ) /* : array|false */ {
771 889 TablePress::load_file( 'class-import-base.php', 'classes' );
772 890 $importer = TablePress::load_class( 'TablePress_Import_Legacy', 'class-import-legacy.php', 'classes' );
773 891 return $importer->import_table( $format, $data );
774 892 }