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 = '
Podcast
'; 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 = '' . $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>/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; } }