PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.5.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.5.4
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 3.5.2 All 199 releases
betterdocs / includes / Admin / Importer / WPImport.php

WPImport.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.5.4, at includes/Admin/Importer/WPImport.php

1,710 lines 54.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Admin\Importer;
4
5 use WP_Error;
6 use WP_Importer;
7 use WPDeveloper\BetterDocs\Admin\Importer\Parsers\CSV_Parser;
8 use WPDeveloper\BetterDocs\Admin\WPMLSupport;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit; // Exit if accessed directly.
12 }
13
14 /**
15 * Originally made by WordPress part of WordPress/Importer.
16 * https://plugins.trac.wordpress.org/browser/wordpress-importer/trunk/class-wp-import.php
17 *
18 * What was done ( by Elementor):
19 * Reformat of the code.
20 * Changed text domain.
21 * Changed methods visibility.
22 * Changed method from `get_authors_from_import` to `set_authors_from_import`.
23 * Changed method from `get_author_mapping` to `set_author_mapping`.
24 * Removed use of '$_POST' the input 'options' will be passed via constructor args.
25 * Removed echos, UI and print methods, all echos replaced with `$this->output` append.
26 * Removed `die` ( exit(s) ).
27 *
28 * What was done ( by Templately):
29 * Add Action For Every Part Of Import for SSE
30 */
31
32 if ( ! class_exists( 'WP_Importer' ) ) {
33 $class_wp_importer = ABSPATH . 'wp-admin/includes/class-wp-importer.php';
34
35 if ( file_exists( $class_wp_importer ) ) {
36 require $class_wp_importer;
37 }
38 }
39
40 if ( ! function_exists( 'wp_import_cleanup' ) ) {
41 $wp_import = ABSPATH . 'wp-admin/includes/import.php';
42
43 if ( file_exists( $wp_import ) ) {
44 require $wp_import;
45 }
46 }
47
48 use WPDeveloper\BetterDocs\Admin\Importer\Parsers\WXR_Parser;
49
50 class WPImport extends WP_Importer {
51 const DEFAULT_BUMP_REQUEST_TIMEOUT = 60;
52 const DEFAULT_ALLOW_CREATE_USERS = true;
53 const DEFAULT_IMPORT_ATTACHMENT_SIZE_LIMIT = 0; // 0 = unlimited.
54
55 /**
56 * @var string
57 */
58 private $requested_file_path;
59
60 /**
61 * @var array
62 */
63 private $args;
64
65 /**
66 * @var array
67 */
68 private $output = [
69 'status' => 'failed',
70 'errors' => []
71 ];
72
73 /*
74 * WXR attachment ID
75 */
76 private $id;
77
78 // Information to import from WXR file.
79 private $version;
80 private $authors = [];
81 public $posts = [];
82 public $terms = [];
83 private $base_url = '';
84 private $page_on_front;
85 private $base_blog_url = '';
86
87 // Mappings from old information to new.
88 public $file_type;
89 public $processed_taxonomies;
90 public $processed_terms = [];
91 public $processed_posts = [];
92 private $processed_authors = [];
93 private $author_mapping = [];
94 private $processed_menu_items = [];
95 private $post_orphans = [];
96 private $menu_item_orphans = [];
97 private $mapped_terms_slug = [];
98
99 private $fetch_attachments = false;
100 private $url_remap = [];
101 private $featured_images = [];
102
103 /**
104 * @var array[] [meta_key => meta_value] Meta value that should be set for every imported post.
105 */
106 private $posts_meta = [];
107
108 /**
109 * @var array[] [meta_key => meta_value] Meta value that should be set for every imported term.
110 */
111 private $terms_meta = [];
112
113 /**
114 * Parses filename from a Content-Disposition header value.
115 *
116 * As per RFC6266:
117 *
118 * content-disposition = "Content-Disposition" ":"
119 * disposition-type *( ";" disposition-parm )
120 *
121 * disposition-type = "inline" | "attachment" | disp-ext-type
122 * ; case-insensitive
123 * disp-ext-type = token
124 *
125 * disposition-parm = filename-parm | disp-ext-parm
126 *
127 * filename-parm = "filename" "=" value
128 * | "filename*" "=" ext-value
129 *
130 * disp-ext-parm = token "=" value
131 * | ext-token "=" ext-value
132 * ext-token = <the characters in token, followed by "*">
133 *
134 * @param string[] $disposition_header List of Content-Disposition header values.
135 *
136 * @return string|null Filename if available, or null if not found.
137 * @link http://tools.ietf.org/html/rfc2388
138 * @link http://tools.ietf.org/html/rfc6266
139 *
140 * @see WP_REST_Attachments_Controller::get_filename_from_disposition()
141 *
142 */
143 protected static function get_filename_from_disposition( $disposition_header ) {
144 // Get the filename.
145 $filename = null;
146
147 foreach ( $disposition_header as $value ) {
148 $value = trim( $value );
149
150 if ( strpos( $value, ';' ) === false ) {
151 continue;
152 }
153
154 list( $type, $attr_parts ) = explode( ';', $value, 2 );
155
156 $attr_parts = explode( ';', $attr_parts );
157 $attributes = [];
158
159 foreach ( $attr_parts as $part ) {
160 if ( strpos( $part, '=' ) === false ) {
161 continue;
162 }
163
164 list( $key, $value ) = explode( '=', $part, 2 );
165
166 $attributes[ trim( $key ) ] = trim( $value );
167 }
168
169 if ( empty( $attributes['filename'] ) ) {
170 continue;
171 }
172
173 $filename = trim( $attributes['filename'] );
174
175 // Unquote quoted filename, but after trimming.
176 if ( substr( $filename, 0, 1 ) === '"' && substr( $filename, -1, 1 ) === '"' ) {
177 $filename = substr( $filename, 1, -1 );
178 }
179 }
180
181 return $filename;
182 }
183
184 /**
185 * Retrieves file extension by mime type.
186 *
187 * @param string $mime_type Mime type to search extension for.
188 *
189 * @return string|null File extension if available, or null if not found.
190 */
191 protected static function get_file_extension_by_mime_type( $mime_type ) {
192 static $map = null;
193
194 if ( is_array( $map ) ) {
195 return isset( $map[ $mime_type ] ) ? $map[ $mime_type ] : null;
196 }
197
198 $mime_types = wp_get_mime_types();
199 $map = array_flip( $mime_types );
200
201 // Some types have multiple extensions, use only the first one.
202 foreach ( $map as $type => $extensions ) {
203 $map[ $type ] = strtok( $extensions, '|' );
204 }
205
206 return isset( $map[ $mime_type ] ) ? $map[ $mime_type ] : null;
207 }
208
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' && $post['post_type'] != 'betterdocs_faq' ) {
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 }
228
229 return $post;
230 }
231
232 /**
233 * The main controller for the actual import stage.
234 *
235 * @param string $file Path to the WXR file for importing
236 */
237 private function import( $file ) {
238 add_filter(
239 'import_post_meta_key',
240 function ( $key ) {
241 return $this->is_valid_meta_key( $key );
242 }
243 );
244 add_filter(
245 'http_request_timeout',
246 function () {
247 return self::DEFAULT_BUMP_REQUEST_TIMEOUT;
248 }
249 );
250
251 if ( ! $this->import_start( $file ) ) {
252 return;
253 }
254
255 $this->set_author_mapping();
256
257 wp_suspend_cache_invalidation( true );
258 $imported_summary = [
259 'terms' => $this->process_terms(),
260 'posts' => $this->process_posts()
261 ];
262 wp_suspend_cache_invalidation( false );
263
264 // Update incorrect/missing information in the DB.
265 $this->backfill_parents();
266 $this->backfill_attachment_urls();
267 $this->remap_featured_images();
268
269 $this->import_end();
270
271 $is_some_succeed = false;
272 foreach ( $imported_summary as $item ) {
273 if ( $item > 0 ) {
274 $is_some_succeed = true;
275 break;
276 }
277 }
278
279 if ( $is_some_succeed ) {
280 $this->output['status'] = 'success';
281 $this->output['summary'] = $imported_summary;
282 }
283 }
284
285 /**
286 * Parses the WXR file and prepares us for the task of processing parsed data.
287 *
288 * @param string $file Path to the WXR file for importing
289 */
290 private function import_start( string $file ): bool {
291 if ( ! is_file( $file ) ) {
292 $this->output['errors'] = [ esc_html__( 'The file does not exist, please try again.', 'betterdocs' ) ];
293
294 return false;
295 }
296
297 $action = $this->args['action'];
298
299 $import_data = $this->parse( $file );
300
301 if ( isset( $import_data['type'] ) && $import_data['type'] === 'sample/csv' ) {
302 $this->import_sample_data( $import_data['posts'], $action );
303 return true;
304 }
305
306 if ( is_wp_error( $import_data ) ) {
307 /**
308 * @var WP_Error $import_data ;
309 */
310 $this->output['errors'] = [ $import_data->get_error_message() ];
311
312 return false;
313 }
314
315 $posts = $import_data['posts'];
316 // Use array_map to apply the callback function to each item in the array
317 $posts = array_map( [ $this, 'modify_post_type' ], $posts );
318
319 if ( ! empty( $action ) ) {
320 $existing_posts = $this->args['existing_slug'];
321 $existing_posts_array = explode( ',', $existing_posts );
322
323 if ( $existing_posts && $action == 'ignore' ) {
324 // Filter out posts with slugs in $existing_slugs_array
325 $filtered_posts = array_filter(
326 $posts,
327 function ( $post ) use ( $existing_posts_array ) {
328 return ! in_array( $post['post_name'], $existing_posts_array );
329 }
330 );
331
332 // If you want the resulting array to have numeric keys
333 $posts = array_values( $filtered_posts );
334 }if ( $existing_posts_array && $action == 'replace' ) {
335 foreach ( $existing_posts_array as $slug ) {
336 $post = get_page_by_path( $slug, OBJECT, 'docs' ); // Replace 'docs' with your custom post type if needed
337
338 if ( $post ) {
339 $post_id = $post->ID;
340 // Permanently delete the post (including from trash)
341 wp_delete_post( $post_id, true );
342 }
343 }
344 }
345 }
346
347 if ( isset( $import_data['version'] ) ) {
348 $this->version = $import_data['version'];
349 }
350
351 $this->set_authors_from_import( $import_data );
352 $this->posts = $posts;
353 $this->terms = $import_data['terms'];
354
355 if ( isset( $import_data['base_url'] ) ) {
356 $this->base_url = esc_url( $import_data['base_url'] );
357 }
358
359 if ( isset( $import_data['base_blog_url'] ) ) {
360 $this->base_blog_url = esc_url( $import_data['base_blog_url'] );
361 }
362
363 if ( isset( $import_data['page_on_front'] ) ) {
364 $this->page_on_front = $import_data['page_on_front'];
365 }
366
367 wp_defer_term_counting( true );
368 wp_defer_comment_counting( true );
369
370 do_action( 'import_start', $this );
371
372 return true;
373 }
374
375 /**
376 * Performs post-import cleanup of files and the cache
377 */
378 private function import_end() {
379 wp_import_cleanup( $this->id );
380
381 wp_cache_flush();
382
383 foreach ( get_taxonomies() as $tax ) {
384 delete_option( "{$tax}_children" );
385 _get_term_hierarchy( $tax );
386 }
387
388 wp_defer_term_counting( false );
389 wp_defer_comment_counting( false );
390
391 do_action( 'import_end' );
392 }
393
394 /**
395 * Retrieve authors from parsed WXR data and set it to `$this->>authors`.
396 *
397 * Uses the provided author information from WXR 1.1 files
398 * or extracts info from each post for WXR 1.0 files
399 *
400 * @param array $import_data Data returned by a WXR parser
401 */
402 private function set_authors_from_import( $import_data ) {
403 if ( ! empty( $import_data['authors'] ) ) {
404 $this->authors = $import_data['authors'];
405 // No author information, grab it from the posts.
406 } else {
407 foreach ( $import_data['posts'] as $post ) {
408 $login = sanitize_user( $post['post_author'], true );
409
410 if ( empty( $login ) ) {
411 /* translators: %s: Post author. */
412 $this->output['errors'][] = sprintf( esc_html__( 'Failed to import author %s. Their posts will be attributed to the current user.', 'betterdocs' ), $post['post_author'] );
413 continue;
414 }
415
416 if ( ! isset( $this->authors[ $login ] ) ) {
417 $this->authors[ $login ] = [
418 'author_login' => $login,
419 'author_display_name' => $post['post_author']
420 ];
421 }
422 }
423 }
424 }
425
426 /**
427 * Map old author logins to local user IDs based on decisions made
428 * in import options form. Can map to an existing user, create a new user
429 * or falls back to the current user in case of error with either of the previous
430 */
431 private function set_author_mapping() {
432 if ( ! isset( $this->args['imported_authors'] ) ) {
433 return;
434 }
435
436 $create_users = apply_filters( 'import_allow_create_users', self::DEFAULT_ALLOW_CREATE_USERS );
437
438 foreach ( (array) $this->args['imported_authors'] as $i => $old_login ) {
439 // Multisite adds strtolower to sanitize_user. Need to sanitize here to stop breakage in process_posts.
440 $santized_old_login = sanitize_user( $old_login, true );
441 $old_id = isset( $this->authors[ $old_login ]['author_id'] ) ? (int) $this->authors[ $old_login ]['author_id'] : false;
442
443 if ( ! empty( $this->args['user_map'][ $i ] ) ) {
444 $user = get_userdata( (int) $this->args['user_map'][ $i ] );
445 if ( isset( $user->ID ) ) {
446 if ( $old_id ) {
447 $this->processed_authors[ $old_id ] = $user->ID;
448 }
449 $this->author_mapping[ $santized_old_login ] = $user->ID;
450 }
451 } elseif ( $create_users ) {
452 $user_id = 0;
453 if ( ! empty( $this->args['user_new'][ $i ] ) ) {
454 $user_id = wp_create_user( $this->args['user_new'][ $i ], wp_generate_password() );
455 } elseif ( '1.0' !== $this->version ) {
456 $user_data = [
457 'user_login' => $old_login,
458 'user_pass' => wp_generate_password(),
459 'user_email' => isset( $this->authors[ $old_login ]['author_email'] ) ? $this->authors[ $old_login ]['author_email'] : '',
460 'display_name' => $this->authors[ $old_login ]['author_display_name'],
461 'first_name' => isset( $this->authors[ $old_login ]['author_first_name'] ) ? $this->authors[ $old_login ]['author_first_name'] : '',
462 'last_name' => isset( $this->authors[ $old_login ]['author_last_name'] ) ? $this->authors[ $old_login ]['author_last_name'] : ''
463 ];
464 $user_id = wp_insert_user( $user_data );
465 }
466
467 if ( ! is_wp_error( $user_id ) ) {
468 if ( $old_id ) {
469 $this->processed_authors[ $old_id ] = $user_id;
470 }
471 $this->author_mapping[ $santized_old_login ] = $user_id;
472 } else {
473 /* translators: %s: Author display name. */
474 $error = sprintf( esc_html__( 'Failed to create new user for %s. Their posts will be attributed to the current user.', 'betterdocs' ), $this->authors[ $old_login ]['author_display_name'] );
475
476 if ( defined( 'IMPORT_DEBUG' ) && IMPORT_DEBUG ) {
477 $error .= PHP_EOL . $user_id->get_error_message();
478 }
479
480 $this->output['errors'][] = $error;
481 }
482 }
483
484 // Failsafe: if the user_id was invalid, default to the current user.
485 if ( ! isset( $this->author_mapping[ $santized_old_login ] ) ) {
486 if ( $old_id ) {
487 $this->processed_authors[ $old_id ] = (int) get_current_user_id();
488 }
489 $this->author_mapping[ $santized_old_login ] = (int) get_current_user_id();
490 }
491 }
492 }
493
494 /**
495 * Create new terms based on import information
496 *
497 * Doesn't create a term its slug already exists
498 *
499 * @return array|array[] the ids of succeed/failed imported terms.
500 */
501 private function process_terms(): array {
502 $result = [
503 'succeed' => [],
504 'failed' => []
505 ];
506
507 $this->terms = apply_filters( 'wp_import_terms', $this->terms );
508
509 if ( empty( $this->terms ) ) {
510 return $result;
511 }
512
513 foreach ( $this->terms as $term ) {
514 // if the term already exists in the correct taxonomy leave it alone
515 $term_id = term_exists( $term['slug'], $term['term_taxonomy'] );
516 if ( $term_id ) {
517 if ( is_array( $term_id ) ) {
518 $term_id = $term_id['term_id'];
519 }
520
521 if ( isset( $term['term_id'] ) ) {
522 if ( 'nav_menu' === $term['term_taxonomy'] ) {
523 // BC - support old kits that the menu terms are part of the 'nav_menu_item' post type
524 // and not part of the taxonomies.
525 if ( ! empty( $this->processed_taxonomies[ $term['term_taxonomy'] ] ) ) {
526 foreach ( $this->processed_taxonomies[ $term['term_taxonomy'] ] as $processed_term ) {
527 $old_slug = $processed_term['old_slug'];
528 $new_slug = $processed_term['new_slug'];
529
530 $this->mapped_terms_slug[ $old_slug ] = $new_slug;
531 $result['succeed'][ $old_slug ] = $new_slug;
532 }
533 continue;
534 } else {
535 $term = $this->handle_duplicated_nav_menu_term( $term );
536 }
537 } else {
538 $this->processed_terms[ (int) $term['term_id'] ] = (int) $term_id;
539 $result['succeed'][ (int) $term['term_id'] ] = (int) $term_id;
540 continue;
541 }
542 }
543 }
544
545 if ( empty( $term['term_parent'] ) ) {
546 $parent = 0;
547 } else {
548 $parent = term_exists( $term['term_parent'], $term['term_taxonomy'] );
549 if ( is_array( $parent ) ) {
550 $parent = $parent['term_id'];
551 }
552 }
553
554 $description = $term['term_description'] ?? '';
555 $args = [
556 'slug' => $term['slug'],
557 'description' => wp_slash( $description ),
558 'parent' => (int) $parent
559 ];
560
561 $id = wp_insert_term( wp_slash( $term['term_name'] ), $term['term_taxonomy'], $args );
562
563 if ( ! is_wp_error( $id ) ) {
564 if ( isset( $term['term_id'] ) ) {
565 $this->processed_terms[ (int) $term['term_id'] ] = $id['term_id'];
566 $result['succeed'][ (int) $term['term_id'] ] = $id['term_id'];
567
568 $this->update_term_meta( $id['term_id'] );
569 }
570 } else {
571 /* translators: 1: Term taxonomy, 2: Term name. */
572 $error = sprintf( esc_html__( 'Failed to import %1$s %2$s', 'betterdocs' ), $term['term_taxonomy'], $term['term_name'] );
573
574 if ( defined( 'IMPORT_DEBUG' ) && IMPORT_DEBUG ) {
575 $error .= PHP_EOL . $id->get_error_message();
576 }
577
578 $result['failed'][] = $id;
579 $this->output['errors'][] = $error;
580 continue;
581 }
582 $this->process_termmeta( $term, $id['term_id'] );
583
584 do_action( 'betterdocs_import.process_term', $term, $this, $result );
585 }
586
587 unset( $this->terms );
588
589 return $result;
590 }
591
592 /**
593 * Add metadata to imported term.
594 *
595 * @param array $term Term data from WXR import.
596 * @param int $term_id ID of the newly created term.
597 */
598 private function process_termmeta( $term, $term_id ) {
599 if ( ! function_exists( 'add_term_meta' ) ) {
600 return;
601 }
602
603 if ( ! isset( $term['termmeta'] ) ) {
604 $term['termmeta'] = [];
605 }
606
607 /**
608 * Filters the metadata attached to an imported term.
609 *
610 * @param array $termmeta Array of term meta.
611 * @param int $term_id ID of the newly created term.
612 * @param array $term Term data from the WXR import.
613 */
614 $term['termmeta'] = apply_filters( 'wp_import_term_meta', $term['termmeta'], $term_id, $term );
615
616 if ( empty( $term['termmeta'] ) ) {
617 return;
618 }
619
620 foreach ( $term['termmeta'] as $meta ) {
621 /**
622 * Filters the meta key for an imported piece of term meta.
623 *
624 * @param string $meta_key Meta key.
625 * @param int $term_id ID of the newly created term.
626 * @param array $term Term data from the WXR import.
627 */
628 $key = apply_filters( 'import_term_meta_key', $meta['key'], $term_id, $term );
629 if ( ! $key ) {
630 continue;
631 }
632
633 // Export gets meta straight from the DB so could have a serialized string
634 $value = maybe_unserialize( $meta['value'] );
635 $insert_meta = add_term_meta( $term_id, wp_slash( $key ), wp_slash_strings_only( $value ) );
636 /**
637 * Fires after term meta is imported.
638 *
639 * @param int $term_id ID of the newly created term.
640 * @param string $key Meta key.
641 * @param mixed $value Meta value.
642 */
643 do_action( 'import_term_meta', $term_id, $key, $value );
644 }
645 }
646
647 public function existing_slug_action( $posts, $action ) {
648 $existing_posts = $this->args['existing_slug'];
649 $existing_posts_array = explode( ',', $existing_posts );
650
651 if ( $existing_posts && $action == 'ignore' ) {
652 // Filter out posts with slugs in $existing_slugs_array
653 $filtered_posts = array_filter(
654 $posts,
655 function ( $post ) use ( $existing_posts_array ) {
656 return ! in_array( $post['post_name'], $existing_posts_array );
657 }
658 );
659
660 // If you want the resulting array to have numeric keys
661 $posts = array_values( $filtered_posts );
662 }if ( $existing_posts_array && $action == 'replace' ) {
663 foreach ( $existing_posts_array as $slug ) {
664 $post = get_page_by_path( $slug, OBJECT, 'docs' ); // Replace 'docs' with your custom post type if needed
665
666 if ( $post ) {
667 $post_id = $post->ID;
668 // Permanently delete the post (including from trash)
669 wp_delete_post( $post_id, true );
670 }
671 }
672 }
673
674 return $posts;
675 }
676
677 /**
678 * Fix encoding issues in CSV data
679 *
680 * @param string $text
681 * @return string
682 */
683 private function fix_encoding_issues( $text ) {
684 if ( empty( $text ) ) {
685 return $text;
686 }
687
688 // Convert to UTF-8 if not already
689 if ( ! mb_check_encoding( $text, 'UTF-8' ) ) {
690 $detected_encoding = mb_detect_encoding( $text, ['UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII'], true );
691 if ( $detected_encoding ) {
692 $text = mb_convert_encoding( $text, 'UTF-8', $detected_encoding );
693 } else {
694 // Fallback: assume ISO-8859-1 if detection fails
695 $text = mb_convert_encoding( $text, 'UTF-8', 'ISO-8859-1' );
696 }
697 }
698
699 // Fix common encoding issues
700 $replacements = [
701 // Smart quotes (using Unicode escape sequences)
702 "\u{2019}" => "'", // Right single quotation mark
703 "\u{2018}" => "'", // Left single quotation mark
704 "\u{201C}" => '"', // Left double quotation mark
705 "\u{201D}" => '"', // Right double quotation mark
706 "\u{2013}" => '-', // En dash
707 "\u{2014}" => '--', // Em dash
708 "\u{2026}" => '...', // Horizontal ellipsis
709 // HTML entities that might be double-encoded
710 '&amp;' => '&',
711 '&lt;' => '<',
712 '&gt;' => '>',
713 '&quot;' => '"',
714 // Remove replacement characters
715 "\u{FFFD}" => '', // Replacement character
716 ];
717
718 $text = str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
719
720 // Remove any remaining non-printable characters except newlines and tabs
721 $text = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text );
722
723 return $text;
724 }
725
726 private function import_sample_data( $posts, $action ) {
727 $result = [
728 'succeed' => [],
729 'failed' => []
730 ];
731
732 if ( ! empty( $action ) ) {
733 $posts = $this->existing_slug_action( $posts, $action );
734 }
735
736 foreach ( $posts as $post ) {
737 $post_title = isset( $post['Docs Title'] ) ? $post['Docs Title'] : '';
738 $post_name = isset( $post['post_name'] ) ? $post['post_name'] : '';
739 $post_content = isset( $post['Docs Content'] ) ? $post['Docs Content'] : '';
740 $featured_image_url = isset( $post['Featured Image'] ) ? $post['Featured Image'] : '';
741 $category_name = isset( $post['Category Name'] ) ? $post['Category Name'] : '';
742 $category_slug = isset( $post['Category Slug'] ) ? $post['Category Slug'] : $category_name;
743 $knowledge_base_name = isset( $post['Knowledge Base'] ) ? $post['Knowledge Base'] : '';
744 $knowledge_base_slug = isset( $post['Knowledge Base Slug'] ) ? $post['Knowledge Base Slug'] : $knowledge_base_name;
745 $post_status = isset( $post['Status'] ) ? $post['Status'] : '';
746
747 // Check if the category and knowledge base term exist, if not, create them
748 $default_multiple_kb = betterdocs()->settings->get( 'multiple_kb' );
749
750 $knowledge_base_id = 0;
751 if ( $default_multiple_kb == 1 && $knowledge_base_slug ) {
752 $existing_kb = term_exists( $knowledge_base_name, 'knowledge_base' );
753 if ( ! $existing_kb && $knowledge_base_name ) {
754 $kb_term = wp_insert_term( $knowledge_base_name, 'knowledge_base', [ 'slug' => $knowledge_base_slug ] );
755 if ( is_wp_error( $kb_term ) ) {
756 $knowledge_base_id = 0;
757 } else {
758 $knowledge_base_id = $kb_term['term_id'];
759 }
760 } elseif ( $existing_kb ) {
761 $knowledge_base_id = $existing_kb['term_id'];
762 }
763 }
764
765 $category_id = 0;
766 if ( $category_slug ) {
767 $existing_category = term_exists( $category_name, 'doc_category' );
768 if ( ! $existing_category ) {
769 $category_term = wp_insert_term( $category_name, 'doc_category', [ 'slug' => $category_slug ] );
770 if ( is_wp_error( $category_term ) ) {
771 $category_id = 0;
772 } else {
773 $category_id = $category_term['term_id'];
774 }
775 } else {
776 $category_id = $existing_category['term_id'];
777 }
778
779 if ( $default_multiple_kb == 1 && $knowledge_base_id && $category_id ) {
780 $kb_slug = get_term_field( 'slug', $knowledge_base_id, 'knowledge_base' );
781 if ( ! is_wp_error( $kb_slug ) ) {
782 $doc_category_kb = rest_sanitize_array( [ $kb_slug ] );
783 update_term_meta( $category_id, 'doc_category_knowledge_base', $doc_category_kb );
784 }
785 }
786 }
787
788 // Validate and sanitize post data with encoding fixes
789 $post_title = $this->fix_encoding_issues( trim( $post_title ) );
790 $post_content = $this->fix_encoding_issues( trim( $post_content ) );
791 $post_name = $this->fix_encoding_issues( trim( $post_name ) );
792 $post_status = !empty( $post_status ) ? $post_status : 'publish';
793
794 // Skip posts with empty titles
795 if ( empty( $post_title ) ) {
796 $result['failed'][] = [
797 'title' => 'Empty Title',
798 'error' => 'Post title is required'
799 ];
800 continue;
801 }
802
803 // Additional WordPress validation checks
804 $validation_errors = [];
805
806 // Check title length (WordPress has a 200 character limit for post_title)
807 if ( strlen( $post_title ) > 200 ) {
808 $validation_errors[] = 'Title too long (max 200 characters)';
809 }
810
811 // Check for valid post status
812 $valid_statuses = get_post_stati();
813 if ( !in_array( $post_status, array_keys( $valid_statuses ) ) ) {
814 $post_status = 'publish';
815 }
816
817 // Check if post with same title already exists
818 $existing_posts = get_posts([
819 'post_type' => 'docs',
820 'title' => $post_title,
821 'post_status' => 'any',
822 'numberposts' => 1
823 ]);
824 if ( !empty( $existing_posts ) ) {
825 $validation_errors[] = 'Post with same title already exists (ID: ' . $existing_posts[0]->ID . ')';
826 }
827
828 // Check if slug already exists (if provided)
829 if ( !empty( $post_name ) ) {
830 $existing_by_slug = get_posts([
831 'post_type' => 'docs',
832 'name' => $post_name,
833 'post_status' => 'any',
834 'numberposts' => 1
835 ]);
836 }
837
838 if ( !empty( $validation_errors ) ) {
839 $result['failed'][] = [
840 'title' => $post_title,
841 'error' => implode(', ', $validation_errors)
842 ];
843 continue;
844 }
845
846 // Set up the post data
847 $post_data = [
848 'post_title' => $post_title,
849 'post_name' => $post_name,
850 'post_content' => $post_content,
851 'post_status' => $post_status,
852 'post_type' => 'docs'
853 ];
854
855 if ( $category_name && $category_id ) {
856 $post_data['tax_input']['doc_category'] = [ $category_id ];
857 }
858
859 if ( $default_multiple_kb == 1 && $knowledge_base_id ) {
860 $post_data['tax_input']['knowledge_base'] = [ $knowledge_base_id ];
861 }
862
863 // Insert the post (without knowledge_base taxonomy to avoid capability issues)
864 $post_data_without_kb = $post_data;
865 if (isset($post_data_without_kb['tax_input']['knowledge_base'])) {
866 $kb_terms = $post_data_without_kb['tax_input']['knowledge_base'];
867 unset($post_data_without_kb['tax_input']['knowledge_base']);
868 }
869
870 $post_id = wp_insert_post( $post_data_without_kb );
871
872 // Manually assign knowledge_base terms after post creation to bypass capability check
873 if ( !is_wp_error($post_id) && $post_id > 0 && isset($kb_terms) && !empty($kb_terms) ) {
874 // Convert term IDs to integers and ensure they exist
875 $term_ids = array_map('intval', $kb_terms);
876
877 $result_kb = wp_set_object_terms( $post_id, $term_ids, 'knowledge_base' );
878 if ( !is_wp_error($result_kb) ) {
879 $assigned_terms = wp_get_object_terms($post_id, 'knowledge_base');
880 if (!empty($assigned_terms)) {
881 $term_names = array_map(function($term) { return $term->name; }, $assigned_terms);
882 }
883 }
884 }
885
886 // Enhanced debugging for failed insertions
887 if ( is_wp_error( $post_id ) ) {
888 $result['failed'][] = [
889 'title' => $post_title,
890 'error' => $post_id->get_error_message()
891 ];
892 continue;
893 } elseif ( $post_id === 0 ) {
894 $result['failed'][] = [
895 'title' => $post_title,
896 'error' => 'Post insertion returned 0 - validation failed'
897 ];
898 continue;
899 } else {
900 $result['succeed'][] = [
901 'title' => $post_title,
902 'id' => $post_id
903 ];
904 }
905 // Set the featured image
906 if ( $featured_image_url ) {
907 $this->set_post_thumbnail( $post_id, $featured_image_url );
908 }
909 }
910
911 return $result;
912 }
913
914 public function import_helpscout_data( $posts ) {
915 $result = [
916 'succeed' => [],
917 'failed' => []
918 ];
919
920 foreach ( $posts as $post ) {
921 $post_title = isset( $post['name'] ) ? $post['name'] : '';
922 $post_slug = isset( $post['slug'] ) ? $post['slug'] : '';
923 $post_content = isset( $post['text'] ) ? $post['text'] : '';
924 $categories = isset( $post['categories'] ) ? $post['categories'] : '';
925 $post_status = isset( $post['status'] ) ? $post['status'] : '';
926
927 // Check if the category and knowledge base term exist, if not, create them
928 $category_ids = [];
929 foreach ( $categories as $category ) {
930 $category_id = term_exists( $category['slug'], 'doc_category' );
931 if ( ! $category_id ) {
932 $category_id = wp_insert_term( $category['name'], 'doc_category', [ 'slug' => $category['slug'] ] );
933 $category_ids[] = $category_id['term_id'];
934 } else {
935 $category_ids[] = $category_id['term_id'];
936 }
937 }
938
939 // Set up the post data
940 $post_data = [
941 'post_title' => $post_title,
942 'post_name' => $post_slug,
943 'post_content' => $post_content,
944 'post_status' => 'publish',
945 'post_type' => 'docs',
946 'tax_input' => [
947 'doc_category' => $category_ids
948 ]
949 ];
950
951 // Insert the post
952 wp_insert_post( $post_data );
953 }
954
955 return $result;
956 }
957
958 public function set_post_thumbnail( $post_id, $attachment_url ) {
959 require_once ABSPATH . 'wp-admin/includes/image.php';
960 require_once ABSPATH . 'wp-admin/includes/media.php';
961 require_once ABSPATH . 'wp-admin/includes/file.php';
962 $post_title = pathinfo( $attachment_url, PATHINFO_FILENAME );
963 $post_name = pathinfo( $attachment_url, PATHINFO_FILENAME );
964
965 $image_id = media_sideload_image( $attachment_url, $post_id, $post_title, $post_name );
966 set_post_thumbnail( $post_id, $image_id );
967
968 return $image_id;
969 }
970
971 /**
972 * Create new posts based on import information
973 *
974 * Posts marked as having a parent which doesn't exist will become top level items.
975 * Doesn't create a new post if: the post type doesn't exist, the given post ID
976 * is already noted as imported or a post with the same title and date already exists.
977 * Note that new/updated terms, comments and meta are imported for the last of the above.
978 *
979 * @return array the ids of succeed/failed imported posts.
980 */
981 private function process_posts(): array {
982 $result = [
983 'succeed' => [],
984 'failed' => []
985 ];
986
987 $this->posts = apply_filters( 'wp_import_posts', $this->posts );
988
989 foreach ( $this->posts as $post ) {
990 $post = apply_filters( 'wp_import_post_data_raw', $post );
991
992 if ( ! post_type_exists( $post['post_type'] ) ) {
993 /* translators: 1: Post title, 2: Post type. */
994 $this->output['errors'][] = sprintf( esc_html__( 'Failed to import %1$s: Invalid post type %2$s', 'betterdocs' ), $post['post_title'], $post['post_type'] );
995 do_action( 'wp_import_post_exists', $post );
996 continue;
997 }
998
999 if ( isset( $this->processed_posts[ $post['post_id'] ] ) && ! empty( $post['post_id'] ) ) {
1000 continue;
1001 }
1002
1003 if ( 'auto-draft' === $post['status'] ) {
1004 continue;
1005 }
1006
1007 $post_type_object = get_post_type_object( $post['post_type'] );
1008
1009 $post_parent = (int) $post['post_parent'];
1010 if ( $post_parent ) {
1011 // if we already know the parent, map it to the new local ID.
1012 if ( isset( $this->processed_posts[ $post_parent ] ) ) {
1013 $post_parent = $this->processed_posts[ $post_parent ];
1014 // otherwise record the parent for later.
1015 } else {
1016 $this->post_orphans[ (int) $post['post_id'] ] = $post_parent;
1017 $post_parent = 0;
1018 }
1019 }
1020
1021 // Map the post author.
1022 $author = sanitize_user( $post['post_author'], true );
1023 if ( isset( $this->author_mapping[ $author ] ) ) {
1024 $author = $this->author_mapping[ $author ];
1025 } else {
1026 $author = (int) get_current_user_id();
1027 }
1028
1029 $postdata = [
1030 'post_author' => $author,
1031 'post_content' => isset( $post['post_content'] ) ? $this->fix_encoding_issues( $post['post_content'] ) : '',
1032 'post_excerpt' => isset( $post['post_excerpt'] ) ? $this->fix_encoding_issues( $post['post_excerpt'] ) : '',
1033 'post_title' => isset( $post['post_title'] ) ? $this->fix_encoding_issues( $post['post_title'] ) : '',
1034 'post_status' => isset( $post['status'] ) ? $post['status'] : '',
1035 'post_name' => isset( $post['post_name'] ) ? $this->fix_encoding_issues( $post['post_name'] ) : '',
1036 'comment_status' => isset( $post['comment_status'] ) ? $post['comment_status'] : '',
1037 'ping_status' => isset( $post['ping_status'] ) ? $post['ping_status'] : '',
1038 'guid' => isset( $post['guid'] ) ? $this->fix_encoding_issues( $post['guid'] ) : '',
1039 'post_parent' => $post_parent,
1040 'menu_order' => isset( $post['menu_order'] ) ? $post['menu_order'] : '',
1041 'post_type' => isset( $post['post_type'] ) ? $post['post_type'] : '',
1042 'post_password' => isset( $post['post_password'] ) ? $this->fix_encoding_issues( $post['post_password'] ) : ''
1043 ];
1044
1045 $original_post_id = $post['post_id'];
1046 $postdata = apply_filters( 'wp_import_post_data_processed', $postdata, $post );
1047 $postdata = wp_slash( $postdata );
1048
1049 if ( 'attachment' === $postdata['post_type'] ) {
1050 $remote_url = ! empty( $post['attachment_url'] ) ? $post['attachment_url'] : $post['guid'];
1051
1052 // try to use _wp_attached file for upload folder placement to ensure the same location as the export site
1053 // e.g. location is 2003/05/image.jpg but the attachment post_date is 2010/09, see media_handle_upload()
1054 $postdata['upload_date'] = isset( $post['post_date'] ) ? $post['post_date'] : '';
1055 if ( isset( $post['postmeta'] ) ) {
1056 foreach ( $post['postmeta'] as $meta ) {
1057 if ( '_wp_attached_file' === $meta['key'] ) {
1058 if ( preg_match( '%^[0-9]{4}/[0-9]{2}%', $meta['value'], $matches ) ) {
1059 $postdata['upload_date'] = $matches[0];
1060 }
1061 break;
1062 }
1063 }
1064 }
1065
1066 // $post_id = $this->set_post_thumbnail( $postdata['post_parent'], $remote_url );
1067 $post_id = $this->process_attachment( $postdata, $remote_url );
1068
1069 $comment_post_id = $post_id;
1070 } else {
1071 $post_id = wp_insert_post( $postdata, true );
1072
1073 $this->update_post_meta( $post_id );
1074
1075 $comment_post_id = $post_id;
1076 do_action( 'wp_import_insert_post', $post_id, $original_post_id, $postdata, $post );
1077 }
1078
1079 if ( is_wp_error( $post_id ) ) {
1080 /* translators: 1: Post type singular label, 2: Post title. */
1081 $error = sprintf( __( 'Failed to import %1$s %2$s', 'betterdocs' ), $post_type_object->labels->singular_name, $post['post_title'] );
1082
1083 if ( defined( 'IMPORT_DEBUG' ) && IMPORT_DEBUG ) {
1084 $error .= PHP_EOL . $post_id->get_error_message();
1085 }
1086
1087 $result['failed'][] = $original_post_id;
1088
1089 $this->output['errors'][] = $error;
1090
1091 continue;
1092 }
1093
1094 $result['succeed'][ $original_post_id ] = $post_id;
1095
1096 if ( isset( $post['is_sticky'] ) && 1 === $post['is_sticky'] ) {
1097 stick_post( $post_id );
1098 }
1099
1100 if ( $this->page_on_front === $original_post_id ) {
1101 update_option( 'page_on_front', $post_id );
1102 }
1103
1104 // Map pre-import ID to local ID.
1105 $this->processed_posts[ (int) $post['post_id'] ] = (int) $post_id;
1106
1107 if ( ! isset( $post['terms'] ) ) {
1108 $post['terms'] = [];
1109 }
1110
1111 $post['terms'] = apply_filters( 'wp_import_post_terms', $post['terms'], $post_id, $post );
1112
1113 // add categories, tags and other terms
1114 if ( ! empty( $post['terms'] ) ) {
1115 $terms_to_set = [];
1116 foreach ( $post['terms'] as $term ) {
1117 // back compat with WXR 1.0 map 'tag' to 'post_tag'
1118 $taxonomy = ( 'tag' === $term['domain'] ) ? 'post_tag' : $term['domain'];
1119
1120 // Apply encoding fixes to term data
1121 $term_name = $this->fix_encoding_issues( $term['name'] );
1122 $term_slug = $this->fix_encoding_issues( $term['slug'] );
1123
1124 $term_exists = term_exists( $term_slug, $taxonomy );
1125 $term_id = is_array( $term_exists ) ? $term_exists['term_id'] : $term_exists;
1126 if ( ! $term_id ) {
1127 $t = wp_insert_term( $term_name, $taxonomy, [ 'slug' => $term_slug ] );
1128 if ( ! is_wp_error( $t ) ) {
1129 $term_id = $t['term_id'];
1130
1131 $this->update_term_meta( $term_id );
1132
1133 do_action( 'wp_import_insert_term', $t, $term, $post_id, $post );
1134 } else {
1135 /* translators: 1: Taxonomy name, 2: Term name. */
1136 $error = sprintf( esc_html__( 'Failed to import %1$s %2$s', 'betterdocs' ), $taxonomy, $term['name'] );
1137
1138 if ( defined( 'IMPORT_DEBUG' ) && IMPORT_DEBUG ) {
1139 $error .= PHP_EOL . $t->get_error_message();
1140 }
1141
1142 $this->output['errors'][] = $error;
1143
1144 do_action( 'wp_import_insert_term_failed', $t, $term, $post_id, $post );
1145 continue;
1146 }
1147 }
1148 $terms_to_set[ $taxonomy ][] = (int) $term_id;
1149 }
1150
1151 foreach ( $terms_to_set as $tax => $ids ) {
1152 // Handle knowledge_base taxonomy capability issues
1153 if ( $tax === 'knowledge_base' ) {
1154 $tt_ids = wp_set_object_terms( $post_id, $ids, $tax );
1155 } else {
1156 $tt_ids = wp_set_post_terms( $post_id, $ids, $tax );
1157 }
1158 do_action( 'wp_import_set_post_terms', $tt_ids, $ids, $tax, $post_id, $post );
1159 }
1160 unset( $post['terms'], $terms_to_set );
1161 }
1162
1163 if ( ! isset( $post['comments'] ) ) {
1164 $post['comments'] = [];
1165 }
1166
1167 $post['comments'] = apply_filters( 'wp_import_post_comments', $post['comments'], $post_id, $post );
1168
1169 // Add/update comments.
1170 if ( ! empty( $post['comments'] ) ) {
1171 $num_comments = 0;
1172 $inserted_comments = [];
1173 foreach ( $post['comments'] as $comment ) {
1174 $comment_id = $comment['comment_id'];
1175 $newcomments[ $comment_id ]['comment_post_ID'] = $comment_post_id;
1176 $newcomments[ $comment_id ]['comment_author'] = $comment['comment_author'];
1177 $newcomments[ $comment_id ]['comment_author_email'] = $comment['comment_author_email'];
1178 $newcomments[ $comment_id ]['comment_author_IP'] = $comment['comment_author_IP'];
1179 $newcomments[ $comment_id ]['comment_author_url'] = $comment['comment_author_url'];
1180 $newcomments[ $comment_id ]['comment_date'] = $comment['comment_date'];
1181 $newcomments[ $comment_id ]['comment_date_gmt'] = $comment['comment_date_gmt'];
1182 $newcomments[ $comment_id ]['comment_content'] = $comment['comment_content'];
1183 $newcomments[ $comment_id ]['comment_approved'] = $comment['comment_approved'];
1184 $newcomments[ $comment_id ]['comment_type'] = $comment['comment_type'];
1185 $newcomments[ $comment_id ]['comment_parent'] = $comment['comment_parent'];
1186 $newcomments[ $comment_id ]['commentmeta'] = isset( $comment['commentmeta'] ) ? $comment['commentmeta'] : [];
1187 if ( isset( $this->processed_authors[ $comment['comment_user_id'] ] ) ) {
1188 $newcomments[ $comment_id ]['user_id'] = $this->processed_authors[ $comment['comment_user_id'] ];
1189 }
1190 }
1191
1192 ksort( $newcomments );
1193
1194 foreach ( $newcomments as $key => $comment ) {
1195 if ( isset( $inserted_comments[ $comment['comment_parent'] ] ) ) {
1196 $comment['comment_parent'] = $inserted_comments[ $comment['comment_parent'] ];
1197 }
1198
1199 $comment_data = wp_slash( $comment );
1200 unset( $comment_data['commentmeta'] ); // Handled separately, wp_insert_comment() also expects `comment_meta`.
1201 $comment_data = wp_filter_comment( $comment_data );
1202
1203 $inserted_comments[ $key ] = wp_insert_comment( $comment_data );
1204
1205 do_action( 'wp_import_insert_comment', $inserted_comments[ $key ], $comment, $comment_post_id, $post );
1206
1207 foreach ( $comment['commentmeta'] as $meta ) {
1208 $value = maybe_unserialize( $meta['value'] );
1209
1210 add_comment_meta( $inserted_comments[ $key ], wp_slash( $meta['key'] ), wp_slash_strings_only( $value ) );
1211 }
1212
1213 ++$num_comments;
1214 }
1215 unset( $newcomments, $inserted_comments, $post['comments'] );
1216 }
1217
1218 if ( ! isset( $post['postmeta'] ) ) {
1219 $post['postmeta'] = [];
1220 }
1221
1222 $post['postmeta'] = apply_filters( 'wp_import_post_meta', $post['postmeta'], $post_id, $post );
1223
1224 // WPML language metadata transported via synthetic postmeta — captured
1225 // here so the keys never persist on the post (WPML's icl_translations
1226 // is the source of truth on the target site).
1227 $wpml_lang = '';
1228 $wpml_source_slug = '';
1229
1230 // Add/update post meta.
1231 if ( ! empty( $post['postmeta'] ) ) {
1232 $imported_meta_keys = []; // Track imported meta keys to prevent duplicates
1233
1234 foreach ( $post['postmeta'] as $meta ) {
1235 if ( WPMLSupport::META_LANG === $meta['key'] ) {
1236 $wpml_lang = (string) $meta['value'];
1237 continue;
1238 }
1239 if ( WPMLSupport::META_SOURCE_SLUG === $meta['key'] ) {
1240 $wpml_source_slug = (string) $meta['value'];
1241 continue;
1242 }
1243
1244 $key = apply_filters( 'import_post_meta_key', $meta['key'], $post_id, $post );
1245 $value = false;
1246
1247 if ( '_edit_last' === $key ) {
1248 if ( isset( $this->processed_authors[ (int) $meta['value'] ] ) ) {
1249 $value = $this->processed_authors[ (int) $meta['value'] ];
1250 } else {
1251 $key = false;
1252 }
1253 }
1254
1255 if ( $key ) {
1256 // Skip if this meta key has already been imported for this post
1257 if ( isset( $imported_meta_keys[ $key ] ) ) {
1258 continue;
1259 }
1260
1261 // Mark this meta key as imported
1262 $imported_meta_keys[ $key ] = true;
1263
1264 // Export gets meta straight from the DB so could have a serialized string.
1265 if ( ! $value ) {
1266 $value = maybe_unserialize( $meta['value'] );
1267 }
1268
1269 add_post_meta( $post_id, wp_slash( $key ), wp_slash_strings_only( $value ) );
1270
1271 do_action( 'import_post_meta', $post_id, $key, $value );
1272
1273 // If the post has a featured image, take note of this in case of remap.
1274 if ( '_thumbnail_id' === $key ) {
1275 $this->featured_images[ $post_id ] = (int) $value;
1276 }
1277 }
1278 }
1279 }
1280
1281 if ( $wpml_lang !== '' ) {
1282 WPMLSupport::assign_post_language( (int) $post_id, $wpml_lang, $wpml_source_slug );
1283 }
1284
1285 do_action( 'templately_import.process_post', $post, $this, $result );
1286 }
1287
1288 unset( $this->posts );
1289
1290 return $result;
1291 }
1292
1293 /**
1294 * If fetching attachments is enabled then attempt to create a new attachment
1295 *
1296 * @param array $post Attachment post details from WXR
1297 * @param string $url URL to fetch attachment from
1298 *
1299 * @return int|WP_Error Post ID on success, WP_Error otherwise
1300 */
1301 private function process_attachment( $post, $url ) {
1302 require_once ABSPATH . 'wp-admin/includes/image.php';
1303
1304 if ( ! $this->fetch_attachments ) {
1305 return new WP_Error( 'attachment_processing_error', esc_html__( 'Fetching attachments is not enabled', 'betterdocs' ) );
1306 }
1307
1308 // if the URL is absolute, but does not contain address, then upload it assuming base_site_url.
1309 if ( preg_match( '|^/[\w\W]+$|', $url ) ) {
1310 $url = rtrim( $this->base_url, '/' ) . $url;
1311 }
1312
1313 $upload = $this->fetch_remote_file( $url, $post );
1314 if ( is_wp_error( $upload ) ) {
1315 return $upload;
1316 }
1317
1318 $info = wp_check_filetype( $upload['file'] );
1319 if ( $info ) {
1320 $post['post_mime_type'] = $info['type'];
1321 } else {
1322 return new WP_Error( 'attachment_processing_error', esc_html__( 'Invalid file type', 'betterdocs' ) );
1323 }
1324
1325 $post['guid'] = $upload['url'];
1326
1327 // As per wp-admin/includes/upload.php.
1328 $post_id = wp_insert_attachment( $post, $upload['file'] );
1329 $this->update_post_meta( $post_id );
1330
1331 wp_update_attachment_metadata( $post_id, wp_generate_attachment_metadata( $post_id, $upload['file'] ) );
1332
1333 // Remap resized image URLs, works by stripping the extension and remapping the URL stub.
1334 if ( preg_match( '!^image/!', $info['type'] ) ) {
1335 $parts = pathinfo( $url );
1336 $name = basename( $parts['basename'], ".{$parts['extension']}" ); // PATHINFO_FILENAME in PHP 5.2
1337
1338 $parts_new = pathinfo( $upload['url'] );
1339 $name_new = basename( $parts_new['basename'], ".{$parts_new['extension']}" );
1340
1341 $this->url_remap[ $parts['dirname'] . '/' . $name ] = $parts_new['dirname'] . '/' . $name_new;
1342 }
1343
1344 return $post_id;
1345 }
1346
1347 /**
1348 * Attempt to download a remote file attachment
1349 *
1350 * @param string $url URL of item to fetch
1351 * @param array $post Attachment details
1352 *
1353 * @return array|WP_Error Local file location details on success, WP_Error otherwise
1354 */
1355 private function fetch_remote_file( $url, $post ) {
1356 include_once ABSPATH . '/wp-admin/includes/file.php';
1357
1358 // Extract the file name from the URL.
1359 $file_name = basename( parse_url( $url, PHP_URL_PATH ) );
1360
1361 if ( ! $file_name ) {
1362 $file_name = md5( $url );
1363 }
1364
1365 $tmp_file_name = wp_tempnam( $file_name );
1366 if ( ! $tmp_file_name ) {
1367 return new WP_Error( 'import_no_file', esc_html__( 'Could not create temporary file.', 'betterdocs' ) );
1368 }
1369
1370 // Fetch the remote URL and write it to the placeholder file.
1371 $remote_response = wp_safe_remote_get(
1372 $url,
1373 [
1374 'timeout' => 300,
1375 'stream' => true,
1376 'filename' => $tmp_file_name,
1377 'headers' => [
1378 'Accept-Encoding' => 'identity'
1379 ]
1380 ]
1381 );
1382
1383 if ( is_wp_error( $remote_response ) ) {
1384 @unlink( $tmp_file_name );
1385
1386 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() ) ) );
1387 }
1388
1389 $remote_response_code = (int) wp_remote_retrieve_response_code( $remote_response );
1390
1391 // Make sure the fetch was successful.
1392 if ( 200 !== $remote_response_code ) {
1393 @unlink( $tmp_file_name );
1394
1395 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 ) ) );
1396 }
1397
1398 $headers = wp_remote_retrieve_headers( $remote_response );
1399
1400 // Request failed.
1401 if ( ! $headers ) {
1402 @unlink( $tmp_file_name );
1403
1404 return new WP_Error( 'import_file_error', esc_html__( 'Remote server did not respond', 'betterdocs' ) );
1405 }
1406
1407 $filesize = (int) filesize( $tmp_file_name );
1408
1409 if ( 0 === $filesize ) {
1410 @unlink( $tmp_file_name );
1411
1412 return new WP_Error( 'import_file_error', esc_html__( 'Zero size file downloaded', 'betterdocs' ) );
1413 }
1414
1415 if ( ! isset( $headers['content-encoding'] ) && isset( $headers['content-length'] ) && $filesize !== (int) $headers['content-length'] ) {
1416 @unlink( $tmp_file_name );
1417
1418 return new WP_Error( 'import_file_error', esc_html__( 'Downloaded file has incorrect size', 'betterdocs' ) );
1419 }
1420
1421 $max_size = (int) apply_filters( 'import_attachment_size_limit', self::DEFAULT_IMPORT_ATTACHMENT_SIZE_LIMIT );
1422 if ( ! empty( $max_size ) && $filesize > $max_size ) {
1423 @unlink( $tmp_file_name );
1424
1425 /* translators: %s: Max file size. */
1426
1427 return new WP_Error( 'import_file_error', sprintf( esc_html__( 'Remote file is too large, limit is %s', 'betterdocs' ), size_format( $max_size ) ) );
1428 }
1429
1430 // Override file name with Content-Disposition header value.
1431 if ( ! empty( $headers['content-disposition'] ) ) {
1432 $file_name_from_disposition = self::get_filename_from_disposition( (array) $headers['content-disposition'] );
1433 if ( $file_name_from_disposition ) {
1434 $file_name = $file_name_from_disposition;
1435 }
1436 }
1437
1438 // Set file extension if missing.
1439 $file_ext = pathinfo( $file_name, PATHINFO_EXTENSION );
1440 if ( ! $file_ext && ! empty( $headers['content-type'] ) ) {
1441 $extension = self::get_file_extension_by_mime_type( $headers['content-type'] );
1442 if ( $extension ) {
1443 $file_name = "{$file_name}.{$extension}";
1444 }
1445 }
1446
1447 // Handle the upload like _wp_handle_upload() does.
1448 $wp_filetype = wp_check_filetype_and_ext( $tmp_file_name, $file_name );
1449 $ext = empty( $wp_filetype['ext'] ) ? '' : $wp_filetype['ext'];
1450 $type = empty( $wp_filetype['type'] ) ? '' : $wp_filetype['type'];
1451 $proper_filename = empty( $wp_filetype['proper_filename'] ) ? '' : $wp_filetype['proper_filename'];
1452
1453 // Check to see if wp_check_filetype_and_ext() determined the filename was incorrect.
1454 if ( $proper_filename ) {
1455 $file_name = $proper_filename;
1456 }
1457
1458 if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
1459 return new WP_Error( 'import_file_error', esc_html__( 'Sorry, this file type is not permitted for security reasons.', 'betterdocs' ) );
1460 }
1461
1462 $uploads = wp_upload_dir( $post['upload_date'] );
1463 if ( ! ( $uploads && false === $uploads['error'] ) ) {
1464 return new WP_Error( 'upload_dir_error', $uploads['error'] );
1465 }
1466
1467 // Move the file to the uploads dir.
1468 $file_name = wp_unique_filename( $uploads['path'], $file_name );
1469 $new_file = $uploads['path'] . "/$file_name";
1470 $move_new_file = copy( $tmp_file_name, $new_file );
1471
1472 if ( ! $move_new_file ) {
1473 @unlink( $tmp_file_name );
1474
1475 return new WP_Error( 'import_file_error', esc_html__( 'The uploaded file could not be moved', 'betterdocs' ) );
1476 }
1477
1478 // Set correct file permissions.
1479 $stat = stat( dirname( $new_file ) );
1480 $perms = $stat['mode'] & 0000666;
1481 chmod( $new_file, $perms );
1482
1483 $upload = [
1484 'file' => $new_file,
1485 'url' => $uploads['url'] . "/$file_name",
1486 'type' => $wp_filetype['type'],
1487 'error' => false
1488 ];
1489
1490 // Keep track of the old and new urls so we can substitute them later.
1491 $this->url_remap[ $url ] = $upload['url'];
1492 $this->url_remap[ $post['guid'] ] = $upload['url']; // r13735, really needed?
1493 // Keep track of the destination if the remote url is redirected somewhere else.
1494 if ( isset( $headers['x-final-location'] ) && $headers['x-final-location'] !== $url ) {
1495 $this->url_remap[ $headers['x-final-location'] ] = $upload['url'];
1496 }
1497
1498 return $upload;
1499 }
1500
1501 /**
1502 * Attempt to associate posts and menu items with previously missing parents
1503 *
1504 * An imported post's parent may not have been imported when it was first created
1505 * so try again. Similarly for child menu items and menu items which were missing
1506 * the object (e.g. post) they represent in the menu
1507 */
1508 private function backfill_parents() {
1509 global $wpdb;
1510
1511 // Find parents for post orphans.
1512 foreach ( $this->post_orphans as $child_id => $parent_id ) {
1513 $local_child_id = false;
1514 $local_parent_id = false;
1515
1516 if ( isset( $this->processed_posts[ $child_id ] ) ) {
1517 $local_child_id = $this->processed_posts[ $child_id ];
1518 }
1519 if ( isset( $this->processed_posts[ $parent_id ] ) ) {
1520 $local_parent_id = $this->processed_posts[ $parent_id ];
1521 }
1522
1523 if ( $local_child_id && $local_parent_id ) {
1524 $wpdb->update( $wpdb->posts, [ 'post_parent' => $local_parent_id ], [ 'ID' => $local_child_id ], '%d', '%d' );
1525 clean_post_cache( $local_child_id );
1526 }
1527 }
1528
1529 // Find parents for menu item orphans.
1530 foreach ( $this->menu_item_orphans as $child_id => $parent_id ) {
1531 $local_child_id = 0;
1532 $local_parent_id = 0;
1533 if ( isset( $this->processed_menu_items[ $child_id ] ) ) {
1534 $local_child_id = $this->processed_menu_items[ $child_id ];
1535 }
1536 if ( isset( $this->processed_menu_items[ $parent_id ] ) ) {
1537 $local_parent_id = $this->processed_menu_items[ $parent_id ];
1538 }
1539
1540 if ( $local_child_id && $local_parent_id ) {
1541 update_post_meta( $local_child_id, '_menu_item_menu_item_parent', (int) $local_parent_id );
1542 }
1543 }
1544 }
1545
1546 /**
1547 * Use stored mapping information to update old attachment URLs
1548 */
1549 private function backfill_attachment_urls() {
1550 global $wpdb;
1551 // Make sure we do the longest urls first, in case one is a substring of another.
1552 uksort(
1553 $this->url_remap,
1554 function ( $a, $b ) {
1555 // Return the difference in length between two strings.
1556 return strlen( $b ) - strlen( $a );
1557 }
1558 );
1559
1560 foreach ( $this->url_remap as $from_url => $to_url ) {
1561 // Remap urls in post_content.
1562 $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)", $from_url, $to_url ) );
1563 // Remap enclosure urls.
1564 $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_key='enclosure'", $from_url, $to_url ) );
1565 }
1566 }
1567
1568 /**
1569 * Update _thumbnail_id meta to new, imported attachment IDs
1570 */
1571 private function remap_featured_images() {
1572 // Cycle through posts that have a featured image.
1573 foreach ( $this->featured_images as $post_id => $value ) {
1574 if ( isset( $this->processed_posts[ $value ] ) ) {
1575 $new_id = $this->processed_posts[ $value ];
1576 // Only update if there's a difference.
1577 if ( $new_id !== $value ) {
1578 update_post_meta( $post_id, '_thumbnail_id', $new_id );
1579 }
1580 }
1581 }
1582 }
1583
1584 /**
1585 * Parse a WXR file
1586 *
1587 * @param string $file Path to WXR file for parsing
1588 *
1589 * @return array Information gathered from the WXR file
1590 */
1591 private function parse( $file ): array {
1592 if ( $this->file_type == 'text/xml' ) {
1593 $parser = new WXR_Parser();
1594 } elseif ( $this->file_type == 'text/csv' ) {
1595 $parser = new CSV_Parser();
1596 }
1597 return $parser->parse( $file );
1598 }
1599
1600 /**
1601 * Decide if the given meta key maps to information we will want to import
1602 *
1603 * @param string $key The meta key to check
1604 *
1605 * @return string|bool The key if we do want to import, false if not
1606 */
1607 private function is_valid_meta_key( $key ) {
1608 // Skip attachment metadata since we'll regenerate it from scratch.
1609 // Skip _edit_lock as not relevant for import
1610 if ( in_array( $key, [ '_wp_attached_file', '_wp_attachment_metadata', '_edit_lock' ] ) ) {
1611 return false;
1612 }
1613
1614 return $key;
1615 }
1616
1617 /**
1618 * @param $term
1619 *
1620 * @return mixed
1621 */
1622 private function handle_duplicated_nav_menu_term( $term ) {
1623 $duplicate_slug = $term['slug'] . '-duplicate';
1624 $duplicate_name = $term['term_name'] . ' duplicate';
1625
1626 while ( term_exists( $duplicate_slug, 'nav_menu' ) ) {
1627 $duplicate_slug .= '-duplicate';
1628 $duplicate_name .= ' duplicate';
1629 }
1630
1631 $this->mapped_terms_slug[ $term['slug'] ] = $duplicate_slug;
1632
1633 $term['slug'] = $duplicate_slug;
1634 $term['term_name'] = $duplicate_name;
1635
1636 return $term;
1637 }
1638
1639 /**
1640 * Add all term_meta to specified term.
1641 *
1642 * @param $term_id
1643 *
1644 * @return void
1645 */
1646 private function update_term_meta( $term_id ) {
1647 foreach ( $this->terms_meta as $meta_key => $meta_value ) {
1648 update_term_meta( $term_id, $meta_key, $meta_value );
1649 }
1650 }
1651
1652 /**
1653 * Add all post_meta to specified term.
1654 *
1655 * @param $post_id
1656 *
1657 * @return void
1658 */
1659 private function update_post_meta( $post_id ) {
1660 foreach ( $this->posts_meta as $meta_key => $meta_value ) {
1661 update_post_meta( $post_id, $meta_key, $meta_value );
1662 }
1663 }
1664
1665 public function run(): array {
1666 $this->import( $this->requested_file_path );
1667
1668 return $this->output;
1669 }
1670
1671 /**
1672 * @param $file
1673 * @param array $args
1674 */
1675 public function __construct( $file, array $args = [] ) {
1676 parent::__construct();
1677
1678 $this->requested_file_path = $file;
1679 $this->args = $args;
1680
1681 if ( ! empty( $this->args['fetch_attachments'] ) ) {
1682 $this->fetch_attachments = true;
1683 }
1684
1685 if ( isset( $this->args['posts'] ) && is_array( $this->args['posts'] ) ) {
1686 $this->processed_posts = $this->args['posts'];
1687 }
1688
1689 if ( isset( $this->args['terms'] ) && is_array( $this->args['terms'] ) ) {
1690 $this->processed_terms = $this->args['terms'];
1691 }
1692
1693 if ( isset( $this->args['taxonomies'] ) && is_array( $this->args['taxonomies'] ) ) {
1694 $this->processed_taxonomies = $this->args['taxonomies'];
1695 }
1696
1697 if ( ! empty( $this->args['posts_meta'] ) ) {
1698 $this->posts_meta = $this->args['posts_meta'];
1699 }
1700
1701 if ( ! empty( $this->args['terms_meta'] ) ) {
1702 $this->terms_meta = $this->args['terms_meta'];
1703 }
1704
1705 if ( ! empty( $this->args['file_type'] ) ) {
1706 $this->file_type = $this->args['file_type'];
1707 }
1708 }
1709 }
1710