PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
betterdocs / includes / Admin / Importer / WPImport.php

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

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