PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Admin/Importer/WPImport.php +560 -427 3.3.44.9.2 View file →
@@ -1,12 +1,14 @@
1 1 <?php
2 -
2 +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- view template receives variables via extract(); prefixing is impractical.
3 +// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk-import write paths from upstream WP Importer; caching unwanted.
4 +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- inherited WP Importer hook contract (import_*, wp_import_*) preserved for compat with external listeners.
3 5 namespace WPDeveloper\BetterDocs\Admin\Importer;
4 6
5 -use WPDeveloper\BetterDocs\Admin\Importer\Parsers\WXR_Parser;
6 -use WPDeveloper\BetterDocs\Admin\Importer\Parsers\CSV_Parser;
7 7 use WP_Error;
8 8 use WP_Importer;
9 +use WPDeveloper\BetterDocs\Admin\Importer\Parsers\CSV_Parser;
10 +use WPDeveloper\BetterDocs\Admin\WPMLSupport;
9 11
10 12 if ( ! defined( 'ABSPATH' ) ) {
11 13 exit; // Exit if accessed directly.
12 14 }
@@ -44,9 +46,9 @@
44 46 require $wp_import;
45 47 }
46 48 }
47 49
48 -use function wp_import_cleanup;
50 +use WPDeveloper\BetterDocs\Admin\Importer\Parsers\WXR_Parser;
49 51
50 52 class WPImport extends WP_Importer {
51 53 const DEFAULT_BUMP_REQUEST_TIMEOUT = 60;
52 54 const DEFAULT_ALLOW_CREATE_USERS = true;
@@ -66,9 +68,9 @@
66 68 * @var array
67 69 */
68 70 private $output = [
69 71 'status' => 'failed',
70 - 'errors' => [],
72 + 'errors' => []
71 73 ];
72 74
73 75 /*
74 76 * WXR attachment ID
@@ -76,20 +78,20 @@
76 78 private $id;
77 79
78 80 // Information to import from WXR file.
79 81 private $version;
80 - private $authors = [];
81 - public $posts = [];
82 - public $terms = [];
83 - private $base_url = '';
82 + private $authors = [];
83 + public $posts = [];
84 + public $terms = [];
85 + private $base_url = '';
84 86 private $page_on_front;
85 87 private $base_blog_url = '';
86 88
87 89 // Mappings from old information to new.
88 - public $file_type;
89 - public $processed_taxonomies;
90 - public $processed_terms = [];
91 - public $processed_posts = [];
90 + public $file_type;
91 + public $processed_taxonomies;
92 + public $processed_terms = [];
93 + public $processed_posts = [];
92 94 private $processed_authors = [];
93 95 private $author_mapping = [];
94 96 private $processed_menu_items = [];
95 97 private $post_orphans = [];
@@ -172,10 +174,10 @@
172 174
173 175 $filename = trim( $attributes['filename'] );
174 176
175 177 // Unquote quoted filename, but after trimming.
176 - if ( substr( $filename, 0, 1 ) === '"' && substr( $filename, - 1, 1 ) === '"' ) {
177 - $filename = substr( $filename, 1, - 1 );
178 + if ( substr( $filename, 0, 1 ) === '"' && substr( $filename, -1, 1 ) === '"' ) {
179 + $filename = substr( $filename, 1, -1 );
178 180 }
179 181 }
180 182
181 183 return $filename;
@@ -205,32 +207,31 @@
205 207
206 208 return isset( $map[ $mime_type ] ) ? $map[ $mime_type ] : null;
207 209 }
208 210
209 - /**
210 - * Modify WordPress Post Type to 'docs'
211 - *
212 - * This function is designed to modify the 'post_type' value of a WordPress post array.
213 - * If the existing 'post_type' is not equal to 'docs', it will be replaced with 'docs'.
214 - * Additionally, any 'terms' associated with the post will be removed.
215 - *
216 - * @param array $post An array representing a WordPress post.
217 - *
218 - * @return array The modified WordPress post array with 'post_type' set to 'docs'.
219 - */
220 - private function modify_post_type($post) {
221 - // Check if [post_type] is not equal to 'docs'
222 - if (isset($post['post_type']) && $post['post_type'] !== 'docs' && $post['post_type'] !== 'attachment') {
223 - // Remove [terms] if [post_type] is not equal to 'docs'
224 - unset($post['terms']);
225 - // Replace [post_type] with 'docs'
226 - $post['post_type'] = 'docs';
227 - }
211 + /**
212 + * Modify WordPress Post Type to 'docs'
213 + *
214 + * This function is designed to modify the 'post_type' value of a WordPress post array.
215 + * If the existing 'post_type' is not equal to 'docs', it will be replaced with 'docs'.
216 + * Additionally, any 'terms' associated with the post will be removed.
217 + *
218 + * @param array $post An array representing a WordPress post.
219 + *
220 + * @return array The modified WordPress post array with 'post_type' set to 'docs'.
221 + */
222 + private function modify_post_type( $post ) {
223 + // Check if [post_type] is not equal to 'docs'
224 + if ( isset( $post['post_type'] ) && $post['post_type'] !== 'docs' && $post['post_type'] !== 'attachment' && $post['post_type'] != 'betterdocs_faq' ) {
225 + // Remove [terms] if [post_type] is not equal to 'docs'
226 + unset( $post['terms'] );
227 + // Replace [post_type] with 'docs'
228 + $post['post_type'] = 'docs';
229 + }
228 230
229 - return $post;
230 - }
231 + return $post;
232 + }
231 233
232 -
233 234 /**
234 235 * The main controller for the actual import stage.
235 236 *
236 237 * @param string $file Path to the WXR file for importing
@@ -235,26 +236,31 @@
235 236 *
236 237 * @param string $file Path to the WXR file for importing
237 238 */
238 239 private function import( $file ) {
239 - add_filter( 'import_post_meta_key', function ( $key ) {
240 - return $this->is_valid_meta_key( $key );
241 - } );
242 - add_filter( 'http_request_timeout', function () {
243 - return self::DEFAULT_BUMP_REQUEST_TIMEOUT;
244 - } );
240 + add_filter(
241 + 'import_post_meta_key',
242 + function ( $key ) {
243 + return $this->is_valid_meta_key( $key );
244 + }
245 + );
246 + add_filter(
247 + 'http_request_timeout',
248 + function () {
249 + return self::DEFAULT_BUMP_REQUEST_TIMEOUT;
250 + }
251 + );
245 252
246 253 if ( ! $this->import_start( $file ) ) {
247 254 return;
248 255 }
249 256
250 -
251 257 $this->set_author_mapping();
252 258
253 259 wp_suspend_cache_invalidation( true );
254 260 $imported_summary = [
255 261 'terms' => $this->process_terms(),
256 - 'posts' => $this->process_posts(),
262 + 'posts' => $this->process_posts()
257 263 ];
258 264 wp_suspend_cache_invalidation( false );
259 265
260 266 // Update incorrect/missing information in the DB.
@@ -289,17 +295,16 @@
289 295
290 296 return false;
291 297 }
292 298
293 - $action = $this->args['action'];
299 + $action = $this->args['action'];
294 300
295 301 $import_data = $this->parse( $file );
296 302
297 - if ( isset( $import_data['type'] ) && $import_data['type'] === 'sample/csv' ) {
298 - $this->import_sample_data( $import_data['posts'], $action );
299 - return true;
300 - }
301 -
303 + // Check for a parser error first: on an invalid/unsupported file the
304 + // parser returns a WP_Error, and reading $import_data['type'] on it
305 + // would fatal ("Cannot use object of type WP_Error as array"). Report
306 + // it cleanly instead.
302 307 if ( is_wp_error( $import_data ) ) {
303 308 /**
304 309 * @var WP_Error $import_data ;
305 310 */
@@ -307,61 +312,76 @@
307 312
308 313 return false;
309 314 }
310 315
311 - $posts = $import_data['posts'];
312 - // Use array_map to apply the callback function to each item in the array
313 - $posts = array_map(array($this, 'modify_post_type'), $posts);
316 + if ( isset( $import_data['type'] ) && $import_data['type'] === 'sample/csv' ) {
317 + $this->import_sample_data( $import_data['posts'], $action );
318 + return true;
319 + }
314 320
315 - if ( ! empty( $action ) ) {
316 - $existing_posts = $this->args['existing_slug'];
317 - $existing_posts_array = explode(',', $existing_posts);
321 + $posts = $import_data['posts'];
322 + // Use array_map to apply the callback function to each item in the array
323 + $posts = array_map( [ $this, 'modify_post_type' ], $posts );
318 324
319 - if ( $existing_posts && $action == 'ignore' ) {
320 - // Filter out posts with slugs in $existing_slugs_array
321 - $filtered_posts = array_filter($posts, function ($post) use ($existing_posts_array) {
322 - return !in_array($post['post_name'], $existing_posts_array);
323 - });
325 + if ( ! empty( $action ) ) {
326 + $existing_posts = $this->args['existing_slug'];
327 + $existing_posts_array = is_array( $existing_posts ) ? $existing_posts : explode( ',', (string) $existing_posts );
324 328
325 - // If you want the resulting array to have numeric keys
326 - $posts = array_values($filtered_posts);
329 + if ( $existing_posts && $action == 'ignore' ) {
330 + // Filter out posts with slugs in $existing_slugs_array
331 + $filtered_posts = array_filter(
332 + $posts,
333 + function ( $post ) use ( $existing_posts_array ) {
334 + return ! in_array( $post['post_name'], $existing_posts_array );
335 + }
336 + );
327 337
328 - } if ( $existing_posts_array && $action == 'replace' ) {
329 - foreach ($existing_posts_array as $slug) {
330 - $post = get_page_by_path($slug, OBJECT, 'docs'); // Replace 'docs' with your custom post type if needed
338 + // If you want the resulting array to have numeric keys
339 + $posts = array_values( $filtered_posts );
340 + }if ( $existing_posts_array && $action == 'replace' ) {
341 + foreach ( $existing_posts_array as $slug ) {
342 + $post = get_page_by_path( $slug, OBJECT, 'docs' ); // Replace 'docs' with your custom post type if needed
331 343
332 - if ($post) {
333 - $post_id = $post->ID;
334 - // Permanently delete the post (including from trash)
335 - wp_delete_post($post_id, true);
336 - }
337 - }
338 - }
339 - }
344 + if ( $post ) {
345 + $post_id = $post->ID;
346 + // Permanently delete the post (including from trash)
347 + wp_delete_post( $post_id, true );
348 + }
349 + }
350 + }
351 + }
340 352
341 - if ( isset($import_data['version']) ) {
342 - $this->version = $import_data['version'];
343 - }
353 + if ( isset( $import_data['version'] ) ) {
354 + $this->version = $import_data['version'];
355 + }
344 356
345 357 $this->set_authors_from_import( $import_data );
346 - $this->posts = $posts;
347 - $this->terms = $import_data['terms'];
358 + $this->posts = $posts;
359 + $this->terms = $import_data['terms'];
348 360
349 - if ( isset($import_data['base_url']) ) {
350 - $this->base_url = esc_url( $import_data['base_url'] );
351 - }
361 + if ( isset( $import_data['base_url'] ) ) {
362 + $this->base_url = esc_url( $import_data['base_url'] );
363 + }
352 364
353 - if ( isset($import_data['base_blog_url']) ) {
354 - $this->base_blog_url = esc_url( $import_data['base_blog_url'] );
355 - }
365 + if ( isset( $import_data['base_blog_url'] ) ) {
366 + $this->base_blog_url = esc_url( $import_data['base_blog_url'] );
367 + }
356 368
357 - if ( isset($import_data['page_on_front']) ) {
358 - $this->page_on_front = $import_data['page_on_front'];
359 - }
369 + if ( isset( $import_data['page_on_front'] ) ) {
370 + $this->page_on_front = $import_data['page_on_front'];
371 + }
360 372
361 373 wp_defer_term_counting( true );
362 374 wp_defer_comment_counting( true );
363 375
376 + // Safety net for an aborted run: a PHP timeout, memory limit, fatal, or a
377 + // cut-off request can end the import before import_end() runs, which would
378 + // strand wp_defer_term_counting(true) and a stale {taxonomy}_children cache
379 + // and silently drop nested-category counts site-wide. A shutdown handler
380 + // guarantees the restore runs on the way out; it is idempotent, so the normal
381 + // import_end() path (which also calls it) makes this a no-op. (#167)
382 + register_shutdown_function( array( $this, 'restore_counting_and_hierarchy' ) );
383 +
364 384 do_action( 'import_start', $this );
365 385
366 386 return true;
367 387 }
@@ -373,8 +393,29 @@
373 393 wp_import_cleanup( $this->id );
374 394
375 395 wp_cache_flush();
376 396
397 + $this->restore_counting_and_hierarchy();
398 +
399 + do_action( 'import_end' );
400 + }
401 +
402 + /**
403 + * Turn term/comment counting back on and rebuild the term hierarchy.
404 + *
405 + * import_start() registers this as a shutdown handler so an aborted run (timeout,
406 + * memory, fatal, cut-off request) that never reaches import_end() cannot strand
407 + * wp_defer_term_counting(true) or a stale {taxonomy}_children cache. Idempotent,
408 + * so it is safe to call from both import_end() and the shutdown handler. (#167)
409 + */
410 + public function restore_counting_and_hierarchy() {
411 + static $restored = false;
412 +
413 + if ( $restored ) {
414 + return;
415 + }
416 + $restored = true;
417 +
377 418 foreach ( get_taxonomies() as $tax ) {
378 419 delete_option( "{$tax}_children" );
379 420 _get_term_hierarchy( $tax );
380 421 }
@@ -380,10 +421,8 @@
380 421 }
381 422
382 423 wp_defer_term_counting( false );
383 424 wp_defer_comment_counting( false );
384 -
385 - do_action( 'import_end' );
386 425 }
387 426
388 427 /**
389 428 * Retrieve authors from parsed WXR data and set it to `$this->>authors`.
@@ -409,9 +448,9 @@
409 448
410 449 if ( ! isset( $this->authors[ $login ] ) ) {
411 450 $this->authors[ $login ] = [
412 451 'author_login' => $login,
413 - 'author_display_name' => $post['post_author'],
452 + 'author_display_name' => $post['post_author']
414 453 ];
415 454 }
416 455 }
417 456 }
@@ -452,9 +491,9 @@
452 491 'user_pass' => wp_generate_password(),
453 492 'user_email' => isset( $this->authors[ $old_login ]['author_email'] ) ? $this->authors[ $old_login ]['author_email'] : '',
454 493 'display_name' => $this->authors[ $old_login ]['author_display_name'],
455 494 'first_name' => isset( $this->authors[ $old_login ]['author_first_name'] ) ? $this->authors[ $old_login ]['author_first_name'] : '',
456 - 'last_name' => isset( $this->authors[ $old_login ]['author_last_name'] ) ? $this->authors[ $old_login ]['author_last_name'] : '',
495 + 'last_name' => isset( $this->authors[ $old_login ]['author_last_name'] ) ? $this->authors[ $old_login ]['author_last_name'] : ''
457 496 ];
458 497 $user_id = wp_insert_user( $user_data );
459 498 }
460 499
@@ -494,9 +533,9 @@
494 533 */
495 534 private function process_terms(): array {
496 535 $result = [
497 536 'succeed' => [],
498 - 'failed' => [],
537 + 'failed' => []
499 538 ];
500 539
501 540 $this->terms = apply_filters( 'wp_import_terms', $this->terms );
502 541
@@ -548,9 +587,9 @@
548 587 $description = $term['term_description'] ?? '';
549 588 $args = [
550 589 'slug' => $term['slug'],
551 590 'description' => wp_slash( $description ),
552 - 'parent' => (int) $parent,
591 + 'parent' => (int) $parent
553 592 ];
554 593
555 594 $id = wp_insert_term( wp_slash( $term['term_name'] ), $term['term_taxonomy'], $args );
556 595
@@ -624,9 +663,9 @@
624 663 continue;
625 664 }
626 665
627 666 // Export gets meta straight from the DB so could have a serialized string
628 - $value = maybe_unserialize( $meta['value'] );
667 + $value = maybe_unserialize( $meta['value'] );
629 668 $insert_meta = add_term_meta( $term_id, wp_slash( $key ), wp_slash_strings_only( $value ) );
630 669 /**
631 670 * Fires after term meta is imported.
632 671 *
@@ -637,130 +676,332 @@
637 676 do_action( 'import_term_meta', $term_id, $key, $value );
638 677 }
639 678 }
640 679
641 - public function existing_slug_action ($posts, $action) {
642 - $existing_posts = $this->args['existing_slug'];
643 - $existing_posts_array = explode(',', $existing_posts);
680 + public function existing_slug_action( $posts, $action ) {
681 + $existing_posts = $this->args['existing_slug'];
682 + $existing_posts_array = is_array( $existing_posts ) ? $existing_posts : explode( ',', (string) $existing_posts );
644 683
645 - if ( $existing_posts && $action == 'ignore' ) {
646 - // Filter out posts with slugs in $existing_slugs_array
647 - $filtered_posts = array_filter($posts, function ($post) use ($existing_posts_array) {
648 - return !in_array($post['post_name'], $existing_posts_array);
649 - });
684 + if ( $existing_posts && $action == 'ignore' ) {
685 + // Filter out posts with slugs in $existing_slugs_array
686 + $filtered_posts = array_filter(
687 + $posts,
688 + function ( $post ) use ( $existing_posts_array ) {
689 + return ! in_array( $post['post_name'], $existing_posts_array );
690 + }
691 + );
650 692
651 - // If you want the resulting array to have numeric keys
652 - $posts = array_values($filtered_posts);
693 + // If you want the resulting array to have numeric keys
694 + $posts = array_values( $filtered_posts );
695 + }if ( $existing_posts_array && $action == 'replace' ) {
696 + foreach ( $existing_posts_array as $slug ) {
697 + $post = get_page_by_path( $slug, OBJECT, 'docs' ); // Replace 'docs' with your custom post type if needed
653 698
654 - } if ( $existing_posts_array && $action == 'replace' ) {
655 - foreach ($existing_posts_array as $slug) {
656 - $post = get_page_by_path($slug, OBJECT, 'docs'); // Replace 'docs' with your custom post type if needed
699 + if ( $post ) {
700 + $post_id = $post->ID;
701 + // Permanently delete the post (including from trash)
702 + wp_delete_post( $post_id, true );
703 + }
704 + }
705 + }
657 706
658 - if ($post) {
659 - $post_id = $post->ID;
660 - // Permanently delete the post (including from trash)
661 - wp_delete_post($post_id, true);
662 - }
663 - }
664 - }
707 + return $posts;
708 + }
665 709
666 - return $posts;
667 - }
710 + /**
711 + * Fix encoding issues in CSV data
712 + *
713 + * @param string $text
714 + * @return string
715 + */
716 + private function fix_encoding_issues( $text ) {
717 + if ( empty( $text ) ) {
718 + return $text;
719 + }
668 720
669 - private function import_sample_data( $posts, $action ) {
670 - $result = [
721 + // Convert to UTF-8 if not already
722 + if ( ! mb_check_encoding( $text, 'UTF-8' ) ) {
723 + $detected_encoding = mb_detect_encoding( $text, ['UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII'], true );
724 + if ( $detected_encoding ) {
725 + $text = mb_convert_encoding( $text, 'UTF-8', $detected_encoding );
726 + } else {
727 + // Fallback: assume ISO-8859-1 if detection fails
728 + $text = mb_convert_encoding( $text, 'UTF-8', 'ISO-8859-1' );
729 + }
730 + }
731 +
732 + // Fix common encoding issues
733 + $replacements = [
734 + // Smart quotes (using Unicode escape sequences)
735 + "\u{2019}" => "'", // Right single quotation mark
736 + "\u{2018}" => "'", // Left single quotation mark
737 + "\u{201C}" => '"', // Left double quotation mark
738 + "\u{201D}" => '"', // Right double quotation mark
739 + "\u{2013}" => '-', // En dash
740 + "\u{2014}" => '--', // Em dash
741 + "\u{2026}" => '...', // Horizontal ellipsis
742 + // HTML entities that might be double-encoded
743 + '&amp;' => '&',
744 + '&lt;' => '<',
745 + '&gt;' => '>',
746 + '&quot;' => '"',
747 + // Remove replacement characters
748 + "\u{FFFD}" => '', // Replacement character
749 + ];
750 +
751 + $text = str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
752 +
753 + // Remove any remaining non-printable characters except newlines and tabs
754 + $text = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text );
755 +
756 + return $text;
757 + }
758 +
759 + private function import_sample_data( $posts, $action ) {
760 + $result = [
671 761 'succeed' => [],
672 - 'failed' => [],
762 + 'failed' => []
673 763 ];
674 764
675 - if ( ! empty( $action ) ) {
676 - $posts = $this->existing_slug_action($posts, $action);
677 - }
765 + if ( ! empty( $action ) ) {
766 + $posts = $this->existing_slug_action( $posts, $action );
767 + }
678 768
679 - foreach ( $posts as $post ) {
680 - $post_title = isset($post['Docs Title']) ? $post['Docs Title'] : '';
681 - $post_name = isset($post['post_name']) ? $post['post_name'] : '';
682 - $post_content = isset($post['Docs Content']) ? $post['Docs Content'] : '';
683 - $featured_image_url = isset($post['Featured Image']) ? $post['Featured Image'] : '';
684 - $category_name = isset($post['Category Name']) ? $post['Category Name'] : '';
685 - $category_slug = isset($post['Category Slug']) ? $post['Category Slug'] : '';
686 - $knowledge_base_name = isset($post['Knowledge Base']) ? $post['Knowledge Base'] : '';
687 - $knowledge_base_slug = isset($post['Knowledge Base Slug']) ? $post['Knowledge Base Slug'] : '';
688 - $post_status = isset($post['Status']) ? $post['Status'] : '';
769 + foreach ( $posts as $post ) {
770 + $post_title = isset( $post['Docs Title'] ) ? $post['Docs Title'] : '';
771 + $post_name = isset( $post['post_name'] ) ? $post['post_name'] : '';
772 + $post_content = isset( $post['Docs Content'] ) ? $post['Docs Content'] : '';
773 + $featured_image_url = isset( $post['Featured Image'] ) ? $post['Featured Image'] : '';
774 + $category_name = isset( $post['Category Name'] ) ? $post['Category Name'] : '';
775 + $category_slug = isset( $post['Category Slug'] ) ? $post['Category Slug'] : $category_name;
776 + $knowledge_base_name = isset( $post['Knowledge Base'] ) ? $post['Knowledge Base'] : '';
777 + $knowledge_base_slug = isset( $post['Knowledge Base Slug'] ) ? $post['Knowledge Base Slug'] : $knowledge_base_name;
778 + $post_status = isset( $post['Status'] ) ? $post['Status'] : '';
689 779
690 - // Check if the category and knowledge base term exist, if not, create them
691 - $default_multiple_kb = betterdocs()->settings->get( 'multiple_kb' );
780 + // Check if the category and knowledge base term exist, if not, create them
781 + $default_multiple_kb = betterdocs()->settings->get( 'multiple_kb' );
692 782
693 - if ( $default_multiple_kb == 1 ) {
694 - $knowledge_base_id = term_exists($knowledge_base_name, 'knowledge_base');
695 - if (!$knowledge_base_id && $knowledge_base_name) {
696 - $knowledge_base_id = wp_insert_term($knowledge_base_name, 'knowledge_base', array('slug' => $knowledge_base_slug));
697 - $knowledge_base_id = $knowledge_base_id['term_id'];
698 - } elseif ( ! $knowledge_base_name ) {
699 - // If no knowledge base term provided, set it to 0
700 - $knowledge_base_id = 0;
701 - } else {
702 - $knowledge_base_id = $knowledge_base_id['term_id'];
703 - }
704 - }
783 + $knowledge_base_id = 0;
784 + if ( $default_multiple_kb == 1 && $knowledge_base_slug ) {
785 + $existing_kb = term_exists( $knowledge_base_name, 'knowledge_base' );
786 + if ( ! $existing_kb && $knowledge_base_name ) {
787 + $kb_term = wp_insert_term( $knowledge_base_name, 'knowledge_base', [ 'slug' => $knowledge_base_slug ] );
788 + if ( is_wp_error( $kb_term ) ) {
789 + $knowledge_base_id = 0;
790 + } else {
791 + $knowledge_base_id = $kb_term['term_id'];
792 + }
793 + } elseif ( $existing_kb ) {
794 + $knowledge_base_id = $existing_kb['term_id'];
795 + }
796 + }
705 797
706 - $category_id = term_exists( $category_name, 'doc_category' );
707 - if ( ! $category_id ) {
708 - $category_id = wp_insert_term($category_name, 'doc_category', array('slug' => $category_slug));
709 - $category_id = $category_id['term_id'];
710 - } else {
711 - $category_id = $category_id['term_id'];
712 - }
798 + $category_id = 0;
799 + if ( $category_slug ) {
800 + $existing_category = term_exists( $category_name, 'doc_category' );
801 + if ( ! $existing_category ) {
802 + $category_term = wp_insert_term( $category_name, 'doc_category', [ 'slug' => $category_slug ] );
803 + if ( is_wp_error( $category_term ) ) {
804 + $category_id = 0;
805 + } else {
806 + $category_id = $category_term['term_id'];
807 + }
808 + } else {
809 + $category_id = $existing_category['term_id'];
810 + }
713 811
714 - if ( $default_multiple_kb == 1 && $knowledge_base_id && $category_id ) {
715 - $kb_slug = get_term_field('slug', $knowledge_base_id, 'knowledge_base');
716 - if ( ! is_wp_error( $kb_slug ) ) {
717 - $doc_category_kb = rest_sanitize_array( [$kb_slug] );
718 - update_term_meta( $category_id, "doc_category_knowledge_base", $doc_category_kb );
719 - }
720 - }
812 + if ( $default_multiple_kb == 1 && $knowledge_base_id && $category_id ) {
813 + $kb_slug = get_term_field( 'slug', $knowledge_base_id, 'knowledge_base' );
814 + if ( ! is_wp_error( $kb_slug ) ) {
815 + $doc_category_kb = rest_sanitize_array( [ $kb_slug ] );
816 + update_term_meta( $category_id, 'doc_category_knowledge_base', $doc_category_kb );
817 + }
818 + }
819 + }
721 820
722 - // Set up the post data
723 - $post_data = [
724 - 'post_title' => $post_title,
725 - 'post_name' => $post_name,
726 - 'post_content' => $post_content,
727 - 'post_status' => $post_status,
728 - 'post_type' => 'docs',
729 - 'tax_input' => array(
730 - 'doc_category' => array( $category_id )
731 - ),
732 - ];
821 + // Validate and sanitize post data with encoding fixes
822 + $post_title = $this->fix_encoding_issues( trim( $post_title ) );
823 + $post_content = $this->fix_encoding_issues( trim( $post_content ) );
824 + $post_name = $this->fix_encoding_issues( trim( $post_name ) );
825 + $post_status = !empty( $post_status ) ? $post_status : 'publish';
733 826
734 - if ( $default_multiple_kb == 1 ) {
735 - $post_data['tax_input']['knowledge_base'] = array( $knowledge_base_id );
736 - }
827 + // Skip posts with empty titles
828 + if ( empty( $post_title ) ) {
829 + $result['failed'][] = [
830 + 'title' => 'Empty Title',
831 + 'error' => 'Post title is required'
832 + ];
833 + continue;
834 + }
737 835
738 - // Insert the post
739 - $post_id = wp_insert_post( $post_data );
836 + // Additional WordPress validation checks
837 + $validation_errors = [];
740 838
741 - // Set the featured image
742 - if ( $featured_image_url ) {
743 - $this->set_post_thumbnail( $post_id, $featured_image_url );
744 - }
745 - }
839 + // Check title length (WordPress has a 200 character limit for post_title)
840 + if ( strlen( $post_title ) > 200 ) {
841 + $validation_errors[] = 'Title too long (max 200 characters)';
842 + }
746 843
747 - return $result;
748 - }
844 + // Check for valid post status
845 + $valid_statuses = get_post_stati();
846 + if ( !in_array( $post_status, array_keys( $valid_statuses ) ) ) {
847 + $post_status = 'publish';
848 + }
749 849
750 - public function set_post_thumbnail( $post_id, $attachment_url ) {
751 - require_once ABSPATH . 'wp-admin/includes/image.php';
752 - require_once ABSPATH . 'wp-admin/includes/media.php';
753 - require_once ABSPATH . 'wp-admin/includes/file.php';
754 - $post_title = pathinfo( $attachment_url, PATHINFO_FILENAME );
755 - $post_name = pathinfo( $attachment_url, PATHINFO_FILENAME );
850 + // Check if post with same title already exists
851 + $existing_posts = get_posts([
852 + 'post_type' => 'docs',
853 + 'title' => $post_title,
854 + 'post_status' => 'any',
855 + 'numberposts' => 1
856 + ]);
857 + if ( !empty( $existing_posts ) ) {
858 + $validation_errors[] = 'Post with same title already exists (ID: ' . $existing_posts[0]->ID . ')';
859 + }
756 860
757 - $image_id = media_sideload_image( $attachment_url, $post_id, $post_title, $post_name );
758 - set_post_thumbnail($post_id, $image_id);
861 + // Check if slug already exists (if provided)
862 + if ( !empty( $post_name ) ) {
863 + $existing_by_slug = get_posts([
864 + 'post_type' => 'docs',
865 + 'name' => $post_name,
866 + 'post_status' => 'any',
867 + 'numberposts' => 1
868 + ]);
869 + }
759 870
760 - return $image_id;
761 - }
871 + if ( !empty( $validation_errors ) ) {
872 + $result['failed'][] = [
873 + 'title' => $post_title,
874 + 'error' => implode(', ', $validation_errors)
875 + ];
876 + continue;
877 + }
762 878
879 + // Set up the post data
880 + $post_data = [
881 + 'post_title' => $post_title,
882 + 'post_name' => $post_name,
883 + 'post_content' => $post_content,
884 + 'post_status' => $post_status,
885 + 'post_type' => 'docs'
886 + ];
887 +
888 + if ( $category_name && $category_id ) {
889 + $post_data['tax_input']['doc_category'] = [ $category_id ];
890 + }
891 +
892 + if ( $default_multiple_kb == 1 && $knowledge_base_id ) {
893 + $post_data['tax_input']['knowledge_base'] = [ $knowledge_base_id ];
894 + }
895 +
896 + // Insert the post (without knowledge_base taxonomy to avoid capability issues)
897 + $post_data_without_kb = $post_data;
898 + if (isset($post_data_without_kb['tax_input']['knowledge_base'])) {
899 + $kb_terms = $post_data_without_kb['tax_input']['knowledge_base'];
900 + unset($post_data_without_kb['tax_input']['knowledge_base']);
901 + }
902 +
903 + $post_id = wp_insert_post( $post_data_without_kb );
904 +
905 + // Manually assign knowledge_base terms after post creation to bypass capability check
906 + if ( !is_wp_error($post_id) && $post_id > 0 && isset($kb_terms) && !empty($kb_terms) ) {
907 + // Convert term IDs to integers and ensure they exist
908 + $term_ids = array_map('intval', $kb_terms);
909 +
910 + $result_kb = wp_set_object_terms( $post_id, $term_ids, 'knowledge_base' );
911 + if ( !is_wp_error($result_kb) ) {
912 + $assigned_terms = wp_get_object_terms($post_id, 'knowledge_base');
913 + if (!empty($assigned_terms)) {
914 + $term_names = array_map(function($term) { return $term->name; }, $assigned_terms);
915 + }
916 + }
917 + }
918 +
919 + // Enhanced debugging for failed insertions
920 + if ( is_wp_error( $post_id ) ) {
921 + $result['failed'][] = [
922 + 'title' => $post_title,
923 + 'error' => $post_id->get_error_message()
924 + ];
925 + continue;
926 + } elseif ( $post_id === 0 ) {
927 + $result['failed'][] = [
928 + 'title' => $post_title,
929 + 'error' => 'Post insertion returned 0 - validation failed'
930 + ];
931 + continue;
932 + } else {
933 + $result['succeed'][] = [
934 + 'title' => $post_title,
935 + 'id' => $post_id
936 + ];
937 + }
938 + // Set the featured image
939 + if ( $featured_image_url ) {
940 + $this->set_post_thumbnail( $post_id, $featured_image_url );
941 + }
942 + }
943 +
944 + return $result;
945 + }
946 +
947 + public function import_helpscout_data( $posts ) {
948 + $result = [
949 + 'succeed' => [],
950 + 'failed' => []
951 + ];
952 +
953 + foreach ( $posts as $post ) {
954 + $post_title = isset( $post['name'] ) ? $post['name'] : '';
955 + $post_slug = isset( $post['slug'] ) ? $post['slug'] : '';
956 + $post_content = isset( $post['text'] ) ? $post['text'] : '';
957 + $categories = isset( $post['categories'] ) ? $post['categories'] : '';
958 + $post_status = isset( $post['status'] ) ? $post['status'] : '';
959 +
960 + // Check if the category and knowledge base term exist, if not, create them
961 + $category_ids = [];
962 + foreach ( $categories as $category ) {
963 + $category_id = term_exists( $category['slug'], 'doc_category' );
964 + if ( ! $category_id ) {
965 + $category_id = wp_insert_term( $category['name'], 'doc_category', [ 'slug' => $category['slug'] ] );
966 + $category_ids[] = $category_id['term_id'];
967 + } else {
968 + $category_ids[] = $category_id['term_id'];
969 + }
970 + }
971 +
972 + // Set up the post data
973 + $post_data = [
974 + 'post_title' => $post_title,
975 + 'post_name' => $post_slug,
976 + 'post_content' => $post_content,
977 + 'post_status' => 'publish',
978 + 'post_type' => 'docs',
979 + 'tax_input' => [
980 + 'doc_category' => $category_ids
981 + ]
982 + ];
983 +
984 + // Insert the post
985 + wp_insert_post( $post_data );
986 + }
987 +
988 + return $result;
989 + }
990 +
991 + public function set_post_thumbnail( $post_id, $attachment_url ) {
992 + require_once ABSPATH . 'wp-admin/includes/image.php';
993 + require_once ABSPATH . 'wp-admin/includes/media.php';
994 + require_once ABSPATH . 'wp-admin/includes/file.php';
995 + $post_title = pathinfo( $attachment_url, PATHINFO_FILENAME );
996 + $post_name = pathinfo( $attachment_url, PATHINFO_FILENAME );
997 +
998 + $image_id = media_sideload_image( $attachment_url, $post_id, $post_title, $post_name );
999 + set_post_thumbnail( $post_id, $image_id );
1000 +
1001 + return $image_id;
1002 + }
1003 +
763 1004 /**
764 1005 * Create new posts based on import information
765 1006 *
766 1007 * Posts marked as having a parent which doesn't exist will become top level items.
@@ -772,9 +1013,9 @@
772 1013 */
773 1014 private function process_posts(): array {
774 1015 $result = [
775 1016 'succeed' => [],
776 - 'failed' => [],
1017 + 'failed' => []
777 1018 ];
778 1019
779 1020 $this->posts = apply_filters( 'wp_import_posts', $this->posts );
780 1021
@@ -795,20 +1036,8 @@
795 1036 if ( 'auto-draft' === $post['status'] ) {
796 1037 continue;
797 1038 }
798 1039
799 - if ( 'nav_menu_item' === $post['post_type'] ) {
800 - $result['succeed'] += $this->process_menu_item( $post );
801 - continue;
802 - }
803 -
804 - if ( 'wp_navigation' === $post['post_type'] ) {
805 - $processed = $this->process_navigation( $post );
806 - if( ! $processed ) {
807 - continue;
808 - }
809 - }
810 -
811 1040 $post_type_object = get_post_type_object( $post['post_type'] );
812 1041
813 1042 $post_parent = (int) $post['post_parent'];
814 1043 if ( $post_parent ) {
@@ -831,25 +1060,25 @@
831 1060 }
832 1061
833 1062 $postdata = [
834 1063 'post_author' => $author,
835 - 'post_content' => isset($post['post_content']) ? $post['post_content'] : '',
836 - 'post_excerpt' => isset($post['post_excerpt']) ? $post['post_excerpt'] : '',
837 - 'post_title' => isset($post['post_title']) ? $post['post_title'] : '',
838 - 'post_status' => isset($post['status']) ? $post['status'] : '',
839 - 'post_name' => isset($post['post_name']) ? $post['post_name'] : '',
840 - 'comment_status' => isset($post['comment_status']) ? $post['comment_status'] : '',
841 - 'ping_status' => isset($post['ping_status']) ? $post['ping_status'] : '',
842 - 'guid' => isset($post['guid']) ? $post['guid'] : '',
1064 + 'post_content' => isset( $post['post_content'] ) ? $this->fix_encoding_issues( $post['post_content'] ) : '',
1065 + 'post_excerpt' => isset( $post['post_excerpt'] ) ? $this->fix_encoding_issues( $post['post_excerpt'] ) : '',
1066 + 'post_title' => isset( $post['post_title'] ) ? $this->fix_encoding_issues( $post['post_title'] ) : '',
1067 + 'post_status' => isset( $post['status'] ) ? $post['status'] : '',
1068 + 'post_name' => isset( $post['post_name'] ) ? $this->fix_encoding_issues( $post['post_name'] ) : '',
1069 + 'comment_status' => isset( $post['comment_status'] ) ? $post['comment_status'] : '',
1070 + 'ping_status' => isset( $post['ping_status'] ) ? $post['ping_status'] : '',
1071 + 'guid' => isset( $post['guid'] ) ? $this->fix_encoding_issues( $post['guid'] ) : '',
843 1072 'post_parent' => $post_parent,
844 - 'menu_order' => isset($post['menu_order']) ? $post['menu_order'] : '',
845 - 'post_type' => isset($post['post_type']) ? $post['post_type'] : '',
846 - 'post_password' => isset($post['post_password']) ? $post['post_password'] : '',
1073 + 'menu_order' => isset( $post['menu_order'] ) ? $post['menu_order'] : '',
1074 + 'post_type' => isset( $post['post_type'] ) ? $post['post_type'] : '',
1075 + 'post_password' => isset( $post['post_password'] ) ? $this->fix_encoding_issues( $post['post_password'] ) : ''
847 1076 ];
848 1077
849 1078 $original_post_id = $post['post_id'];
850 1079 $postdata = apply_filters( 'wp_import_post_data_processed', $postdata, $post );
851 - $postdata = wp_slash( $postdata );
1080 + $postdata = wp_slash( $postdata );
852 1081
853 1082 if ( 'attachment' === $postdata['post_type'] ) {
854 1083 $remote_url = ! empty( $post['attachment_url'] ) ? $post['attachment_url'] : $post['guid'];
855 1084
@@ -854,9 +1083,9 @@
854 1083 $remote_url = ! empty( $post['attachment_url'] ) ? $post['attachment_url'] : $post['guid'];
855 1084
856 1085 // try to use _wp_attached file for upload folder placement to ensure the same location as the export site
857 1086 // e.g. location is 2003/05/image.jpg but the attachment post_date is 2010/09, see media_handle_upload()
858 - $postdata['upload_date'] = isset($post['post_date']) ? $post['post_date'] : '';
1087 + $postdata['upload_date'] = isset( $post['post_date'] ) ? $post['post_date'] : '';
859 1088 if ( isset( $post['postmeta'] ) ) {
860 1089 foreach ( $post['postmeta'] as $meta ) {
861 1090 if ( '_wp_attached_file' === $meta['key'] ) {
862 1091 if ( preg_match( '%^[0-9]{4}/[0-9]{2}%', $meta['value'], $matches ) ) {
@@ -866,10 +1095,10 @@
866 1095 }
867 1096 }
868 1097 }
869 1098
870 - // $post_id = $this->set_post_thumbnail( $postdata['post_parent'], $remote_url );
871 - $post_id = $this->process_attachment( $postdata, $remote_url );
1099 + // $post_id = $this->set_post_thumbnail( $postdata['post_parent'], $remote_url );
1100 + $post_id = $this->process_attachment( $postdata, $remote_url );
872 1101
873 1102 $comment_post_id = $post_id;
874 1103 } else {
875 1104 $post_id = wp_insert_post( $postdata, true );
@@ -919,12 +1148,17 @@
919 1148 $terms_to_set = [];
920 1149 foreach ( $post['terms'] as $term ) {
921 1150 // back compat with WXR 1.0 map 'tag' to 'post_tag'
922 1151 $taxonomy = ( 'tag' === $term['domain'] ) ? 'post_tag' : $term['domain'];
923 - $term_exists = term_exists( $term['slug'], $taxonomy );
1152 +
1153 + // Apply encoding fixes to term data
1154 + $term_name = $this->fix_encoding_issues( $term['name'] );
1155 + $term_slug = $this->fix_encoding_issues( $term['slug'] );
1156 +
1157 + $term_exists = term_exists( $term_slug, $taxonomy );
924 1158 $term_id = is_array( $term_exists ) ? $term_exists['term_id'] : $term_exists;
925 1159 if ( ! $term_id ) {
926 - $t = wp_insert_term( $term['name'], $taxonomy, [ 'slug' => $term['slug'] ] );
1160 + $t = wp_insert_term( $term_name, $taxonomy, [ 'slug' => $term_slug ] );
927 1161 if ( ! is_wp_error( $t ) ) {
928 1162 $term_id = $t['term_id'];
929 1163
930 1164 $this->update_term_meta( $term_id );
@@ -947,9 +1181,14 @@
947 1181 $terms_to_set[ $taxonomy ][] = (int) $term_id;
948 1182 }
949 1183
950 1184 foreach ( $terms_to_set as $tax => $ids ) {
951 - $tt_ids = wp_set_post_terms( $post_id, $ids, $tax );
1185 + // Handle knowledge_base taxonomy capability issues
1186 + if ( $tax === 'knowledge_base' ) {
1187 + $tt_ids = wp_set_object_terms( $post_id, $ids, $tax );
1188 + } else {
1189 + $tt_ids = wp_set_post_terms( $post_id, $ids, $tax );
1190 + }
952 1191 do_action( 'wp_import_set_post_terms', $tt_ids, $ids, $tax, $post_id, $post );
953 1192 }
954 1193 unset( $post['terms'], $terms_to_set );
955 1194 }
@@ -964,11 +1203,11 @@
964 1203 if ( ! empty( $post['comments'] ) ) {
965 1204 $num_comments = 0;
966 1205 $inserted_comments = [];
967 1206 foreach ( $post['comments'] as $comment ) {
968 - $comment_id = $comment['comment_id'];
969 - $newcomments[ $comment_id ]['comment_post_ID'] = $comment_post_id;
970 - $newcomments[ $comment_id ]['comment_author'] = $comment['comment_author'];
1207 + $comment_id = $comment['comment_id'];
1208 + $newcomments[ $comment_id ]['comment_post_ID'] = $comment_post_id;
1209 + $newcomments[ $comment_id ]['comment_author'] = $comment['comment_author'];
971 1210 $newcomments[ $comment_id ]['comment_author_email'] = $comment['comment_author_email'];
972 1211 $newcomments[ $comment_id ]['comment_author_IP'] = $comment['comment_author_IP'];
973 1212 $newcomments[ $comment_id ]['comment_author_url'] = $comment['comment_author_url'];
974 1213 $newcomments[ $comment_id ]['comment_date'] = $comment['comment_date'];
@@ -1003,9 +1242,9 @@
1003 1242
1004 1243 add_comment_meta( $inserted_comments[ $key ], wp_slash( $meta['key'] ), wp_slash_strings_only( $value ) );
1005 1244 }
1006 1245
1007 - $num_comments ++;
1246 + ++$num_comments;
1008 1247 }
1009 1248 unset( $newcomments, $inserted_comments, $post['comments'] );
1010 1249 }
1011 1250
@@ -1014,11 +1253,28 @@
1014 1253 }
1015 1254
1016 1255 $post['postmeta'] = apply_filters( 'wp_import_post_meta', $post['postmeta'], $post_id, $post );
1017 1256
1257 + // WPML language metadata transported via synthetic postmeta — captured
1258 + // here so the keys never persist on the post (WPML's icl_translations
1259 + // is the source of truth on the target site).
1260 + $wpml_lang = '';
1261 + $wpml_source_slug = '';
1262 +
1018 1263 // Add/update post meta.
1019 1264 if ( ! empty( $post['postmeta'] ) ) {
1265 + $imported_meta_keys = []; // Track imported meta keys to prevent duplicates
1266 +
1020 1267 foreach ( $post['postmeta'] as $meta ) {
1268 + if ( WPMLSupport::META_LANG === $meta['key'] ) {
1269 + $wpml_lang = (string) $meta['value'];
1270 + continue;
1271 + }
1272 + if ( WPMLSupport::META_SOURCE_SLUG === $meta['key'] ) {
1273 + $wpml_source_slug = (string) $meta['value'];
1274 + continue;
1275 + }
1276 +
1021 1277 $key = apply_filters( 'import_post_meta_key', $meta['key'], $post_id, $post );
1022 1278 $value = false;
1023 1279
1024 1280 if ( '_edit_last' === $key ) {
@@ -1029,8 +1285,16 @@
1029 1285 }
1030 1286 }
1031 1287
1032 1288 if ( $key ) {
1289 + // Skip if this meta key has already been imported for this post
1290 + if ( isset( $imported_meta_keys[ $key ] ) ) {
1291 + continue;
1292 + }
1293 +
1294 + // Mark this meta key as imported
1295 + $imported_meta_keys[ $key ] = true;
1296 +
1033 1297 // Export gets meta straight from the DB so could have a serialized string.
1034 1298 if ( ! $value ) {
1035 1299 $value = maybe_unserialize( $meta['value'] );
1036 1300 }
@@ -1046,8 +1310,12 @@
1046 1310 }
1047 1311 }
1048 1312 }
1049 1313
1314 + if ( $wpml_lang !== '' ) {
1315 + WPMLSupport::assign_post_language( (int) $post_id, $wpml_lang, $wpml_source_slug );
1316 + }
1317 +
1050 1318 do_action( 'templately_import.process_post', $post, $this, $result );
1051 1319 }
1052 1320
1053 1321 unset( $this->posts );
@@ -1055,172 +1323,8 @@
1055 1323 return $result;
1056 1324 }
1057 1325
1058 1326 /**
1059 - * Attempt to create a new menu item from import data
1060 - *
1061 - * Fails for draft, orphaned menu items and those without an associated nav_menu
1062 - * or an invalid nav_menu term. If the post type or term object which the menu item
1063 - * represents doesn't exist then the menu item will not be imported (waits until the
1064 - * end of the import to retry again before discarding).
1065 - *
1066 - * @param array $item Menu item details from WXR file
1067 - */
1068 - private function process_menu_item( $item ) {
1069 - $result = [];
1070 -
1071 - // Skip draft, orphaned menu items.
1072 - if ( 'draft' === $item['status'] ) {
1073 - return;
1074 - }
1075 -
1076 - $menu_slug = false;
1077 - if ( isset( $item['terms'] ) ) {
1078 - // Loop through terms, assume first nav_menu term is correct menu.
1079 - foreach ( $item['terms'] as $term ) {
1080 - if ( 'nav_menu' === $term['domain'] ) {
1081 - $menu_slug = $term['slug'];
1082 - break;
1083 - }
1084 - }
1085 - }
1086 -
1087 - // No nav_menu term associated with this menu item.
1088 - if ( ! $menu_slug ) {
1089 - $this->output['errors'][] = esc_html__( 'Menu item skipped due to missing menu slug', 'betterdocs' );
1090 -
1091 - return $result;
1092 - }
1093 -
1094 - // If menu was already exists, refer the items to the duplicated menu created.
1095 - if ( array_key_exists( $menu_slug, $this->mapped_terms_slug ) ) {
1096 - $menu_slug = $this->mapped_terms_slug[ $menu_slug ];
1097 - }
1098 -
1099 - $menu_id = term_exists( $menu_slug, 'nav_menu' );
1100 - if ( ! $menu_id ) {
1101 - /* translators: %s: Menu slug. */
1102 - $this->output['errors'][] = sprintf( esc_html__( 'Menu item skipped due to invalid menu slug: %s', 'betterdocs' ), $menu_slug );
1103 -
1104 - return $result;
1105 - } else {
1106 - $menu_id = is_array( $menu_id ) ? $menu_id['term_id'] : $menu_id;
1107 - }
1108 -
1109 - $post_meta_key_value = [];
1110 - foreach ( $item['postmeta'] as $meta ) {
1111 - $post_meta_key_value[ $meta['key'] ] = $meta['value'];
1112 - }
1113 -
1114 - $_menu_item_type = $post_meta_key_value['_menu_item_type'];
1115 - $_menu_item_url = $post_meta_key_value['_menu_item_url'];
1116 -
1117 - // Skip menu items 'taxonomy' type, when the taxonomy is not exits.
1118 - if ( 'taxonomy' === $_menu_item_type && ! taxonomy_exists( $post_meta_key_value['_menu_item_object'] ) ) {
1119 - return $result;
1120 - }
1121 -
1122 - // Skip menu items 'post_type' type, when the post type is not exits.
1123 - if ( 'post_type' === $_menu_item_type && ! post_type_exists( $post_meta_key_value['_menu_item_object'] ) ) {
1124 - return $result;
1125 - }
1126 -
1127 - $_menu_item_object_id = $post_meta_key_value['_menu_item_object_id'];
1128 - if ( 'taxonomy' === $_menu_item_type && isset( $this->processed_terms[ (int) $_menu_item_object_id ] ) ) {
1129 - $_menu_item_object_id = $this->processed_terms[ (int) $_menu_item_object_id ];
1130 - } elseif ( 'post_type' === $_menu_item_type && isset( $this->processed_posts[ (int) $_menu_item_object_id ] ) ) {
1131 - $_menu_item_object_id = $this->processed_posts[ (int) $_menu_item_object_id ];
1132 - } elseif ( 'custom' === $_menu_item_type ) {
1133 - $_menu_item_url = Url::migrate( $_menu_item_url, $this->base_blog_url );
1134 - } else {
1135 - return $result;
1136 - }
1137 -
1138 - $_menu_item_menu_item_parent = $post_meta_key_value['_menu_item_menu_item_parent'];
1139 - if ( isset( $this->processed_menu_items[ (int) $_menu_item_menu_item_parent ] ) ) {
1140 - $_menu_item_menu_item_parent = $this->processed_menu_items[ (int) $_menu_item_menu_item_parent ];
1141 - } elseif ( $_menu_item_menu_item_parent ) {
1142 - $this->menu_item_orphans[ (int) $item['post_id'] ] = (int) $_menu_item_menu_item_parent;
1143 - $_menu_item_menu_item_parent = 0;
1144 - }
1145 -
1146 - // wp_update_nav_menu_item expects CSS classes as a space separated string
1147 - $_menu_item_classes = maybe_unserialize( $post_meta_key_value['_menu_item_classes'] );
1148 - if ( is_array( $_menu_item_classes ) ) {
1149 - $_menu_item_classes = implode( ' ', $_menu_item_classes );
1150 - }
1151 -
1152 - $args = [
1153 - 'menu-item-object-id' => $_menu_item_object_id,
1154 - 'menu-item-object' => $post_meta_key_value['_menu_item_object'],
1155 - 'menu-item-parent-id' => $_menu_item_menu_item_parent,
1156 - 'menu-item-position' => (int) $item['menu_order'],
1157 - 'menu-item-type' => $_menu_item_type,
1158 - 'menu-item-title' => $item['post_title'],
1159 - 'menu-item-url' => $_menu_item_url,
1160 - 'menu-item-description' => $item['post_content'],
1161 - 'menu-item-attr-title' => $item['post_excerpt'],
1162 - 'menu-item-target' => $post_meta_key_value['_menu_item_target'],
1163 - 'menu-item-classes' => $_menu_item_classes,
1164 - 'menu-item-xfn' => $post_meta_key_value['_menu_item_xfn'],
1165 - 'menu-item-status' => $item['status'],
1166 - ];
1167 -
1168 - $id = wp_update_nav_menu_item( $menu_id, 0, $args );
1169 - if ( $id && ! is_wp_error( $id ) ) {
1170 - $this->processed_menu_items[ (int) $item['post_id'] ] = (int) $id;
1171 - $result[ $item['post_id'] ] = $id;
1172 -
1173 - $this->update_post_meta( $id );
1174 - }
1175 -
1176 - return $result;
1177 - }
1178 -
1179 - private function process_navigation( &$item ): bool {
1180 - // Skip draft, orphaned menu items.
1181 - if ( 'draft' === $item['status'] ) {
1182 - return false;
1183 - }
1184 -
1185 - $content = parse_blocks( $item['post_content'] );
1186 -
1187 - $parsed_blocks = [];
1188 - foreach ( $content as $block ) {
1189 - if ( empty( $block['blockName'] ) ) {
1190 - continue;
1191 - }
1192 -
1193 - $this->prepare_block( $block );
1194 - $parsed_blocks[] = $block;
1195 - }
1196 -
1197 - $item['post_content'] = serialize_blocks( $parsed_blocks );
1198 -
1199 - return true;
1200 - }
1201 -
1202 - private function prepare_block( &$block ) {
1203 - if ( $block['blockName'] == 'core/navigation-link' ) {
1204 - $attrs = &$block['attrs'];
1205 - switch ( $attrs['kind'] ) {
1206 - case 'post-type':
1207 - if ( isset( $this->processed_posts[ (int) $attrs['id'] ] ) ) {
1208 - $attrs['id'] = $this->processed_posts[ (int) $attrs['id'] ];
1209 - $attrs['url'] = get_permalink( $attrs['id'] );
1210 - }
1211 - break;
1212 - case 'taxonomy':
1213 - if ( isset( $this->processed_terms[ (int) $attrs['id'] ] ) ) {
1214 - $attrs['id'] = $this->processed_terms[ (int) $attrs['id'] ];
1215 - $attrs['url'] = get_term_link( $attrs['id'], $attrs['type'] );
1216 - }
1217 - break;
1218 - }
1219 - }
1220 - }
1221 -
1222 - /**
1223 1327 * If fetching attachments is enabled then attempt to create a new attachment
1224 1328 *
1225 1329 * @param array $post Attachment post details from WXR
1226 1330 * @param string $url URL to fetch attachment from
@@ -1227,9 +1331,9 @@
1227 1331 *
1228 1332 * @return int|WP_Error Post ID on success, WP_Error otherwise
1229 1333 */
1230 1334 private function process_attachment( $post, $url ) {
1231 - require_once ABSPATH . 'wp-admin/includes/image.php';
1335 + require_once ABSPATH . 'wp-admin/includes/image.php';
1232 1336
1233 1337 if ( ! $this->fetch_attachments ) {
1234 1338 return new WP_Error( 'attachment_processing_error', esc_html__( 'Fetching attachments is not enabled', 'betterdocs' ) );
1235 1339 }
@@ -1281,12 +1385,12 @@
1281 1385 *
1282 1386 * @return array|WP_Error Local file location details on success, WP_Error otherwise
1283 1387 */
1284 1388 private function fetch_remote_file( $url, $post ) {
1285 - include_once ABSPATH . '/wp-admin/includes/file.php';
1389 + include_once ABSPATH . '/wp-admin/includes/file.php';
1286 1390
1287 1391 // Extract the file name from the URL.
1288 - $file_name = basename( parse_url( $url, PHP_URL_PATH ) );
1392 + $file_name = basename( (string) wp_parse_url( $url, PHP_URL_PATH ) );
1289 1393
1290 1394 if ( ! $file_name ) {
1291 1395 $file_name = md5( $url );
1292 1396 }
@@ -1296,21 +1400,24 @@
1296 1400 return new WP_Error( 'import_no_file', esc_html__( 'Could not create temporary file.', 'betterdocs' ) );
1297 1401 }
1298 1402
1299 1403 // Fetch the remote URL and write it to the placeholder file.
1300 - $remote_response = wp_safe_remote_get( $url, [
1301 - 'timeout' => 300,
1302 - 'stream' => true,
1303 - 'filename' => $tmp_file_name,
1304 - 'headers' => [
1305 - 'Accept-Encoding' => 'identity',
1404 + $remote_response = wp_safe_remote_get(
1405 + $url,
1406 + [
1407 + 'timeout' => 300,
1408 + 'stream' => true,
1409 + 'filename' => $tmp_file_name,
1410 + 'headers' => [
1411 + 'Accept-Encoding' => 'identity'
1412 + ]
1306 1413 ]
1307 - ] );
1414 + );
1308 1415
1309 1416 if ( is_wp_error( $remote_response ) ) {
1310 - @unlink( $tmp_file_name );
1417 + wp_delete_file( $tmp_file_name );
1311 1418
1312 - return new WP_Error( 'import_file_error', sprintf( /* translators: 1: WordPress error message, 2: WordPress error code. */ esc_html__( 'Request failed due to an error: %1$s (%2$s)', 'betterdocs' ), esc_html( $remote_response->get_error_message() ), esc_html( $remote_response->get_error_code() ) ) );
1419 + return new WP_Error( 'import_file_error', sprintf( /* translators: 1: WordPress error message, 2: WordPress error code. */esc_html__( 'Request failed due to an error: %1$s (%2$s)', 'betterdocs' ), esc_html( $remote_response->get_error_message() ), esc_html( $remote_response->get_error_code() ) ) );
1313 1420 }
1314 1421
1315 1422 $remote_response_code = (int) wp_remote_retrieve_response_code( $remote_response );
1316 1423
@@ -1315,11 +1422,11 @@
1315 1422 $remote_response_code = (int) wp_remote_retrieve_response_code( $remote_response );
1316 1423
1317 1424 // Make sure the fetch was successful.
1318 1425 if ( 200 !== $remote_response_code ) {
1319 - @unlink( $tmp_file_name );
1426 + wp_delete_file( $tmp_file_name );
1320 1427
1321 - return new WP_Error( 'import_file_error', sprintf( /* translators: 1: HTTP error message, 2: HTTP error code. */ esc_html__( 'Remote server returned the following unexpected result: %1$s (%2$s)', 'betterdocs' ), get_status_header_desc( $remote_response_code ), esc_html( $remote_response_code ) ) );
1428 + return new WP_Error( 'import_file_error', sprintf( /* translators: 1: HTTP error message, 2: HTTP error code. */esc_html__( 'Remote server returned the following unexpected result: %1$s (%2$s)', 'betterdocs' ), get_status_header_desc( $remote_response_code ), esc_html( $remote_response_code ) ) );
1322 1429 }
1323 1430
1324 1431 $headers = wp_remote_retrieve_headers( $remote_response );
1325 1432
@@ -1324,9 +1431,9 @@
1324 1431 $headers = wp_remote_retrieve_headers( $remote_response );
1325 1432
1326 1433 // Request failed.
1327 1434 if ( ! $headers ) {
1328 - @unlink( $tmp_file_name );
1435 + wp_delete_file( $tmp_file_name );
1329 1436
1330 1437 return new WP_Error( 'import_file_error', esc_html__( 'Remote server did not respond', 'betterdocs' ) );
1331 1438 }
1332 1439
@@ -1332,15 +1439,15 @@
1332 1439
1333 1440 $filesize = (int) filesize( $tmp_file_name );
1334 1441
1335 1442 if ( 0 === $filesize ) {
1336 - @unlink( $tmp_file_name );
1443 + wp_delete_file( $tmp_file_name );
1337 1444
1338 1445 return new WP_Error( 'import_file_error', esc_html__( 'Zero size file downloaded', 'betterdocs' ) );
1339 1446 }
1340 1447
1341 1448 if ( ! isset( $headers['content-encoding'] ) && isset( $headers['content-length'] ) && $filesize !== (int) $headers['content-length'] ) {
1342 - @unlink( $tmp_file_name );
1449 + wp_delete_file( $tmp_file_name );
1343 1450
1344 1451 return new WP_Error( 'import_file_error', esc_html__( 'Downloaded file has incorrect size', 'betterdocs' ) );
1345 1452 }
1346 1453
@@ -1345,9 +1452,9 @@
1345 1452 }
1346 1453
1347 1454 $max_size = (int) apply_filters( 'import_attachment_size_limit', self::DEFAULT_IMPORT_ATTACHMENT_SIZE_LIMIT );
1348 1455 if ( ! empty( $max_size ) && $filesize > $max_size ) {
1349 - @unlink( $tmp_file_name );
1456 + wp_delete_file( $tmp_file_name );
1350 1457
1351 1458 /* translators: %s: Max file size. */
1352 1459
1353 1460 return new WP_Error( 'import_file_error', sprintf( esc_html__( 'Remote file is too large, limit is %s', 'betterdocs' ), size_format( $max_size ) ) );
@@ -1395,16 +1502,17 @@
1395 1502 $new_file = $uploads['path'] . "/$file_name";
1396 1503 $move_new_file = copy( $tmp_file_name, $new_file );
1397 1504
1398 1505 if ( ! $move_new_file ) {
1399 - @unlink( $tmp_file_name );
1506 + wp_delete_file( $tmp_file_name );
1400 1507
1401 1508 return new WP_Error( 'import_file_error', esc_html__( 'The uploaded file could not be moved', 'betterdocs' ) );
1402 1509 }
1403 1510
1404 - // Set correct file permissions.
1511 + // Set correct file permissions to match parent directory.
1405 1512 $stat = stat( dirname( $new_file ) );
1406 1513 $perms = $stat['mode'] & 0000666;
1514 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- WP_Filesystem chmod requires init; mirrors WP core media handler.
1407 1515 chmod( $new_file, $perms );
1408 1516
1409 1517 $upload = [
1410 1518 'file' => $new_file,
@@ -1409,9 +1517,9 @@
1409 1517 $upload = [
1410 1518 'file' => $new_file,
1411 1519 'url' => $uploads['url'] . "/$file_name",
1412 1520 'type' => $wp_filetype['type'],
1413 - 'error' => false,
1521 + 'error' => false
1414 1522 ];
1415 1523
1416 1524 // Keep track of the old and new urls so we can substitute them later.
1417 1525 $this->url_remap[ $url ] = $upload['url'];
@@ -1474,12 +1582,15 @@
1474 1582 */
1475 1583 private function backfill_attachment_urls() {
1476 1584 global $wpdb;
1477 1585 // Make sure we do the longest urls first, in case one is a substring of another.
1478 - uksort( $this->url_remap, function ( $a, $b ) {
1479 - // Return the difference in length between two strings.
1480 - return strlen( $b ) - strlen( $a );
1481 - } );
1586 + uksort(
1587 + $this->url_remap,
1588 + function ( $a, $b ) {
1589 + // Return the difference in length between two strings.
1590 + return strlen( $b ) - strlen( $a );
1591 + }
1592 + );
1482 1593
1483 1594 foreach ( $this->url_remap as $from_url => $to_url ) {
1484 1595 // Remap urls in post_content.
1485 1596 $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)", $from_url, $to_url ) );
@@ -1510,14 +1621,29 @@
1510 1621 * @param string $file Path to WXR file for parsing
1511 1622 *
1512 1623 * @return array Information gathered from the WXR file
1513 1624 */
1514 - private function parse( $file ): array {
1515 - if ($this->file_type == 'text/xml') {
1516 - $parser = new WXR_Parser();
1517 - } else if ($this->file_type == 'text/csv') {
1518 - $parser = new CSV_Parser();
1519 - }
1625 + private function parse( $file ) {
1626 + $type = strtolower( (string) $this->file_type );
1627 +
1628 + // The uploaded temp file has no extension, and browsers report a .xml
1629 + // upload inconsistently (text/xml, application/xml, text/plain, or
1630 + // nothing) — so prefer the original file name's extension when it was
1631 + // passed through, fall back to the MIME, and default anything that is
1632 + // not clearly CSV to the WordPress export parser. WXR_Parser validates
1633 + // the file and returns a WP_Error for a non-WXR file, which
1634 + // import_start() already reports cleanly; previously an unrecognised
1635 + // MIME left $parser null and fataled with "Call to a member function
1636 + // parse() on null".
1637 + $name = isset( $this->args['file_name'] ) ? (string) $this->args['file_name'] : (string) $this->requested_file_path;
1638 + $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
1639 +
1640 + if ( 'csv' === $ext || ( '' === $ext && strpos( $type, 'csv' ) !== false ) ) {
1641 + $parser = new CSV_Parser();
1642 + } else {
1643 + $parser = new WXR_Parser();
1644 + }
1645 +
1520 1646 return $parser->parse( $file );
1521 1647 }
1522 1648
1523 1649 /**
@@ -1595,9 +1721,16 @@
1595 1721 * @param $file
1596 1722 * @param array $args
1597 1723 */
1598 1724 public function __construct( $file, array $args = [] ) {
1599 - parent::__construct();
1725 + // WP core's WP_Importer has no constructor, and on PHP 8 calling
1726 + // parent::__construct() on a constructor-less parent throws
1727 + // "Error: Cannot call constructor" — which aborted every import. Only
1728 + // call the parent when it actually defines one (future-proof if WP adds
1729 + // it back).
1730 + if ( method_exists( get_parent_class( $this ), '__construct' ) ) {
1731 + parent::__construct();
1732 + }
1600 1733
1601 1734 $this->requested_file_path = $file;
1602 1735 $this->args = $args;
1603 1736
@@ -1624,9 +1757,9 @@
1624 1757 if ( ! empty( $this->args['terms_meta'] ) ) {
1625 1758 $this->terms_meta = $this->args['terms_meta'];
1626 1759 }
1627 1760
1628 - if ( ! empty( $this->args['file_type'] ) ) {
1761 + if ( ! empty( $this->args['file_type'] ) ) {
1629 1762 $this->file_type = $this->args['file_type'];
1630 1763 }
1631 1764 }
1632 1765 }