PluginProbe
Substack Importer / 0.1.0
Substack Importer v0.1.0
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 0.1.0, at includes/class-converter.php

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