diff --git a/admin/section/class-convertkit-admin-section-broadcasts.php b/admin/section/class-convertkit-admin-section-broadcasts.php index 6afa9f133..fcf879807 100644 --- a/admin/section/class-convertkit-admin-section-broadcasts.php +++ b/admin/section/class-convertkit-admin-section-broadcasts.php @@ -353,6 +353,45 @@ public function register_fields() { ) ); + add_settings_field( + 'export_greeting', + __( 'Greeting', 'convertkit' ), + array( $this, 'export_greeting_callback' ), + $this->settings_key, + $this->name . '-export', + array( + 'name' => 'export_greeting', + 'label_for' => 'export_greeting', + 'description' => __( 'Optional. Added to the start of draft broadcasts. Supports HTML and Liquid, for example: Hi {{ subscriber.first_name | default: "there" }},', 'convertkit' ), + ) + ); + + add_settings_field( + 'export_sign_off', + __( 'Sign-off', 'convertkit' ), + array( $this, 'export_sign_off_callback' ), + $this->settings_key, + $this->name . '-export', + array( + 'name' => 'export_sign_off', + 'label_for' => 'export_sign_off', + 'description' => __( 'Optional. Added to the end of draft broadcasts. Supports HTML and Liquid.', 'convertkit' ), + ) + ); + + add_settings_field( + 'export_web_link', + __( 'Read on the Web', 'convertkit' ), + array( $this, 'export_web_link_callback' ), + $this->settings_key, + $this->name . '-export', + array( + 'name' => 'export_web_link', + 'label_for' => $this->settings_key . '_export_web_link', + 'description' => __( 'Adds a link to read the post on your site to draft broadcasts. Only added once the post is published.', 'convertkit' ), + ) + ); + add_settings_field( 'no_styles', __( 'Disable Styles', 'convertkit' ), @@ -720,6 +759,76 @@ public function export_public_callback( $args ) { } + /** + * Renders the input for the Greeting setting. + * + * @since 3.4.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function export_greeting_callback( $args ) { + + // Output field. + $this->output_textarea_field( + $args['name'], + esc_textarea( $this->settings->export_greeting() ), + $args['description'], + array( + 'large-text', + 'enabled_export', + ) + ); + + } + + /** + * Renders the input for the Sign-off setting. + * + * @since 3.4.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function export_sign_off_callback( $args ) { + + // Output field. + $this->output_textarea_field( + $args['name'], + esc_textarea( $this->settings->export_sign_off() ), + $args['description'], + array( + 'large-text', + 'enabled_export', + ) + ); + + } + + /** + * Renders the input for the Read on the Web setting. + * + * @since 3.4.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function export_web_link_callback( $args ) { + + // Output field. + $this->output_select_field( + $args['name'], + $this->settings->export_web_link(), + array( + '' => esc_html__( 'None', 'convertkit' ), + 'top' => esc_html__( 'Top of broadcast', 'convertkit' ), + 'bottom' => esc_html__( 'Bottom of broadcast', 'convertkit' ), + ), + $args['description'], + array( + 'enabled_export', + ) + ); + + } + /** * Renders the input for the No Styles setting. * @@ -763,6 +872,13 @@ public function sanitize_settings( $settings ) { $settings['import_images'] = ''; } + // Remove disallowed HTML from the greeting and sign-off. + foreach ( array( 'export_greeting', 'export_sign_off' ) as $key ) { + if ( array_key_exists( $key, $settings ) ) { + $settings[ $key ] = wp_kses_post( $settings[ $key ] ); + } + } + // Merge settings with defaults. $settings = wp_parse_args( $settings, $this->settings->get_defaults() ); diff --git a/includes/class-convertkit-broadcasts-exporter.php b/includes/class-convertkit-broadcasts-exporter.php index 43cbdb1ed..87f809966 100644 --- a/includes/class-convertkit-broadcasts-exporter.php +++ b/includes/class-convertkit-broadcasts-exporter.php @@ -248,6 +248,9 @@ public function export_post_to_broadcast( $post_id ) { // Remove responsive image attributes, which email clients don't support. $content = $this->remove_responsive_image_attributes( $content ); + // Add the greeting, sign-off and link to read the Post on the web. + $content = $this->add_greeting_sign_off_and_web_link( $content, $post ); + // Convert relative URLs to absolute URLs, as emails have no site context. $content = links_add_base_url( $content, get_permalink( $post ) ); @@ -270,7 +273,8 @@ public function export_post_to_broadcast( $post_id ) { } // Define the Broadcast's parameters. - $args = array( + $thumbnail = $this->get_thumbnail( $post ); + $args = array( $post->post_title, $content, $post->post_excerpt, @@ -279,8 +283,8 @@ public function export_post_to_broadcast( $post_id ) { null, '', '', - '', - '', + $thumbnail['alt'], + $thumbnail['url'], $this->get_preview_text( $post ), $this->get_subscriber_filter(), ); @@ -390,6 +394,46 @@ private function get_existing_draft_broadcast_id( $api, $post_id ) { } + /** + * Returns the Post's featured image URL and alt text, to use as the Broadcast's thumbnail + * if the Broadcast is public. + * + * @since 3.4.8 + * + * @param WP_Post $post WordPress Post. + * @return array Thumbnail URL and alt text (blank if no thumbnail) + */ + private function get_thumbnail( $post ) { + + $thumbnail = array( + 'url' => '', + 'alt' => '', + ); + + // Bail if the Broadcast isn't public, as Kit only uses thumbnails for public Broadcasts. + if ( ! $this->broadcasts_settings->export_public() ) { + return $thumbnail; + } + + // Bail if the Post has no featured image. + $image_id = get_post_thumbnail_id( $post ); + if ( ! $image_id ) { + return $thumbnail; + } + + // Bail if the featured image's URL can't be fetched. + $url = wp_get_attachment_image_url( $image_id, 'full' ); + if ( ! $url ) { + return $thumbnail; + } + + return array( + 'url' => $url, + 'alt' => (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true ), + ); + + } + /** * Returns the Post's published date and time, if the Broadcast is public and the Post is published. * @@ -456,6 +500,57 @@ private function get_subscriber_filter() { } + /** + * Adds the greeting, sign-off and link to read the Post on the web to the given content, + * based on the Plugin's settings. + * + * @since 3.4.8 + * + * @param string $content HTML Content. + * @param WP_Post $post WordPress Post. + * @return string HTML Content + */ + private function add_greeting_sign_off_and_web_link( $content, $post ) { + + // Add the greeting and sign-off, converting any line breaks to paragraphs. + if ( $this->broadcasts_settings->export_greeting() ) { + $content = wpautop( $this->broadcasts_settings->export_greeting() ) . $content; + } + if ( $this->broadcasts_settings->export_sign_off() ) { + $content .= wpautop( $this->broadcasts_settings->export_sign_off() ); + } + + // Bail if no link to read the Post on the web is required, or the Post isn't published, as its link won't work. + if ( ! $this->broadcasts_settings->export_web_link() || $post->post_status !== 'publish' ) { + return $content; + } + + /** + * Define the text for the link to read the Post on the web, in Broadcasts created from WordPress Posts. + * + * @since 3.4.8 + * + * @param string $text Link text. + * @param WP_Post $post WordPress Post. + */ + $text = apply_filters( 'convertkit_broadcasts_exporter_web_link_text', __( 'Read this on the web', 'convertkit' ), $post ); + + // Build link. + $link = sprintf( + '

%s

', + esc_url( get_permalink( $post ) ), + esc_html( $text ) + ); + + // Add link to the top or bottom of the content. + if ( $this->broadcasts_settings->export_web_link() === 'top' ) { + return $link . $content; + } + + return $content . $link; + + } + /** * Removes `srcset` and `sizes` attributes from images in the given content. * diff --git a/includes/class-convertkit-settings-broadcasts.php b/includes/class-convertkit-settings-broadcasts.php index 27339bb06..f89b48a6b 100644 --- a/includes/class-convertkit-settings-broadcasts.php +++ b/includes/class-convertkit-settings-broadcasts.php @@ -203,6 +203,46 @@ public function export_public() { } + /** + * Returns the greeting to add to the start of Broadcasts created from WordPress Posts. + * + * @since 3.4.8 + * + * @return string HTML, which may include Liquid + */ + public function export_greeting() { + + return (string) $this->settings['export_greeting']; + + } + + /** + * Returns the sign-off to add to the end of Broadcasts created from WordPress Posts. + * + * @since 3.4.8 + * + * @return string HTML, which may include Liquid + */ + public function export_sign_off() { + + return (string) $this->settings['export_sign_off']; + + } + + /** + * Returns where to add a link to read the WordPress Post on the web, in Broadcasts + * created from WordPress Posts. + * + * @since 3.4.8 + * + * @return string Position (blank = none, top, bottom) + */ + public function export_web_link() { + + return ( in_array( $this->settings['export_web_link'], array( 'top', 'bottom' ), true ) ? $this->settings['export_web_link'] : '' ); + + } + /** * Returns whether Broadcasts should have their styles imported. * @@ -318,6 +358,19 @@ public function get_schema() { 'enum' => array( '', 'on' ), 'description' => __( 'Whether Broadcasts created from WordPress Posts are published to the web (Kit Creator Profile and Landing Pages).', 'convertkit' ), ), + 'export_greeting' => array( + 'type' => 'string', + 'description' => __( 'HTML to add to the start of Broadcasts created from WordPress Posts. Supports Liquid e.g. {{ subscriber.first_name }}.', 'convertkit' ), + ), + 'export_sign_off' => array( + 'type' => 'string', + 'description' => __( 'HTML to add to the end of Broadcasts created from WordPress Posts. Supports Liquid.', 'convertkit' ), + ), + 'export_web_link' => array( + 'type' => 'string', + 'enum' => array( '', 'top', 'bottom' ), + 'description' => __( 'Where to add a link to read the published WordPress Post on the web, in Broadcasts created from WordPress Posts. Blank for none.', 'convertkit' ), + ), 'no_styles' => array( 'type' => 'string', 'enum' => array( '', 'on' ), @@ -352,6 +405,9 @@ public function get_defaults() { 'enabled_export' => '', 'export_send_to' => '', 'export_public' => '', + 'export_greeting' => '', + 'export_sign_off' => '', + 'export_web_link' => '', 'no_styles' => '', ); @@ -378,6 +434,13 @@ public function get_defaults() { */ public function save( $settings ) { + // Remove disallowed HTML from the greeting and sign-off. + foreach ( array( 'export_greeting', 'export_sign_off' ) as $key ) { + if ( array_key_exists( $key, $settings ) ) { + $settings[ $key ] = wp_kses_post( $settings[ $key ] ); + } + } + update_option( self::SETTINGS_NAME, array_merge( $this->get(), $settings ) ); // Reload settings in class, to reflect changes. diff --git a/resources/backend/js/settings-conditional-display.js b/resources/backend/js/settings-conditional-display.js index 26027b946..04ede10f2 100644 --- a/resources/backend/js/settings-conditional-display.js +++ b/resources/backend/js/settings-conditional-display.js @@ -46,10 +46,10 @@ function convertKitConditionallyDisplaySettings(input) { return; } - // Show or hide this row if the input, select, link or span element within the row has the CSS class of the setting ID. + // Show or hide this row if the input, select, textarea, link or span element within the row has the CSS class of the setting ID. if ( row.querySelector( - `input.${input.id}, select.${input.id}, a.${input.id}, span.${input.id}` + `input.${input.id}, select.${input.id}, textarea.${input.id}, a.${input.id}, span.${input.id}` ) ) { row.style.display = input.checked ? '' : 'none'; diff --git a/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php b/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php index 664354f50..cbbcdaaea 100644 --- a/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php +++ b/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php @@ -265,6 +265,39 @@ public function testSaveSendToSegment(EndToEndTester $I) $I->seeInField('_wp_convertkit_settings_broadcasts[export_send_to]', $_ENV['CONVERTKIT_API_SEGMENT_NAME']); } + /** + * Tests that saving the Greeting, Sign-off and Read on the Web settings works. + * + * @since 3.4.8 + * + * @param EndToEndTester $I Tester. + */ + public function testSaveGreetingSignOffAndWebLink(EndToEndTester $I) + { + // Go to the Plugin's Broadcasts Screen. + $I->loadKitSettingsBroadcastsScreen($I); + + // Enable export, and define the Greeting, Sign-off and Read on the Web settings. + $I->checkOption('#enabled_export'); + $I->fillField('_wp_convertkit_settings_broadcasts[export_greeting]', 'Hi {{ subscriber.first_name | default: "there" }},'); + $I->fillField('_wp_convertkit_settings_broadcasts[export_sign_off]', 'Thanks'); + $I->selectOption('_wp_convertkit_settings_broadcasts[export_web_link]', 'Bottom of broadcast'); + + // Click the Save Changes button. + $I->click('Save Changes'); + + // Wait for the settings to save. + $I->waitForElementVisible('#setting-error-settings_updated'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Confirm that the settings saved, keeping Liquid and permitted HTML, and removing scripts. + $I->seeInField('_wp_convertkit_settings_broadcasts[export_greeting]', 'Hi {{ subscriber.first_name | default: "there" }},'); + $I->seeInField('_wp_convertkit_settings_broadcasts[export_sign_off]', 'Thanksalert(1)'); + $I->seeOptionIsSelected('_wp_convertkit_settings_broadcasts[export_web_link]', 'Bottom of broadcast'); + } + /** * Deactivate and reset Plugin(s) after each test, if the test passes. * We don't use _after, as this would provide a screenshot of the Plugin diff --git a/tests/Integration/BroadcastsExportTest.php b/tests/Integration/BroadcastsExportTest.php index 30e0cc91b..8c83b1f0b 100644 --- a/tests/Integration/BroadcastsExportTest.php +++ b/tests/Integration/BroadcastsExportTest.php @@ -396,6 +396,206 @@ public function testExportPublicDraftPost() $this->assertArrayNotHasKey( 'published_at', $this->request ); } + /** + * Test that the Greeting and Sign-off settings are added to the start and end of the Broadcast, + * keeping Liquid tags. + * + * @since 3.4.8 + */ + public function testExportGreetingAndSignOff() + { + // Define Greeting and Sign-off settings. + $this->saveBroadcastsSettings( + [ + 'export_greeting' => 'Hi {{ subscriber.first_name | default: "there" }},', + 'export_sign_off' => 'Thanks', + ] + ); + + // Create Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Greeting and Sign-off', + 'post_content' => '

Post content.

', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm the Greeting, content and Sign-off are in order. + $this->assertStringStartsWith( '

Hi {{ subscriber.first_name | default: "there" }},

', $this->request['content'] ); + $this->assertStringContainsString( '

Post content.

', $this->request['content'] ); + $this->assertStringEndsWith( '

Thanks

' . "\n", $this->request['content'] ); + } + + /** + * Test that disallowed HTML is removed from the Greeting and Sign-off settings when saved + * programmatically e.g. using MCP, and isn't included in the Broadcast. + * + * @since 3.4.8 + */ + public function testExportGreetingAndSignOffSanitizedOnSave() + { + // Define Greeting and Sign-off settings, including disallowed HTML. + $this->saveBroadcastsSettings( + [ + 'export_greeting' => 'Hi {{ subscriber.first_name }},', + 'export_sign_off' => 'Thanks', + ] + ); + + // Confirm the settings were stored without disallowed HTML. + $settings = new \ConvertKit_Settings_Broadcasts(); + $this->assertEquals( 'Hi {{ subscriber.first_name }},alert(1)', $settings->export_greeting() ); + $this->assertEquals( 'Thanksalert(2)', $settings->export_sign_off() ); + + // Create Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Greeting and Sign-off: Sanitized', + 'post_content' => '

Post content.

', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm no script tags are in the Broadcast. + $this->assertStringNotContainsString( 'request['content'] ); + } + + /** + * Test that a link to read the Post on the web is added to the bottom or top of the Broadcast, + * with link text that can be changed using a filter. + * + * @since 3.4.8 + */ + public function testExportWebLink() + { + // Create Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Web Link', + 'post_content' => '

Post content.

', + ] + ); + $link = '

Read this on the web

'; + + // Confirm the link is added to the bottom. + $this->saveBroadcastsSettings( [ 'export_web_link' => 'bottom' ] ); + $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertStringEndsWith( $link, $this->request['content'] ); + + // Confirm the link is added to the top. + $this->saveBroadcastsSettings( [ 'export_web_link' => 'top' ] ); + $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertStringStartsWith( $link, $this->request['content'] ); + + // Confirm the link text can be changed. + add_filter( + 'convertkit_broadcasts_exporter_web_link_text', + function () { + return 'View online'; + } + ); + $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertStringStartsWith( '

View online

', $this->request['content'] ); + } + + /** + * Test that a link to read the Post on the web isn't added when the Post isn't published. + * + * @since 3.4.8 + */ + public function testExportWebLinkNotAddedToDraftPost() + { + // Define Read on the Web setting. + $this->saveBroadcastsSettings( [ 'export_web_link' => 'bottom' ] ); + + // Create draft Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Web Link: Draft', + 'post_content' => '

Post content.

', + 'post_status' => 'draft', + ] + ); + + // Export Post. + $this->exporter->export_post_to_broadcast( $post_id ); + + // Confirm no link was added. + $this->assertStringNotContainsString( 'Read this on the web', $this->request['content'] ); + } + + /** + * Test that the Post's featured image is used as the Broadcast's thumbnail when the Make Public setting is enabled. + * + * @since 3.4.8 + */ + public function testExportFeaturedImageAsThumbnail() + { + // Enable Make Public setting. + $this->saveBroadcastsSettings( [ 'export_public' => 'on' ] ); + + // Create Post with a featured image. + $post_id = $this->createPostWithFeaturedImage( 'Kit: Export: Featured Image' ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm the featured image is the Broadcast's thumbnail. + $this->assertEquals( wp_get_attachment_image_url( get_post_thumbnail_id( $post_id ), 'full' ), $this->request['thumbnail_url'] ); + $this->assertEquals( 'Featured image alt text', $this->request['thumbnail_alt'] ); + } + + /** + * Test that the Post's featured image isn't sent when the Make Public setting is disabled, + * as Kit only uses thumbnails for public Broadcasts. + * + * @since 3.4.8 + */ + public function testExportFeaturedImageNotUsedWhenNotPublic() + { + // Create Post with a featured image. + $post_id = $this->createPostWithFeaturedImage( 'Kit: Export: Featured Image: Not Public' ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm no thumbnail was sent. + $this->assertArrayNotHasKey( 'thumbnail_url', $this->request ); + $this->assertArrayNotHasKey( 'thumbnail_alt', $this->request ); + } + + /** + * Creates a Post with a featured image that has alt text. + * + * @since 3.4.8 + * + * @param string $title Post title. + * @return int + */ + private function createPostWithFeaturedImage( $title ) + { + $post_id = $this->factory->post->create( + [ + 'post_title' => $title, + 'post_content' => '

Post content.

', + ] + ); + $image_id = $this->factory->attachment->create_upload_object( dirname( __DIR__ ) . '/Support/Data/icon.png', $post_id ); + update_post_meta( $image_id, '_wp_attachment_image_alt', 'Featured image alt text' ); + set_post_thumbnail( $post_id, $image_id ); + + return $post_id; + } + /** * Test that exporting a Post creates a Broadcast, storing its ID in the Post's meta. *