PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.2
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 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.8.2, at includes/Admin/Importer/WPImport.php

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