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

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