PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / wp-admin / import / class-mastodon.php

class-mastodon.php in ActivityPub trunk, at includes/wp-admin/import/class-mastodon.php

719 lines 21.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Mastodon importer file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\WP_Admin\Import;
9
10 use Activitypub\Attachments;
11 use Activitypub\Sanitize;
12
13 use function Activitypub\is_activity_public;
14
15 /**
16 * Mastodon importer class.
17 */
18 class Mastodon {
19
20 /**
21 * Import file attachment ID.
22 *
23 * @var int
24 */
25 private static $import_id;
26
27 /**
28 * Archive folder.
29 *
30 * @var string
31 */
32 private static $archive;
33
34 /**
35 * Outbox file.
36 *
37 * @var array
38 */
39 private static $outbox;
40
41 /**
42 * Author ID.
43 *
44 * @var int
45 */
46 private static $author;
47
48 /**
49 * Whether to fetch attachments.
50 *
51 * @var bool
52 */
53 private static $fetch_attachments;
54
55 /**
56 * Dispatch
57 */
58 public static function dispatch() {
59 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
60 $step = \absint( $_GET['step'] ?? 0 );
61
62 self::header();
63
64 switch ( $step ) {
65 case 0:
66 self::greet();
67 break;
68
69 case 1:
70 \check_admin_referer( 'import-upload' );
71 if ( self::handle_upload() ) {
72 self::import_options();
73 }
74 break;
75
76 case 2:
77 \check_admin_referer( 'import-mastodon' );
78 self::$import_id = \absint( $_POST['import_id'] ?? 0 );
79 self::$author = \absint( $_POST['author'] ?? \get_current_user_id() );
80 self::$fetch_attachments = ! empty( $_POST['fetch_attachments'] );
81
82 \set_time_limit( 0 );
83 self::import();
84 break;
85 }
86
87 self::footer();
88 }
89
90 /**
91 * Handle upload.
92 *
93 * @return bool
94 */
95 public static function handle_upload() {
96 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
97
98 \check_admin_referer( 'import-upload' );
99
100 if ( ! isset( $_FILES['import']['name'] ) ) {
101 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
102 \printf(
103 /* translators: 1: php.ini, 2: post_max_size, 3: upload_max_filesize */
104 \esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your %1$s file or by %2$s being defined as smaller than %3$s in %1$s.', 'activitypub' ),
105 'php.ini',
106 'post_max_size',
107 'upload_max_filesize'
108 );
109 echo '</p>';
110 return false;
111 }
112
113 $file_info = \wp_check_filetype( \sanitize_file_name( $_FILES['import']['name'] ), array( 'zip' => 'application/zip' ) );
114 if ( 'application/zip' !== $file_info['type'] ) {
115 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
116 \esc_html_e( 'The uploaded file must be a ZIP archive. Please try again with the correct file format.', 'activitypub' );
117 echo '</p>';
118 return false;
119 }
120
121 $overrides = array(
122 'test_form' => false,
123 'test_type' => false,
124 );
125
126 $upload = \wp_handle_upload( $_FILES['import'], $overrides );
127
128 if ( isset( $upload['error'] ) ) {
129 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
130 echo \esc_html( $upload['error'] ) . '</p>';
131 return false;
132 }
133
134 // Construct the attachment array.
135 $attachment = array(
136 'post_title' => \wp_basename( $upload['file'] ),
137 'post_content' => $upload['url'],
138 'post_mime_type' => $upload['type'],
139 'guid' => $upload['url'],
140 'context' => 'import',
141 'post_status' => 'private',
142 );
143
144 // Save the data.
145 self::$import_id = \wp_insert_attachment( $attachment, $upload['file'] );
146
147 // Schedule a cleanup for one day from now in case of failed import or missing wp_import_cleanup() call.
148 \wp_schedule_single_event( \time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( self::$import_id ) );
149
150 return true;
151 }
152
153 /**
154 * Import options.
155 */
156 public static function import_options() {
157 $author = 0;
158 if ( isset( self::$outbox['orderedItems'][0] ) ) {
159 $users = \get_users(
160 array(
161 'fields' => 'ID',
162 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
163 'meta_query' => array(
164 array(
165 'key' => $GLOBALS['wpdb']->get_blog_prefix() . 'activitypub_also_known_as',
166 'value' => self::$outbox['orderedItems'][0]['actor'],
167 'compare' => 'LIKE',
168 ),
169 ),
170 )
171 );
172
173 if ( ! empty( $users ) ) {
174 $author = $users[0];
175 }
176 }
177
178 ?>
179 <form action="<?php echo \esc_url( \admin_url( 'admin.php?import=mastodon&amp;step=2' ) ); ?>" method="post">
180 <?php \wp_nonce_field( 'import-mastodon' ); ?>
181 <input type="hidden" name="import_id" value="<?php echo \esc_attr( self::$import_id ); ?>" />
182 <h3><?php \esc_html_e( 'Assign Author', 'activitypub' ); ?></h3>
183 <p>
184 <label for="author"><?php \esc_html_e( 'Author:', 'activitypub' ); ?></label>
185 <?php
186 \wp_dropdown_users(
187 array(
188 'name' => 'author',
189 'id' => 'author',
190 'show' => 'display_name_with_login',
191 'selected' => $author,
192 'capability' => 'activitypub',
193 )
194 );
195 ?>
196 </p>
197 <h3><?php \esc_html_e( 'Import Attachments', 'activitypub' ); ?></h3>
198 <p>
199 <input type="checkbox" value="1" name="fetch_attachments" id="import-attachments" checked />
200 <label for="import-attachments"><?php \esc_html_e( 'Download and import file attachments', 'activitypub' ); ?></label>
201 </p>
202 <p class="submit">
203 <input type="submit" class="button button-primary" value="<?php \esc_attr_e( 'Import', 'activitypub' ); ?>" />
204 </p>
205 </form>
206 <?php
207 }
208
209 /**
210 * Import.
211 */
212 public static function import() {
213 $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
214 $file = \get_attached_file( self::$import_id );
215
216 \WP_Filesystem();
217
218 global $wp_filesystem;
219 $import_folder = $wp_filesystem->wp_content_dir() . 'import/';
220 self::$archive = $import_folder . \basename( \basename( $file, '.txt' ), '.zip' );
221
222 // Clean up working directory.
223 if ( $wp_filesystem->is_dir( self::$archive ) ) {
224 $wp_filesystem->delete( self::$archive, true );
225 }
226
227 // Unzip package to working directory.
228 \unzip_file( $file, self::$archive );
229 self::maybe_unwrap_archive();
230
231 if ( ! $wp_filesystem->exists( self::$archive . '/outbox.json' ) ) {
232 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
233 echo \esc_html__( 'The archive does not contain an Outbox file, please try again.', 'activitypub' ) . '</p>';
234 return;
235 }
236
237 self::$outbox = \json_decode( $wp_filesystem->get_contents( self::$archive . '/outbox.json' ), true );
238
239 \wp_suspend_cache_invalidation();
240 \wp_defer_term_counting( true );
241 \wp_defer_comment_counting( true );
242
243 /**
244 * Fires when the Mastodon import starts.
245 */
246 \do_action( 'import_start' );
247
248 $result = self::import_posts();
249
250 \wp_suspend_cache_invalidation( false );
251 \wp_defer_term_counting( false );
252 \wp_defer_comment_counting( false );
253
254 $wp_filesystem->delete( $import_folder, true );
255 \wp_import_cleanup( self::$import_id );
256
257 if ( \is_wp_error( $result ) ) {
258 echo '<p><strong>' . \esc_html( $error_message ) . '</strong><br />';
259 echo \esc_html( $result->get_error_message() ) . '</p>';
260 } else {
261 echo '<p>';
262 /* translators: Home URL */
263 \printf( \wp_kses_post( \__( 'All done. <a href="%s">Have fun!</a>', 'activitypub' ) ), \esc_url( \admin_url() ) );
264 echo '</p>';
265 }
266
267 /**
268 * Fires when the Mastodon import ends.
269 */
270 \do_action( 'import_end' );
271 }
272
273 /**
274 * Process posts.
275 *
276 * Uses a multi-pass approach:
277 * 1. Categorize posts into regular posts and self-replies.
278 * 2. Import regular posts (root posts and external replies) as WordPress posts.
279 * 3. Import self-replies as comments on their parent posts.
280 *
281 * @return true|\WP_Error True on success, WP_Error on failure.
282 */
283 public static function import_posts() {
284 $skipped = array();
285 $imported = 0;
286
287 // Pass 1: Categorize posts.
288 $posts_to_import = array();
289 $self_replies = array();
290
291 foreach ( self::$outbox['orderedItems'] as $post ) {
292 // Skip boosts.
293 if ( 'Announce' === $post['type'] ) {
294 continue;
295 }
296
297 if ( ! is_activity_public( $post ) ) {
298 continue;
299 }
300
301 if ( self::is_self_reply( $post ) ) {
302 $self_replies[] = $post;
303 } else {
304 // Root posts and external replies are imported as WordPress posts.
305 $posts_to_import[] = $post;
306 }
307 }
308
309 // Pass 2: Import regular posts as WordPress posts.
310 $source_to_post_id = array();
311 foreach ( $posts_to_import as $post ) {
312 $created = false;
313 $result = self::import_as_post( $post, $created );
314
315 if ( \is_wp_error( $result ) ) {
316 return $result;
317 }
318
319 // Map it either way: an already-imported post is still the parent for its replies.
320 $source_to_post_id[ $post['object']['id'] ] = $result;
321
322 if ( $created ) {
323 ++$imported;
324 } else {
325 $skipped[] = $post['object']['id'];
326 }
327 }
328
329 // Pass 3: Import self-replies as comments (sorted by date for correct threading).
330 \usort(
331 $self_replies,
332 static function ( $a, $b ) {
333 return \strtotime( $a['published'] ) <=> \strtotime( $b['published'] );
334 }
335 );
336
337 $source_to_comment_id = array();
338 $comments_skipped = array();
339 $comments_imported = 0;
340
341 foreach ( $self_replies as $post ) {
342 $result = self::import_as_comment( $post, $source_to_post_id, $source_to_comment_id );
343
344 if ( $result ) {
345 ++$comments_imported;
346 } else {
347 $comments_skipped[] = $post['object']['id'];
348 }
349 }
350
351 // Output results.
352 if ( ! empty( $skipped ) ) {
353 echo '<p>' . \esc_html__( 'Skipped posts:', 'activitypub' ) . '<br>';
354 echo \wp_kses( \implode( '<br>', $skipped ), array( 'br' => array() ) );
355 echo '</p>';
356 }
357
358 if ( ! empty( $comments_skipped ) ) {
359 echo '<p>' . \esc_html__( 'Skipped comments:', 'activitypub' ) . '<br>';
360 echo \wp_kses( \implode( '<br>', $comments_skipped ), array( 'br' => array() ) );
361 echo '</p>';
362 }
363
364 /* translators: %s: Number of posts */
365 echo '<p>' . \esc_html( \sprintf( \_n( 'Imported %s post.', 'Imported %s posts.', $imported, 'activitypub' ), \number_format_i18n( $imported ) ) ) . '</p>';
366
367 if ( $comments_imported > 0 ) {
368 /* translators: %s: Number of comments */
369 echo '<p>' . \esc_html( \sprintf( \_n( 'Imported %s comment from self-reply threads.', 'Imported %s comments from self-reply threads.', $comments_imported, 'activitypub' ), \number_format_i18n( $comments_imported ) ) ) . '</p>';
370 }
371
372 return true;
373 }
374
375 /**
376 * Check if a post is a self-reply (thread continuation).
377 *
378 * A self-reply is when a user replies to their own post, creating a thread.
379 *
380 * @param array $post The Mastodon activity.
381 *
382 * @return bool True if replying to own post.
383 */
384 private static function is_self_reply( $post ) {
385 if ( empty( $post['object']['inReplyTo'] ) ) {
386 return false;
387 }
388
389 /*
390 * Compare base URLs (actor URL should be a prefix of inReplyTo for self-replies).
391 *
392 * Example:
393 * - actor: https://mastodon.social/users/example
394 * - inReplyTo: https://mastodon.social/users/example/statuses/123
395 *
396 * Adding a trailing slash ensures we don't match partial usernames
397 * (e.g., "example" shouldn't match "example2").
398 */
399 return \str_starts_with( $post['object']['inReplyTo'], \rtrim( $post['actor'], '/' ) . '/' );
400 }
401
402 /**
403 * Import a single activity as a WordPress post.
404 *
405 * @param array $post The Mastodon activity.
406 * @param bool $created Set to false when the post was already imported and the
407 * return value is the existing id rather than a new one.
408 *
409 * @return int|\WP_Error Post ID, either newly created or the one already imported,
410 * or WP_Error on failure.
411 */
412 private static function import_as_post( $post, &$created ) {
413 /*
414 * An imported post is handled the same as a federated one: `Sanitize::content()`
415 * normalizes and sanitizes the content. The
416 * import runs as a user with `unfiltered_html`, so kses filters are not installed
417 * for this request and `wp_insert_post()` would otherwise store whatever the archive
418 * contained; these posts are published publicly, so they are the wider exposure.
419 */
420 $post_data = array(
421 'post_author' => self::$author,
422 'post_date' => $post['published'],
423 // Slashed like the federated path: wp_insert_post() unslashes what it is given.
424 'post_excerpt' => \wp_slash( \is_string( $post['object']['summary'] ?? null ) ? \wp_strip_all_tags( $post['object']['summary'] ) : '' ),
425 'post_content' => \wp_slash( Sanitize::content( $post['object']['content'] ?? '' ) ),
426 'post_status' => 'publish',
427 'post_type' => 'post',
428 'meta_input' => array( '_source_id' => $post['object']['id'] ),
429 'tags_input' => \array_map(
430 static function ( $tag ) {
431 if ( 'Hashtag' === $tag['type'] ) {
432 return \ltrim( $tag['name'], '#' );
433 }
434
435 return '';
436 },
437 $post['object']['tag'] ?? array()
438 ),
439 );
440
441 /**
442 * Filter the post data before inserting it into the database.
443 *
444 * @param array $post_data The post data to be inserted.
445 * @param array $post The Mastodon Create activity.
446 */
447 $post_data = \apply_filters( 'activitypub_import_mastodon_post_data', $post_data, $post );
448
449 /*
450 * Match on the archive's own id first. Falling back to `post_exists()` alone keys
451 * de-duplication on an exact `post_content` match, so any change to what we store
452 * (a new sanitizer, for one) makes everything imported by an older version stop
453 * matching and come back as a duplicate.
454 */
455 $post_exists = self::get_post_by_source_id( $post['object']['id'] ?? '', $post_data['post_type'] );
456
457 if ( ! $post_exists ) {
458 $post_exists = \post_exists( '', $post_data['post_content'], $post_data['post_date'], $post_data['post_type'] );
459 }
460
461 /**
462 * Filter ID of the existing post corresponding to post currently importing.
463 *
464 * Return 0 to force the post to be imported. Filter the ID to be something else
465 * to override which existing post is mapped to the imported post.
466 *
467 * @see post_exists()
468 *
469 * @param int $post_exists Post ID, or 0 if post did not exist.
470 * @param array $post_data The post array to be inserted.
471 */
472 $post_exists = \apply_filters( 'wp_import_existing_post', $post_exists, $post_data );
473
474 if ( $post_exists ) {
475 /*
476 * Report it as already imported, but hand the id back: pass 3 maps self-replies
477 * onto their parent through this return value, and a reply whose parent we
478 * skipped would otherwise find no parent and be dropped.
479 */
480 $created = false;
481
482 return $post_exists;
483 }
484
485 $created = true;
486
487 $post_id = \wp_insert_post( $post_data, true );
488
489 if ( \is_wp_error( $post_id ) ) {
490 return $post_id;
491 }
492
493 \set_post_format( $post_id, 'status' );
494
495 // Process attachments if enabled.
496 if ( self::$fetch_attachments && ! empty( $post['object']['attachment'] ) ) {
497 // Prepend archive path to attachment URLs for local files.
498 $attachments = \array_map( array( self::class, 'prepend_archive_path' ), $post['object']['attachment'] );
499
500 Attachments::import( $attachments, $post_id, self::$author );
501 }
502
503 return $post_id;
504 }
505
506 /**
507 * Find a post already imported under an archive object id.
508 *
509 * @since 9.3.0
510 *
511 * @param string $source_id The archive object id.
512 * @param string $post_type The post type to look in.
513 *
514 * @return int The post ID, or 0 when the object has not been imported yet.
515 */
516 private static function get_post_by_source_id( $source_id, $post_type ) {
517 if ( ! \is_string( $source_id ) || '' === $source_id ) {
518 return 0;
519 }
520
521 /*
522 * `suppress_filters` is left at the get_posts() default of true on purpose: a
523 * de-duplication probe is the last query a third-party `posts_where` should be
524 * able to rewrite, since hiding the existing post brings the duplicates back.
525 */
526
527 /*
528 * Every status, trash included. `post_exists()` below takes no status argument, so
529 * it always matched a trashed import on content; `'any'` would be narrower, because
530 * it drops the statuses flagged `exclude_from_search`.
531 */
532 $posts = \get_posts(
533 array(
534 'post_type' => $post_type,
535 'post_status' => \get_post_stati(),
536 'numberposts' => 1,
537 'fields' => 'ids',
538 'meta_key' => '_source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
539 'meta_value' => $source_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
540 )
541 );
542
543 return $posts ? (int) $posts[0] : 0;
544 }
545
546 /**
547 * Import a self-reply as a comment on its parent post.
548 *
549 * @param array $post The Mastodon activity.
550 * @param array $source_to_post_id Mapping of source IDs to WordPress post IDs.
551 * @param array $source_to_comment_id Mapping of source IDs to WordPress comment IDs (passed by reference).
552 *
553 * @return int|false Comment ID on success, false if parent not found or skipped.
554 */
555 private static function import_as_comment( $post, $source_to_post_id, &$source_to_comment_id ) {
556 $in_reply_to = $post['object']['inReplyTo'];
557
558 // Find parent - could be a post or another comment.
559 $parent_post_id = null;
560 $parent_comment_id = 0;
561
562 if ( isset( $source_to_post_id[ $in_reply_to ] ) ) {
563 // Replying to a root post or external reply.
564 $parent_post_id = $source_to_post_id[ $in_reply_to ];
565 } elseif ( isset( $source_to_comment_id[ $in_reply_to ] ) ) {
566 // Replying to another comment (nested thread).
567 $parent_comment_id = $source_to_comment_id[ $in_reply_to ];
568 $parent_comment = \get_comment( $parent_comment_id );
569
570 if ( $parent_comment ) {
571 $parent_post_id = $parent_comment->comment_post_ID;
572 }
573 }
574
575 // If we couldn't find the parent, skip this comment.
576 if ( ! $parent_post_id ) {
577 return false;
578 }
579
580 // Check for duplicate.
581 $existing_comments = \get_comments(
582 array(
583 'post_id' => $parent_post_id,
584 'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
585 'meta_value' => $post['object']['id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
586 'number' => 1,
587 )
588 );
589
590 if ( ! empty( $existing_comments ) ) {
591 // Already imported, add to mapping and skip.
592 $source_to_comment_id[ $post['object']['id'] ] = $existing_comments[0]->comment_ID;
593
594 return false;
595 }
596
597 $comment_data = array(
598 'comment_post_ID' => $parent_post_id,
599 'comment_parent' => $parent_comment_id,
600 'comment_author' => \wp_slash( \get_the_author_meta( 'display_name', self::$author ) ),
601
602 /*
603 * Sanitize the archive's content explicitly. The import runs as a user with
604 * `unfiltered_html`, so `kses_init()` installs none of the kses filters for
605 * this request: neither `wp_insert_comment()` nor the `pre_comment_*` chain
606 * would touch this value. Core then prints `comment_content` unescaped, on
607 * the front end and in the Dashboard "Activity" widget.
608 *
609 * The comment allowlist, not the post one, so this field is cleaned the same
610 * way Collection\Interactions cleans a live-federated reply.
611 */
612 // Slashed like the federated path: wp_insert_comment() unslashes what it is given.
613 'comment_content' => \wp_slash( Sanitize::comment_content( $post['object']['content'] ?? '' ) ),
614 'comment_date' => $post['published'],
615 'user_id' => self::$author,
616 'comment_approved' => 1,
617 );
618
619 $comment_id = \wp_insert_comment( $comment_data );
620
621 if ( $comment_id ) {
622 \update_comment_meta( $comment_id, 'source_id', $post['object']['id'] );
623
624 $source_to_comment_id[ $post['object']['id'] ] = $comment_id;
625 }
626
627 return $comment_id;
628 }
629
630 /**
631 * Header.
632 */
633 public static function header() {
634 echo '<div class="wrap">';
635 echo '<h2>' . \esc_html__( 'Import from Mastodon (Beta)', 'activitypub' ) . '</h2>';
636 }
637
638 /**
639 * Footer.
640 */
641 public static function footer() {
642 echo '</div>';
643 }
644
645 /**
646 * Intro.
647 */
648 public static function greet() {
649 echo '<div class="narrow">';
650 echo '<p>' . \wp_kses(
651 \sprintf(
652 /* translators: %s: URL to Mastodon export documentation */
653 \__( 'This importer allows you to bring your Mastodon posts into your WordPress site. For a smooth import experience, check out the <a href="%s" target="_blank">Mastodon documentation</a>.', 'activitypub' ),
654 'https://docs.joinmastodon.org/user/moving/#export'
655 ),
656 array(
657 'a' => array(
658 'href' => array(),
659 'target' => array(),
660 ),
661 )
662 ) . '</p>';
663 echo '<p>' . \esc_html__( 'Here&#8217;s how to get started:', 'activitypub' ) . '</p>';
664
665 echo '<ol>';
666 echo '<li>' . \wp_kses( \__( 'Log in to your Mastodon account and go to <strong>Preferences > Import and Export</strong>.', 'activitypub' ), array( 'strong' => array() ) ) . '</li>';
667 echo '<li>' . \esc_html__( 'Request a new archive of your data and wait for the email notification.', 'activitypub' ) . '</li>';
668 echo '<li>' . \wp_kses( \__( 'Download the archive file (it will be a <code>.zip</code> file).', 'activitypub' ), array( 'code' => array() ) ) . '</li>';
669 echo '<li>' . \esc_html__( 'Upload that file below to begin the import process.', 'activitypub' ) . '</li>';
670 echo '</ol>';
671
672 \wp_import_upload_form( 'admin.php?import=mastodon&amp;step=1' );
673 echo '</div>';
674 }
675
676 /**
677 * Prepend archive path to local attachment URLs.
678 *
679 * @param array $attachment The attachment array.
680 *
681 * @return array The attachment array with updated URL.
682 */
683 private static function prepend_archive_path( $attachment ) {
684 if ( ! empty( $attachment['url'] ) && ! \preg_match( '#^https?://#i', $attachment['url'] ) ) {
685 $attachment['url'] = self::$archive . $attachment['url'];
686 }
687
688 return $attachment;
689 }
690
691 /**
692 * Detect and unwrap single nested directory in archive.
693 *
694 * Some Mastodon exports wrap all files in a root folder. This method
695 * detects this pattern and updates the archive path to point inside it.
696 */
697 private static function maybe_unwrap_archive() {
698 global $wp_filesystem;
699
700 $files = $wp_filesystem->dirlist( self::$archive );
701
702 // Check if there's exactly one directory at root level.
703 if ( \count( $files ) !== 1 ) {
704 return;
705 }
706
707 $first = \reset( $files );
708 if ( 'd' !== $first['type'] ) {
709 return;
710 }
711
712 // Check if outbox.json exists inside the nested directory.
713 $nested_path = self::$archive . '/' . $first['name'];
714 if ( $wp_filesystem->exists( $nested_path . '/outbox.json' ) ) {
715 self::$archive = $nested_path;
716 }
717 }
718 }
719