PluginProbe
Substack Importer / trunk
Substack Importer vtrunk
trunk 0.1.0 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0
substack-importer / includes / class-converter.php

class-converter.php in Substack Importer trunk, at includes/class-converter.php

1,814 lines 54.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
3 namespace SubstackImporter;
4
5 use WP_Error;
6 use WXR_Generator\Generator;
7 use ZipArchive;
8 use DOMDocument;
9 use DomComment;
10 use DomElement;
11 use DOMText;
12
13
14 /**
15 * The Substack Converter is responsible for taking in a Substack export and providing
16 * data to the WXR generator.
17 *
18 * @package SubstackImporter
19 */
20 class Converter {
21
22 /**
23 * @var string $export_file_path Path of the export file.
24 */
25 protected $export_file_path;
26
27 /**
28 * Instance of the WXR generator
29 * @var Generator $generator
30 */
31 protected $generator;
32
33 /**
34 * Authors.
35 * @var array
36 */
37 protected $authors = array();
38
39 /**
40 * Categories.
41 *
42 * @var array
43 */
44 protected $categories = array();
45
46 /**
47 * URL of the Substack Newsletter.
48 *
49 * @var string
50 */
51 protected $substack_url;
52
53 /**
54 * The classnames of all possible embed nodes in the Substack HTML.
55 *
56 * @var string[]
57 */
58 protected $supported_embeds = array(
59 'tweet',
60 'instagram', // No longer supported.
61 'youtube-wrap',
62 'spotify-wrap',
63 'soundcloud-wrap',
64 'vimeo-wrap',
65 'bandcamp-wrap', // Shortcode embed,
66 'github-gist', // Not supported in core, using shortcode embed
67 'tiktok-wrap',
68 );
69
70
71 /**
72 * Converter constructor.
73 *
74 * @param Generator $generator Instance of the WXR Generator.
75 * @param string $export_file_path Path to the Substack export zip file.
76 * @param null $substack_url URL of the Substack newsletter.
77 */
78 public function __construct( Generator $generator, $export_file_path, $substack_url = null ) {
79 $this->generator = $generator;
80 $this->export_file_path = $export_file_path;
81 $this->substack_url = $substack_url;
82 }
83
84 /**
85 * Convert the Substack export to a WXR.
86 *
87 * @returns WP_Error|void
88 *
89 * @throws \OxymelException
90 */
91 public function convert() {
92 if ( ! $this->export_file_path || ! file_exists( $this->export_file_path ) ) {
93 return new WP_Error( 'export_file_not_exist', 'The export file does not exist' );
94 }
95
96 $this->generator->initialize();
97
98 // Add posts.
99 $out = $this->add_posts();
100
101 if ( is_wp_error( $out ) ) {
102 return $out;
103 }
104
105 // Add Authors.
106 foreach ( $this->authors as $author ) {
107 $this->generator->add_author( $author );
108 }
109
110 // Add categories.
111 foreach ( $this->categories as $category ) {
112 $this->generator->add_category( $category );
113 }
114
115 $this->generator->finalize();
116 }
117
118 /**
119 * Lets make sure that the url always has a https protocol.
120 * @param null $url
121 */
122 private function ensure_protocol( $url ) {
123 if ( is_null( $url ) ) {
124 return null;
125 }
126
127 // Ensure substack_url has a protocol (http or https)
128 $parsed_url = parse_url( $url );
129 if ( ! isset( $parsed_url['scheme'] ) ) {
130 $url = 'https://' . $url;
131 }
132
133 return preg_replace( '/^(https?:\/\/)?/', 'https://', $url );
134 }
135
136 /**
137 * Load additional information retrieved through the Substack API into the export zip file.
138 *
139 * @param int $offset 0-indexed starting offset for the post to start with.
140 * @param int $limit Number of posts to process.
141 *
142 * @return array|WP_Error
143 */
144 public function load_meta_data( $offset = 0, $limit = 1 ) {
145
146 $zip = $this->get_export_zip();
147
148 if ( is_wp_error( $zip ) ) {
149 return $zip;
150 }
151
152 $total_count = 0;
153
154 $this->substack_url = $this->ensure_protocol( $this->substack_url );
155
156 foreach ( $this->get_posts() as $idx => $post ) {
157 ++$total_count;
158
159 if ( $idx < $offset || $idx >= $offset + $limit ) {
160 continue;
161 }
162
163 list($id, $slug) = explode( '.', $post['post_id'], 2 );
164 $meta = $this->fetch_post_meta( $slug );
165
166 if ( $meta ) {
167 $zip->addFromString( sprintf( 'meta/%s.json', $id ), $meta );
168 }
169 }
170
171 return array(
172 'total' => $total_count,
173 'processed' => min( $offset + $limit, $total_count ),
174 );
175 }
176
177 /**
178 * Convert each Substack post to a WordPress post and add it to the WXR.
179 *
180 * @return void|WP_Error
181 *
182 * @throws \OxymelException
183 */
184 protected function add_posts() {
185
186 $posts_generator = $this->get_posts();
187
188 if ( is_wp_error( $posts_generator ) ) {
189 return $posts_generator;
190 }
191
192 foreach ( $posts_generator as $post ) {
193
194 $post_id = explode( '.', $post['post_id'], 2 );
195 $id = (int) $post_id[0];
196 $post_meta = $this->get_post_meta_from_export( $id );
197
198 /**
199 * Filter the post metadata loaded from the Substack export.
200 *
201 * Allows modification of the metadata retrieved from the Substack API
202 * before it is used for author, comments, and other post data.
203 *
204 * @since 1.2.0
205 *
206 * @param array|null $post_meta The post metadata from the Substack API response.
207 * @param array $post The raw Substack post data from the CSV.
208 * @param int $id The Substack post ID.
209 */
210 $post_meta = apply_filters( 'substack_importer_post_meta', $post_meta, $post, $id );
211
212 /**
213 * Fires before a single Substack post is processed and converted.
214 *
215 * Useful for setting up state or performing actions before conversion begins.
216 *
217 * @since 1.2.0
218 *
219 * @param array $post The raw Substack post data from the CSV.
220 * @param array|null $post_meta The post metadata from the Substack API response.
221 * @param int $id The Substack post ID.
222 */
223 do_action( 'substack_importer_before_post', $post, $post_meta, $id );
224
225 if ( ! empty( $post['subtitle'] ) ) {
226 $post['html_body'] = $this->add_subtitle( $post );
227 }
228
229 /**
230 * Filter the raw HTML content before Gutenberg conversion.
231 *
232 * This filter runs after the subtitle has been prepended (if present)
233 * but before the HTML is parsed and converted to Gutenberg blocks.
234 * Useful for cleaning up or transforming Substack-specific HTML,
235 * adding custom elements, or stripping unwanted markup.
236 *
237 * @since 1.2.0
238 *
239 * @param string $html_body The raw HTML content from the Substack export.
240 * @param array $post The raw Substack post data from the CSV.
241 * @param array|null $post_meta The post metadata from the Substack API response.
242 */
243 $post['html_body'] = apply_filters(
244 'substack_importer_raw_content',
245 $post['html_body'],
246 $post,
247 $post_meta
248 );
249
250 $post_content = $this->convert_html_to_gutenberg( $post['html_body'] );
251
252 /**
253 * Filter the post content after Gutenberg conversion.
254 *
255 * This filter allows modification of the converted Gutenberg block content
256 * before it is added to the WXR. Useful for wrapping paywalled content in
257 * custom blocks (e.g., membership plugins).
258 *
259 * @since 1.2.0
260 *
261 * @param string $post_content The converted Gutenberg block content.
262 * @param array $post The original Substack post data.
263 * @param array|null $post_meta Additional post metadata from Substack API.
264 */
265 $post_content = apply_filters(
266 'substack_importer_post_content_after_conversion',
267 $post_content,
268 $post,
269 $post_meta
270 );
271
272 $post_data = array(
273 'id' => $id,
274 'title' => $post['title'],
275 'content' => $post_content,
276 'date' => 'true' === $post['is_published'] ? $post['post_date'] : '',
277 'status' => 'true' === $post['is_published'] ? 'publish' : 'draft',
278 'post_date_gmt' => $post['post_date'],
279 'post_date' => $post['post_date'],
280 'post_taxonomies' => array(),
281 'metas' => array(),
282 );
283
284 $first_image_url = $this->get_first_image_url_from_html( $post['html_body'] );
285 if ( ! empty( $first_image_url ) ) {
286 $post_data['metas'][] = array(
287 'key' => '_substack_first_image_url',
288 'value' => $first_image_url,
289 );
290 }
291
292 if ( isset( $post_id[1] ) ) {
293 $post_data['post_name'] = $post_id[1];
294 }
295
296 // If we were able to retrieve more information through the Substack API, we might have
297 // author information and comments.
298 $post_data['author'] = $post_meta ? $this->get_post_author( $post_meta, $post_data['status'] ) : $this->get_default_author( $post_data['status'] );
299 $post_data['comments'] = $post_meta ? $this->get_post_comments( $post_meta ) : array();
300
301 // Set the comment status.
302 $post_data['comment_status'] = ! empty( $post_meta['write_comment_permissions'] ) && 'none' === $post_meta['write_comment_permissions']
303 ? 'closed'
304 : 'open';
305
306 // Handle podcast posts - prepend a Gutenberg audio block to the post content.
307 if ( 'podcast' === $post['type'] && ! empty( $post['podcast_url'] ) ) {
308 $post_data = $this->handle_podcast_post( $post_data, $post );
309 }
310
311 /**
312 * Allow for custom modifications to the post data.
313 *
314 * @param array $post_data The post data.
315 * @param array $post The original post data.
316 */
317 $post_data = apply_filters( 'substack_importer_post_data', $post_data, $post );
318
319 $this->generator->add_post( $post_data );
320
321 /**
322 * Fires after a single Substack post has been converted and added to the WXR.
323 *
324 * Useful for logging, progress tracking, or performing cleanup after each post.
325 *
326 * @since 1.2.0
327 *
328 * @param array $post_data The final post data that was added to the WXR.
329 * @param array $post The raw Substack post data from the CSV.
330 * @param array|null $post_meta The post metadata from the Substack API response.
331 * @param int $id The Substack post ID.
332 */
333 do_action( 'substack_importer_after_post', $post_data, $post, $post_meta, $id );
334 }
335 }
336
337 protected function handle_podcast_post( $post_data, $post ) {
338 $post_data['content'] = $this->get_audio_block( $post['podcast_url'] ) . $post_data['content'];
339
340 // Create a new attachment
341 $this->generator->add_post(
342 array(
343 'title' => urldecode( basename( $post['podcast_url'] ) ),
344 'link' => $post['podcast_url'],
345 'post_date' => $post_data['post_date'],
346 'type' => 'attachment',
347 'attachment_url' => $post['podcast_url'],
348 'metas' => array(
349 array(
350 'key' => '_wp_original_image_link',
351 'value' => $post['podcast_url'],
352 ),
353 ),
354 )
355 );
356
357 // Add this post to the podcast category.
358 $this->categories['podcast'] = array(
359 'slug' => 'podcast',
360 'name' => 'Podcast',
361 );
362 $post_data['post_taxonomies'][] = array(
363 'name' => 'Podcast',
364 'slug' => 'podcast',
365 'domain' => 'category',
366 );
367
368 return $post_data;
369 }
370
371 /**
372 * Add the subtitle to the html_content by prepending a h2.
373 *
374 * @param array $post The post data containing subtitle and html_body.
375 *
376 * @return string html body content.
377 */
378 protected function add_subtitle( $post ) {
379 $heading = sprintf( '<h2>%s</h2>', $post['subtitle'] );
380
381 /**
382 * Filter the subtitle HTML before it is prepended to the post content.
383 *
384 * Return an empty string to skip the subtitle entirely.
385 * Useful for changing the heading level, wrapping in custom markup,
386 * or conditionally removing subtitles.
387 *
388 * @since 1.2.0
389 *
390 * @param string $heading The subtitle HTML (default: an h2 element).
391 * @param array $post The raw Substack post data containing 'subtitle' and 'html_body'.
392 */
393 $heading = apply_filters( 'substack_importer_subtitle', $heading, $post );
394
395 return $heading . $post['html_body'];
396 }
397
398 /**
399 * Get a Gutenberg Audio block for the podcast.
400 *
401 * @param string $audio_url The URL of the podcast audio file.
402 *
403 * @return string The Gutenberg audio block HTML.
404 */
405 protected function get_audio_block( $audio_url ) {
406 $code = '<!-- wp:audio --><figure class="wp-block-audio"><audio controls src="%s"></audio><figcaption>Podcast</figcaption></figure><!-- /wp:audio -->';
407 $block = sprintf( $code, $audio_url );
408
409 /**
410 * Filter the Gutenberg audio block HTML for podcast posts.
411 *
412 * Allows modification or replacement of the audio block that is
413 * prepended to podcast post content. Useful for using a custom
414 * audio player block or adding additional markup.
415 *
416 * @since 1.2.0
417 *
418 * @param string $block The Gutenberg audio block HTML.
419 * @param string $audio_url The URL of the podcast audio file.
420 */
421 return apply_filters( 'substack_importer_audio_block', $block, $audio_url );
422 }
423
424 protected function get_post_author( $post_meta, $post_status ) {
425 // If we can't get the author information, return a default author.
426 if ( empty( $post_meta['publishedBylines'] ) ) {
427 return $this->get_default_author( $post_status );
428 }
429
430 $byline = $post_meta['publishedBylines'][0];
431 $this->authors[ $byline['id'] ] = array(
432 'login' => $byline['name'],
433 'display_name' => $byline['name'],
434 'id' => $byline['id'],
435 );
436
437 return $byline['name'];
438 }
439
440 protected function get_default_author( $post_status ) {
441 $unknown_author_key = '_unknown';
442 $unknown_author_value = array(
443 'login' => 'Unknown',
444 'display_name' => 'Unknown',
445 'id' => 1,
446 );
447 $draft_author_key = '_draft';
448 $draft_author_value = array(
449 'login' => 'Draft',
450 'display_name' => 'Draft Posts',
451 'id' => 2,
452 );
453
454 if ( 'publish' === $post_status ) {
455 $this->authors[ $unknown_author_key ] = $unknown_author_value;
456 return $unknown_author_key;
457 } else {
458 $this->authors[ $draft_author_key ] = $draft_author_value;
459 return $draft_author_key;
460 }
461 }
462
463 /**
464 * Get post comments retrieved through the Substack api.
465 *
466 * @param array $post_data Additional data about a post retrieved through the Substack Post API.
467 *
468 * @return mixed
469 */
470 protected function get_post_comments( $post_meta ) {
471 if ( empty( $post_meta['comments'] ) ) {
472 return array();
473 }
474
475 return $this->parse_comments( $post_meta['comments'] );
476 }
477
478 /**
479 * Recursively parse the comments and prepare the data required for the WXR output.
480 *
481 * @param array $comments An array of comments provided by the Substack posts API endpoint.
482 * @param array $out Output that is ready to be passed to the WXR generator.
483 * @param null $parent If we are in a recursive call, the parent must be provided.
484 *
485 * @return array|mixed
486 */
487 protected function parse_comments( $comments, $out = array(), $parent = null ) {
488 foreach ( $comments as $comment ) {
489 $out[] = array(
490 'id' => $comment['id'],
491 'author' => $comment['name'],
492 'date' => $comment['date'],
493 'date_gmt' => $comment['date'],
494 'content' => $comment['body'],
495 'parent' => $parent,
496 'metas' => array(),
497 );
498
499 if ( ! empty( $comment['children'] ) ) {
500 $out = $this->parse_comments( $comment['children'], $out, (int) $comment['id'] );
501 }
502 }
503
504 return $out;
505 }
506
507 /**
508 * Convert the content HTML to Gutenberg blocks and return the result.
509 *
510 * @param string $content The HTML provided by Substack.
511 *
512 * @return string|string[]|null
513 *
514 * @todo Load the content as XML to prevent errors from loadHTML.
515 */
516 protected function convert_html_to_gutenberg( $content ) {
517 $dom = new DOMDocument();
518
519 // Suppress warnings and errors when loading HTML
520 libxml_use_internal_errors( true );
521
522 // By inserting a meta tag with utf-8 encoding we make sure the content is converted to utf-8
523 $content = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . $content;
524 $dom->loadHTML( $content );
525
526 // Clear any errors that were logged
527 libxml_clear_errors();
528
529 $body = $dom->getElementsByTagName( 'body' )->item( 0 );
530
531 // We don't want to use the DomNodeList because it will change while we are iterating over the nodes.
532 $nodes = array();
533 foreach ( $body->childNodes as $node ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
534 if ( ! $node instanceof DomElement ) {
535 continue;
536 }
537 $nodes[] = $node;
538 }
539
540 // We go through the top-level nodes and handle each of them.
541 foreach ( $nodes as $idx => $node ) {
542 $next_sibling = count( $nodes ) - 1 > $idx ? $nodes[ $idx + 1 ] : null;
543 $this->convert_node( $node, $body, $next_sibling );
544 }
545
546 // Save as XML otherwise we don't get HTMl5 elements correctly.
547 $content = $dom->saveXML( $body );
548
549 // Strip the body tag.
550 $content = preg_replace( '/<body>(.+)<\/body>/s', '$1', $content );
551
552 return $content;
553 }
554
555
556 /**
557 * Convert a single node to a Gutenberg block.
558 *
559 * Tries to convert a given HTML node into a Gutenberg block.
560 *
561 * @param DomElement $node The node to be converted.
562 * @param DomElement $parent The parent of the node to be converted.
563 * @param DomElement|null $next_sibling The next sibling of the node to be converted, if it exists.
564 *
565 */
566 protected function convert_node( DOMElement $node, DomElement $parent, ?DomElement $next_sibling = null ) {
567 $block_name = null;
568 $block_attributes = array();
569
570 $node_name = $node->nodeName; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
571 switch ( $node_name ) {
572
573 case 'p':
574 $block_name = 'wp:paragraph';
575 $class = $node->getAttribute( 'class' );
576
577 // remove empty paragraphs.
578 /** @todo Perhaps we can remove all empty nodes, not just paragraphs? */
579 if ( ! $node->childNodes->length ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
580 $parent->removeChild( $node );
581 $node = null;
582 }
583
584 // Button
585 if ( 'button-wrapper' === $class ) {
586 $node = $this->convert_button_node( $node, $parent );
587 $block_name = 'wp:button';
588 }
589
590 break;
591
592 case 'blockquote':
593 $block_name = 'wp:quote';
594 $node->setAttribute( 'class', 'wp-block-quote' );
595 break;
596
597 case 'div':
598 case 'iframe':
599 $class = $node->getAttribute( 'class' );
600
601 // Preformatted text - these are Poetry blocks in Substack
602 if ( 'preformatted-block' === $class ) {
603 $node = $this->convert_preformatted_node( $node, $parent );
604 $block_name = 'wp:verse';
605 }
606
607 // Pull Quotes
608 if ( 'pullquote' === $class ) {
609 $node = $this->convert_pullquote_node( $node, $parent );
610 $block_name = 'wp:pullquote';
611 }
612
613 // Images
614 if ( 'captioned-image-container' === $class ) {
615 $result = $this->convert_image_node( $node, $parent );
616 $node = $result['node'];
617 $block_attributes = $result['block_attributes'];
618 $block_name = 'wp:image';
619 }
620
621 // Horizontal separator
622 if (
623 $node &&
624 $node->hasChildNodes() && //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
625 $node->childNodes->length && //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
626 'hr' === $node->childNodes[0]->nodeName //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
627 ) {
628 $node = $this->convert_separator_node( $node, $parent );
629 $block_name = 'wp:separator';
630 }
631
632 // Embeds
633 $first_class = explode( ' ', $class );
634 if ( ! empty( $first_class ) && in_array( $first_class[0], $this->supported_embeds, true ) ) {
635 $result = $this->convert_embed_node( $node, $parent );
636 $node = $result['node'];
637 $block_attributes = $result['block_attributes'];
638 $block_name = $result['block_name'];
639 }
640
641 if ( 'paywall-jump' === $class ) {
642 $result = $this->convert_paywall_node( $node, $parent );
643 $node = $result['node'];
644 $block_attributes = $result['block_attributes'];
645 $block_name = $result['block_name'];
646 }
647
648 if ( 'subscription-widget-wrap' === $class ) {
649 $result = $this->convert_subscription_node( $node, $parent );
650 $node = $result['node'];
651 $block_attributes = $result['block_attributes'];
652 $block_name = $result['block_name'];
653 }
654
655 break;
656
657 case 'ol':
658 case 'ul':
659 $block_name = 'wp:list';
660
661 if ( 'ol' === $node_name ) {
662 $block_attributes['ordered'] = true;
663 }
664
665 break;
666
667 case 'pre':
668 $block_name = 'wp:code';
669 $node->setAttribute( 'class', 'wp-block-code' );
670 break;
671
672 case 'h1':
673 case 'h2':
674 case 'h3':
675 case 'h4':
676 case 'h5':
677 case 'h6':
678 $block_name = 'wp:heading';
679
680 // Gutenberg defaults to h2 for heading blocks without a level attribute.
681 if ( 'h1' === $node_name ) {
682 $node = $this->replace_html_node_tag( $node, $parent, 'h2' );
683 }
684
685 $node->setAttribute( 'class', 'wp-block-heading' );
686
687 if ( ! in_array( $node_name, array( 'h1', 'h2' ), true ) ) {
688 $block_attributes['level'] = (int) substr( $node_name, 1, 1 );
689 }
690 break;
691
692 case 'a':
693 $class = $node->getAttribute( 'class' );
694 if ( 'image-link image2' === trim( $class ) ) {
695 $result = $this->convert_image_node( $node, $parent );
696 $node = $result['node'];
697 $block_attributes = $result['block_attributes'];
698 $block_name = 'wp:image';
699 }
700
701 break;
702
703 }
704
705 if ( ! $block_name || ! $node ) {
706 return;
707 }
708
709 /**
710 * Filter the result of a single node conversion to a Gutenberg block.
711 *
712 * Allows modification of the block name and attributes after the default
713 * conversion logic has run. Return a block_name of null to skip the node.
714 * Useful for overriding how specific Substack elements are converted,
715 * adding custom attributes, or changing block types.
716 *
717 * @since 1.2.0
718 *
719 * @param array $block_data {
720 * The block conversion result.
721 *
722 * @type string $block_name The Gutenberg block name (e.g. 'wp:paragraph').
723 * @type array $block_attributes The block attributes array.
724 * }
725 * @param DomElement $node The converted DOM node.
726 * @param string $node_name The original HTML tag name (e.g. 'p', 'div', 'h2').
727 */
728 $block_data = apply_filters(
729 'substack_importer_converted_node',
730 array(
731 'block_name' => $block_name,
732 'block_attributes' => $block_attributes,
733 ),
734 $node,
735 $node_name
736 );
737
738 $block_name = $block_data['block_name'];
739 $block_attributes = $block_data['block_attributes'];
740
741 if ( ! $block_name ) {
742 return;
743 }
744
745 // Create the Gutenberg block code
746 $attributes_part = '';
747 if ( is_countable( $block_attributes ) && count( $block_attributes ) ) {
748 $attributes_part = ' ' . wp_json_encode( $block_attributes );
749 }
750 $block_open = new DOMComment( ' ' . $block_name . $attributes_part . ' ' );
751 $block_close = new DOMComment( ' /' . $block_name . ' ' );
752
753 $parent->insertBefore( $block_open, $node );
754
755 $next_sibling
756 ? $parent->insertBefore( $block_close, $next_sibling )
757 : $parent->appendChild( $block_close );
758 }
759
760 /**
761 * Convert a preformatted text node to valid Gutenberg markup.
762 *
763 * @param DomElement $node The node to be converted.
764 * @param DomElement $parent The parent of the node.
765 *
766 * @return DomElement The converted node.
767 */
768 protected function convert_preformatted_node( DomElement $node, DomElement $parent ) {
769 $node_pres = $node->getElementsByTagName( 'pre' );
770 if ( 0 === $node_pres->length ) {
771 return null;
772 }
773
774 $new_node = new DomElement( 'pre', $node_pres[0]->textContent );
775 $parent->replaceChild( $new_node, $node );
776 $new_node->setAttribute( 'class', 'wp-block-verse' );
777
778 return $new_node;
779 }
780
781 /**
782 * Convert a pullquote node to valid Gutenberg markup.
783 *
784 * @param DomElement $node The node to be converted.
785 * @param DomElement $parent The parent of the node.
786 *
787 * @return DomElement The converted node.
788 */
789 protected function convert_pullquote_node( DomElement $node, DomElement $parent ) {
790 $node_paragraphs = $node->getElementsByTagName( 'p' );
791 if ( 0 === $node_paragraphs->length ) {
792 return null;
793 }
794
795 // Create the figure and blockquote elements for pullquote block.
796 $new_node = new DomElement( 'figure' );
797 $blockquote = new DomElement( 'blockquote' );
798 $paragraph = new DomElement( 'p' );
799 $quote_text = new DOMText( $node_paragraphs[0]->textContent );
800
801 // Assemble and replace node.
802 $parent->replaceChild( $new_node, $node );
803 $new_node->appendChild( $blockquote );
804 $blockquote->appendChild( $paragraph );
805 $paragraph->appendChild( $quote_text );
806 $new_node->setAttribute( 'class', 'wp-block-pullquote' );
807
808 return $new_node;
809 }
810
811 /**
812 * Handle a button node.
813 *
814 * @param DomElement $node The node to be converted.
815 * @param DomElement $parent The parent of the node.
816 *
817 * @return DomElement
818 *
819 * @todo Support multiple types of buttons. For now buttons are removed.
820 */
821 protected function convert_button_node( DomElement $node, DomElement $parent ) {
822 $parent->removeChild( $node );
823 return null;
824 }
825
826 /**
827 * Convert an image node to a Gutenberg valid markup.
828 *
829 * @param DomElement $node The node to be converted.
830 * @param DomElement $parent The parent of the node.
831 *
832 * @return array An array containing the Block attributes and the new node.
833 *
834 * @todo If the node is a (a) link we need to make this image a link as well.
835 */
836 protected function convert_image_node( DomElement $node, DomElement $parent ) {
837
838 // Check if the image needs to be resized
839 // Can we already upload the image here?
840 /** @var DomElement $image */
841 $image = $node->getElementsByTagName( 'img' )[0];
842
843 // if there is no image we can't proceed.
844 if ( ! $image ) {
845 $parent->removeChild( $node );
846 return array(
847 'block_attributes' => array(),
848 'node' => null,
849 );
850 }
851
852 $block_attributes = array();
853
854 $new_node = new DomElement( 'figure' );
855
856 $parent->replaceChild( $new_node, $node );
857
858 $classes = array( 'wp-block-image', 'size-large' );
859
860 // The data we need is set as json data attribute on the img node.
861 $image_data = json_decode( $image->getAttribute( 'data-attrs' ), true );
862
863 // Add the image as an attachement post to the WXR.
864 $this->generator->add_post(
865 array(
866 'title' => urldecode( basename( $image_data['src'] ) ),
867 'link' => $image_data['src'],
868 'type' => 'attachment',
869 'attachment_url' => $image_data['src'],
870 'metas' => array(
871 array(
872 'key' => '_wp_original_image_link',
873 'value' => $image_data['src'],
874 ),
875 ),
876 )
877 );
878
879 // Create the new image element.
880 $new_image = new DomElement( 'img' );
881 $new_node->appendChild( $new_image );
882 $new_image->setAttribute( 'src', $image_data['src'] );
883 if ( ! is_null( $image_data['alt'] ) ) {
884 $new_image->setAttribute( 'alt', $image_data['alt'] );
885 }
886
887 // Deal with resizing.
888 if ( $image_data['resizeWidth'] ) {
889 $classes[] = 'is-resized';
890 $new_image->setAttribute( 'width', $image_data['resizeWidth'] );
891 $block_attributes['width'] = $image_data['resizeWidth'];
892 }
893
894 // Handle caption if it exists
895 $caption = $node->getElementsByTagName( 'figcaption' );
896 if ( $caption->length > 0 ) {
897 $caption_text = $caption->item( 0 )->textContent;
898 $block_attributes['caption'] = $caption_text;
899
900 // Create figcaption element
901 $figcaption = new DomElement( 'figcaption' );
902 $new_node->appendChild( $figcaption );
903 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
904 $figcaption->textContent = $caption_text;
905 }
906
907 // Set the classes on the figure element.
908 $new_node->setAttribute( 'class', implode( ' ', $classes ) );
909
910 $block_attributes['sizeSlug'] = 'large';
911 $block_attributes['linkDestination'] = 'none';
912
913 $result = array(
914 'block_attributes' => $block_attributes,
915 'node' => $new_node,
916 );
917
918 /**
919 * Filter the image node conversion result.
920 *
921 * Allows modification of the image block attributes and node after
922 * the default conversion. Useful for adjusting image sizes, adding
923 * custom classes, modifying captions, or changing link destinations.
924 *
925 * @since 1.2.0
926 *
927 * @param array $result {
928 * The image conversion result.
929 *
930 * @type array $block_attributes The image block attributes (sizeSlug, linkDestination, width, caption).
931 * @type DomElement $node The figure DOM element for the image block.
932 * }
933 * @param array|null $image_data The decoded image data from the Substack data-attrs attribute.
934 */
935 return apply_filters( 'substack_importer_image_result', $result, $image_data );
936 }
937
938 /**
939 * Convert the node to a valid Gutenberg separator block.
940 *
941 * @param DomElement $node The node to be converted.
942 * @param DomElement $parent The parent of the node to be converted.
943 *
944 * @return DomElement The new node.
945 */
946 protected function convert_separator_node( DomElement $node, DomElement $parent ) {
947
948 $new_node = new DomElement( 'hr' );
949 $parent->replaceChild( $new_node, $node );
950 $new_node->setAttribute( 'class', 'wp-block-separator' );
951
952 return $new_node;
953 }
954
955 /**
956 * Convert a node that represents an embed to valid Gutenberg embed block markup.
957 *
958 * @param DomElement $node The node to be converted.
959 * @param DomElement $parent The parent of the node to be coverted.
960 *
961 * @return array Containing the block_name, block_attributes and node.
962 */
963 protected function convert_embed_node( DomElement $node, DomElement $parent ) {
964
965 $first_class = explode( ' ', $node->getAttribute( 'class' ) )[0];
966
967 /**
968 * Short-circuit the embed node conversion before default handling.
969 *
970 * Return a non-null array to skip the built-in switch statement entirely.
971 * The returned array must have keys: 'node', 'block_attributes', 'block_name'.
972 * Useful for handling unsupported embed types, overriding the default
973 * conversion for a specific provider, or adding entirely new providers.
974 *
975 * @since 1.2.0
976 *
977 * @param array|null $pre_result Return non-null to short-circuit. Expected keys:
978 * 'node' (DomElement|null), 'block_attributes' (array),
979 * 'block_name' (string|null).
980 * @param DomElement $node The embed DOM node before conversion.
981 * @param DomElement $parent The parent DOM element.
982 * @param string $first_class The CSS class identifying the embed type (e.g. 'youtube-wrap', 'tweet').
983 */
984 $pre_result = apply_filters(
985 'substack_importer_pre_embed_conversion',
986 null,
987 $node,
988 $parent,
989 $first_class
990 );
991
992 if ( null !== $pre_result ) {
993 return $pre_result;
994 }
995
996 switch ( $first_class ) {
997
998 case 'youtube-wrap':
999 $output = $this->convert_youtube_embed( $node, $parent );
1000 break;
1001
1002 case 'vimeo-wrap':
1003 $output = $this->convert_vimeo_embed( $node, $parent );
1004 break;
1005
1006 case 'soundcloud-wrap':
1007 $output = $this->convert_soundcloud_embed( $node, $parent );
1008 break;
1009
1010 case 'tweet':
1011 $output = $this->convert_tweet_embed( $node, $parent );
1012 break;
1013
1014 case 'spotify-wrap':
1015 $output = $this->convert_spotify_embed( $node, $parent );
1016 break;
1017
1018 case 'bandcamp-wrap':
1019 $output = $this->convert_bandcamp_embed( $node, $parent );
1020 break;
1021
1022 case 'github-gist':
1023 $output = $this->convert_gist_embed( $node, $parent );
1024 break;
1025
1026 case 'instagram':
1027 $output = $this->convert_instagram_embed( $node, $parent );
1028 break;
1029
1030 case 'tiktok-wrap':
1031 $output = $this->convert_tiktok_embed( $node, $parent );
1032 break;
1033
1034 default:
1035 $parent->removeChild( $node );
1036 $output = array(
1037 'node' => null,
1038 'block_attributes' => array(),
1039 'block_name' => null,
1040 );
1041
1042 }
1043
1044 /**
1045 * Filter the embed node conversion result.
1046 *
1047 * Allows modification of the embed block name, attributes, and node
1048 * after the default conversion. Useful for adding support for
1049 * additional embed providers, modifying embed URLs, or changing
1050 * how specific embeds are represented.
1051 *
1052 * @since 1.2.0
1053 *
1054 * @param array $output {
1055 * The embed conversion result.
1056 *
1057 * @type string|null $block_name The Gutenberg block name (e.g. 'wp:embed').
1058 * @type array $block_attributes The block attributes (url, type, providerNameSlug, etc.).
1059 * @type DomElement|null $node The converted DOM node.
1060 * }
1061 * @param string $first_class The CSS class identifying the embed type (e.g. 'youtube-wrap', 'tweet').
1062 */
1063 return apply_filters( 'substack_importer_embed_result', $output, $first_class );
1064 }
1065
1066 /**
1067 * Convert the embed node into Gutenberg markup for a Youtube embed.
1068 *
1069 * @param DomElement $node The node to be converted.
1070 * @param DomElement $parent The parent of the node to be coverted.
1071 *
1072 * @return array Containing the block_name, block_attributes and node.
1073 */
1074 protected function convert_youtube_embed( DomElement $node, DomElement $parent ) {
1075
1076 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1077
1078 $block_attributes = array(
1079 'url' => 'https://youtu.be/' . $data_attributes['videoId'],
1080 'type' => 'video',
1081 'providerNameSlug' => 'youtube',
1082 'responsive' => true,
1083 'className' => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
1084 );
1085
1086 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
1087 $classes = 'wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio';
1088 $node->setAttribute( 'class', $classes );
1089
1090 return array(
1091 'block_name' => 'wp:embed',
1092 'block_attributes' => $block_attributes,
1093 'node' => $node,
1094 );
1095 }
1096
1097 /**
1098 * Convert the embed node into Gutenberg markup for a Vimeo embed.
1099 *
1100 * @param DomElement $node The node to be converted.
1101 * @param DomElement $parent The parent of the node to be coverted.
1102 *
1103 * @return array Containing the block_name, block_attributes and node.
1104 */
1105 protected function convert_vimeo_embed( DomElement $node, DomElement $parent ) {
1106
1107 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1108
1109 $block_attributes = array(
1110 'url' => 'https://vimeo.com/' . $data_attributes['videoId'],
1111 'type' => 'video',
1112 'providerNameSlug' => 'vimeo',
1113 'responsive' => true,
1114 'className' => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
1115 );
1116
1117 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
1118 $classes = 'wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo wp-embed-aspect-16-9 wp-has-aspect-ratio';
1119 $node->setAttribute( 'class', $classes );
1120
1121 return array(
1122 'block_name' => 'wp:embed',
1123 'block_attributes' => $block_attributes,
1124 'node' => $node,
1125 );
1126 }
1127
1128 /**
1129 * Convert the embed node into Gutenberg markup for a Soundcloud embed.
1130 *
1131 * @param DomElement $node The node to be converted.
1132 * @param DomElement $parent The parent of the node to be coverted.
1133 *
1134 * @return array Containing the block_name, block_attributes and node.
1135 */
1136 protected function convert_soundcloud_embed( DomElement $node, DomElement $parent ) {
1137
1138 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1139
1140 // We construct the Soundcloud URL as a combination of Author URL and the
1141 // Soundcloud Embed ID as this is recognized as a valid embed URL within
1142 // WordPress.
1143 $url_parts = explode( '/', $data_attributes['url'] );
1144 $id = array_pop( $url_parts );
1145 $url = $data_attributes['author_url'] . '/' . $id;
1146
1147 $block_attributes = array(
1148 'url' => $url,
1149 'type' => 'rich',
1150 'providerNameSlug' => 'soundcloud',
1151 'responsive' => true,
1152 );
1153
1154 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
1155 $classes = 'wp-block-embed is-type-rich is-provider-soundcloud wp-block-embed-soundcloud';
1156 $node->setAttribute( 'class', $classes );
1157
1158 return array(
1159 'block_name' => 'wp:embed',
1160 'block_attributes' => $block_attributes,
1161 'node' => $node,
1162 );
1163 }
1164
1165 /**
1166 * Convert the embed node into Gutenberg markup for a Tweet embed.
1167 *
1168 * @param DomElement $node The node to be converted.
1169 * @param DomElement $parent The parent of the node to be coverted.
1170 *
1171 * @return array Containing the block_name, block_attributes and node.
1172 */
1173 protected function convert_tweet_embed( DomElement $node, DomElement $parent ) {
1174
1175 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1176
1177 $block_attributes = array(
1178 'url' => $data_attributes['url'],
1179 'type' => 'rich',
1180 'providerNameSlug' => 'twitter',
1181 'responsive' => true,
1182 );
1183
1184 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
1185 $classes = 'wp-block-embed is-type-rich is-provider-twitter wp-block-embed-twitter';
1186 $node->setAttribute( 'class', $classes );
1187
1188 return array(
1189 'block_name' => 'wp:embed',
1190 'block_attributes' => $block_attributes,
1191 'node' => $node,
1192 );
1193 }
1194
1195 /**
1196 * Convert the embed node into Gutenberg markup for a Spotify embed.
1197 *
1198 * @param DomElement $node The node to be converted.
1199 * @param DomElement $parent The parent of the node to be coverted.
1200 *
1201 * @return array Containing the block_name, block_attributes and node.
1202 */
1203 protected function convert_spotify_embed( DomElement $node, DomElement $parent ) {
1204
1205 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1206
1207 $block_attributes = array(
1208 'url' => $data_attributes['url'],
1209 'type' => 'rich',
1210 'providerNameSlug' => 'spotify',
1211 'responsive' => true,
1212 'className' => 'wp-embed-aspect-21-9 wp-has-aspect-ratio',
1213 );
1214
1215 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
1216 $classes = 'wp-block-embed is-type-rich is-provider-spotify wp-block-embed-spotify wp-embed-aspect-21-9 wp-has-aspect-ratio';
1217 $node->setAttribute( 'class', $classes );
1218
1219 return array(
1220 'block_name' => 'wp:embed',
1221 'block_attributes' => $block_attributes,
1222 'node' => $node,
1223 );
1224 }
1225
1226 /**
1227 * Converts the node into a shortcode for Bandcamp.
1228 *
1229 * The shortcode is currently not supported in Core but is available by enabling the embeds module
1230 * of the Jetpack plugin.
1231 *
1232 * @example [bandcamp width=350 height=470 album=473417827 size=large bgcol=ffffff linkcol=0687f5 tracklist=false]
1233 *
1234 * @param DomElement $node The node to be converted.
1235 * @param DomElement $parent The parent of the node to be coverted.
1236 *
1237 * @return array
1238 */
1239 protected function convert_bandcamp_embed( DomElement $node, DomElement $parent ) {
1240
1241 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1242
1243 // The embed URL contains the attributes for the shortcode. Here we extract them and add them to the shortcode.
1244 preg_match_all( '/[a-z]+=[a-z0-9]+/', $data_attributes['embed_url'], $matches );
1245 $shortcode = sprintf( '[bandcamp %s]', implode( ' ', $matches[0] ) );
1246
1247 $new_node = new DOMText( $shortcode );
1248 $parent->replaceChild( $new_node, $node );
1249
1250 return array(
1251 'block_name' => 'wp:shortcode',
1252 'block_attributes' => array(),
1253 'node' => $new_node,
1254 );
1255 }
1256
1257 /**
1258 * Convert a Github Gist node into a shortcode.
1259 *
1260 * Tries to get the Gist id from the raw link or removes the entire Gist if the ID can not be determined.
1261 *
1262 * @param DomElement $node The node to be converted.
1263 * @param DomElement $parent The parent of the node to be coverted.
1264 *
1265 * @return array
1266 */
1267 protected function convert_gist_embed( DomElement $node, DomElement $parent ) {
1268
1269 $a_elements = $node->getElementsByTagName( 'a' );
1270
1271 $url = $a_elements->length > 0
1272 ? $a_elements[0]->getAttribute( 'href' )
1273 : null;
1274
1275 if ( ! $url || ! preg_match( '/\/([a-z0-9]+)\/raw/', $a_elements[0]->getAttribute( 'href' ), $matches ) ) {
1276 $parent->removeChild( $node );
1277 return array(
1278 'node' => null,
1279 'block_attributes' => array(),
1280 'block_name' => null,
1281 );
1282 }
1283
1284 $shortcode = sprintf( '[gist https://gist.github.com/%s]', $matches[1] );
1285
1286 $new_node = new DOMText( $shortcode );
1287 $parent->replaceChild( $new_node, $node );
1288
1289 return array(
1290 'block_name' => 'wp:shortcode',
1291 'block_attributes' => array(),
1292 'node' => $new_node,
1293 );
1294 }
1295
1296 /**
1297 * Convert the embed node into Gutenberg markup for a TikTok embed.
1298 *
1299 * @param DomElement $node The node to be converted.
1300 * @param DomElement $parent The parent of the node to be coverted.
1301 *
1302 * @return array Containing the block_name, block_attributes and node.
1303 */
1304 protected function convert_tiktok_embed( DomElement $node, DomElement $parent ) {
1305
1306 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1307
1308 $block_attributes = array(
1309 'url' => $data_attributes['url'],
1310 'type' => 'video',
1311 'providerNameSlug' => 'tiktok',
1312 'responsive' => true,
1313 'className' => 'wp-embed-aspect-9-16 wp-has-aspect-ratio',
1314 );
1315
1316 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
1317 $classes = 'wp-block-embed is-type-video is-provider-tiktok wp-block-embed-tiktok';
1318 $node->setAttribute( 'class', $classes );
1319
1320 return array(
1321 'block_name' => 'wp:embed',
1322 'block_attributes' => $block_attributes,
1323 'node' => $node,
1324 );
1325 }
1326
1327 /**
1328 * Convert Instagram embed to a link to the Instagram post.
1329 *
1330 * Currently, Instagram embeds are not supported without the installation
1331 * of additional plugins. For this reason, the embed will be converted in
1332 * a link to the post.
1333 *
1334 * @param DomElement $node
1335 * @param DomElement $parent
1336 *
1337 * @return array
1338 */
1339 protected function convert_instagram_embed( DomElement $node, DomElement $parent ) {
1340
1341 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
1342
1343 $new_node = new DomElement( 'p' );
1344 $link_node = new DomElement( 'a' );
1345
1346 $parent->replaceChild( $new_node, $node );
1347
1348 $new_node->appendChild( $link_node );
1349
1350 $instagram_link = sprintf( 'https://instagram.com/p/%s/', $data_attributes['instagram_id'] );
1351 $link_node->setAttribute( 'href', $instagram_link );
1352 $link_node->setAttribute( 'target', '_blank' );
1353 $link_node->setAttribute( 'rel', 'noreferrer noopener' );
1354 $link_node->textContent = $instagram_link; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1355
1356 return array(
1357 'block_name' => 'wp:paragraph',
1358 'block_attributes' => array(),
1359 'node' => $new_node,
1360 );
1361 }
1362
1363 /**
1364 * Convert the node to paragraph block indicating the content was paywalled.
1365 *
1366 * @param DomElement $node The node to be converted.
1367 * @param DomElement $parent The parent of the node to be converted.
1368 *
1369 * @return array The converted node data.
1370 */
1371 protected function convert_paywall_node( DomElement $node, DomElement $parent ) {
1372 /**
1373 * Filter the paywall marker text.
1374 *
1375 * @since 1.2.0
1376 *
1377 * @param string $marker_text The default paywall marker text.
1378 * @param DomElement $node The paywall node being converted.
1379 * @param DomElement $parent The parent element.
1380 */
1381 $marker_text = apply_filters(
1382 'substack_importer_paywall_marker_text',
1383 __( 'The content below was originally paywalled.', 'substack-importer' ),
1384 $node,
1385 $parent
1386 );
1387
1388 /**
1389 * Filter the entire paywall conversion result.
1390 *
1391 * Return a non-null value to override the default conversion.
1392 * The returned array should have keys: 'node', 'block_attributes', 'block_name'.
1393 *
1394 * @since 1.2.0
1395 *
1396 * @param array|null $result The conversion result, null to use default.
1397 * @param DomElement $node The paywall node being converted.
1398 * @param DomElement $parent The parent element.
1399 */
1400 $filtered_result = apply_filters(
1401 'substack_importer_paywall_content',
1402 null,
1403 $node,
1404 $parent
1405 );
1406
1407 if ( null !== $filtered_result ) {
1408 return $filtered_result;
1409 }
1410
1411 // Default behavior: create a paragraph with the marker text.
1412 $new_node = new DomElement( 'p' );
1413 $text = new DOMText( $marker_text );
1414
1415 $parent->replaceChild( $new_node, $node );
1416 $new_node->appendChild( $text );
1417
1418 return array(
1419 'node' => $new_node,
1420 'block_attributes' => array(),
1421 'block_name' => 'wp:paragraph',
1422 );
1423 }
1424
1425 /**
1426 * Removes the Subscription input field.
1427 *
1428 * @param DomElement $node The node to be converted.
1429 * @param DomElement $parent The parent of the node to be converted.
1430 *
1431 * @return DomElement The new node.
1432 */
1433 protected function convert_subscription_node( DomElement $node, DomElement $parent ) {
1434 $parent->removeChild( $node );
1435
1436 return array(
1437 'node' => null,
1438 'block_attributes' => array(),
1439 'block_name' => null,
1440 );
1441 }
1442
1443 /**
1444 * Replace the Substack Embed node with embed markup that is valid for Gutenberg.
1445 *
1446 * Returns the replacement node.
1447 *
1448 * @param DomElement $node
1449 * @param DomElement $parent
1450 *
1451 * @return DomElement
1452 */
1453 protected function replace_embed_node( DomElement $node, DomElement $parent, $content ) {
1454 $new_node = new DomElement( 'figure' );
1455 $wrapper = new DomElement( 'div' );
1456
1457 $parent->replaceChild( $new_node, $node );
1458 $new_node->appendChild( $wrapper );
1459 $wrapper->setAttribute( 'class', 'wp-block-embed__wrapper' );
1460
1461 // URL needs to be on its own line, see:
1462 // https://github.com/wordpress/gutenberg/blob/trunk/packages/block-library/src/embed/save.js#L27
1463 $content = new DOMText( "\n" . $content . "\n" );
1464 $new_node->getElementsByTagName( 'div' )[0]->appendChild( $content );
1465
1466 return $new_node;
1467 }
1468
1469 /**
1470 * Replace an HTML node tag while preserving its attributes and child nodes.
1471 *
1472 * @param DomElement $node Existing node.
1473 * @param DomElement $parent Parent node.
1474 * @param string $new_tag_name New node tag.
1475 *
1476 * @return DomElement
1477 */
1478 protected function replace_html_node_tag( DomElement $node, DomElement $parent, $new_tag_name ) {
1479 $document = $node->ownerDocument; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1480 $new_node = $document->createElement( $new_tag_name );
1481
1482 if ( $node->hasAttributes() ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1483 foreach ( $node->attributes as $attribute ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1484 $new_node->setAttribute( $attribute->nodeName, $attribute->nodeValue ); //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1485 }
1486 }
1487
1488 while ( $node->hasChildNodes() ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1489 $new_node->appendChild( $node->firstChild ); //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1490 }
1491
1492 $parent->replaceChild( $new_node, $node );
1493
1494 return $new_node;
1495 }
1496
1497 /**
1498 * Retrieve additional post information through the Substack Post API.
1499 *
1500 * The most important data we are after includes author information and comments as this currently is not provided
1501 * in the export file.
1502 *
1503 * It is important to note that comments might not be included or might not contain any information
1504 * if the comments are only visible to paid users or if post itself is only accessible to paid users.
1505 *
1506 * The completeness of information in the response depends on the type of the post (paid vs. public).
1507 *
1508 * @param string $slug The slug of the post.
1509 *
1510 * @return string|null Returns a JSON string with post information or null if it could not be retrieved.
1511 */
1512 protected function fetch_post_meta( $slug ) {
1513
1514 // If the substack url is not set, we skip this step.
1515 if ( ! $this->substack_url ) {
1516 return null;
1517 }
1518
1519 $post_url = sprintf( '%s/api/v1/posts/%s?all_comments=true', $this->substack_url, $slug );
1520
1521 $response = wp_remote_get( $post_url, array( 'redirection' => 0 ) );
1522
1523 if ( is_wp_error( $response ) || 200 !== $response['response']['code'] ) {
1524 return null;
1525 }
1526
1527 return wp_remote_retrieve_body( $response );
1528 }
1529
1530 /**
1531 * Get meta info from the substack export zip. Returns null if no meta was found.
1532 *
1533 * @param int $id Substack Post ID.
1534 *
1535 * @return array|null
1536 */
1537 protected function get_post_meta_from_export( $id ) {
1538 $zip = $this->get_export_zip();
1539
1540 if ( is_wp_error( $zip ) ) {
1541 return null;
1542 }
1543
1544 $meta = $zip->getFromName( sprintf( 'meta/%s.json', $id ) );
1545
1546 return $meta
1547 ? json_decode( $meta, true )
1548 : null;
1549 }
1550
1551 /**
1552 * Returns a generator yielding posts retrieved from the Substack export.
1553 *
1554 * If a there was a problem retrieving the Zip file, a WP_Error will be returned.
1555 *
1556 * @return \Generator|WP_Error
1557 */
1558 public function get_posts() {
1559
1560 $zip = $this->get_export_zip();
1561
1562 if ( is_wp_error( $zip ) ) {
1563 return $zip;
1564 }
1565
1566 return $this->get_posts_generator( $zip );
1567 }
1568
1569 public function get_subscribers() {
1570 $zip = $this->get_export_zip();
1571
1572 if ( is_wp_error( $zip ) ) {
1573 return $zip;
1574 }
1575 $email_list_csv_filename = null;
1576 for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1577 $stat = $zip->statIndex( $i ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1578 if ( str_starts_with( $stat['name'], 'email_list' ) ) {
1579 $email_list_csv_filename = $stat['name'];
1580 break;
1581 }
1582 }
1583
1584 if ( is_null( $email_list_csv_filename ) ) {
1585 return new WP_Error(
1586 'no_email_list_in_export_file',
1587 __( 'No email_list.*.csv file was found in the archive.' ),
1588 $this->get_error_data( $zip, true )
1589 );
1590 }
1591
1592 $subscriber_csv = $zip->getFromName( $email_list_csv_filename );
1593
1594 $subscribers = explode( "\n", trim( $subscriber_csv ) );
1595 $map = str_getcsv( array_shift( $subscribers ) );
1596
1597 $mapped_subscribers = [];
1598 foreach ( $subscribers as $subscriber ) {
1599 $subscriber_data = str_getcsv( $subscriber );
1600 // Check if the number of columns matches the number of headers
1601 if ( count( $subscriber_data ) === count( $map ) ) {
1602 $mapped_subscribers[] = array_combine( $map, $subscriber_data );
1603 } else {
1604 $extra = wp_json_encode(
1605 [
1606 'headers' => $map,
1607 'data' => $subscriber_data,
1608 ],
1609 JSON_PRETTY_PRINT
1610 );
1611 return new WP_Error(
1612 'csv_mismatch',
1613 __( 'The number of columns in the email_list.*.csv file does not match the number of headers.' ),
1614 $extra
1615 );
1616 }
1617 }
1618
1619 return [ $mapped_subscribers, $email_list_csv_filename ];
1620 }
1621
1622 public function summarize_subscribers( $subscribers ) {
1623 if ( is_wp_error( $subscribers ) ) {
1624 return $subscribers;
1625 }
1626
1627 $num_subscribers = count( $subscribers );
1628 $active = 0;
1629 $oldest = strtotime( 'now' );
1630 $newest = 0;
1631 $plans = [];
1632 foreach ( $subscribers as $subscriber ) {
1633 if (
1634 isset( $subscriber['active_subscription'] )
1635 && 'true' === $subscriber['active_subscription']
1636 ) {
1637 ++$active;
1638 }
1639 if ( isset( $subscriber['created_at'] ) ) {
1640 $created_at = strtotime( $subscriber['created_at'] );
1641 $oldest = min( $oldest, $created_at );
1642 $newest = max( $newest, $created_at );
1643 }
1644 if ( isset( $subscriber['plan'] ) ) {
1645 $plans[ $subscriber['plan'] ] = ( $plans[ $subscriber['plan'] ] ?? 0 ) + 1;
1646 }
1647 }
1648
1649 return [
1650 'newest' => gmdate( 'F j, Y', $newest ),
1651 'oldest' => gmdate( 'F j, Y', $oldest ),
1652 'active' => $active,
1653 'num_subscribers' => $num_subscribers,
1654 'plans' => $plans,
1655 ];
1656 }
1657
1658 protected function get_posts_generator( ZipArchive $zip ) {
1659 $post_csv = $zip->getFromName( 'posts.csv' );
1660
1661 $posts = explode( "\n", trim( $post_csv ) );
1662 $map = str_getcsv( array_shift( $posts ) );
1663
1664 foreach ( $posts as $post ) {
1665 $post = str_getcsv( $post, ',' );
1666 $post = array_combine( $map, $post );
1667 $post['html_body'] = $zip->getFromName( sprintf( 'posts/%s.html', $post['post_id'] ) );
1668 yield $post;
1669 }
1670 }
1671
1672 /**
1673 * Extract the first image URL from post HTML.
1674 *
1675 * @param string $html HTML body from the Substack export.
1676 *
1677 * @return string|null
1678 */
1679 protected function get_first_image_url_from_html( $html ) {
1680 if ( empty( $html ) ) {
1681 return null;
1682 }
1683
1684 $document = new DOMDocument();
1685 $previous = libxml_use_internal_errors( true );
1686 $loaded = $document->loadHTML( '<?xml encoding="utf-8" ?>' . $html );
1687 libxml_clear_errors();
1688 libxml_use_internal_errors( $previous );
1689
1690 if ( ! $loaded ) {
1691 return null;
1692 }
1693
1694 $images = $document->getElementsByTagName( 'img' );
1695 if ( 0 === $images->count() ) {
1696 return null;
1697 }
1698
1699 $image = $images->item( 0 );
1700 if ( ! $image instanceof DOMElement ) {
1701 return null;
1702 }
1703
1704 $data_attrs = $image->getAttribute( 'data-attrs' );
1705 if ( ! empty( $data_attrs ) ) {
1706 $decoded_data = json_decode( html_entity_decode( $data_attrs, ENT_QUOTES ), true );
1707 if ( is_array( $decoded_data ) && ! empty( $decoded_data['src'] ) ) {
1708 return esc_url_raw( $decoded_data['src'] );
1709 }
1710 }
1711
1712 $src = $image->getAttribute( 'src' );
1713 if ( ! empty( $src ) ) {
1714 return esc_url_raw( $src );
1715 }
1716
1717 return null;
1718 }
1719
1720 /**
1721 * Get error data for a zip archive.
1722 *
1723 * @param ZipArchive|null $zip The zip archive.
1724 * @param int|bool|null $file_open_result The result of the file open operation.
1725 *
1726 * @return array The error data.
1727 */
1728 private function get_error_data( $zip = null, $file_open_result = null ) {
1729 $files = [];
1730
1731 if ( is_numeric( $file_open_result ) ) {
1732 switch ( $file_open_result ) {
1733 case ZipArchive::ER_INCONS:
1734 $file_open_result = 'Zip archive inconsistent';
1735 break;
1736
1737 case ZipArchive::ER_INVAL:
1738 $file_open_result = 'Invalid argument';
1739 break;
1740
1741 case ZipArchive::ER_MEMORY:
1742 $file_open_result = 'Malloc failure';
1743 break;
1744
1745 case ZipArchive::ER_INVAL:
1746 $file_open_result = 'No such file';
1747 break;
1748
1749 case ZipArchive::ER_NOZIP:
1750 $file_open_result = 'Not a zip archive';
1751 break;
1752
1753 case ZipArchive::ER_OPEN:
1754 $file_open_result = 'Can\'t open file';
1755 break;
1756
1757 case ZipArchive::ER_READ:
1758 $file_open_result = 'Read error';
1759 break;
1760
1761 case ZipArchive::ER_SEEK:
1762 $file_open_result = 'Seek error';
1763 break;
1764
1765 default:
1766 $file_open_result = sprintf( 'Unknow error code (%s)', $file_open_result );
1767 break;
1768 }
1769 }
1770
1771 if ( is_bool( $file_open_result ) ) {
1772 $file_open_result = $file_open_result ? 'Success' : 'Failure, no error code';
1773 }
1774
1775 if ( ! empty( $zip ) ) {
1776 for ( $i = 0; $i < $zip->numFiles; $i++ ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArchive property
1777 $files[] = $zip->getNameIndex( $i );
1778 }
1779 }
1780
1781 return [
1782 'file-path' => $this->export_file_path,
1783 'file-open-result' => $file_open_result,
1784 'files' => $files,
1785 'substack-url' => $this->substack_url,
1786 ];
1787 }
1788
1789 /**
1790 * Get a ZipArchive instance of the export file or return an error if it failed.
1791 *
1792 * @return WP_Error|ZipArchive The zip archive or a WP_error instance on failure.
1793 */
1794 protected function get_export_zip() {
1795 if ( ! class_exists( 'ZipArchive' ) ) {
1796 return new WP_Error( 'missing_zip_extension', __( 'Could not unzip the Substack export file.' ), $this->get_error_data() );
1797 }
1798
1799 $zip = new ZipArchive();
1800 $result = $zip->open( $this->export_file_path );
1801
1802 if ( true !== $result || 0 === $zip->numFiles ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArchive property
1803 return new WP_Error( 'invalid_export_file', __( 'Could not unzip the Substack export file.' ), $this->get_error_data( $zip, $result ) );
1804 }
1805
1806 // If posts.csv was not found in the zip archive, the export is invalid.
1807 if ( false === $zip->getFromName( 'posts.csv' ) ) {
1808 return new WP_Error( 'no_posts_in_export_file', __( 'The export file is not a valid Substack export, no posts.csv was found in the archive. ' ), $this->get_error_data( $zip, $result ) );
1809 }
1810
1811 return $zip;
1812 }
1813 }
1814