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

1,682 lines 53.7 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 // Double-encoded quotes
709 '""' => '"',
710 // HTML entities that might be double-encoded
711 '&amp;' => '&',
712 '&lt;' => '<',
713 '&gt;' => '>',
714 '&quot;' => '"',
715 // Remove replacement characters
716 "\u{FFFD}" => '', // Replacement character
717 ];
718
719 $text = str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
720
721 // Remove any remaining non-printable characters except newlines and tabs
722 $text = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text );
723
724 return $text;
725 }
726
727 private function import_sample_data( $posts, $action ) {
728 $result = [
729 'succeed' => [],
730 'failed' => []
731 ];
732
733 if ( ! empty( $action ) ) {
734 $posts = $this->existing_slug_action( $posts, $action );
735 }
736
737 foreach ( $posts as $post ) {
738 $post_title = isset( $post['Docs Title'] ) ? $post['Docs Title'] : '';
739 $post_name = isset( $post['post_name'] ) ? $post['post_name'] : '';
740 $post_content = isset( $post['Docs Content'] ) ? $post['Docs Content'] : '';
741 $featured_image_url = isset( $post['Featured Image'] ) ? $post['Featured Image'] : '';
742 $category_name = isset( $post['Category Name'] ) ? $post['Category Name'] : '';
743 $category_slug = isset( $post['Category Slug'] ) ? $post['Category Slug'] : $category_name;
744 $knowledge_base_name = isset( $post['Knowledge Base'] ) ? $post['Knowledge Base'] : '';
745 $knowledge_base_slug = isset( $post['Knowledge Base Slug'] ) ? $post['Knowledge Base Slug'] : $knowledge_base_name;
746 $post_status = isset( $post['Status'] ) ? $post['Status'] : '';
747
748 // Check if the category and knowledge base term exist, if not, create them
749 $default_multiple_kb = betterdocs()->settings->get( 'multiple_kb' );
750
751 $knowledge_base_id = 0;
752 if ( $default_multiple_kb == 1 && $knowledge_base_slug ) {
753 $existing_kb = term_exists( $knowledge_base_name, 'knowledge_base' );
754 if ( ! $existing_kb && $knowledge_base_name ) {
755 $kb_term = wp_insert_term( $knowledge_base_name, 'knowledge_base', [ 'slug' => $knowledge_base_slug ] );
756 if ( is_wp_error( $kb_term ) ) {
757 $knowledge_base_id = 0;
758 } else {
759 $knowledge_base_id = $kb_term['term_id'];
760 }
761 } elseif ( $existing_kb ) {
762 $knowledge_base_id = $existing_kb['term_id'];
763 }
764 }
765
766 $category_id = 0;
767 if ( $category_slug ) {
768 $existing_category = term_exists( $category_name, 'doc_category' );
769 if ( ! $existing_category ) {
770 $category_term = wp_insert_term( $category_name, 'doc_category', [ 'slug' => $category_slug ] );
771 if ( is_wp_error( $category_term ) ) {
772 $category_id = 0;
773 } else {
774 $category_id = $category_term['term_id'];
775 }
776 } else {
777 $category_id = $existing_category['term_id'];
778 }
779
780 if ( $default_multiple_kb == 1 && $knowledge_base_id && $category_id ) {
781 $kb_slug = get_term_field( 'slug', $knowledge_base_id, 'knowledge_base' );
782 if ( ! is_wp_error( $kb_slug ) ) {
783 $doc_category_kb = rest_sanitize_array( [ $kb_slug ] );
784 update_term_meta( $category_id, 'doc_category_knowledge_base', $doc_category_kb );
785 }
786 }
787 }
788
789 // Validate and sanitize post data with encoding fixes
790 $post_title = $this->fix_encoding_issues( trim( $post_title ) );
791 $post_content = $this->fix_encoding_issues( trim( $post_content ) );
792 $post_name = $this->fix_encoding_issues( trim( $post_name ) );
793 $post_status = !empty( $post_status ) ? $post_status : 'publish';
794
795 // Skip posts with empty titles
796 if ( empty( $post_title ) ) {
797 $result['failed'][] = [
798 'title' => 'Empty Title',
799 'error' => 'Post title is required'
800 ];
801 continue;
802 }
803
804 // Additional WordPress validation checks
805 $validation_errors = [];
806
807 // Check title length (WordPress has a 200 character limit for post_title)
808 if ( strlen( $post_title ) > 200 ) {
809 $validation_errors[] = 'Title too long (max 200 characters)';
810 }
811
812 // Check for valid post status
813 $valid_statuses = get_post_stati();
814 if ( !in_array( $post_status, array_keys( $valid_statuses ) ) ) {
815 $post_status = 'publish';
816 }
817
818 // Check if post with same title already exists
819 $existing_posts = get_posts([
820 'post_type' => 'docs',
821 'title' => $post_title,
822 'post_status' => 'any',
823 'numberposts' => 1
824 ]);
825 if ( !empty( $existing_posts ) ) {
826 $validation_errors[] = 'Post with same title already exists (ID: ' . $existing_posts[0]->ID . ')';
827 }
828
829 // Check if slug already exists (if provided)
830 if ( !empty( $post_name ) ) {
831 $existing_by_slug = get_posts([
832 'post_type' => 'docs',
833 'name' => $post_name,
834 'post_status' => 'any',
835 'numberposts' => 1
836 ]);
837 }
838
839 if ( !empty( $validation_errors ) ) {
840 $result['failed'][] = [
841 'title' => $post_title,
842 'error' => implode(', ', $validation_errors)
843 ];
844 continue;
845 }
846
847 // Set up the post data
848 $post_data = [
849 'post_title' => $post_title,
850 'post_name' => $post_name,
851 'post_content' => $post_content,
852 'post_status' => $post_status,
853 'post_type' => 'docs'
854 ];
855
856 if ( $category_name && $category_id ) {
857 $post_data['tax_input']['doc_category'] = [ $category_id ];
858 }
859
860 if ( $default_multiple_kb == 1 && $knowledge_base_id ) {
861 $post_data['tax_input']['knowledge_base'] = [ $knowledge_base_id ];
862 }
863
864 // Insert the post (without knowledge_base taxonomy to avoid capability issues)
865 $post_data_without_kb = $post_data;
866 if (isset($post_data_without_kb['tax_input']['knowledge_base'])) {
867 $kb_terms = $post_data_without_kb['tax_input']['knowledge_base'];
868 unset($post_data_without_kb['tax_input']['knowledge_base']);
869 }
870
871 $post_id = wp_insert_post( $post_data_without_kb );
872
873 // Manually assign knowledge_base terms after post creation to bypass capability check
874 if ( !is_wp_error($post_id) && $post_id > 0 && isset($kb_terms) && !empty($kb_terms) ) {
875 // Convert term IDs to integers and ensure they exist
876 $term_ids = array_map('intval', $kb_terms);
877
878 $result_kb = wp_set_object_terms( $post_id, $term_ids, 'knowledge_base' );
879 if ( !is_wp_error($result_kb) ) {
880 $assigned_terms = wp_get_object_terms($post_id, 'knowledge_base');
881 if (!empty($assigned_terms)) {
882 $term_names = array_map(function($term) { return $term->name; }, $assigned_terms);
883 }
884 }
885 }
886
887 // Enhanced debugging for failed insertions
888 if ( is_wp_error( $post_id ) ) {
889 $result['failed'][] = [
890 'title' => $post_title,
891 'error' => $post_id->get_error_message()
892 ];
893 continue;
894 } elseif ( $post_id === 0 ) {
895 $result['failed'][] = [
896 'title' => $post_title,
897 'error' => 'Post insertion returned 0 - validation failed'
898 ];
899 continue;
900 } else {
901 $result['succeed'][] = [
902 'title' => $post_title,
903 'id' => $post_id
904 ];
905 }
906 // Set the featured image
907 if ( $featured_image_url ) {
908 $this->set_post_thumbnail( $post_id, $featured_image_url );
909 }
910 }
911
912 return $result;
913 }
914
915 public function import_helpscout_data( $posts ) {
916 $result = [
917 'succeed' => [],
918 'failed' => []
919 ];
920
921 foreach ( $posts as $post ) {
922 $post_title = isset( $post['name'] ) ? $post['name'] : '';
923 $post_slug = isset( $post['slug'] ) ? $post['slug'] : '';
924 $post_content = isset( $post['text'] ) ? $post['text'] : '';
925 $categories = isset( $post['categories'] ) ? $post['categories'] : '';
926 $post_status = isset( $post['status'] ) ? $post['status'] : '';
927
928 // Check if the category and knowledge base term exist, if not, create them
929 $category_ids = [];
930 foreach ( $categories as $category ) {
931 $category_id = term_exists( $category['slug'], 'doc_category' );
932 if ( ! $category_id ) {
933 $category_id = wp_insert_term( $category['name'], 'doc_category', [ 'slug' => $category['slug'] ] );
934 $category_ids[] = $category_id['term_id'];
935 } else {
936 $category_ids[] = $category_id['term_id'];
937 }
938 }
939
940 // Set up the post data
941 $post_data = [
942 'post_title' => $post_title,
943 'post_name' => $post_slug,
944 'post_content' => $post_content,
945 'post_status' => 'publish',
946 'post_type' => 'docs',
947 'tax_input' => [
948 'doc_category' => $category_ids
949 ]
950 ];
951
952 // Insert the post
953 wp_insert_post( $post_data );
954 }
955
956 return $result;
957 }
958
959 public function set_post_thumbnail( $post_id, $attachment_url ) {
960 require_once ABSPATH . 'wp-admin/includes/image.php';
961 require_once ABSPATH . 'wp-admin/includes/media.php';
962 require_once ABSPATH . 'wp-admin/includes/file.php';
963 $post_title = pathinfo( $attachment_url, PATHINFO_FILENAME );
964 $post_name = pathinfo( $attachment_url, PATHINFO_FILENAME );
965
966 $image_id = media_sideload_image( $attachment_url, $post_id, $post_title, $post_name );
967 set_post_thumbnail( $post_id, $image_id );
968
969 return $image_id;
970 }
971
972 /**
973 * Create new posts based on import information
974 *
975 * Posts marked as having a parent which doesn't exist will become top level items.
976 * Doesn't create a new post if: the post type doesn't exist, the given post ID
977 * is already noted as imported or a post with the same title and date already exists.
978 * Note that new/updated terms, comments and meta are imported for the last of the above.
979 *
980 * @return array the ids of succeed/failed imported posts.
981 */
982 private function process_posts(): array {
983 $result = [
984 'succeed' => [],
985 'failed' => []
986 ];
987
988 $this->posts = apply_filters( 'wp_import_posts', $this->posts );
989
990 foreach ( $this->posts as $post ) {
991 $post = apply_filters( 'wp_import_post_data_raw', $post );
992
993 if ( ! post_type_exists( $post['post_type'] ) ) {
994 /* translators: 1: Post title, 2: Post type. */
995 $this->output['errors'][] = sprintf( esc_html__( 'Failed to import %1$s: Invalid post type %2$s', 'betterdocs' ), $post['post_title'], $post['post_type'] );
996 do_action( 'wp_import_post_exists', $post );
997 continue;
998 }
999
1000 if ( isset( $this->processed_posts[ $post['post_id'] ] ) && ! empty( $post['post_id'] ) ) {
1001 continue;
1002 }
1003
1004 if ( 'auto-draft' === $post['status'] ) {
1005 continue;
1006 }
1007
1008 $post_type_object = get_post_type_object( $post['post_type'] );
1009
1010 $post_parent = (int) $post['post_parent'];
1011 if ( $post_parent ) {
1012 // if we already know the parent, map it to the new local ID.
1013 if ( isset( $this->processed_posts[ $post_parent ] ) ) {
1014 $post_parent = $this->processed_posts[ $post_parent ];
1015 // otherwise record the parent for later.
1016 } else {
1017 $this->post_orphans[ (int) $post['post_id'] ] = $post_parent;
1018 $post_parent = 0;
1019 }
1020 }
1021
1022 // Map the post author.
1023 $author = sanitize_user( $post['post_author'], true );
1024 if ( isset( $this->author_mapping[ $author ] ) ) {
1025 $author = $this->author_mapping[ $author ];
1026 } else {
1027 $author = (int) get_current_user_id();
1028 }
1029
1030 $postdata = [
1031 'post_author' => $author,
1032 'post_content' => isset( $post['post_content'] ) ? $this->fix_encoding_issues( $post['post_content'] ) : '',
1033 'post_excerpt' => isset( $post['post_excerpt'] ) ? $this->fix_encoding_issues( $post['post_excerpt'] ) : '',
1034 'post_title' => isset( $post['post_title'] ) ? $this->fix_encoding_issues( $post['post_title'] ) : '',
1035 'post_status' => isset( $post['status'] ) ? $post['status'] : '',
1036 'post_name' => isset( $post['post_name'] ) ? $this->fix_encoding_issues( $post['post_name'] ) : '',
1037 'comment_status' => isset( $post['comment_status'] ) ? $post['comment_status'] : '',
1038 'ping_status' => isset( $post['ping_status'] ) ? $post['ping_status'] : '',
1039 'guid' => isset( $post['guid'] ) ? $this->fix_encoding_issues( $post['guid'] ) : '',
1040 'post_parent' => $post_parent,
1041 'menu_order' => isset( $post['menu_order'] ) ? $post['menu_order'] : '',
1042 'post_type' => isset( $post['post_type'] ) ? $post['post_type'] : '',
1043 'post_password' => isset( $post['post_password'] ) ? $this->fix_encoding_issues( $post['post_password'] ) : ''
1044 ];
1045
1046 $original_post_id = $post['post_id'];
1047 $postdata = apply_filters( 'wp_import_post_data_processed', $postdata, $post );
1048 $postdata = wp_slash( $postdata );
1049
1050 if ( 'attachment' === $postdata['post_type'] ) {
1051 $remote_url = ! empty( $post['attachment_url'] ) ? $post['attachment_url'] : $post['guid'];
1052
1053 // try to use _wp_attached file for upload folder placement to ensure the same location as the export site
1054 // e.g. location is 2003/05/image.jpg but the attachment post_date is 2010/09, see media_handle_upload()
1055 $postdata['upload_date'] = isset( $post['post_date'] ) ? $post['post_date'] : '';
1056 if ( isset( $post['postmeta'] ) ) {
1057 foreach ( $post['postmeta'] as $meta ) {
1058 if ( '_wp_attached_file' === $meta['key'] ) {
1059 if ( preg_match( '%^[0-9]{4}/[0-9]{2}%', $meta['value'], $matches ) ) {
1060 $postdata['upload_date'] = $matches[0];
1061 }
1062 break;
1063 }
1064 }
1065 }
1066
1067 // $post_id = $this->set_post_thumbnail( $postdata['post_parent'], $remote_url );
1068 $post_id = $this->process_attachment( $postdata, $remote_url );
1069
1070 $comment_post_id = $post_id;
1071 } else {
1072 $post_id = wp_insert_post( $postdata, true );
1073
1074 $this->update_post_meta( $post_id );
1075
1076 $comment_post_id = $post_id;
1077 do_action( 'wp_import_insert_post', $post_id, $original_post_id, $postdata, $post );
1078 }
1079
1080 if ( is_wp_error( $post_id ) ) {
1081 /* translators: 1: Post type singular label, 2: Post title. */
1082 $error = sprintf( __( 'Failed to import %1$s %2$s', 'betterdocs' ), $post_type_object->labels->singular_name, $post['post_title'] );
1083
1084 if ( defined( 'IMPORT_DEBUG' ) && IMPORT_DEBUG ) {
1085 $error .= PHP_EOL . $post_id->get_error_message();
1086 }
1087
1088 $result['failed'][] = $original_post_id;
1089
1090 $this->output['errors'][] = $error;
1091
1092 continue;
1093 }
1094
1095 $result['succeed'][ $original_post_id ] = $post_id;
1096
1097 if ( isset( $post['is_sticky'] ) && 1 === $post['is_sticky'] ) {
1098 stick_post( $post_id );
1099 }
1100
1101 if ( $this->page_on_front === $original_post_id ) {
1102 update_option( 'page_on_front', $post_id );
1103 }
1104
1105 // Map pre-import ID to local ID.
1106 $this->processed_posts[ (int) $post['post_id'] ] = (int) $post_id;
1107
1108 if ( ! isset( $post['terms'] ) ) {
1109 $post['terms'] = [];
1110 }
1111
1112 $post['terms'] = apply_filters( 'wp_import_post_terms', $post['terms'], $post_id, $post );
1113
1114 // add categories, tags and other terms
1115 if ( ! empty( $post['terms'] ) ) {
1116 $terms_to_set = [];
1117 foreach ( $post['terms'] as $term ) {
1118 // back compat with WXR 1.0 map 'tag' to 'post_tag'
1119 $taxonomy = ( 'tag' === $term['domain'] ) ? 'post_tag' : $term['domain'];
1120
1121 // Apply encoding fixes to term data
1122 $term_name = $this->fix_encoding_issues( $term['name'] );
1123 $term_slug = $this->fix_encoding_issues( $term['slug'] );
1124
1125 $term_exists = term_exists( $term_slug, $taxonomy );
1126 $term_id = is_array( $term_exists ) ? $term_exists['term_id'] : $term_exists;
1127 if ( ! $term_id ) {
1128 $t = wp_insert_term( $term_name, $taxonomy, [ 'slug' => $term_slug ] );
1129 if ( ! is_wp_error( $t ) ) {
1130 $term_id = $t['term_id'];
1131
1132 $this->update_term_meta( $term_id );
1133
1134 do_action( 'wp_import_insert_term', $t, $term, $post_id, $post );
1135 } else {
1136 /* translators: 1: Taxonomy name, 2: Term name. */
1137 $error = sprintf( esc_html__( 'Failed to import %1$s %2$s', 'betterdocs' ), $taxonomy, $term['name'] );
1138
1139 if ( defined( 'IMPORT_DEBUG' ) && IMPORT_DEBUG ) {
1140 $error .= PHP_EOL . $t->get_error_message();
1141 }
1142
1143 $this->output['errors'][] = $error;
1144
1145 do_action( 'wp_import_insert_term_failed', $t, $term, $post_id, $post );
1146 continue;
1147 }
1148 }
1149 $terms_to_set[ $taxonomy ][] = (int) $term_id;
1150 }
1151
1152 foreach ( $terms_to_set as $tax => $ids ) {
1153 // Handle knowledge_base taxonomy capability issues
1154 if ( $tax === 'knowledge_base' ) {
1155 $tt_ids = wp_set_object_terms( $post_id, $ids, $tax );
1156 } else {
1157 $tt_ids = wp_set_post_terms( $post_id, $ids, $tax );
1158 }
1159 do_action( 'wp_import_set_post_terms', $tt_ids, $ids, $tax, $post_id, $post );
1160 }
1161 unset( $post['terms'], $terms_to_set );
1162 }
1163
1164 if ( ! isset( $post['comments'] ) ) {
1165 $post['comments'] = [];
1166 }
1167
1168 $post['comments'] = apply_filters( 'wp_import_post_comments', $post['comments'], $post_id, $post );
1169
1170 // Add/update comments.
1171 if ( ! empty( $post['comments'] ) ) {
1172 $num_comments = 0;
1173 $inserted_comments = [];
1174 foreach ( $post['comments'] as $comment ) {
1175 $comment_id = $comment['comment_id'];
1176 $newcomments[ $comment_id ]['comment_post_ID'] = $comment_post_id;
1177 $newcomments[ $comment_id ]['comment_author'] = $comment['comment_author'];
1178 $newcomments[ $comment_id ]['comment_author_email'] = $comment['comment_author_email'];
1179 $newcomments[ $comment_id ]['comment_author_IP'] = $comment['comment_author_IP'];
1180 $newcomments[ $comment_id ]['comment_author_url'] = $comment['comment_author_url'];
1181 $newcomments[ $comment_id ]['comment_date'] = $comment['comment_date'];
1182 $newcomments[ $comment_id ]['comment_date_gmt'] = $comment['comment_date_gmt'];
1183 $newcomments[ $comment_id ]['comment_content'] = $comment['comment_content'];
1184 $newcomments[ $comment_id ]['comment_approved'] = $comment['comment_approved'];
1185 $newcomments[ $comment_id ]['comment_type'] = $comment['comment_type'];
1186 $newcomments[ $comment_id ]['comment_parent'] = $comment['comment_parent'];
1187 $newcomments[ $comment_id ]['commentmeta'] = isset( $comment['commentmeta'] ) ? $comment['commentmeta'] : [];
1188 if ( isset( $this->processed_authors[ $comment['comment_user_id'] ] ) ) {
1189 $newcomments[ $comment_id ]['user_id'] = $this->processed_authors[ $comment['comment_user_id'] ];
1190 }
1191 }
1192
1193 ksort( $newcomments );
1194
1195 foreach ( $newcomments as $key => $comment ) {
1196 if ( isset( $inserted_comments[ $comment['comment_parent'] ] ) ) {
1197 $comment['comment_parent'] = $inserted_comments[ $comment['comment_parent'] ];
1198 }
1199
1200 $comment_data = wp_slash( $comment );
1201 unset( $comment_data['commentmeta'] ); // Handled separately, wp_insert_comment() also expects `comment_meta`.
1202 $comment_data = wp_filter_comment( $comment_data );
1203
1204 $inserted_comments[ $key ] = wp_insert_comment( $comment_data );
1205
1206 do_action( 'wp_import_insert_comment', $inserted_comments[ $key ], $comment, $comment_post_id, $post );
1207
1208 foreach ( $comment['commentmeta'] as $meta ) {
1209 $value = maybe_unserialize( $meta['value'] );
1210
1211 add_comment_meta( $inserted_comments[ $key ], wp_slash( $meta['key'] ), wp_slash_strings_only( $value ) );
1212 }
1213
1214 ++$num_comments;
1215 }
1216 unset( $newcomments, $inserted_comments, $post['comments'] );
1217 }
1218
1219 if ( ! isset( $post['postmeta'] ) ) {
1220 $post['postmeta'] = [];
1221 }
1222
1223 $post['postmeta'] = apply_filters( 'wp_import_post_meta', $post['postmeta'], $post_id, $post );
1224
1225 // Add/update post meta.
1226 if ( ! empty( $post['postmeta'] ) ) {
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 // Export gets meta straight from the DB so could have a serialized string.
1241 if ( ! $value ) {
1242 $value = maybe_unserialize( $meta['value'] );
1243 }
1244
1245 add_post_meta( $post_id, wp_slash( $key ), wp_slash_strings_only( $value ) );
1246
1247 do_action( 'import_post_meta', $post_id, $key, $value );
1248
1249 // If the post has a featured image, take note of this in case of remap.
1250 if ( '_thumbnail_id' === $key ) {
1251 $this->featured_images[ $post_id ] = (int) $value;
1252 }
1253 }
1254 }
1255 }
1256
1257 do_action( 'templately_import.process_post', $post, $this, $result );
1258 }
1259
1260 unset( $this->posts );
1261
1262 return $result;
1263 }
1264
1265 /**
1266 * If fetching attachments is enabled then attempt to create a new attachment
1267 *
1268 * @param array $post Attachment post details from WXR
1269 * @param string $url URL to fetch attachment from
1270 *
1271 * @return int|WP_Error Post ID on success, WP_Error otherwise
1272 */
1273 private function process_attachment( $post, $url ) {
1274 require_once ABSPATH . 'wp-admin/includes/image.php';
1275
1276 if ( ! $this->fetch_attachments ) {
1277 return new WP_Error( 'attachment_processing_error', esc_html__( 'Fetching attachments is not enabled', 'betterdocs' ) );
1278 }
1279
1280 // if the URL is absolute, but does not contain address, then upload it assuming base_site_url.
1281 if ( preg_match( '|^/[\w\W]+$|', $url ) ) {
1282 $url = rtrim( $this->base_url, '/' ) . $url;
1283 }
1284
1285 $upload = $this->fetch_remote_file( $url, $post );
1286 if ( is_wp_error( $upload ) ) {
1287 return $upload;
1288 }
1289
1290 $info = wp_check_filetype( $upload['file'] );
1291 if ( $info ) {
1292 $post['post_mime_type'] = $info['type'];
1293 } else {
1294 return new WP_Error( 'attachment_processing_error', esc_html__( 'Invalid file type', 'betterdocs' ) );
1295 }
1296
1297 $post['guid'] = $upload['url'];
1298
1299 // As per wp-admin/includes/upload.php.
1300 $post_id = wp_insert_attachment( $post, $upload['file'] );
1301 $this->update_post_meta( $post_id );
1302
1303 wp_update_attachment_metadata( $post_id, wp_generate_attachment_metadata( $post_id, $upload['file'] ) );
1304
1305 // Remap resized image URLs, works by stripping the extension and remapping the URL stub.
1306 if ( preg_match( '!^image/!', $info['type'] ) ) {
1307 $parts = pathinfo( $url );
1308 $name = basename( $parts['basename'], ".{$parts['extension']}" ); // PATHINFO_FILENAME in PHP 5.2
1309
1310 $parts_new = pathinfo( $upload['url'] );
1311 $name_new = basename( $parts_new['basename'], ".{$parts_new['extension']}" );
1312
1313 $this->url_remap[ $parts['dirname'] . '/' . $name ] = $parts_new['dirname'] . '/' . $name_new;
1314 }
1315
1316 return $post_id;
1317 }
1318
1319 /**
1320 * Attempt to download a remote file attachment
1321 *
1322 * @param string $url URL of item to fetch
1323 * @param array $post Attachment details
1324 *
1325 * @return array|WP_Error Local file location details on success, WP_Error otherwise
1326 */
1327 private function fetch_remote_file( $url, $post ) {
1328 include_once ABSPATH . '/wp-admin/includes/file.php';
1329
1330 // Extract the file name from the URL.
1331 $file_name = basename( parse_url( $url, PHP_URL_PATH ) );
1332
1333 if ( ! $file_name ) {
1334 $file_name = md5( $url );
1335 }
1336
1337 $tmp_file_name = wp_tempnam( $file_name );
1338 if ( ! $tmp_file_name ) {
1339 return new WP_Error( 'import_no_file', esc_html__( 'Could not create temporary file.', 'betterdocs' ) );
1340 }
1341
1342 // Fetch the remote URL and write it to the placeholder file.
1343 $remote_response = wp_safe_remote_get(
1344 $url,
1345 [
1346 'timeout' => 300,
1347 'stream' => true,
1348 'filename' => $tmp_file_name,
1349 'headers' => [
1350 'Accept-Encoding' => 'identity'
1351 ]
1352 ]
1353 );
1354
1355 if ( is_wp_error( $remote_response ) ) {
1356 @unlink( $tmp_file_name );
1357
1358 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() ) ) );
1359 }
1360
1361 $remote_response_code = (int) wp_remote_retrieve_response_code( $remote_response );
1362
1363 // Make sure the fetch was successful.
1364 if ( 200 !== $remote_response_code ) {
1365 @unlink( $tmp_file_name );
1366
1367 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 ) ) );
1368 }
1369
1370 $headers = wp_remote_retrieve_headers( $remote_response );
1371
1372 // Request failed.
1373 if ( ! $headers ) {
1374 @unlink( $tmp_file_name );
1375
1376 return new WP_Error( 'import_file_error', esc_html__( 'Remote server did not respond', 'betterdocs' ) );
1377 }
1378
1379 $filesize = (int) filesize( $tmp_file_name );
1380
1381 if ( 0 === $filesize ) {
1382 @unlink( $tmp_file_name );
1383
1384 return new WP_Error( 'import_file_error', esc_html__( 'Zero size file downloaded', 'betterdocs' ) );
1385 }
1386
1387 if ( ! isset( $headers['content-encoding'] ) && isset( $headers['content-length'] ) && $filesize !== (int) $headers['content-length'] ) {
1388 @unlink( $tmp_file_name );
1389
1390 return new WP_Error( 'import_file_error', esc_html__( 'Downloaded file has incorrect size', 'betterdocs' ) );
1391 }
1392
1393 $max_size = (int) apply_filters( 'import_attachment_size_limit', self::DEFAULT_IMPORT_ATTACHMENT_SIZE_LIMIT );
1394 if ( ! empty( $max_size ) && $filesize > $max_size ) {
1395 @unlink( $tmp_file_name );
1396
1397 /* translators: %s: Max file size. */
1398
1399 return new WP_Error( 'import_file_error', sprintf( esc_html__( 'Remote file is too large, limit is %s', 'betterdocs' ), size_format( $max_size ) ) );
1400 }
1401
1402 // Override file name with Content-Disposition header value.
1403 if ( ! empty( $headers['content-disposition'] ) ) {
1404 $file_name_from_disposition = self::get_filename_from_disposition( (array) $headers['content-disposition'] );
1405 if ( $file_name_from_disposition ) {
1406 $file_name = $file_name_from_disposition;
1407 }
1408 }
1409
1410 // Set file extension if missing.
1411 $file_ext = pathinfo( $file_name, PATHINFO_EXTENSION );
1412 if ( ! $file_ext && ! empty( $headers['content-type'] ) ) {
1413 $extension = self::get_file_extension_by_mime_type( $headers['content-type'] );
1414 if ( $extension ) {
1415 $file_name = "{$file_name}.{$extension}";
1416 }
1417 }
1418
1419 // Handle the upload like _wp_handle_upload() does.
1420 $wp_filetype = wp_check_filetype_and_ext( $tmp_file_name, $file_name );
1421 $ext = empty( $wp_filetype['ext'] ) ? '' : $wp_filetype['ext'];
1422 $type = empty( $wp_filetype['type'] ) ? '' : $wp_filetype['type'];
1423 $proper_filename = empty( $wp_filetype['proper_filename'] ) ? '' : $wp_filetype['proper_filename'];
1424
1425 // Check to see if wp_check_filetype_and_ext() determined the filename was incorrect.
1426 if ( $proper_filename ) {
1427 $file_name = $proper_filename;
1428 }
1429
1430 if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
1431 return new WP_Error( 'import_file_error', esc_html__( 'Sorry, this file type is not permitted for security reasons.', 'betterdocs' ) );
1432 }
1433
1434 $uploads = wp_upload_dir( $post['upload_date'] );
1435 if ( ! ( $uploads && false === $uploads['error'] ) ) {
1436 return new WP_Error( 'upload_dir_error', $uploads['error'] );
1437 }
1438
1439 // Move the file to the uploads dir.
1440 $file_name = wp_unique_filename( $uploads['path'], $file_name );
1441 $new_file = $uploads['path'] . "/$file_name";
1442 $move_new_file = copy( $tmp_file_name, $new_file );
1443
1444 if ( ! $move_new_file ) {
1445 @unlink( $tmp_file_name );
1446
1447 return new WP_Error( 'import_file_error', esc_html__( 'The uploaded file could not be moved', 'betterdocs' ) );
1448 }
1449
1450 // Set correct file permissions.
1451 $stat = stat( dirname( $new_file ) );
1452 $perms = $stat['mode'] & 0000666;
1453 chmod( $new_file, $perms );
1454
1455 $upload = [
1456 'file' => $new_file,
1457 'url' => $uploads['url'] . "/$file_name",
1458 'type' => $wp_filetype['type'],
1459 'error' => false
1460 ];
1461
1462 // Keep track of the old and new urls so we can substitute them later.
1463 $this->url_remap[ $url ] = $upload['url'];
1464 $this->url_remap[ $post['guid'] ] = $upload['url']; // r13735, really needed?
1465 // Keep track of the destination if the remote url is redirected somewhere else.
1466 if ( isset( $headers['x-final-location'] ) && $headers['x-final-location'] !== $url ) {
1467 $this->url_remap[ $headers['x-final-location'] ] = $upload['url'];
1468 }
1469
1470 return $upload;
1471 }
1472
1473 /**
1474 * Attempt to associate posts and menu items with previously missing parents
1475 *
1476 * An imported post's parent may not have been imported when it was first created
1477 * so try again. Similarly for child menu items and menu items which were missing
1478 * the object (e.g. post) they represent in the menu
1479 */
1480 private function backfill_parents() {
1481 global $wpdb;
1482
1483 // Find parents for post orphans.
1484 foreach ( $this->post_orphans as $child_id => $parent_id ) {
1485 $local_child_id = false;
1486 $local_parent_id = false;
1487
1488 if ( isset( $this->processed_posts[ $child_id ] ) ) {
1489 $local_child_id = $this->processed_posts[ $child_id ];
1490 }
1491 if ( isset( $this->processed_posts[ $parent_id ] ) ) {
1492 $local_parent_id = $this->processed_posts[ $parent_id ];
1493 }
1494
1495 if ( $local_child_id && $local_parent_id ) {
1496 $wpdb->update( $wpdb->posts, [ 'post_parent' => $local_parent_id ], [ 'ID' => $local_child_id ], '%d', '%d' );
1497 clean_post_cache( $local_child_id );
1498 }
1499 }
1500
1501 // Find parents for menu item orphans.
1502 foreach ( $this->menu_item_orphans as $child_id => $parent_id ) {
1503 $local_child_id = 0;
1504 $local_parent_id = 0;
1505 if ( isset( $this->processed_menu_items[ $child_id ] ) ) {
1506 $local_child_id = $this->processed_menu_items[ $child_id ];
1507 }
1508 if ( isset( $this->processed_menu_items[ $parent_id ] ) ) {
1509 $local_parent_id = $this->processed_menu_items[ $parent_id ];
1510 }
1511
1512 if ( $local_child_id && $local_parent_id ) {
1513 update_post_meta( $local_child_id, '_menu_item_menu_item_parent', (int) $local_parent_id );
1514 }
1515 }
1516 }
1517
1518 /**
1519 * Use stored mapping information to update old attachment URLs
1520 */
1521 private function backfill_attachment_urls() {
1522 global $wpdb;
1523 // Make sure we do the longest urls first, in case one is a substring of another.
1524 uksort(
1525 $this->url_remap,
1526 function ( $a, $b ) {
1527 // Return the difference in length between two strings.
1528 return strlen( $b ) - strlen( $a );
1529 }
1530 );
1531
1532 foreach ( $this->url_remap as $from_url => $to_url ) {
1533 // Remap urls in post_content.
1534 $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)", $from_url, $to_url ) );
1535 // Remap enclosure urls.
1536 $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_key='enclosure'", $from_url, $to_url ) );
1537 }
1538 }
1539
1540 /**
1541 * Update _thumbnail_id meta to new, imported attachment IDs
1542 */
1543 private function remap_featured_images() {
1544 // Cycle through posts that have a featured image.
1545 foreach ( $this->featured_images as $post_id => $value ) {
1546 if ( isset( $this->processed_posts[ $value ] ) ) {
1547 $new_id = $this->processed_posts[ $value ];
1548 // Only update if there's a difference.
1549 if ( $new_id !== $value ) {
1550 update_post_meta( $post_id, '_thumbnail_id', $new_id );
1551 }
1552 }
1553 }
1554 }
1555
1556 /**
1557 * Parse a WXR file
1558 *
1559 * @param string $file Path to WXR file for parsing
1560 *
1561 * @return array Information gathered from the WXR file
1562 */
1563 private function parse( $file ): array {
1564 if ( $this->file_type == 'text/xml' ) {
1565 $parser = new WXR_Parser();
1566 } elseif ( $this->file_type == 'text/csv' ) {
1567 $parser = new CSV_Parser();
1568 }
1569 return $parser->parse( $file );
1570 }
1571
1572 /**
1573 * Decide if the given meta key maps to information we will want to import
1574 *
1575 * @param string $key The meta key to check
1576 *
1577 * @return string|bool The key if we do want to import, false if not
1578 */
1579 private function is_valid_meta_key( $key ) {
1580 // Skip attachment metadata since we'll regenerate it from scratch.
1581 // Skip _edit_lock as not relevant for import
1582 if ( in_array( $key, [ '_wp_attached_file', '_wp_attachment_metadata', '_edit_lock' ] ) ) {
1583 return false;
1584 }
1585
1586 return $key;
1587 }
1588
1589 /**
1590 * @param $term
1591 *
1592 * @return mixed
1593 */
1594 private function handle_duplicated_nav_menu_term( $term ) {
1595 $duplicate_slug = $term['slug'] . '-duplicate';
1596 $duplicate_name = $term['term_name'] . ' duplicate';
1597
1598 while ( term_exists( $duplicate_slug, 'nav_menu' ) ) {
1599 $duplicate_slug .= '-duplicate';
1600 $duplicate_name .= ' duplicate';
1601 }
1602
1603 $this->mapped_terms_slug[ $term['slug'] ] = $duplicate_slug;
1604
1605 $term['slug'] = $duplicate_slug;
1606 $term['term_name'] = $duplicate_name;
1607
1608 return $term;
1609 }
1610
1611 /**
1612 * Add all term_meta to specified term.
1613 *
1614 * @param $term_id
1615 *
1616 * @return void
1617 */
1618 private function update_term_meta( $term_id ) {
1619 foreach ( $this->terms_meta as $meta_key => $meta_value ) {
1620 update_term_meta( $term_id, $meta_key, $meta_value );
1621 }
1622 }
1623
1624 /**
1625 * Add all post_meta to specified term.
1626 *
1627 * @param $post_id
1628 *
1629 * @return void
1630 */
1631 private function update_post_meta( $post_id ) {
1632 foreach ( $this->posts_meta as $meta_key => $meta_value ) {
1633 update_post_meta( $post_id, $meta_key, $meta_value );
1634 }
1635 }
1636
1637 public function run(): array {
1638 $this->import( $this->requested_file_path );
1639
1640 return $this->output;
1641 }
1642
1643 /**
1644 * @param $file
1645 * @param array $args
1646 */
1647 public function __construct( $file, array $args = [] ) {
1648 parent::__construct();
1649
1650 $this->requested_file_path = $file;
1651 $this->args = $args;
1652
1653 if ( ! empty( $this->args['fetch_attachments'] ) ) {
1654 $this->fetch_attachments = true;
1655 }
1656
1657 if ( isset( $this->args['posts'] ) && is_array( $this->args['posts'] ) ) {
1658 $this->processed_posts = $this->args['posts'];
1659 }
1660
1661 if ( isset( $this->args['terms'] ) && is_array( $this->args['terms'] ) ) {
1662 $this->processed_terms = $this->args['terms'];
1663 }
1664
1665 if ( isset( $this->args['taxonomies'] ) && is_array( $this->args['taxonomies'] ) ) {
1666 $this->processed_taxonomies = $this->args['taxonomies'];
1667 }
1668
1669 if ( ! empty( $this->args['posts_meta'] ) ) {
1670 $this->posts_meta = $this->args['posts_meta'];
1671 }
1672
1673 if ( ! empty( $this->args['terms_meta'] ) ) {
1674 $this->terms_meta = $this->args['terms_meta'];
1675 }
1676
1677 if ( ! empty( $this->args['file_type'] ) ) {
1678 $this->file_type = $this->args['file_type'];
1679 }
1680 }
1681 }
1682