# substack-importer/0.1.0/includes/class-converter.php

Substack Importer, version 0.1.0. 1,040 lines.

- Page: https://pluginprobe.com/plugins/substack-importer/0.1.0/code/includes/class-converter.php
- Raw: https://pluginprobe.com/plugins/substack-importer/0.1.0/raw/includes/class-converter.php
- Modified: 2021-05-06T11:23:52+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/substack-importer/0.1.0/code/includes/class-converter.php#L10-L20`.

```php
<?php

namespace SubstackImporter;

use WP_Error;
use WXR_Generator\Generator;
use ZipArchive;
use DOMDocument;
use DomComment;
use DomElement;
use DOMText;


/**
 * The Substack Converter is responsible for taking in a Substack export and providing
 * data to the WXR generator.
 *
 * @package SubstackImporter
 */
class Converter {

	/**
	 * @var string $export_file_path Path of the export file.
	 */
	protected $export_file_path;

	/**
	 * Instance of the WXR generator
	 * @var Generator $generator
	 */
	protected $generator;

	/**
	 * Authors.
	 * @var array
	 */
	protected $authors = array();

	/**
	 * Categories.
	 *
	 * @var array
	 */
	protected $categories = array();

	/**
	 * URL of the Substack Newsletter.
	 *
	 * @var string
	 */
	protected $substack_url;

	/**
	 * The classnames of all possible embed nodes in the Substack HTML.
	 *
	 * @var string[]
	 */
	protected $supported_embeds = array(
		'tweet',
		'instagram', // No longer supported.
		'youtube-wrap',
		'spotify-wrap',
		'soundcloud-wrap',
		'vimeo-wrap',
		'bandcamp-wrap', // Shortcode embed,
		'github-gist', // Not supported in core, using shortcode embed
	);


	/**
	 * Converter constructor.
	 *
	 * @param Generator $generator Instance of the WXR Generator.
	 * @param string $export_file_path Path to the Substack export zip file.
	 * @param null $substack_url URL of the Substack newsletter.
	 */
	public function __construct( Generator $generator, $export_file_path, $substack_url = null ) {
		$this->generator        = $generator;
		$this->export_file_path = $export_file_path;
		$this->substack_url     = $substack_url;
	}

	/**
	 * Convert the Substack export to a WXR.
	 *
	 * @returns WP_Error|void
	 *
	 * @throws \OxymelException
	 */
	public function convert() {

		if ( ! $this->export_file_path || ! file_exists( $this->export_file_path ) ) {
			return new WP_Error( 'export_file_not_exist', 'The export file does not exist' );
		}

		$this->generator->initialize();

		// Add posts.
		$out = $this->add_posts();

		if ( is_wp_error( $out ) ) {
			return $out;
		}

		// Add Authors.
		foreach ( $this->authors as $author ) {
			$this->generator->add_author( $author );
		}

		// Add categories.
		foreach ( $this->categories as $category ) {
			$this->generator->add_category( $category );
		}

		$this->generator->finalize();
	}

	/**
	 * Load additional information retrieved through the Substack API into the export zip file.
	 *
	 * @param int $offset 0-indexed starting offset for the post to start with.
	 * @param int $limit Number of posts to process.
	 *
	 * @return array|WP_Error
	 */
	public function load_meta_data( $offset = 0, $limit = 1 ) {

		$zip = $this->get_export_zip();

		if ( is_wp_error( $zip ) ) {
			return $zip;
		}

		$total_count = 0;

		foreach ( $this->get_posts() as $idx => $post ) {
			$total_count++;

			if ( $idx < $offset || $idx >= $offset + $limit ) {
				continue;
			}

			list($id, $slug) = explode( '.', $post['post_id'], 2 );
			$meta            = $this->fetch_post_meta( $slug );

			if ( $meta ) {
				$zip->addFromString( sprintf( 'meta/%s.json', $id ), $meta );
			}
		}

		return array(
			'total'     => $total_count,
			'processed' => min( $offset + $limit, $total_count ),
		);

	}

	/**
	 * Convert each Substack post to a WordPress post and add it to the WXR.
	 *
	 * @return void|WP_Error
	 *
	 * @throws \OxymelException
	 */
	protected function add_posts() {

		$posts_generator = $this->get_posts();

		if ( is_wp_error( $posts_generator ) ) {
			return $posts_generator;
		}

		foreach ( $posts_generator as $post ) {

			$id        = (int) $post['post_id'];
			$post_meta = $this->get_post_meta_from_export( $id );
			$post_data = array(
				'id'              => $id,
				'title'           => $post['title'],
				'content'         => $this->convert_html_to_gutenberg( $post['html_body'] ),
				'date'            => 'true' === $post['is_published'] ? $post['post_date'] : '',
				'status'          => 'true' === $post['is_published'] ? 'publish' : 'draft',
				'post_date_gmt'   => $post['post_date'],
				'post_date'       => $post['post_date'],
				'post_taxonomies' => array(),
				'metas'           => array(),
			);

			// If we were able to retrieve more information through the Substack API, we might have
			// author information and comments.
			$post_data['author']   = $post_meta ? $this->get_post_author( $post_meta ) : $this->get_default_author();
			$post_data['comments'] = $post_meta ? $this->get_post_comments( $post_meta ) : array();

			// Handle podcast posts - prepend an Gutenberg audio block to the post content.
			if ( 'podcast' === $post['type'] && ! empty( $post['podcast_url'] ) ) {
				$post_data = $this->handle_podcast_post( $post_data, $post );
			}

			$this->generator->add_post( $post_data );
		}
	}

	protected function handle_podcast_post( $post_data, $post ) {
		$post_data['content'] = $this->get_audio_block( $post['podcast_url'] ) . $post_data['content'];

		// Create a new attachment
		$this->generator->add_post(
			array(
				'title'          => urldecode( basename( $post['podcast_url'] ) ),
				'link'           => $post['podcast_url'],
				'post_date'      => $post_data['post_date'],
				'type'           => 'attachment',
				'attachment_url' => $post['podcast_url'],
				'metas'          => array(
					array(
						'key'   => '_wp_original_image_link',
						'value' => $post['podcast_url'],
					),
				),
			)
		);

		// Add this post to the podcast category.
		$this->categories['podcast']    = array(
			'slug' => 'podcast',
			'name' => 'Podcast',
		);
		$post_data['post_taxonomies'][] = array(
			'name'   => 'Podcast',
			'slug'   => 'podcast',
			'domain' => 'category',
		);

		return $post_data;
	}

	/**
	 * Get a Gutenberg Audio block for the podcast
	 * @param $audio_url
	 *
	 * @return string
	 */
	protected function get_audio_block( $audio_url ) {
		$code = '<!-- wp:audio --><figure class="wp-block-audio"><audio controls src="%s"></audio><figcaption>Podcast</figcaption></figure><!-- /wp:audio -->';
		return sprintf( $code, $audio_url );
	}

	protected function get_post_author( $post_meta ) {
		// If we can't get the author information, return a default author.
		if ( empty( $post_meta['publishedBylines'] ) ) {
			return $this->get_default_author();
		}

		$byline                         = $post_meta['publishedBylines'][0];
		$this->authors[ $byline['id'] ] = array(
			'login'        => $byline['name'],
			'display_name' => $byline['name'],
			'id'           => $byline['id'],
		);

		return $byline['name'];
	}

	protected function get_default_author() {
		$this->authors['unknown'] = array(
			'login'        => 'Unknown',
			'display_name' => 'Unknown',
			'id'           => 1,
		);

		return 'unknown';
	}

	/**
	 * Get post comments retrieved through the Substack api.
	 *
	 * @param array $post_data Additional data about a post retrieved through the Substack Post API.
	 *
	 * @return mixed
	 */
	protected function get_post_comments( $post_meta ) {
		if ( empty( $post_meta['comments'] ) ) {
			return array();
		}

		return $this->parse_comments( $post_meta['comments'] );
	}

	/**
	 * Recursively parse the comments and prepare the data required for the WXR output.
	 *
	 * @param array $comments An array of comments provided by the Substack posts API endpoint.
	 * @param array $out Output that is ready to be passed to the WXR generator.
	 * @param null $parent If we are in a recursive call, the parent must be provided.
	 *
	 * @return array|mixed
	 */
	protected function parse_comments( $comments, $out = array(), $parent = null ) {
		foreach ( $comments as $comment ) {
			$out[] = array(
				'id'       => $comment['id'],
				'author'   => $comment['name'],
				'date'     => $comment['date'],
				'date_gmt' => $comment['date'],
				'content'  => $comment['body'],
				'parent'   => $parent,
				'metas'    => array(),
			);

			if ( ! empty( $comment['children'] ) ) {
				$out = $this->parse_comments( $comment['children'], $out, (int) $comment['id'] );
			}
		}

		return $out;
	}

	/**
	 * Convert the content HTML to Gutenberg blocks and return the result.
	 *
	 * @param string $content The HTML provided by Substack.
	 *
	 * @return string|string[]|null
	 *
	 * @todo Load the content as XML to prevent errors from loadHTML.
	 */
	protected function convert_html_to_gutenberg( $content ) {

		$dom = new DOMDocument();

		// By inserting a meta tag with utf-8 encoding we make sure the content is converted to utf-8
		$content = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . $content;
		@$dom->loadHTML( $content ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged

		$body = $dom->getElementsByTagName( 'body' )->item( 0 );

		// We don't want to use the DomNodeList because it will change while we are iterating over the nodes.
		$nodes = array();
		foreach ( $body->childNodes as $node ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			if ( ! $node instanceof DomElement ) {
				continue;
			}
			$nodes[] = $node;
		}

		// We go through the top-level nodes and handle each of them.
		foreach ( $nodes as $idx => $node ) {
			$next_sibling = count( $nodes ) - 1 > $idx ? $nodes[ $idx + 1 ] : null;
			$this->convert_node( $node, $body, $next_sibling );
		}

		// Save as XML otherwise we don't get HTMl5 elements correctly.
		$content = $dom->saveXML( $body );

		// Strip the body tag.
		$content = preg_replace( '/<body>(.+)<\/body>/s', '$1', $content );

		return $content;
	}


	/**
	 * Convert a single node to a Gutenberg block.
	 *
	 * Tries to convert a given HTML node into a Gutenberg block.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be converted.
	 * @param DomElement|null $next_sibling The next sibling of the node to be converted, if it exists.
	 *
	 */
	protected function convert_node( DOMElement $node, DomElement $parent, DomElement $next_sibling = null ) {

		$block_name       = null;
		$block_attributes = array();

		$node_name = $node->nodeName; //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		switch ( $node_name ) {

			case 'p':
				$block_name = 'wp:paragraph';
				$class      = $node->getAttribute( 'class' );

				// remove empty paragraphs.
				/** @todo Perhaps we can remove all empty nodes, not just paragraphs? */
				if ( ! $node->childNodes->length ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
					$parent->removeChild( $node );
					$node = null;
				}

				// Button
				if ( 'button-wrapper' === $class ) {
					$node       = $this->convert_button_node( $node, $parent );
					$block_name = 'wp:button';
				}

				break;

			case 'blockquote':
				$block_name = 'wp:quote';
				$node->setAttribute( 'class', 'wp-block-quote' );
				break;

			case 'div':
			case 'iframe':
				$class = $node->getAttribute( 'class' );

				// Preformatted text
				if ( 'preformatted-block' === $class ) {
					$node       = $this->convert_preformatted_node( $node, $parent );
					$block_name = 'wp:preformatted';
				}

				// Images
				if ( 'captioned-image-container' === $class ) {
					$result           = $this->convert_image_node( $node, $parent );
					$node             = $result['node'];
					$block_attributes = $result['block_attributes'];
					$block_name       = 'wp:image';
				}

				// Horizontal separator
				if ( $node && $node->getElementsByTagName( 'hr' )->length ) {
					$node       = $this->convert_separator_node( $node, $parent );
					$block_name = 'wp:separator';
				}

				// Embeds
				$first_class = explode( ' ', $class );
				if ( ! empty( $first_class ) && in_array( $first_class[0], $this->supported_embeds, true ) ) {
					$result           = $this->convert_embed_node( $node, $parent );
					$node             = $result['node'];
					$block_attributes = $result['block_attributes'];
					$block_name       = $result['block_name'];
				}

				break;

			case 'ol':
			case 'ul':
				$block_name = 'wp:list';

				if ( 'ol' === $node_name ) {
					$block_attributes['ordered'] = true;
				}

				break;

			case 'pre':
				$block_name = 'wp:code';
				$node->setAttribute( 'class', 'wp-block-code' );
				break;

			case 'h1':
			case 'h2':
			case 'h3':
			case 'h4':
			case 'h5':
			case 'h6':
				$block_name                = 'wp:heading';
				$block_attributes['level'] = (int) substr( $node_name, 1, 1 );
				break;

			case 'a':
				$class = $node->getAttribute( 'class' );
				if ( 'image-link image2' === trim( $class ) ) {
					$result           = $this->convert_image_node( $node, $parent );
					$node             = $result['node'];
					$block_attributes = $result['block_attributes'];
					$block_name       = 'wp:image';
				}

				break;

		}

		if ( ! $block_name || ! $node ) {
			return;
		}

		// Create the Gutenberg block code
		$attributes_part = '';
		if ( count( $block_attributes ) ) {
			$attributes_part = ' ' . wp_json_encode( $block_attributes );
		}
		$block_open  = new DOMComment( ' ' . $block_name . $attributes_part . ' ' );
		$block_close = new DOMComment( ' /' . $block_name . ' ' );

		$parent->insertBefore( $block_open, $node );

		$next_sibling
			? $parent->insertBefore( $block_close, $next_sibling )
			: $parent->appendChild( $block_close );
	}

	/**
	 * Convert a preformatted text node to valid Gutenberg markup.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node.
	 *
	 * @return DomElement The converted node.
	 */
	protected function convert_preformatted_node( DomElement $node, DomElement $parent ) {

		$node_value = $node->getElementsByTagName( 'pre' )[0]->textContent;
		$new_node   = new DomElement( 'pre', $node_value );
		$parent->replaceChild( $new_node, $node );
		$new_node->setAttribute( 'class', 'wp-block-preformatted' );

		return $new_node;
	}

	/**
	 * Handle a button node.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node.
	 *
	 * @return DomElement
	 *
	 * @todo Support multiple types of buttons. For now buttons are removed.
	 */
	protected function convert_button_node( DomElement $node, DomElement $parent ) {
		$parent->removeChild( $node );
		return null;
	}

	/**
	 * Convert an image node to a Gutenberg valid markup.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node.
	 *
	 * @return array An array containing the Block attributes and the new node.
	 *
	 * @todo If the node is a (a) link we need to make this image a link as well.
	 */
	protected function convert_image_node( DomElement $node, DomElement $parent ) {

		// Check if the image needs to be resized
		// Can we already upload the image here?
		/** @var DomElement $image */
		$image = $node->getElementsByTagName( 'img' )[0];

		// if there is no image we can't proceed.
		if ( ! $image ) {
			$parent->removeChild( $node );
			return array(
				'block_attributes' => array(),
				'node'             => null,
			);
		}

		$block_attributes = array();

		$new_node = new DomElement( 'figure' );

		$parent->replaceChild( $new_node, $node );

		$classes = array( 'wp-block-image', 'size-large' );

		// The data we need is set as json data attribute on the img node.
		$image_data = json_decode( $image->getAttribute( 'data-attrs' ), true );

		// Add the image as an attachement post to the WXR.
		$this->generator->add_post(
			array(
				'title'          => urldecode( basename( $image_data['src'] ) ),
				'link'           => $image_data['src'],
				'type'           => 'attachment',
				'attachment_url' => $image_data['src'],
				'metas'          => array(
					array(
						'key'   => '_wp_original_image_link',
						'value' => $image_data['src'],
					),
				),
			)
		);

		// Create the new image element.
		$new_image = new DomElement( 'img' );
		$new_node->appendChild( $new_image );
		$new_image->setAttribute( 'src', $image_data['src'] );
		$new_image->setAttribute( 'alt', $image_data['alt'] );

		// Deal with resizing.
		if ( $image_data['resizeWidth'] ) {
			$classes[] = 'is-resized';
			$new_image->setAttribute( 'width', $image_data['resizeWidth'] );
			$block_attributes['width'] = $image_data['resizeWidth'];
		}

		// Set the classes on the figure element.
		$new_node->setAttribute( 'class', implode( ' ', $classes ) );

		$block_attributes['sizeSlug']        = 'large';
		$block_attributes['linkDestination'] = 'none';

		return array(
			'block_attributes' => $block_attributes,
			'node'             => $new_node,
		);
	}

	/**
	 * Convert the node to a valid Gutenberg separator block.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be converted.
	 *
	 * @return DomElement The new node.
	 */
	protected function convert_separator_node( DomElement $node, DomElement $parent ) {

		$new_node = new DomElement( 'hr' );
		$parent->replaceChild( $new_node, $node );
		$new_node->setAttribute( 'class', 'wp-block-separator' );

		return $new_node;
	}

	/**
	 * Convert a node that represents an embed to valid Gutenberg embed block markup.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array Containing the block_name, block_attributes and node.
	 */
	protected function convert_embed_node( DomElement $node, DomElement $parent ) {

		$first_class = explode( ' ', $node->getAttribute( 'class' ) )[0];

		switch ( $first_class ) {

			case 'youtube-wrap':
				$output = $this->convert_youtube_embed( $node, $parent );
				break;

			case 'vimeo-wrap':
				$output = $this->convert_vimeo_embed( $node, $parent );
				break;

			case 'soundcloud-wrap':
				$output = $this->convert_soundcloud_embed( $node, $parent );
				break;

			case 'tweet':
				$output = $this->convert_tweet_embed( $node, $parent );
				break;

			case 'spotify-wrap':
				$output = $this->convert_spotify_embed( $node, $parent );
				break;

			case 'bandcamp-wrap':
				$output = $this->convert_bandcamp_embed( $node, $parent );
				break;

			case 'github-gist':
				$output = $this->convert_gist_embed( $node, $parent );
				break;

			default:
				$parent->removeChild( $node );
				$output = array(
					'node'             => null,
					'block_attributes' => array(),
					'block_name'       => null,
				);

		}

		return $output;
	}

	/**
	 * Convert the embed node into Gutenberg markup for a Youtube embed.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array Containing the block_name, block_attributes and node.
	 */
	protected function convert_youtube_embed( DomElement $node, DomElement $parent ) {

		$data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );

		$block_attributes = array(
			'url'              => 'https://youtu.be/' . $data_attributes['videoId'],
			'type'             => 'video',
			'providerNameSlug' => 'youtube',
			'responsive'       => true,
			'className'        => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
		);

		$node    = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
		$classes = 'wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio';
		$node->setAttribute( 'class', $classes );

		return array(
			'block_name'       => 'wp:embed',
			'block_attributes' => $block_attributes,
			'node'             => $node,
		);
	}

	/**
	 * Convert the embed node into Gutenberg markup for a Vimeo embed.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array Containing the block_name, block_attributes and node.
	 */
	protected function convert_vimeo_embed( DomElement $node, DomElement $parent ) {

		$data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );

		$block_attributes = array(
			'url'              => 'https://vimeo.com/' . $data_attributes['videoId'],
			'type'             => 'video',
			'providerNameSlug' => 'vimeo',
			'responsive'       => true,
			'className'        => 'wp-embed-aspect-16-9 wp-has-aspect-ratio',
		);

		$node    = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
		$classes = 'wp-block-embed is-type-video is-provider-vimeo wp-block-embed-vimeo wp-embed-aspect-16-9 wp-has-aspect-ratio';
		$node->setAttribute( 'class', $classes );

		return array(
			'block_name'       => 'wp:embed',
			'block_attributes' => $block_attributes,
			'node'             => $node,
		);
	}

	/**
	 * Convert the embed node into Gutenberg markup for a Soundcloud embed.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array Containing the block_name, block_attributes and node.
	 */
	protected function convert_soundcloud_embed( DomElement $node, DomElement $parent ) {

		$data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );

		$block_attributes = array(
			'url'              => $data_attributes['url'],
			'type'             => 'rich',
			'providerNameSlug' => 'soundcloud',
			'responsive'       => true,
			'className'        => 'wp-embed-aspect-4-3 wp-has-aspect-ratio',
		);

		$node    = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
		$classes = 'wp-block-embed is-type-rich is-provider-soundcloud wp-block-embed-soundcloud wp-embed-aspect-4-3 wp-has-aspect-ratio';
		$node->setAttribute( 'class', $classes );

		return array(
			'block_name'       => 'wp:embed',
			'block_attributes' => $block_attributes,
			'node'             => $node,
		);
	}

	/**
	 * Convert the embed node into Gutenberg markup for a Tweet embed.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array Containing the block_name, block_attributes and node.
	 */
	protected function convert_tweet_embed( DomElement $node, DomElement $parent ) {

		$data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );

		$block_attributes = array(
			'url'              => $data_attributes['url'],
			'type'             => 'rich',
			'providerNameSlug' => 'twitter',
			'responsive'       => true,
		);

		$node    = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
		$classes = 'wp-block-embed is-type-rich is-provider-twitter wp-block-embed-twitter';
		$node->setAttribute( 'class', $classes );

		return array(
			'block_name'       => 'wp:embed',
			'block_attributes' => $block_attributes,
			'node'             => $node,
		);
	}

	/**
	 * Convert the embed node into Gutenberg markup for a Spotify embed.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array Containing the block_name, block_attributes and node.
	 */
	protected function convert_spotify_embed( DomElement $node, DomElement $parent ) {

		$data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );

		$block_attributes = array(
			'url'              => $data_attributes['url'],
			'type'             => 'rich',
			'providerNameSlug' => 'spotify',
			'responsive'       => true,
			'className'        => 'wp-embed-aspect-9-16 wp-has-aspect-ratio',
		);

		$node    = $this->replace_embed_node( $node, $parent, $block_attributes['url'] );
		$classes = 'wp-block-embed is-type-rich is-provider-spotify wp-block-embed-spotify wp-embed-aspect-9-16 wp-has-aspect-ratio';
		$node->setAttribute( 'class', $classes );

		return array(
			'block_name'       => 'wp:embed',
			'block_attributes' => $block_attributes,
			'node'             => $node,
		);
	}

	/**
	 * Converts the node into a shortcode for Bandcamp.
	 *
	 * The shortcode is currently not supported in Core but is available by enabling the embeds module
	 * of the Jetpack plugin.
	 *
	 * @example [bandcamp width=350 height=470 album=473417827 size=large bgcol=ffffff linkcol=0687f5 tracklist=false]
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array
	 */
	protected function convert_bandcamp_embed( DomElement $node, DomElement $parent ) {

		$data_attributes = json_decode( $node->getAttribute( 'data-attrs' ), true );

		// The embed URL contains the attributes for the shortcode. Here we extract them and add them to the shortcode.
		preg_match_all( '/[a-z]+=[a-z0-9]+/', $data_attributes['embed_url'], $matches );
		$shortcode = sprintf( '[bandcamp %s]', implode( ' ', $matches[0] ) );

		$new_node = new DOMText( $shortcode );
		$parent->replaceChild( $new_node, $node );

		return array(
			'block_name'       => 'wp:shortcode',
			'block_attributes' => array(),
			'node'             => $new_node,
		);
	}

	/**
	 * Convert a Github Gist node into a shortcode.
	 *
	 * Tries to get the Gist id from the raw link or removes the entire Gist if the ID can not be determined.
	 *
	 * @param DomElement $node The node to be converted.
	 * @param DomElement $parent The parent of the node to be coverted.
	 *
	 * @return array
	 */
	protected function convert_gist_embed( DomElement $node, DomElement $parent ) {

		$a_elements = $node->getElementsByTagName( 'a' );

		$url = $a_elements->length > 0
			? $a_elements[0]->getAttribute( 'href' )
			: null;

		if ( ! $url || ! preg_match( '/\/([a-z0-9]+)\/raw/', $a_elements[0]->getAttribute( 'href' ), $matches ) ) {
			$parent->removeChild( $node );
			return array(
				'node'             => null,
				'block_attributes' => array(),
				'block_name'       => null,
			);
		}

		$shortcode = sprintf( '[gist https://gist.github.com/%s]', $matches[1] );

		$new_node = new DOMText( $shortcode );
		$parent->replaceChild( $new_node, $node );

		return array(
			'block_name'       => 'wp:shortcode',
			'block_attributes' => array(),
			'node'             => $new_node,
		);
	}

	/**
	 * Replace the Substack Embed node with embed markup that is valid for Gutenberg.
	 *
	 * Returns the replacement node.
	 *
	 * @param DomElement $node
	 * @param DomElement $parent
	 *
	 * @return DomElement
	 */
	protected function replace_embed_node( DomElement $node, DomElement $parent, $content ) {
		$new_node = new DomElement( 'figure' );
		$wrapper  = new DomElement( 'div' );

		$parent->replaceChild( $new_node, $node );
		$new_node->appendChild( $wrapper );
		$wrapper->setAttribute( 'class', 'wp-block-embed__wrapper' );

		$content = new DOMText( $content );
		$new_node->getElementsByTagName( 'div' )[0]->appendChild( $content );

		return $new_node;
	}


	/**
	 * Retrieve additional post information through the Substack Post API.
	 *
	 * The most important data we are after includes author information and comments as this currently is not provided
	 * in the export file.
	 *
	 * It is important to note that comments might not be included or might not contain any information
	 * if the comments are only visible to paid users or if post itself is only accessible to paid users.
	 *
	 * The completeness of information in the response depends on the type of the post (paid vs. public).
	 *
	 * @param string $slug The slug of the post.
	 *
	 * @return string|null Returns a JSON string with post information or null if it could not be retrieved.
	 */
	protected function fetch_post_meta( $slug ) {

		// If the substack url is not set, we skip this step.
		if ( ! $this->substack_url ) {
			return null;
		}

		$post_url = sprintf( '%s/api/v1/posts/%s?all_comments=true', $this->substack_url, $slug );

		$response = wp_remote_get( $post_url, array( 'redirection' => 0 ) );

		if ( is_wp_error( $response ) || 200 !== $response['response']['code'] ) {
			return null;
		}

		return wp_remote_retrieve_body( $response );
	}

	/**
	 * Get meta info from the substack export zip. Returns null if no meta was found.
	 *
	 * @param int $id Substack Post ID.
	 *
	 * @return array|null
	 */
	protected function get_post_meta_from_export( $id ) {
		$zip = $this->get_export_zip();

		if ( is_wp_error( $zip ) ) {
			return null;
		}

		$meta = $zip->getFromName( sprintf( 'meta/%s.json', $id ) );

		return $meta
			? json_decode( $meta, true )
			: null;
	}

	/**
	 * Returns a generator yielding posts retrieved from the Substack export.
	 *
	 * If a there was a problem retrieving the Zip file, a WP_Error will be returned.
	 *
	 * @return \Generator|WP_Error
	 */
	public function get_posts() {

		$zip = $this->get_export_zip();

		if ( is_wp_error( $zip ) ) {
			return $zip;
		}

		return $this->get_posts_generator( $zip );
	}

	protected function get_posts_generator( ZipArchive $zip ) {
		$post_csv = $zip->getFromName( 'posts.csv' );

		$posts = explode( "\n", trim( $post_csv ) );
		$map   = str_getcsv( array_shift( $posts ) );

		foreach ( $posts as $post ) {
			$post              = str_getcsv( $post, ',' );
			$post              = array_combine( $map, $post );
			$post['html_body'] = $zip->getFromName( sprintf( 'posts/%s.html', $post['post_id'] ) );
			yield $post;
		}
	}

	/**
	 * Get a ZipArchive instance of the export file or return an error if it failed.
	 *
	 * @return WP_Error|ZipArchive The zip archive or a WP_error instance on failure.
	 */
	protected function get_export_zip() {

		if ( ! class_exists( 'ZipArchive' ) ) {
			return new WP_Error( 'missing_zip_extension', __( 'Could not unzip the substack export file.' ) );
		}

		$zip     = new ZipArchive();
		$success = $zip->open( $this->export_file_path );

		if ( true !== $success || 0 === $zip->numFiles ) { //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArcive property
			return new WP_Error( 'invalid_export_file', __( 'Could not unzip the substack export file.' ) );
		}

		// If posts.csv was not found in the zip archive, the export is invalid.
		if ( false === $zip->getFromName( 'posts.csv' ) ) {
			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. ' ) );
		}

		return $zip;
	}
}

```
