diff --git a/.wp-env.json b/.wp-env.json index 3695c1a0f..84d044542 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -2,7 +2,8 @@ "core": null, "plugins": [ ".", - "./tests/Basic-Auth-master" + "./tests/Basic-Auth-master", + "https://downloads.wordpress.org/plugin/classic-editor.zip" ], "env": { "tests": { diff --git a/includes/classes/DistributorPost.php b/includes/classes/DistributorPost.php index 26dd2a7f2..1bebe4a0e 100644 --- a/includes/classes/DistributorPost.php +++ b/includes/classes/DistributorPost.php @@ -626,6 +626,11 @@ protected function get_media() { $raw_media = $this->parse_media_blocks(); } else { $raw_media = get_attached_media( get_allowed_mime_types(), $post_id ); + // Parse images from post content. + $parsed_media = $this->parse_images_from_post_content(); + foreach ( $parsed_media as $media_post ) { + $raw_media[ $media_post->ID ] = $media_post; + } } $featured_image_id = $this->get_post_thumbnail_id(); @@ -652,6 +657,49 @@ protected function get_media() { return $media_array; } + /** + * Parse the post's content to obtain media items by image tags. + * + * @return array Array of media posts. + */ + protected function parse_images_from_post_content() { + $processor = new \WP_HTML_Tag_Processor( $this->post->post_content ); + + $media = array(); + while ( $processor->next_tag( 'img' ) ) { + $classes = explode( ' ', $processor->get_attribute( 'class' ) ?? ' ' ); + + if ( ! is_array( $classes ) ) { + continue; + } + + // Filter out classes that are not image classes. + $classes = array_filter( + $classes, + function( $class ) { + return strpos( $class, 'wp-image-' ) === 0; + } + ); + + if ( empty( $classes ) ) { + continue; + } + + $image_id = (int) str_replace( 'wp-image-', '', current( $classes ) ); + $media_post = get_post( $image_id ); + + if ( empty( $media_post ) ) { + continue; + } + + $media[ $media_post->ID ] = $media_post; + } + + $media = array_filter( $media ); + + return $media; + } + /** * Parse the post's content to obtain media items. * diff --git a/includes/classes/InternalConnections/NetworkSiteConnection.php b/includes/classes/InternalConnections/NetworkSiteConnection.php index 0428347f7..ee3d5a98f 100644 --- a/includes/classes/InternalConnections/NetworkSiteConnection.php +++ b/includes/classes/InternalConnections/NetworkSiteConnection.php @@ -180,7 +180,7 @@ public function push( $post, $args = array() ) { */ if ( apply_filters( 'dt_push_post_media', true, $new_post_id, $post_media, $post_id, $args, $this ) ) { Utils\set_media( $new_post_id, $post_media, [ 'use_filesystem' => true ] ); - }; + } $media_errors = get_transient( 'dt_media_errors_' . $new_post_id ); diff --git a/includes/utils.php b/includes/utils.php index b5f1c8bc9..a06b038d7 100644 --- a/includes/utils.php +++ b/includes/utils.php @@ -736,6 +736,8 @@ function set_media( $post_id, $media, $args = [] ) { $media = ( false !== $featured_key ) ? array( $media[ $featured_key ] ) : array(); } + $image_urls_to_update = []; + foreach ( $media as $media_item ) { $args['source_file'] = $media_item['source_file']; @@ -790,6 +792,11 @@ function set_media( $post_id, $media, $args = [] ) { set_meta( $image_id, $media_item['meta'] ); } + // Save the images that we need to try updating in the content. + if ( 'featured' !== $settings['media_handling'] ) { + $image_urls_to_update[ $image_id ] = $media_item; + } + // Transfer post properties wp_update_post( [ @@ -801,6 +808,11 @@ function set_media( $post_id, $media, $args = [] ) { ); } + // Update image URLs in content if needed. + if ( ! empty( $image_urls_to_update ) ) { + update_content_image_urls( (int) $post_id, $image_urls_to_update ); + } + if ( ! $found_featured_image ) { delete_post_meta( $post_id, '_thumbnail_id' ); } @@ -1039,6 +1051,176 @@ function process_media( $url, $post_id, $args = [] ) { return (int) $result; } +/** + * Find and update an image tag. + * + * @param string $content The post content. + * @param array $media_item The old media item details. + * @param int $image_id The new image ID. + * @return string + */ +function update_image_tag( string $content, array $media_item, int $image_id ) { + $processor = new \WP_HTML_Tag_Processor( $content ); + + while ( $processor->next_tag( 'img' ) ) { + $classes = explode( ' ', $processor->get_attribute( 'class' ) ?? ' ' ); + + // Only process the image that matches the old ID. + if ( + ! is_array( $classes ) || + ! in_array( 'wp-image-' . $media_item['id'], $classes, true ) + ) { + continue; + } + + // Try to determine the image size from the size class WordPress adds. + $image_size = 'full'; + $classes = explode( ' ', $processor->get_attribute( 'class' ) ?? [] ); + $size_classes = array_filter( + $classes, + function ( $image_class ) { + return false !== strpos( $image_class, 'size-' ); + } + ); + + if ( ! empty( $size_classes ) ) { + // If an image happens to have multiple size classes, just use the first. + $size_class = reset( $size_classes ); + $image_size = str_replace( 'size-', '', $size_class ); + } + + $src = wp_get_attachment_image_url( $image_id, $image_size ); + + // If the image size can't be found, try to get the full size. + if ( ! $src ) { + $src = wp_get_attachment_image_url( $image_id, 'full' ); + + // If we still don't have an image, skip this block. + if ( ! $src ) { + continue; + } + } + + $processor->set_attribute( 'src', $src ); + $processor->add_class( 'wp-image-' . $image_id ); + $processor->remove_class( 'wp-image-' . $media_item['id'] ); + $processor->remove_attribute( 'srcset' ); + $processor->remove_attribute( 'sizes' ); + } + + return $processor->get_updated_html(); +} + +/** + * Find and update an image block. + * + * @param array $blocks All blocks in a post. + * @param array $media_item The old media item details. + * @param int $image_id The new image ID. + * @return array + */ +function update_image_block( array $blocks, array $media_item, int $image_id ) { + // Find and update all image blocks that match the old image ID. + foreach ( $blocks as $key => $block ) { + // Recurse into inner blocks. + if ( ! empty( $block['innerBlocks'] ) ) { + $blocks[ $key ]['innerBlocks'] = update_image_block( $block['innerBlocks'], $media_item, $image_id ); + } + + // If the block is an image block and the ID matches, update the ID and URL. + if ( 'core/image' === $block['blockName'] && (int) $media_item['id'] === (int) $block['attrs']['id'] ) { + $image_size = $block['attrs']['sizeSlug'] ?? 'full'; + + $blocks[ $key ]['attrs']['id'] = $image_id; + + $processor = new \WP_HTML_Tag_Processor( $blocks[ $key ]['innerHTML'] ); + + // Use the HTML API to update the image src and class. + if ( $processor->next_tag( 'img' ) ) { + $src = wp_get_attachment_image_url( $image_id, $image_size ); + + // If the image size can't be found, try to get the full size. + if ( ! $src ) { + $src = wp_get_attachment_image_url( $image_id, 'full' ); + + // If we still don't have an image, skip this block. + if ( ! $src ) { + continue; + } + } + + $processor->set_attribute( 'src', $src ); + $processor->add_class( 'wp-image-' . $image_id ); + $processor->remove_class( 'wp-image-' . $media_item['id'] ); + + $blocks[ $key ]['innerHTML'] = $processor->get_updated_html(); + $blocks[ $key ]['innerContent'][0] = $processor->get_updated_html(); + } + } + } + + return $blocks; +} + +/** + * Update all old image URLs with the new ones. + * + * @param int $post_id The post ID. + * @param array $images The old image details. + */ +function update_content_image_urls( int $post_id, array $images ) { + $dt_post = new DistributorPost( $post_id ); + + if ( ! $dt_post ) { + return; + } + + /** + * Filter whether image URLS should be updated in the content. + * + * @since x.x.x + * @hook dt_update_content_image_urls + * + * @param {bool} true Whether image URLs should be updated. Default `true`. + * @param {int} $post_id The post ID. + * @param {array} $images The old image details. + * + * @return {bool} Whether image URLs should be updated. + */ + if ( ! apply_filters( 'dt_update_content_image_urls', true, $post_id, $images ) ) { + return; + } + + $content = $dt_post->post->post_content; + $has_blocks = $dt_post->has_blocks(); + + foreach ( $images as $image_id => $media_item ) { + // Process block and classic editor content differently. + if ( $has_blocks ) { + $blocks = parse_blocks( $content ); + + // Update the image block attributes. + $updated_blocks = update_image_block( $blocks, $media_item, $image_id ); + $content = serialize_blocks( $updated_blocks ); + } else { + $content = update_image_tag( $content, $media_item, $image_id ); + } + } + + // No need to update if the content wasn't modified. + if ( $content === $dt_post->post->post_content ) { + return; + } + + // Update the post content. + wp_update_post( + [ + 'ID' => $post_id, + 'post_content' => $content, + ] + ); +} + /** * Get existing media ID based on the original source URL and original media ID. * diff --git a/tests/bin/initialize.sh b/tests/bin/initialize.sh index 7b15041c6..120183b9f 100755 --- a/tests/bin/initialize.sh +++ b/tests/bin/initialize.sh @@ -3,6 +3,7 @@ set -e wp-env run tests-wordpress chmod -c ugo+w /var/www/html wp-env run tests-cli wp rewrite structure '/%postname%/' --hard +wp-env run tests-cli wp plugin deactivate classic-editor status=0 wp-env run tests-cli wp site list || status=$? diff --git a/tests/cypress/e2e/images-classic-editor.test.js b/tests/cypress/e2e/images-classic-editor.test.js new file mode 100644 index 000000000..14c2d3bad --- /dev/null +++ b/tests/cypress/e2e/images-classic-editor.test.js @@ -0,0 +1,174 @@ +const { randomName } = require( '../support/functions' ); + +describe( '[Classic Editor] Image distribution tests', () => { + let externalConnectionOneToTwo, externalConnectionTwoToOne; + const attachImages = () => { + cy.get( '#postimagediv a#set-post-thumbnail' ).click(); + cy.get( '.media-menu-item' ).contains( 'Media Library' ).click(); + cy.get( '.attachments-browser .attachment' ).first().click(); + cy.get( '.media-button-select' ).click(); + cy.get( '#postimagediv img' ).should( 'be.visible' ); + + cy.get( 'button#insert-media-button' ).click(); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait( 1000 ); + cy.get( '.attachments-wrapper .attachments li.attachment:visible' ) + .eq( 1 ) + .click(); + cy.get( '.button-primary.media-button-insert' ).click(); + }; + + before( () => { + // Prevent uncaught exceptions from failing the test on WP trunk. + Cypress.on( 'uncaught:exception', () => { + return false; + } ); + cy.login(); + cy.networkActivatePlugin( 'distributor' ); + cy.networkActivatePlugin( 'classic-editor' ); + cy.networkActivatePlugin( 'json-basic-authentication' ); + + externalConnectionOneToTwo = 'Site Two ' + randomName(); + cy.createExternalConnection( + externalConnectionOneToTwo, + 'http://localhost/second/wp-json' + ); + + externalConnectionTwoToOne = 'Site One ' + randomName(); + cy.createExternalConnection( + externalConnectionTwoToOne, + 'http://localhost/wp-json', + 'admin', + 'password', + 'second' + ); + + cy.visit( 'wp-admin/admin.php?page=distributor-settings' ); + cy.get( '.form-table input[type="checkbox"]' ).first().check(); + cy.get( 'input[type="radio"]' ).check( 'attached' ); + cy.get( '#submit' ).click(); + + cy.visit( '/second/wp-admin/admin.php?page=distributor-settings' ); + cy.get( '.form-table input[type="checkbox"]' ).first().check(); + cy.get( 'input[type="radio"]' ).check( 'attached' ); + cy.get( '#submit' ).click(); + + cy.visit( '/wp-admin/upload.php?mode=grid' ); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait( 2000 ); + cy.get( 'body' ).then( ( $body ) => { + if ( $body.find( 'ul.attachments li' ).length === 0 ) { + cy.uploadImage( './assets/img/banner-772x250.png' ); + cy.uploadImage( './assets/img/banner-1544x500.png' ); + } + } ); + } ); + + after( () => { + cy.networkDeactivatePlugin( 'classic-editor' ); + } ); + + it( 'Should distribute images when pushing to network connections.', () => { + const postTitle = 'Post to push ' + randomName(); + + cy.classicCreatePost( { + title: postTitle, + beforeSave: attachImages, + } ).then( ( sourcePostID ) => { + cy.distributorPushPost( + sourcePostID, + 'second', + '', + 'publish', + false, + true + ).then( ( distributedPost ) => { + cy.postContains( + distributedPost.distributedPostId, + ' src="http://localhost/wp-content/uploads/sites/2/', // For the push to network connection, image url will be generated from the source site, this is due to https://core.trac.wordpress.org/ticket/25650 + 'http://localhost/second/' + ); + } ); + } ); + } ); + + it( 'Should distribute images when pulling from network connections.', () => { + const postTitle = 'Post to pull ' + randomName(); + + cy.classicCreatePost( { + title: postTitle, + beforeSave: attachImages, + } ).then( ( sourcePostID ) => { + cy.distributorPullPost( + sourcePostID, + 'second', + '', + 'localhost' + ).then( ( distributedPost ) => { + const matches = + distributedPost.distributedEditUrl.match( /post=(\d+)/ ); + let distributedPostId; + if ( matches ) { + distributedPostId = matches[ 1 ]; + } + cy.postContains( + distributedPostId, + ' src="http://localhost/second/wp-content/uploads/', + 'http://localhost/second/' + ); + } ); + } ); + } ); + + it( 'Should distribute images when pushing to external connections.', () => { + const postTitle = 'Post to push ' + randomName(); + + cy.classicCreatePost( { + title: postTitle, + beforeSave: attachImages, + } ).then( ( sourcePostID ) => { + cy.distributorPushPost( + sourcePostID, + externalConnectionOneToTwo, + '', + 'publish', + false, + true + ).then( ( distributedPost ) => { + cy.postContains( + distributedPost.distributedPostId, + ' src="http://localhost/second/wp-content/uploads/', + 'http://localhost/second/' + ); + } ); + } ); + } ); + + it( 'Should distribute image when pulling from external connections.', () => { + const postTitle = 'Post to pull ' + randomName(); + + cy.classicCreatePost( { + title: postTitle, + beforeSave: attachImages, + } ).then( ( sourcePostID ) => { + cy.distributorPullPost( + sourcePostID, + '/second/', // Pull to second site. + '', // From primary site. + externalConnectionTwoToOne + ).then( ( distributedPost ) => { + const matches = + distributedPost.distributedEditUrl.match( /post=(\d+)/ ); + let distributedPostId; + if ( matches ) { + distributedPostId = matches[ 1 ]; + } + cy.postContains( + distributedPostId, + ' src="http://localhost/second/wp-content/uploads/', + 'http://localhost/second/' + ); + } ); + } ); + } ); +} ); diff --git a/tests/cypress/e2e/images.test.js b/tests/cypress/e2e/images.test.js new file mode 100644 index 000000000..8ac70aa0e --- /dev/null +++ b/tests/cypress/e2e/images.test.js @@ -0,0 +1,192 @@ +const { randomName } = require( '../support/functions' ); + +describe( '[Block Editor] Image distribution tests', () => { + // prevent uncaught exceptions from failing the test on WP trunk. + let externalConnectionOneToTwo, externalConnectionTwoToOne; + const attachImages = () => { + cy.openDocumentSettingsSidebar( 'Post' ); + cy.get( 'body' ).then( ( $body ) => { + if ( + $body.find( + '.editor-post-featured-image .editor-post-featured-image__toggle' + ).length + ) { + cy.get( '.editor-post-featured-image__toggle' ).click(); + cy.get( '.media-menu-item' ) + .contains( 'Media Library' ) + .click(); + cy.get( '.attachments-browser .attachment' ).first().click(); + cy.get( '.media-button-select' ).click(); + } else { + cy.openDocumentSettingsPanel( 'Featured Image' ); + cy.get( '.editor-post-featured-image__toggle' ).click(); + cy.get( '.media-menu-item' ) + .contains( 'Media Library' ) + .click(); + cy.get( '.attachments-browser .attachment' ).first().click(); + cy.get( '.media-button-select' ).click(); + } + } ); + + cy.insertBlock( 'core/image', 'Image' ).then( ( id ) => { + cy.getBlockEditor() + .find( `#${ id } button.components-button` ) + .contains( 'Media Library' ) + .click(); + cy.get( '.attachments-browser .attachment' ).eq( 1 ).click(); + cy.get( '.media-button-select' ).click(); + cy.getBlockEditor().find( `#${ id } img` ).should( 'be.visible' ); + } ); + }; + + before( () => { + // Prevent uncaught exceptions from failing the test on WP trunk. + Cypress.on( 'uncaught:exception', () => { + return false; + } ); + cy.login(); + cy.networkDeactivatePlugin( 'classic-editor' ); + cy.networkActivatePlugin( 'distributor' ); + cy.networkActivatePlugin( 'json-basic-authentication' ); + + externalConnectionOneToTwo = 'Site Two ' + randomName(); + cy.createExternalConnection( + externalConnectionOneToTwo, + 'http://localhost/second/wp-json' + ); + + externalConnectionTwoToOne = 'Site One ' + randomName(); + cy.createExternalConnection( + externalConnectionTwoToOne, + 'http://localhost/wp-json', + 'admin', + 'password', + 'second' + ); + + cy.visit( 'wp-admin/admin.php?page=distributor-settings' ); + cy.get( '.form-table input[type="checkbox"]' ).first().check(); + cy.get( 'input[type="radio"]' ).check( 'attached' ); + cy.get( '#submit' ).click(); + + cy.visit( '/second/wp-admin/admin.php?page=distributor-settings' ); + cy.get( '.form-table input[type="checkbox"]' ).first().check(); + cy.get( 'input[type="radio"]' ).check( 'attached' ); + cy.get( '#submit' ).click(); + + cy.visit( '/wp-admin/upload.php?mode=grid' ); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait( 2000 ); + cy.get( 'body' ).then( ( $body ) => { + if ( $body.find( 'ul.attachments' ).length === 0 ) { + cy.uploadImage( './assets/img/banner-772x250.png' ); + cy.uploadImage( './assets/img/banner-1544x500.png' ); + } + } ); + } ); + + it( 'Should distribute images when pushing to network connections.', () => { + const postTitle = 'Post to push ' + randomName(); + + cy.createPost( { + title: postTitle, + beforeSave: attachImages, + } ).then( ( sourcePost ) => { + cy.distributorPushPost( + sourcePost.id, + 'second', + '', + 'publish' + ).then( ( distributedPost ) => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait( 1000 ); + cy.postContains( + distributedPost.distributedPostId, + ' { + const postTitle = 'Post to push ' + randomName(); + + cy.createPost( { title: postTitle, beforeSave: attachImages } ).then( + ( sourcePost ) => { + cy.distributorPushPost( + sourcePost.id, + externalConnectionOneToTwo, + '', + 'publish' + ).then( ( distributedPost ) => { + cy.postContains( + distributedPost.distributedPostId, + ' { } ); } ); +Cypress.Commands.add( 'networkDeactivatePlugin', ( slug ) => { + cy.visit( '/wp-admin/network/plugins.php' ); + cy.get( `#the-list tr[data-slug="${ slug }"]` ).then( ( $pluginRow ) => { + if ( $pluginRow.find( '.deactivate > a' ).length > 0 ) { + cy.get( `#the-list tr[data-slug="${ slug }"] .deactivate > a` ) + .should( 'have.text', 'Network Deactivate' ) + .click(); + } + } ); +} ); + Cypress.Commands.add( 'networkEnableTheme', ( slug ) => { cy.visit( '/wp-admin/network/themes.php' ); cy.get( `#the-list tr[data-slug="${ slug }"]` ).then( ( $themeRow ) => { @@ -130,7 +141,8 @@ Cypress.Commands.add( toConnectionName, fromBlogSlug = '', postStatus = 'publish', - external = false + external = false, + classicEditor = false ) => { const info = { originalEditUrl: @@ -158,9 +170,11 @@ Cypress.Commands.add( info.originalFrontUrl = originalFrontUrl; } ); - cy.disableFullscreenEditor(); - cy.dismissNUXTip(); - cy.closeWelcomeGuide(); + if ( ! classicEditor ) { + cy.disableFullscreenEditor(); + cy.dismissNUXTip(); + cy.closeWelcomeGuide(); + } cy.get( '#wp-admin-bar-distributor' ) .contains( 'Distributor' ) @@ -297,3 +311,21 @@ Cypress.Commands.add( 'postContains', ( postId, content, siteUrl ) => { } cy.wpCli( cliCommand ).its( 'stdout' ).should( 'contain', content ); } ); + +Cypress.Commands.add( 'uploadImage', ( imagePath ) => { + cy.visit( '/wp-admin/media-new.php' ); + cy.get( '#plupload-upload-ui' ).should( 'exist' ); + cy.get( '#plupload-upload-ui input[type=file]' ).selectFile( imagePath, { + force: true, + } ); + + cy.get( '#media-items .media-item a.edit-attachment', { + timeout: 20000, + } ).should( 'exist' ); + cy.get( '#media-items .media-item a.edit-attachment' ) + .invoke( 'attr', 'href' ) + .then( ( editLink = '' ) => { + const mediaId = editLink?.split( 'post=' )[ 1 ]?.split( '&' )[ 0 ]; + cy.wrap( mediaId ); + } ); +} ); diff --git a/tests/php/includes/common.php b/tests/php/includes/common.php index d7ba845c9..ea3e10088 100644 --- a/tests/php/includes/common.php +++ b/tests/php/includes/common.php @@ -242,6 +242,30 @@ public function __construct( $post ) { } } +/** + * Mock WP_HTML_Tag_Processor + */ +class WP_HTML_Tag_Processor { + protected $html; + + /** + * Constructor. + * + * @param string $html HTML to process. + */ + public function __construct( $html ) { + $this->html = $html; + } + + public function next_tag() { + return false; + } + + public function get_attribute() { + return null; + } +} + /** * Return testing friendly url *