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

1,125 lines 32.2 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 $block_attributes = array(
801 'url' => $data_attributes['url'],
802 'type' => 'rich',
803 'providerNameSlug' => 'soundcloud',
804 'responsive' => true,
805 'className' => 'wp-embed-aspect-4-3 wp-has-aspect-ratio',
806 );
807
808 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
809 $classes = 'wp-block-embed is-type-rich is-provider-soundcloud wp-block-embed-soundcloud wp-embed-aspect-4-3 wp-has-aspect-ratio';
810 $node->setAttribute( 'class', $classes );
811
812 return array(
813 'block_name' => 'wp:embed',
814 'block_attributes' => $block_attributes,
815 'node' => $node,
816 );
817 }
818
819 /**
820 * Convert the embed node into Gutenberg markup for a Tweet embed.
821 *
822 * @param DomElement $node The node to be converted.
823 * @param DomElement $parent The parent of the node to be coverted.
824 *
825 * @return array Containing the block_name, block_attributes and node.
826 */
827 protected function convert_tweet_embed( DomElement $node, DomElement $parent ) {
828
829 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
830
831 $block_attributes = array(
832 'url' => $data_attributes['url'],
833 'type' => 'rich',
834 'providerNameSlug' => 'twitter',
835 'responsive' => true,
836 );
837
838 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
839 $classes = 'wp-block-embed is-type-rich is-provider-twitter wp-block-embed-twitter';
840 $node->setAttribute( 'class', $classes );
841
842 return array(
843 'block_name' => 'wp:embed',
844 'block_attributes' => $block_attributes,
845 'node' => $node,
846 );
847 }
848
849 /**
850 * Convert the embed node into Gutenberg markup for a Spotify embed.
851 *
852 * @param DomElement $node The node to be converted.
853 * @param DomElement $parent The parent of the node to be coverted.
854 *
855 * @return array Containing the block_name, block_attributes and node.
856 */
857 protected function convert_spotify_embed( DomElement $node, DomElement $parent ) {
858
859 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
860
861 $block_attributes = array(
862 'url' => $data_attributes['url'],
863 'type' => 'rich',
864 'providerNameSlug' => 'spotify',
865 'responsive' => true,
866 'className' => 'wp-embed-aspect-9-16 wp-has-aspect-ratio',
867 );
868
869 $node = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
870 $classes = 'wp-block-embed is-type-rich is-provider-spotify wp-block-embed-spotify wp-embed-aspect-9-16 wp-has-aspect-ratio';
871 $node->setAttribute( 'class', $classes );
872
873 return array(
874 'block_name' => 'wp:embed',
875 'block_attributes' => $block_attributes,
876 'node' => $node,
877 );
878 }
879
880 /**
881 * Converts the node into a shortcode for Bandcamp.
882 *
883 * The shortcode is currently not supported in Core but is available by enabling the embeds module
884 * of the Jetpack plugin.
885 *
886 * @example [bandcamp width=350 height=470 album=473417827 size=large bgcol=ffffff linkcol=0687f5 tracklist=false]
887 *
888 * @param DomElement $node The node to be converted.
889 * @param DomElement $parent The parent of the node to be coverted.
890 *
891 * @return array
892 */
893 protected function convert_bandcamp_embed( DomElement $node, DomElement $parent ) {
894
895 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
896
897 // The embed URL contains the attributes for the shortcode. Here we extract them and add them to the shortcode.
898 preg_match_all( '/[a-z]+=[a-z0-9]+/', $data_attributes['embed_url'], $matches );
899 $shortcode = sprintf( '[bandcamp %s]', implode( ' ', $matches[0] ) );
900
901 $new_node = new DOMText( $shortcode );
902 $parent->replaceChild( $new_node, $node );
903
904 return array(
905 'block_name' => 'wp:shortcode',
906 'block_attributes' => array(),
907 'node' => $new_node,
908 );
909 }
910
911 /**
912 * Convert a Github Gist node into a shortcode.
913 *
914 * Tries to get the Gist id from the raw link or removes the entire Gist if the ID can not be determined.
915 *
916 * @param DomElement $node The node to be converted.
917 * @param DomElement $parent The parent of the node to be coverted.
918 *
919 * @return array
920 */
921 protected function convert_gist_embed( DomElement $node, DomElement $parent ) {
922
923 $a_elements = $node->getElementsByTagName( 'a' );
924
925 $url = $a_elements->length > 0
926 ? $a_elements[0]->getAttribute( 'href' )
927 : null;
928
929 if ( ! $url || ! preg_match( '/\/([a-z0-9]+)\/raw/', $a_elements[0]->getAttribute( 'href' ), $matches ) ) {
930 $parent->removeChild( $node );
931 return array(
932 'node' => null,
933 'block_attributes' => array(),
934 'block_name' => null,
935 );
936 }
937
938 $shortcode = sprintf( '[gist https://gist.github.com/%s]', $matches[1] );
939
940 $new_node = new DOMText( $shortcode );
941 $parent->replaceChild( $new_node, $node );
942
943 return array(
944 'block_name' => 'wp:shortcode',
945 'block_attributes' => array(),
946 'node' => $new_node,
947 );
948 }
949
950 /**
951 * Convert Instagram embed to a link to the Instagram post.
952 *
953 * Currently, Instagram embeds are not supported without the installation
954 * of additional plugins. For this reason, the embed will be converted in
955 * a link to the post.
956 *
957 * @param DomElement $node
958 * @param DomElement $parent
959 *
960 * @return array
961 */
962 protected function convert_instagram_embed( DomElement $node, DomElement $parent ) {
963
964 $data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );
965
966 $new_node = new DomElement( 'p' );
967 $link_node = new DomElement( 'a' );
968
969 $parent->replaceChild( $new_node, $node );
970
971 $new_node->appendChild( $link_node );
972
973 $instagram_link = sprintf( 'https://instagram.com/p/%s/', $data_attributes['instagram_id'] );
974 $link_node->setAttribute( 'href', $instagram_link );
975 $link_node->setAttribute( 'target', '_blank' );
976 $link_node->setAttribute( 'rel', 'noreferrer noopener' );
977 $link_node->textContent = $instagram_link; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
978
979 return array(
980 'block_name' => 'wp:paragraph',
981 'block_attributes' => array(),
982 'node' => $new_node,
983 );
984 }
985
986 /**
987 * Replace the Substack Embed node with embed markup that is valid for Gutenberg.
988 *
989 * Returns the replacement node.
990 *
991 * @param DomElement $node
992 * @param DomElement $parent
993 *
994 * @return DomElement
995 */
996 protected function replace_embed_node( DomElement $node, DomElement $parent, $content ) {
997 $new_node = new DomElement( 'figure' );
998 $wrapper = new DomElement( 'div' );
999
1000 $parent->replaceChild( $new_node, $node );
1001 $new_node->appendChild( $wrapper );
1002 $wrapper->setAttribute( 'class', 'wp-block-embed__wrapper' );
1003
1004 // URL needs to be on its own line, see:
1005 // https://github.com/wordpress/gutenberg/blob/trunk/packages/block-library/src/embed/save.js#L27
1006 $content = new DOMText( "\n" . $content . "\n" );
1007 $new_node->getElementsByTagName( 'div' )[0]->appendChild( $content );
1008
1009 return $new_node;
1010 }
1011
1012
1013 /**
1014 * Retrieve additional post information through the Substack Post API.
1015 *
1016 * The most important data we are after includes author information and comments as this currently is not provided
1017 * in the export file.
1018 *
1019 * It is important to note that comments might not be included or might not contain any information
1020 * if the comments are only visible to paid users or if post itself is only accessible to paid users.
1021 *
1022 * The completeness of information in the response depends on the type of the post (paid vs. public).
1023 *
1024 * @param string $slug The slug of the post.
1025 *
1026 * @return string|null Returns a JSON string with post information or null if it could not be retrieved.
1027 */
1028 protected function fetch_post_meta( $slug ) {
1029
1030 // If the substack url is not set, we skip this step.
1031 if ( ! $this->substack_url ) {
1032 return null;
1033 }
1034
1035 $post_url = sprintf( '%s/api/v1/posts/%s?all_comments=true', $this->substack_url, $slug );
1036
1037 $response = wp_remote_get( $post_url, array( 'redirection' => 0 ) );
1038
1039 if ( is_wp_error( $response ) || 200 !== $response['response']['code'] ) {
1040 return null;
1041 }
1042
1043 return wp_remote_retrieve_body( $response );
1044 }
1045
1046 /**
1047 * Get meta info from the substack export zip. Returns null if no meta was found.
1048 *
1049 * @param int $id Substack Post ID.
1050 *
1051 * @return array|null
1052 */
1053 protected function get_post_meta_from_export( $id ) {
1054 $zip = $this->get_export_zip();
1055
1056 if ( is_wp_error( $zip ) ) {
1057 return null;
1058 }
1059
1060 $meta = $zip->getFromName( sprintf( 'meta/%s.json', $id ) );
1061
1062 return $meta
1063 ? json_decode( $meta, true )
1064 : null;
1065 }
1066
1067 /**
1068 * Returns a generator yielding posts retrieved from the Substack export.
1069 *
1070 * If a there was a problem retrieving the Zip file, a WP_Error will be returned.
1071 *
1072 * @return \Generator|WP_Error
1073 */
1074 public function get_posts() {
1075
1076 $zip = $this->get_export_zip();
1077
1078 if ( is_wp_error( $zip ) ) {
1079 return $zip;
1080 }
1081
1082 return $this->get_posts_generator( $zip );
1083 }
1084
1085 protected function get_posts_generator( ZipArchive $zip ) {
1086 $post_csv = $zip->getFromName( 'posts.csv' );
1087
1088 $posts = explode( "\n", trim( $post_csv ) );
1089 $map = str_getcsv( array_shift( $posts ) );
1090
1091 foreach ( $posts as $post ) {
1092 $post = str_getcsv( $post, ',' );
1093 $post = array_combine( $map, $post );
1094 $post['html_body'] = $zip->getFromName( sprintf( 'posts/%s.html', $post['post_id'] ) );
1095 yield $post;
1096 }
1097 }
1098
1099 /**
1100 * Get a ZipArchive instance of the export file or return an error if it failed.
1101 *
1102 * @return WP_Error|ZipArchive The zip archive or a WP_error instance on failure.
1103 */
1104 protected function get_export_zip() {
1105
1106 if ( ! class_exists( 'ZipArchive' ) ) {
1107 return new WP_Error( 'missing_zip_extension', __( 'Could not unzip the substack export file.' ) );
1108 }
1109
1110 $zip = new ZipArchive();
1111 $success = $zip->open( $this->export_file_path );
1112
1113 if ( true !== $success || 0 === $zip->numFiles ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArcive property
1114 return new WP_Error( 'invalid_export_file', __( 'Could not unzip the substack export file.' ) );
1115 }
1116
1117 // If posts.csv was not found in the zip archive, the export is invalid.
1118 if ( false === $zip->getFromName( 'posts.csv' ) ) {
1119 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. ' ) );
1120 }
1121
1122 return $zip;
1123 }
1124 }
1125