PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.5.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.5.0
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.0, at includes/Admin/Importer/WPImport.php

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