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