PluginProbe
Substack Importer / 1.0.2
Substack Importer v1.0.2
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 1.0.2, at includes/class-converter.php

1,112 lines 31.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
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 );
68
69
70 /**
71 * Converter constructor.
72 *
73 * @param Generator $generator Instance of the WXR Generator.
74 * @param string $export_file_path Path to the Substack export zip file.
75 * @param null $substack_url URL of the Substack newsletter.
76 */
77 public function __construct( Generator $generator, $export_file_path, $substack_url = null ) {
78 $this->generator = $generator;
79 $this->export_file_path = $export_file_path;
80 $this->substack_url = $substack_url;
81 }
82
83 /**
84 * Convert the Substack export to a WXR.
85 *
86 * @returns WP_Error|void
87 *
88 * @throws \OxymelException
89 */
90 public function convert() {
91
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 * Load additional information retrieved through the Substack API into the export zip file.
120 *
121 * @param int $offset 0-indexed starting offset for the post to start with.
122 * @param int $limit Number of posts to process.
123 *
124 * @return array|WP_Error
125 */
126 public function load_meta_data( $offset = 0, $limit = 1 ) {
127
128 $zip = $this->get_export_zip();
129
130 if ( is_wp_error( $zip ) ) {
131 return $zip;
132 }
133
134 $total_count = 0;
135
136 foreach ( $this->get_posts() as $idx => $post ) {
137 $total_count++;
138
139 if ( $idx < $offset || $idx >= $offset + $limit ) {
140 continue;
141 }
142
143 list($id, $slug) = explode( '.', $post['post_id'], 2 );
144 $meta = $this->fetch_post_meta( $slug );
145
146 if ( $meta ) {
147 $zip->addFromString( sprintf( 'meta/%s.json', $id ), $meta );
148 }
149 }
150
151 return array(
152 'total' => $total_count,
153 'processed' => min( $offset + $limit, $total_count ),
154 );
155
156 }
157
158 /**
159 * Convert each Substack post to a WordPress post and add it to the WXR.
160 *
161 * @return void|WP_Error
162 *
163 * @throws \OxymelException
164 */
165 protected function add_posts() {
166
167 $posts_generator = $this->get_posts();
168
169 if ( is_wp_error( $posts_generator ) ) {
170 return $posts_generator;
171 }
172
173 foreach ( $posts_generator as $post ) {
174
175 $id = (int) $post['post_id'];
176 $post_meta = $this->get_post_meta_from_export( $id );
177
178 if ( ! empty( $post['subtitle'] ) ) {
179 $post['html_body'] = $this->add_subtitle( $post );
180 }
181
182 $post_data = array(
183 'id' => $id,
184 'title' => $post['title'],
185 'content' => $this->convert_html_to_gutenberg( $post['html_body'] ),
186 'date' => 'true' === $post['is_published'] ? $post['post_date'] : '',
187 'status' => 'true' === $post['is_published'] ? 'publish' : 'draft',
188 'post_date_gmt' => $post['post_date'],
189 'post_date' => $post['post_date'],
190 'post_taxonomies' => array(),
191 'metas' => array(),
192 );
193
194 // If we were able to retrieve more information through the Substack API, we might have
195 // author information and comments.
196 $post_data['author'] = $post_meta ? $this->get_post_author( $post_meta ) : $this->get_default_author();
197 $post_data['comments'] = $post_meta ? $this->get_post_comments( $post_meta ) : array();
198
199 // Set the comment status
200 $post_data['comment_status'] = ! empty( $post_meta['write_comment_permissions'] ) && 'none' === $post_meta['write_comment_permissions']
201 ? 'closed'
202 : 'open';
203
204 // Handle podcast posts - prepend an Gutenberg audio block to the post content.
205 if ( 'podcast' === $post['type'] && ! empty( $post['podcast_url'] ) ) {
206 $post_data = $this->handle_podcast_post( $post_data, $post );
207 }
208
209 // Set meta for paid content
210 if ( 'only_paid' === $post['audience'] ) {
211 $post_data['metas'][] = array(
212 'key' => 'is_paid_content',
213 'value' => true,
214 );
215 }
216
217 $this->generator->add_post( $post_data );
218 }
219 }
220
221 protected function handle_podcast_post( $post_data, $post ) {
222 $post_data['content'] = $this->get_audio_block( $post['podcast_url'] ) . $post_data['content'];
223
224 // Create a new attachment
225 $this->generator->add_post(
226 array(
227 'title' => urldecode( basename( $post['podcast_url'] ) ),
228 'link' => $post['podcast_url'],
229 'post_date' => $post_data['post_date'],
230 'type' => 'attachment',
231 'attachment_url' => $post['podcast_url'],
232 'metas' => array(
233 array(
234 'key' => '_wp_original_image_link',
235 'value' => $post['podcast_url'],
236 ),
237 ),
238 )
239 );
240
241 // Add this post to the podcast category.
242 $this->categories['podcast'] = array(
243 'slug' => 'podcast',
244 'name' => 'Podcast',
245 );
246 $post_data['post_taxonomies'][] = array(
247 'name' => 'Podcast',
248 'slug' => 'podcast',
249 'domain' => 'category',
250 );
251
252 return $post_data;
253 }
254
255 /**
256 * Add the subtitle to the html_content by prepending a h2
257 *
258 * @param array $post
259 *
260 * @return string html body content
261 */
262 protected function add_subtitle( $post ) {
263 $heading = sprintf( '<h2>%s</h2>', $post['subtitle'] );
264 return $heading . $post['html_body'];
265 }
266
267 /**
268 * Get a Gutenberg Audio block for the podcast
269 * @param $audio_url
270 *
271 * @return string
272 */
273 protected function get_audio_block( $audio_url ) {
274 $code = '<!-- wp:audio --><figure class="wp-block-audio"><audio controls src="%s"></audio><figcaption>Podcast</figcaption></figure><!-- /wp:audio -->';
275 return sprintf( $code, $audio_url );
276 }
277
278 protected function get_post_author( $post_meta ) {
279 // If we can't get the author information, return a default author.
280 if ( empty( $post_meta['publishedBylines'] ) ) {
281 return $this->get_default_author();
282 }
283
284 $byline = $post_meta['publishedBylines'][0];
285 $this->authors[ $byline['id'] ] = array(
286 'login' => $byline['name'],
287 'display_name' => $byline['name'],
288 'id' => $byline['id'],
289 );
290
291 return $byline['name'];
292 }
293
294 protected function get_default_author() {
295 $this->authors['unknown'] = array(
296 'login' => 'Unknown',
297 'display_name' => 'Unknown',
298 'id' => 1,
299 );
300
301 return 'unknown';
302 }
303
304 /**
305 * Get post comments retrieved through the Substack api.
306 *
307 * @param array $post_data Additional data about a post retrieved through the Substack Post API.
308 *
309 * @return mixed
310 */
311 protected function get_post_comments( $post_meta ) {
312 if ( empty( $post_meta['comments'] ) ) {
313 return array();
314 }
315
316 return $this->parse_comments( $post_meta['comments'] );
317 }
318
319 /**
320 * Recursively parse the comments and prepare the data required for the WXR output.
321 *
322 * @param array $comments An array of comments provided by the Substack posts API endpoint.
323 * @param array $out Output that is ready to be passed to the WXR generator.
324 * @param null $parent If we are in a recursive call, the parent must be provided.
325 *
326 * @return array|mixed
327 */
328 protected function parse_comments( $comments, $out = array(), $parent = null ) {
329 foreach ( $comments as $comment ) {
330 $out[] = array(
331 'id' => $comment['id'],
332 'author' => $comment['name'],
333 'date' => $comment['date'],
334 'date_gmt' => $comment['date'],
335 'content' => $comment['body'],
336 'parent' => $parent,
337 'metas' => array(),
338 );
339
340 if ( ! empty( $comment['children'] ) ) {
341 $out = $this->parse_comments( $comment['children'], $out, (int) $comment['id'] );
342 }
343 }
344
345 return $out;
346 }
347
348 /**
349 * Convert the content HTML to Gutenberg blocks and return the result.
350 *
351 * @param string $content The HTML provided by Substack.
352 *
353 * @return string|string[]|null
354 *
355 * @todo Load the content as XML to prevent errors from loadHTML.
356 */
357 protected function convert_html_to_gutenberg( $content ) {
358
359 $dom = new DOMDocument();
360
361 // By inserting a meta tag with utf-8 encoding we make sure the content is converted to utf-8
362 $content = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . $content;
363 @$dom->loadHTML( $content ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
364
365 $body = $dom->getElementsByTagName( 'body' )->item( 0 );
366
367 // We don't want to use the DomNodeList because it will change while we are iterating over the nodes.
368 $nodes = array();
369 foreach ( $body->childNodes as $node ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
370 if ( ! $node instanceof DomElement ) {
371 continue;
372 }
373 $nodes[] = $node;
374 }
375
376 // We go through the top-level nodes and handle each of them.
377 foreach ( $nodes as $idx => $node ) {
378 $next_sibling = count( $nodes ) - 1 > $idx ? $nodes[ $idx + 1 ] : null;
379 $this->convert_node( $node, $body, $next_sibling );
380 }
381
382 // Save as XML otherwise we don't get HTMl5 elements correctly.
383 $content = $dom->saveXML( $body );
384
385 // Strip the body tag.
386 $content = preg_replace( '/<body>(.+)<\/body>/s', '$1', $content );
387
388 return $content;
389 }
390
391
392 /**
393 * Convert a single node to a Gutenberg block.
394 *
395 * Tries to convert a given HTML node into a Gutenberg block.
396 *
397 * @param DomElement $node The node to be converted.
398 * @param DomElement $parent The parent of the node to be converted.
399 * @param DomElement|null $next_sibling The next sibling of the node to be converted, if it exists.
400 *
401 */
402 protected function convert_node( DOMElement $node, DomElement $parent, DomElement $next_sibling = null ) {
403
404 $block_name = null;
405 $block_attributes = array();
406
407 $node_name = $node->nodeName; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
408 switch ( $node_name ) {
409
410 case 'p':
411 $block_name = 'wp:paragraph';
412 $class = $node->getAttribute( 'class' );
413
414 // remove empty paragraphs.
415 /** @todo Perhaps we can remove all empty nodes, not just paragraphs? */
416 if ( ! $node->childNodes->length ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
417 $parent->removeChild( $node );
418 $node = null;
419 }
420
421 // Button
422 if ( 'button-wrapper' === $class ) {
423 $node = $this->convert_button_node( $node, $parent );
424 $block_name = 'wp:button';
425 }
426
427 break;
428
429 case 'blockquote':
430 $block_name = 'wp:quote';
431 $node->setAttribute( 'class', 'wp-block-quote' );
432 break;
433
434 case 'div':
435 case 'iframe':
436 $class = $node->getAttribute( 'class' );
437
438 // Preformatted text
439 if ( 'preformatted-block' === $class ) {
440 $node = $this->convert_preformatted_node( $node, $parent );
441 $block_name = 'wp:preformatted';
442 }
443
444 // Images
445 if ( 'captioned-image-container' === $class ) {
446 $result = $this->convert_image_node( $node, $parent );
447 $node = $result['node'];
448 $block_attributes = $result['block_attributes'];
449 $block_name = 'wp:image';
450 }
451
452 // Horizontal separator
453 if ( $node && $node->getElementsByTagName( 'hr' )->length ) {
454 $node = $this->convert_separator_node( $node, $parent );
455 $block_name = 'wp:separator';
456 }
457
458 // Embeds
459 $first_class = explode( ' ', $class );
460 if ( ! empty( $first_class ) && in_array( $first_class[0], $this->supported_embeds, true ) ) {
461 $result = $this->convert_embed_node( $node, $parent );
462 $node = $result['node'];
463 $block_attributes = $result['block_attributes'];
464 $block_name = $result['block_name'];
465 }
466
467 break;
468
469 case 'ol':
470 case 'ul':
471 $block_name = 'wp:list';
472
473 if ( 'ol' === $node_name ) {
474 $block_attributes['ordered'] = true;
475 }
476
477 break;
478
479 case 'pre':
480 $block_name = 'wp:code';
481 $node->setAttribute( 'class', 'wp-block-code' );
482 break;
483
484 case 'h1':
485 case 'h2':
486 case 'h3':
487 case 'h4':
488 case 'h5':
489 case 'h6':
490 $block_name = 'wp:heading';
491 $block_attributes['level'] = (int) substr( $node_name, 1, 1 );
492 break;
493
494 case 'a':
495 $class = $node->getAttribute( 'class' );
496 if ( 'image-link image2' === trim( $class ) ) {
497 $result = $this->convert_image_node( $node, $parent );
498 $node = $result['node'];
499 $block_attributes = $result['block_attributes'];
500 $block_name = 'wp:image';
501 }
502
503 break;
504
505 }
506
507 if ( ! $block_name || ! $node ) {
508 return;
509 }
510
511 // Create the Gutenberg block code
512 $attributes_part = '';
513 if ( count( $block_attributes ) ) {
514 $attributes_part = ' ' . wp_json_encode( $block_attributes );
515 }
516 $block_open = new DOMComment( ' ' . $block_name . $attributes_part . ' ' );
517 $block_close = new DOMComment( ' /' . $block_name . ' ' );
518
519 $parent->insertBefore( $block_open, $node );
520
521 $next_sibling
522 ? $parent->insertBefore( $block_close, $next_sibling )
523 : $parent->appendChild( $block_close );
524 }
525
526 /**
527 * Convert a preformatted text node to valid Gutenberg markup.
528 *
529 * @param DomElement $node The node to be converted.
530 * @param DomElement $parent The parent of the node.
531 *
532 * @return DomElement The converted node.
533 */
534 protected function convert_preformatted_node( DomElement $node, DomElement $parent ) {
535
536 $node_value = $node->getElementsByTagName( 'pre' )[0]->textContent;
537 $new_node = new DomElement( 'pre', $node_value );
538 $parent->replaceChild( $new_node, $node );
539 $new_node->setAttribute( 'class', 'wp-block-preformatted' );
540
541 return $new_node;
542 }
543
544 /**
545 * Handle a button node.
546 *
547 * @param DomElement $node The node to be converted.
548 * @param DomElement $parent The parent of the node.
549 *
550 * @return DomElement
551 *
552 * @todo Support multiple types of buttons. For now buttons are removed.
553 */
554 protected function convert_button_node( DomElement $node, DomElement $parent ) {
555 $parent->removeChild( $node );
556 return null;
557 }
558
559 /**
560 * Convert an image node to a Gutenberg valid markup.
561 *
562 * @param DomElement $node The node to be converted.
563 * @param DomElement $parent The parent of the node.
564 *
565 * @return array An array containing the Block attributes and the new node.
566 *
567 * @todo If the node is a (a) link we need to make this image a link as well.
568 */
569 protected function convert_image_node( DomElement $node, DomElement $parent ) {
570
571 // Check if the image needs to be resized
572 // Can we already upload the image here?
573 /** @var DomElement $image */
574 $image = $node->getElementsByTagName( 'img' )[0];
575
576 // if there is no image we can't proceed.
577 if ( ! $image ) {
578 $parent->removeChild( $node );
579 return array(
580 'block_attributes' => array(),
581 'node' => null,
582 );
583 }
584
585 $block_attributes = array();
586
587 $new_node = new DomElement( 'figure' );
588
589 $parent->replaceChild( $new_node, $node );
590
591 $classes = array( 'wp-block-image', 'size-large' );
592
593 // The data we need is set as json data attribute on the img node.
594 $image_data = json_decode( $image->getAttribute( 'data-attrs' ), true );
595
596 // Add the image as an attachement post to the WXR.
597 $this->generator->add_post(
598 array(
599 'title' => urldecode( basename( $image_data['src'] ) ),
600 'link' => $image_data['src'],
601 'type' => 'attachment',
602 'attachment_url' => $image_data['src'],
603 'metas' => array(
604 array(
605 'key' => '_wp_original_image_link',
606 'value' => $image_data['src'],
607 ),
608 ),
609 )
610 );
611
612 // Create the new image element.
613 $new_image = new DomElement( 'img' );
614 $new_node->appendChild( $new_image );
615 $new_image->setAttribute( 'src', $image_data['src'] );
616 $new_image->setAttribute( 'alt', $image_data['alt'] );
617
618 // Deal with resizing.
619 if ( $image_data['resizeWidth'] ) {
620 $classes[] = 'is-resized';
621 $new_image->setAttribute( 'width', $image_data['resizeWidth'] );
622 $block_attributes['width'] = $image_data['resizeWidth'];
623 }
624
625 // Set the classes on the figure element.
626 $new_node->setAttribute( 'class', implode( ' ', $classes ) );
627
628 $block_attributes['sizeSlug'] = 'large';
629 $block_attributes['linkDestination'] = 'none';
630
631 return array(
632 'block_attributes' => $block_attributes,
633 'node' => $new_node,
634 );
635 }
636
637 /**
638 * Convert the node to a valid Gutenberg separator block.
639 *
640 * @param DomElement $node The node to be converted.
641 * @param DomElement $parent The parent of the node to be converted.
642 *
643 * @return DomElement The new node.
644 */
645 protected function convert_separator_node( DomElement $node, DomElement $parent ) {
646
647 $new_node = new DomElement( 'hr' );
648 $parent->replaceChild( $new_node, $node );
649 $new_node->setAttribute( 'class', 'wp-block-separator' );
650
651 return $new_node;
652 }
653
654 /**
655 * Convert a node that represents an embed to valid Gutenberg embed block markup.
656 *
657 * @param DomElement $node The node to be converted.
658 * @param DomElement $parent The parent of the node to be coverted.
659 *
660 * @return array Containing the block_name, block_attributes and node.
661 */
662 protected function convert_embed_node( DomElement $node, DomElement $parent ) {
663
664 $first_class = explode( ' ', $node->getAttribute( 'class' ) )[0];
665
666 switch ( $first_class ) {
667
668 case 'youtube-wrap':
669 $output = $this->convert_youtube_embed( $node, $parent );
670 break;
671
672 case 'vimeo-wrap':
673 $output = $this->convert_vimeo_embed( $node, $parent );
674 break;
675
676 case 'soundcloud-wrap':
677 $output = $this->convert_soundcloud_embed( $node, $parent );
678 break;
679
680 case 'tweet':
681 $output = $this->convert_tweet_embed( $node, $parent );
682 break;
683
684 case 'spotify-wrap':
685 $output = $this->convert_spotify_embed( $node, $parent );
686 break;
687
688 case 'bandcamp-wrap':
689 $output = $this->convert_bandcamp_embed( $node, $parent );
690 break;
691
692 case 'github-gist':
693 $output = $this->convert_gist_embed( $node, $parent );
694 break;
695
696 case 'instagram':
697 $output = $this->convert_instagram_embed( $node, $parent );
698 break;
699
700 default:
701 $parent->removeChild( $node );
702 $output = array(
703 'node' => null,
704 'block_attributes' => array(),
705 'block_name' => null,
706 );
707
708 }
709
710 return $output;
711 }
712
713 /**
714 * Convert the embed node into Gutenberg markup for a Youtube embed.
715 *
716 * @param DomElement $node The node to be converted.
717 * @param DomElement $parent The parent of the node to be coverted.
718 *
719 * @return array Containing the block_name, block_attributes and node.
720 */
721 protected function convert_youtube_embed( DomElement $node, DomElement $parent ) {
722
723 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
724
725 $block_attributes = array(
726 'url' => 'https://youtu.be/' . $data_attributes['videoId'],
727 'type' => 'video',
728 'providerNameSlug' => 'youtube',
729 'responsive' => true,
730 'className' => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
731 );
732
733 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
734 $classes = 'wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio';
735 $node->setAttribute( 'class', $classes );
736
737 return array(
738 'block_name' => 'wp:embed',
739 'block_attributes' => $block_attributes,
740 'node' => $node,
741 );
742 }
743
744 /**
745 * Convert the embed node into Gutenberg markup for a Vimeo embed.
746 *
747 * @param DomElement $node The node to be converted.
748 * @param DomElement $parent The parent of the node to be coverted.
749 *
750 * @return array Containing the block_name, block_attributes and node.
751 */
752 protected function convert_vimeo_embed( DomElement $node, DomElement $parent ) {
753
754 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
755
756 $block_attributes = array(
757 'url' => 'https://vimeo.com/' . $data_attributes['videoId'],
758 'type' => 'video',
759 'providerNameSlug' => 'vimeo',
760 'responsive' => true,
761 'className' => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
762 );
763
764 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
765 $classes = 'wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo wp-embed-aspect-16-9 wp-has-aspect-ratio';
766 $node->setAttribute( 'class', $classes );
767
768 return array(
769 'block_name' => 'wp:embed',
770 'block_attributes' => $block_attributes,
771 'node' => $node,
772 );
773 }
774
775 /**
776 * Convert the embed node into Gutenberg markup for a Soundcloud embed.
777 *
778 * @param DomElement $node The node to be converted.
779 * @param DomElement $parent The parent of the node to be coverted.
780 *
781 * @return array Containing the block_name, block_attributes and node.
782 */
783 protected function convert_soundcloud_embed( DomElement $node, DomElement $parent ) {
784
785 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
786
787 $block_attributes = array(
788 'url' => $data_attributes['url'],
789 'type' => 'rich',
790 'providerNameSlug' => 'soundcloud',
791 'responsive' => true,
792 'className' => 'wp-embed-aspect-4-3 wp-has-aspect-ratio',
793 );
794
795 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
796 $classes = 'wp-block-embed is-type-rich is-provider-soundcloud wp-block-embed-soundcloud wp-embed-aspect-4-3 wp-has-aspect-ratio';
797 $node->setAttribute( 'class', $classes );
798
799 return array(
800 'block_name' => 'wp:embed',
801 'block_attributes' => $block_attributes,
802 'node' => $node,
803 );
804 }
805
806 /**
807 * Convert the embed node into Gutenberg markup for a Tweet embed.
808 *
809 * @param DomElement $node The node to be converted.
810 * @param DomElement $parent The parent of the node to be coverted.
811 *
812 * @return array Containing the block_name, block_attributes and node.
813 */
814 protected function convert_tweet_embed( DomElement $node, DomElement $parent ) {
815
816 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
817
818 $block_attributes = array(
819 'url' => $data_attributes['url'],
820 'type' => 'rich',
821 'providerNameSlug' => 'twitter',
822 'responsive' => true,
823 );
824
825 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
826 $classes = 'wp-block-embed is-type-rich is-provider-twitter wp-block-embed-twitter';
827 $node->setAttribute( 'class', $classes );
828
829 return array(
830 'block_name' => 'wp:embed',
831 'block_attributes' => $block_attributes,
832 'node' => $node,
833 );
834 }
835
836 /**
837 * Convert the embed node into Gutenberg markup for a Spotify embed.
838 *
839 * @param DomElement $node The node to be converted.
840 * @param DomElement $parent The parent of the node to be coverted.
841 *
842 * @return array Containing the block_name, block_attributes and node.
843 */
844 protected function convert_spotify_embed( DomElement $node, DomElement $parent ) {
845
846 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
847
848 $block_attributes = array(
849 'url' => $data_attributes['url'],
850 'type' => 'rich',
851 'providerNameSlug' => 'spotify',
852 'responsive' => true,
853 'className' => 'wp-embed-aspect-9-16 wp-has-aspect-ratio',
854 );
855
856 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
857 $classes = 'wp-block-embed is-type-rich is-provider-spotify wp-block-embed-spotify wp-embed-aspect-9-16 wp-has-aspect-ratio';
858 $node->setAttribute( 'class', $classes );
859
860 return array(
861 'block_name' => 'wp:embed',
862 'block_attributes' => $block_attributes,
863 'node' => $node,
864 );
865 }
866
867 /**
868 * Converts the node into a shortcode for Bandcamp.
869 *
870 * The shortcode is currently not supported in Core but is available by enabling the embeds module
871 * of the Jetpack plugin.
872 *
873 * @example [bandcamp width=350 height=470 album=473417827 size=large bgcol=ffffff linkcol=0687f5 tracklist=false]
874 *
875 * @param DomElement $node The node to be converted.
876 * @param DomElement $parent The parent of the node to be coverted.
877 *
878 * @return array
879 */
880 protected function convert_bandcamp_embed( DomElement $node, DomElement $parent ) {
881
882 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
883
884 // The embed URL contains the attributes for the shortcode. Here we extract them and add them to the shortcode.
885 preg_match_all( '/[a-z]+=[a-z0-9]+/', $data_attributes['embed_url'], $matches );
886 $shortcode = sprintf( '[bandcamp %s]', implode( ' ', $matches[0] ) );
887
888 $new_node = new DOMText( $shortcode );
889 $parent->replaceChild( $new_node, $node );
890
891 return array(
892 'block_name' => 'wp:shortcode',
893 'block_attributes' => array(),
894 'node' => $new_node,
895 );
896 }
897
898 /**
899 * Convert a Github Gist node into a shortcode.
900 *
901 * Tries to get the Gist id from the raw link or removes the entire Gist if the ID can not be determined.
902 *
903 * @param DomElement $node The node to be converted.
904 * @param DomElement $parent The parent of the node to be coverted.
905 *
906 * @return array
907 */
908 protected function convert_gist_embed( DomElement $node, DomElement $parent ) {
909
910 $a_elements = $node->getElementsByTagName( 'a' );
911
912 $url = $a_elements->length > 0
913 ? $a_elements[0]->getAttribute( 'href' )
914 : null;
915
916 if ( ! $url || ! preg_match( '/\/([a-z0-9]+)\/raw/', $a_elements[0]->getAttribute( 'href' ), $matches ) ) {
917 $parent->removeChild( $node );
918 return array(
919 'node' => null,
920 'block_attributes' => array(),
921 'block_name' => null,
922 );
923 }
924
925 $shortcode = sprintf( '[gist https://gist.github.com/%s]', $matches[1] );
926
927 $new_node = new DOMText( $shortcode );
928 $parent->replaceChild( $new_node, $node );
929
930 return array(
931 'block_name' => 'wp:shortcode',
932 'block_attributes' => array(),
933 'node' => $new_node,
934 );
935 }
936
937 /**
938 * Convert Instagram embed to a link to the Instagram post.
939 *
940 * Currently, Instagram embeds are not supported without the installation
941 * of additional plugins. For this reason, the embed will be converted in
942 * a link to the post.
943 *
944 * @param DomElement $node
945 * @param DomElement $parent
946 *
947 * @return array
948 */
949 protected function convert_instagram_embed( DomElement $node, DomElement $parent ) {
950
951 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
952
953 $new_node = new DomElement( 'p' );
954 $link_node = new DomElement( 'a' );
955
956 $parent->replaceChild( $new_node, $node );
957
958 $new_node->appendChild( $link_node );
959
960 $instagram_link = sprintf( 'https://instagram.com/p/%s/', $data_attributes['instagram_id'] );
961 $link_node->setAttribute( 'href', $instagram_link );
962 $link_node->setAttribute( 'target', '_blank' );
963 $link_node->setAttribute( 'rel', 'noreferrer noopener' );
964 $link_node->textContent = $instagram_link; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
965
966 return array(
967 'block_name' => 'wp:paragraph',
968 'block_attributes' => array(),
969 'node' => $new_node,
970 );
971 }
972
973 /**
974 * Replace the Substack Embed node with embed markup that is valid for Gutenberg.
975 *
976 * Returns the replacement node.
977 *
978 * @param DomElement $node
979 * @param DomElement $parent
980 *
981 * @return DomElement
982 */
983 protected function replace_embed_node( DomElement $node, DomElement $parent, $content ) {
984 $new_node = new DomElement( 'figure' );
985 $wrapper = new DomElement( 'div' );
986
987 $parent->replaceChild( $new_node, $node );
988 $new_node->appendChild( $wrapper );
989 $wrapper->setAttribute( 'class', 'wp-block-embed__wrapper' );
990
991 // URL needs to be on its own line, see:
992 // https://github.com/wordpress/gutenberg/blob/trunk/packages/block-library/src/embed/save.js#L27
993 $content = new DOMText( "\n" . $content . "\n" );
994 $new_node->getElementsByTagName( 'div' )[0]->appendChild( $content );
995
996 return $new_node;
997 }
998
999
1000 /**
1001 * Retrieve additional post information through the Substack Post API.
1002 *
1003 * The most important data we are after includes author information and comments as this currently is not provided
1004 * in the export file.
1005 *
1006 * It is important to note that comments might not be included or might not contain any information
1007 * if the comments are only visible to paid users or if post itself is only accessible to paid users.
1008 *
1009 * The completeness of information in the response depends on the type of the post (paid vs. public).
1010 *
1011 * @param string $slug The slug of the post.
1012 *
1013 * @return string|null Returns a JSON string with post information or null if it could not be retrieved.
1014 */
1015 protected function fetch_post_meta( $slug ) {
1016
1017 // If the substack url is not set, we skip this step.
1018 if ( ! $this->substack_url ) {
1019 return null;
1020 }
1021
1022 $post_url = sprintf( '%s/api/v1/posts/%s?all_comments=true', $this->substack_url, $slug );
1023
1024 $response = wp_remote_get( $post_url, array( 'redirection' => 0 ) );
1025
1026 if ( is_wp_error( $response ) || 200 !== $response['response']['code'] ) {
1027 return null;
1028 }
1029
1030 return wp_remote_retrieve_body( $response );
1031 }
1032
1033 /**
1034 * Get meta info from the substack export zip. Returns null if no meta was found.
1035 *
1036 * @param int $id Substack Post ID.
1037 *
1038 * @return array|null
1039 */
1040 protected function get_post_meta_from_export( $id ) {
1041 $zip = $this->get_export_zip();
1042
1043 if ( is_wp_error( $zip ) ) {
1044 return null;
1045 }
1046
1047 $meta = $zip->getFromName( sprintf( 'meta/%s.json', $id ) );
1048
1049 return $meta
1050 ? json_decode( $meta, true )
1051 : null;
1052 }
1053
1054 /**
1055 * Returns a generator yielding posts retrieved from the Substack export.
1056 *
1057 * If a there was a problem retrieving the Zip file, a WP_Error will be returned.
1058 *
1059 * @return \Generator|WP_Error
1060 */
1061 public function get_posts() {
1062
1063 $zip = $this->get_export_zip();
1064
1065 if ( is_wp_error( $zip ) ) {
1066 return $zip;
1067 }
1068
1069 return $this->get_posts_generator( $zip );
1070 }
1071
1072 protected function get_posts_generator( ZipArchive $zip ) {
1073 $post_csv = $zip->getFromName( 'posts.csv' );
1074
1075 $posts = explode( "\n", trim( $post_csv ) );
1076 $map = str_getcsv( array_shift( $posts ) );
1077
1078 foreach ( $posts as $post ) {
1079 $post = str_getcsv( $post, ',' );
1080 $post = array_combine( $map, $post );
1081 $post['html_body'] = $zip->getFromName( sprintf( 'posts/%s.html', $post['post_id'] ) );
1082 yield $post;
1083 }
1084 }
1085
1086 /**
1087 * Get a ZipArchive instance of the export file or return an error if it failed.
1088 *
1089 * @return WP_Error|ZipArchive The zip archive or a WP_error instance on failure.
1090 */
1091 protected function get_export_zip() {
1092
1093 if ( ! class_exists( 'ZipArchive' ) ) {
1094 return new WP_Error( 'missing_zip_extension', __( 'Could not unzip the substack export file.' ) );
1095 }
1096
1097 $zip = new ZipArchive();
1098 $success = $zip->open( $this->export_file_path );
1099
1100 if ( true !== $success || 0 === $zip->numFiles ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArcive property
1101 return new WP_Error( 'invalid_export_file', __( 'Could not unzip the substack export file.' ) );
1102 }
1103
1104 // If posts.csv was not found in the zip archive, the export is invalid.
1105 if ( false === $zip->getFromName( 'posts.csv' ) ) {
1106 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. ' ) );
1107 }
1108
1109 return $zip;
1110 }
1111 }
1112