From ee60e22384d6b6cc3e202c6c31527ae7d47e3d1c Mon Sep 17 00:00:00 2001 From: leogermani Date: Tue, 19 Nov 2024 10:58:39 -0300 Subject: [PATCH 01/20] chore: remove dependabot pulls from changelog workflow (#152) --- .github/workflows/changelog.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 52534ece..85f67cbd 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -5,7 +5,7 @@ on: jobs: labeler: - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'trunk' + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'trunk' && github.event.pull_request.user.login != 'dependabot[bot]' permissions: contents: read pull-requests: write @@ -14,7 +14,7 @@ jobs: - uses: actions/labeler@v5 comment_pr: - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'trunk' + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'trunk' && github.event.pull_request.user.login != 'dependabot[bot]' permissions: contents: read pull-requests: write @@ -25,7 +25,7 @@ jobs: uses: thollander/actions-comment-pull-request@v3 with: message: | - Hey @${{ github.event.pull_request.assignee.login }}, good job getting this PR merged! :tada: + Hey @${{ github.event.pull_request.user.login }}, good job getting this PR merged! :tada: Now, the `needs-changelog` label has been added to it. From dc837d884ab4992a90e99347e363cd61116db770 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 12 Dec 2024 10:00:20 -0300 Subject: [PATCH 02/20] feat: content distribution - experimental (#168) Co-authored-by: Camilla Krag Jensen --- includes/class-accepted-actions.php | 2 + includes/class-content-distribution.php | 140 +++++++ includes/class-initializer.php | 1 + .../class-incoming-post.php | 394 ++++++++++++++++++ .../class-outgoing-post.php | 192 +++++++++ .../class-network-post-updated.php | 51 +++ includes/utils/class-network.php | 48 +++ newspack-network.php | 1 + phpcs.xml | 2 +- tests/unit-tests/test-incoming-post.php | 309 ++++++++++++++ tests/unit-tests/test-outgoing-post.php | 126 ++++++ 11 files changed, 1265 insertions(+), 1 deletion(-) create mode 100644 includes/class-content-distribution.php create mode 100644 includes/content-distribution/class-incoming-post.php create mode 100644 includes/content-distribution/class-outgoing-post.php create mode 100644 includes/incoming-events/class-network-post-updated.php create mode 100644 includes/utils/class-network.php create mode 100644 tests/unit-tests/test-incoming-post.php create mode 100644 tests/unit-tests/test-outgoing-post.php diff --git a/includes/class-accepted-actions.php b/includes/class-accepted-actions.php index b3ae46ac..767b8d44 100644 --- a/includes/class-accepted-actions.php +++ b/includes/class-accepted-actions.php @@ -40,6 +40,7 @@ class Accepted_Actions { 'network_manual_sync_user' => 'User_Manually_Synced', 'network_nodes_synced' => 'Nodes_Synced', 'newspack_network_membership_plan_updated' => 'Membership_Plan_Updated', + 'network_post_updated' => 'Network_Post_Updated', ]; /** @@ -61,5 +62,6 @@ class Accepted_Actions { 'network_nodes_synced', 'newspack_node_subscription_changed', 'newspack_network_membership_plan_updated', + 'network_post_updated', ]; } diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php new file mode 100644 index 00000000..991d2c83 --- /dev/null +++ b/includes/class-content-distribution.php @@ -0,0 +1,140 @@ +get_payload(); + } + } + + /** + * Incoming post inserted listener callback. + * + * @param int $post_id The post ID. + * @param boolean $is_linked Whether the post is unlinked. + * @param array $post_payload The post payload. + */ + public static function handle_incoming_post_inserted( $post_id, $is_linked, $post_payload ) { + return [ + 'origin' => [ + 'site_url' => $post_payload['site_url'], + 'post_id' => $post_payload['post_id'], + ], + 'destination' => [ + 'site_url' => get_bloginfo( 'url' ), + 'post_id' => $post_id, + 'is_linked' => $is_linked, + ], + ]; + } + + /** + * Get the post types that are allowed to be distributed across the network. + * + * @return array Array of post types. + */ + public static function get_distributed_post_types() { + /** + * Filters the post types that are allowed to be distributed across the network. + * + * @param array $post_types Array of post types. + */ + return apply_filters( 'newspack_network_distributed_post_types', [ 'post' ] ); + } + + /** + * Get a distributed post. + * + * @param WP_Post|int $post The post object or ID. + * + * @return Outgoing_Post|null The distributed post or null if not found. + */ + public static function get_distributed_post( $post ) { + $outgoing_post = new Outgoing_Post( $post ); + if ( ! $outgoing_post->is_distributed() ) { + return null; + } + return $outgoing_post; + } + + /** + * Manually trigger post distribution. + * + * @param WP_Post|Outgoing_Post|int $post The post object or ID. + * + * @return void + */ + public static function distribute_post( $post ) { + $data = self::handle_post_updated( $post ); + if ( $data ) { + Data_Events::dispatch( 'network_post_updated', $data ); + } + } +} diff --git a/includes/class-initializer.php b/includes/class-initializer.php index 984794db..3e45ac52 100644 --- a/includes/class-initializer.php +++ b/includes/class-initializer.php @@ -50,6 +50,7 @@ public static function init() { User_Manual_Sync::init(); Distributor_Customizations::init(); Esp_Metadata_Sync::init(); + Content_Distribution::init(); Synchronize_All::init(); Data_Backfill::init(); diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php new file mode 100644 index 00000000..9c09539a --- /dev/null +++ b/includes/content-distribution/class-incoming-post.php @@ -0,0 +1,394 @@ +get_error_message() ) ); + } + + $this->payload = $payload; + $this->network_post_id = $payload['config']['network_post_id']; + + $post = $this->query_post(); + if ( $post ) { + $this->ID = $post->ID; + $this->post = $post; + } + } + + /** + * Validate a payload. + * + * @param array $payload The payload to validate. + * + * @return WP_Error|null WP_Error if the payload is invalid, null otherwise. + */ + public static function get_payload_error( $payload ) { + if ( + ! is_array( $payload ) || + empty( $payload['post_id'] ) || + empty( $payload['config'] ) || + empty( $payload['post_data'] ) + ) { + return new WP_Error( 'invalid_post', __( 'Invalid post payload.', 'newspack-network' ) ); + } + + $config = $payload['config']; + + if ( empty( $config['network_post_id'] ) || empty( $config['site_urls'] ) ) { + return new WP_Error( 'not_distributed', __( 'Post is not configured for distribution.', 'newspack-network' ) ); + } + + $site_url = get_bloginfo( 'url' ); + if ( ! in_array( $site_url, $config['site_urls'], true ) ) { + return new WP_Error( 'not_distributed_to_site', __( 'Post is not configured for distribution on this site.', 'newspack-network' ) ); + } + } + + /** + * Get the stored payload for a post. + * + * @return array The stored payload. + */ + protected function get_post_payload() { + if ( ! $this->ID ) { + return []; + } + return get_post_meta( $this->ID, self::PAYLOAD_META, true ); + } + + /** + * Find the post from the payload's network post ID. + * + * @return WP_Post|null The post or null if not found. + */ + protected function query_post() { + $posts = get_posts( + [ + 'post_type' => Content_Distribution::get_distributed_post_types(), + 'post_status' => [ 'publish', 'pending', 'draft', 'auto-draft', 'future', 'private', 'inherit', 'trash' ], + 'posts_per_page' => 1, + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => self::NETWORK_POST_ID_META, + 'value' => $this->network_post_id, + ], + ], + ] + ); + if ( empty( $posts ) ) { + return null; + } + return $posts[0]; + } + + /** + * Get the post. + * + * @return WP_Post|null The post or null if not found. + */ + public function get_post() { + return $this->post; + } + + /** + * Set a post as unlinked. + * + * This will prevent the post from being updated when the distributed post is + * updated. + * + * @param bool $unlinked Whether to set the post as unlinked. Default is true. + * + * @return void|WP_Error Void on success, WP_Error on failure. + */ + public function set_unlinked( $unlinked = true ) { + if ( ! $this->ID ) { + return new WP_Error( 'invalid_post', __( 'Invalid post.', 'newspack-network' ) ); + } + update_post_meta( $this->ID, self::UNLINKED_META, (bool) $unlinked ); + + // If the post is being re-linked, update content. + if ( ! $unlinked ) { + self::insert(); + } + } + + /** + * Whether a post is unlinked. + * + * @return bool + */ + protected function is_unlinked() { + return get_post_meta( $this->ID, self::UNLINKED_META, true ); + } + + /** + * Whether a post is linked. + * + * This helper method is to improve readability. + * + * @return bool + */ + public function is_linked() { + return $this->ID && ! $this->is_unlinked(); + } + + /** + * Upload the thumbnail for a linked post. + */ + protected function upload_thumbnail() { + $thumbnail_url = $this->payload['post_data']['thumbnail_url']; + $payload = $this->get_post_payload(); + $current_thumbnail_id = get_post_thumbnail_id( $this->ID ); + + // Bail if the post has a thumbnail and the thumbnail URL is the same. + if ( + $current_thumbnail_id && + $payload && + $payload['post_data']['thumbnail_url'] === $thumbnail_url + ) { + return; + } + + if ( ! function_exists( 'media_sideload_image' ) ) { + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + $attachment_id = media_sideload_image( $thumbnail_url, $this->ID, '', 'id' ); + if ( is_wp_error( $attachment_id ) ) { + Debugger::log( 'Failed to upload featured image for post ' . $this->ID . ' with message: ' . $attachment_id->get_error_message() ); + return; + } + + update_post_meta( $attachment_id, self::ATTACHMENT_META, true ); + + set_post_thumbnail( $this->ID, $attachment_id ); + } + + /** + * Update the taxonomy terms for a linked post. + * + * @return void + */ + protected function update_taxonomy_terms() { + $data = $this->payload['post_data']['taxonomy']; + foreach ( $data as $taxonomy => $terms ) { + if ( ! taxonomy_exists( $taxonomy ) ) { + continue; + } + $term_ids = []; + foreach ( $terms as $term_data ) { + $term = get_term_by( 'slug', $term_data['slug'], $taxonomy, ARRAY_A ); + if ( ! $term ) { + $term = wp_insert_term( $term_data['name'], $taxonomy, [ 'slug' => $term_data['slug'] ] ); + if ( is_wp_error( $term ) ) { + continue; + } + $term = get_term_by( 'id', $term['term_id'], $taxonomy, ARRAY_A ); + } + $term_ids[] = $term['term_id']; + } + wp_set_object_terms( $this->ID, $term_ids, $taxonomy ); + } + } + + /** + * Update the object payload. + * + * @param array $payload The payload to update the object with. + * + * @return WP_Error|void WP_Error on failure. + */ + protected function update_payload( $payload ) { + $error = self::get_payload_error( $payload ); + if ( is_wp_error( $error ) ) { + return $error; + } + + // Do not update if network post ID mismatches. + if ( $this->network_post_id !== $payload['config']['network_post_id'] ) { + return new WP_Error( 'mismatched_post_id', __( 'Mismatched post ID.', 'newspack-network' ) ); + } + + $this->payload = $payload; + } + + /** + * Insert the incoming post. + * + * This will create or update an existing post and the stored payload. + * + * @param array $payload Optional payload to insert the post with. + * + * @return int|WP_Error The linked post ID or WP_Error on failure. + */ + public function insert( $payload = [] ) { + if ( ! empty( $payload ) ) { + $error = $this->update_payload( $payload ); + if ( is_wp_error( $error ) ) { + return $error; + } + } + + $post_data = $this->payload['post_data']; + $post_type = $post_data['post_type']; + + /** + * Do not insert if payload is older than the linked post's stored payload. + */ + $current_payload = $this->get_post_payload(); + if ( + ! empty( $current_payload ) && + $current_payload['post_data']['modified_gmt'] > $post_data['modified_gmt'] + ) { + return new WP_Error( 'old_modified_date', __( 'Linked post content is newer than the post payload.', 'newspack-network' ) ); + } + + $postarr = [ + 'ID' => $this->ID, + 'post_date_gmt' => $post_data['date_gmt'], + 'post_title' => $post_data['title'], + 'post_name' => $post_data['slug'], + 'post_content' => use_block_editor_for_post_type( $post_type ) ? + $post_data['raw_content'] : + $post_data['content'], + 'post_excerpt' => $post_data['excerpt'], + 'post_type' => $post_type, + ]; + + // New post, set post status. + if ( ! $this->ID ) { + $postarr['post_status'] = 'draft'; + } + + // Insert the post if it's linked or a new post. + if ( ! $this->ID || $this->is_linked() ) { + // Remove filters that may alter content updates. + remove_all_filters( 'content_save_pre' ); + + $post_id = wp_insert_post( $postarr, true ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + // The wp_insert_post() function might return `0` on failure. + if ( ! $post_id ) { + return new WP_Error( 'insert_error', __( 'Error inserting post.', 'newspack-network' ) ); + } + + // Update the object. + $this->ID = $post_id; + $this->post = get_post( $this->ID ); + + // Handle thumbnail. + $thumbnail_url = $post_data['thumbnail_url']; + if ( $thumbnail_url ) { + $this->upload_thumbnail(); + } else { + // Delete thumbnail for existing post if it's not set in the payload. + $current_thumbnail_id = get_post_thumbnail_id( $this->ID ); + if ( $current_thumbnail_id ) { + delete_post_thumbnail( $this->ID ); + } + } + + // Handle taxonomy terms. + $this->update_taxonomy_terms(); + } + + update_post_meta( $this->ID, self::PAYLOAD_META, $this->payload ); + update_post_meta( $this->ID, self::NETWORK_POST_ID_META, $this->network_post_id ); + + /** + * Fires after an incoming post is inserted. + * + * @param int $post_id The post ID. + * @param bool $is_linked Whether the post is linked. + * @param array $payload The post payload. + */ + do_action( 'newspack_network_incoming_post_inserted', $this->ID, $this->is_linked(), $this->payload ); + + return $this->ID; + } +} diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php new file mode 100644 index 00000000..ee259872 --- /dev/null +++ b/includes/content-distribution/class-outgoing-post.php @@ -0,0 +1,192 @@ +ID ) ) { + throw new \InvalidArgumentException( esc_html( __( 'Invalid post.', 'newspack-network' ) ) ); + } + + $this->post = $post; + } + + /** + * Get the post object. + * + * @return WP_Post The post object. + */ + public function get_post() { + return $this->post; + } + + /** + * Set the distribution configuration for a given post. + * + * @param int[] $site_urls Array of site URLs to distribute the post to. + * + * @return void|WP_Error Void on success, WP_Error on failure. + */ + public function set_config( $site_urls = [] ) { + $config = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); + if ( ! is_array( $config ) ) { + $config = []; + } + // Set post network ID. + if ( empty( $config['network_post_id'] ) ) { + $config['network_post_id'] = md5( $this->post->ID . get_bloginfo( 'url' ) ); + } + $config['site_urls'] = $site_urls; + update_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, $config ); + } + + /** + * Whether the post is distributed. Optionally provide a $site_url to check if + * the post is distributed to that site. + * + * @param string|null $site_url Optional site URL. + * + * @return bool + */ + public function is_distributed( $site_url = null ) { + $distributed_post_types = Content_Distribution::get_distributed_post_types(); + if ( ! in_array( $this->post->post_type, $distributed_post_types, true ) ) { + return false; + } + + $config = $this->get_config(); + if ( empty( $config['site_urls'] ) ) { + return false; + } + + if ( ! empty( $site_url ) ) { + return in_array( $site_url, $config['site_urls'], true ); + } + + return true; + } + + /** + * Get the distribution configuration for a given post. + * + * @return array The distribution configuration. + */ + public function get_config() { + $config = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); + if ( ! is_array( $config ) ) { + $config = []; + } + $config = wp_parse_args( + $config, + [ + 'site_urls' => [], + 'network_post_id' => '', + ] + ); + return $config; + } + + /** + * Get the post payload for distribution. + * + * @return array|WP_Error The post payload or WP_Error if the post is invalid. + */ + public function get_payload() { + $config = self::get_config(); + return [ + 'site_url' => get_bloginfo( 'url' ), + 'post_id' => $this->post->ID, + 'config' => $config, + 'post_data' => [ + 'title' => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ), + 'date_gmt' => $this->post->post_date_gmt, + 'modified_gmt' => $this->post->post_modified_gmt, + 'slug' => $this->post->post_name, + 'post_type' => $this->post->post_type, + 'raw_content' => $this->post->post_content, + 'content' => $this->get_processed_post_content(), + 'excerpt' => $this->post->post_excerpt, + 'taxonomy' => $this->get_post_taxonomy_terms(), + 'thumbnail_url' => get_the_post_thumbnail_url( $this->post->ID, 'full' ), + ], + ]; + } + + /** + * Get the processed post content for distribution. + * + * @return string The post content. + */ + protected function get_processed_post_content() { + global $wp_embed; + /** + * Remove autoembed filter so that actual URL will be pushed and not the generated markup. + */ + remove_filter( 'the_content', [ $wp_embed, 'autoembed' ], 8 ); + // Filter documented in WordPress core. + $post_content = apply_filters( 'the_content', $this->post->post_content ); + add_filter( 'the_content', [ $wp_embed, 'autoembed' ], 8 ); + return $post_content; + } + + /** + * Get post taxonomy terms for distribution. + * + * @return array The taxonomy term data. + */ + protected function get_post_taxonomy_terms() { + $taxonomies = get_object_taxonomies( $this->post->post_type, 'objects' ); + $data = []; + foreach ( $taxonomies as $taxonomy ) { + if ( ! $taxonomy->public ) { + continue; + } + $terms = get_the_terms( $this->post->ID, $taxonomy->name ); + if ( ! $terms ) { + continue; + } + $data[ $taxonomy->name ] = array_map( + function( $term ) { + return [ + 'name' => $term->name, + 'slug' => $term->slug, + ]; + }, + $terms + ); + } + return $data; + } +} diff --git a/includes/incoming-events/class-network-post-updated.php b/includes/incoming-events/class-network-post-updated.php new file mode 100644 index 00000000..3ddd8ca5 --- /dev/null +++ b/includes/incoming-events/class-network-post-updated.php @@ -0,0 +1,51 @@ +process_post_updated(); + } + + /** + * Process event in Node + * + * @return void + */ + public function process_in_node() { + $this->process_post_updated(); + } + + /** + * Process post updated + */ + protected function process_post_updated() { + $payload = (array) $this->get_data(); + + Debugger::log( 'Processing network_post_updated ' . wp_json_encode( $payload['config'] ) ); + + $error = Incoming_Post::get_payload_error( $payload ); + if ( is_wp_error( $error ) ) { + Debugger::log( 'Error processing network_post_updated: ' . $error->get_error_message() ); + return; + } + $incoming_post = new Incoming_Post( $payload ); + $incoming_post->insert(); + } +} diff --git a/includes/utils/class-network.php b/includes/utils/class-network.php new file mode 100644 index 00000000..28416413 --- /dev/null +++ b/includes/utils/class-network.php @@ -0,0 +1,48 @@ + untrailingslashit( $node->get_url() ), Hub_Nodes::get_all_nodes() ); + } + $urls = [ + Settings::get_hub_url(), + ...array_map( fn( $node ) => $node['url'], get_option( Hub_Node::HUB_NODES_SYNCED_OPTION, [] ) ), + ]; + + return array_map( 'untrailingslashit', $urls ); + } + + /** + * Check if a URL is networked. + * + * @param string $url URL to check. + * + * @return bool True if the URL is networked, false otherwise. + */ + public static function is_networked_url( string $url ): bool { + return in_array( untrailingslashit( $url ), self::get_networked_urls(), true ); + } +} diff --git a/newspack-network.php b/newspack-network.php index 6af2e40f..d728a8c1 100644 --- a/newspack-network.php +++ b/newspack-network.php @@ -6,6 +6,7 @@ * Author: Automattic * Author URI: https://newspack.com/ * License: GPL3 + * Requires PHP: 8.1 * Text Domain: newspack-network * Domain Path: /languages/ * diff --git a/phpcs.xml b/phpcs.xml index 6a6550f7..33246845 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -19,7 +19,7 @@ - + diff --git a/tests/unit-tests/test-incoming-post.php b/tests/unit-tests/test-incoming-post.php new file mode 100644 index 00000000..b946c74c --- /dev/null +++ b/tests/unit-tests/test-incoming-post.php @@ -0,0 +1,309 @@ + $this->node_1, + 'post_id' => 1, + 'config' => [ + 'enabled' => true, + 'site_urls' => [ $this->node_2 ], + 'network_post_id' => '1234567890abcdef1234567890abcdef', + ], + 'post_data' => [ + 'title' => 'Title', + 'date_gmt' => '2021-01-01 00:00:00', + 'modified_gmt' => '2021-01-01 00:00:00', + 'slug' => 'slug', + 'post_type' => 'post', + 'raw_content' => 'Content', + 'content' => '

Content

', + 'excerpt' => 'Excerpt', + 'thumbnail_url' => 'https://picsum.photos/id/1/300/300.jpg', + 'taxonomy' => [ + 'category' => [ + [ + 'name' => 'Category 1', + 'slug' => 'category-1', + ], + [ + 'name' => 'Category 2', + 'slug' => 'category-2', + ], + ], + 'post_tag' => [ + [ + 'name' => 'Tag 1', + 'slug' => 'tag-1', + ], + [ + 'name' => 'Tag 2', + 'slug' => 'tag-2', + ], + ], + ], + ], + ]; + } + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + // Set the site URL for the node that receives posts. + update_option( 'siteurl', $this->node_2 ); + update_option( 'home', $this->node_2 ); + + $this->incoming_post = new Incoming_Post( $this->get_sample_payload() ); + } + + /** + * Test get payload error + */ + public function test_validate_payload() { + $payload = $this->get_sample_payload(); + $error = Incoming_Post::get_payload_error( $payload ); + $this->assertFalse( is_wp_error( $error ) ); + + // Assert with invalid post. + $error = Incoming_Post::get_payload_error( [] ); + $this->assertTrue( is_wp_error( $error ) ); + $this->assertSame( 'invalid_post', $error->get_error_code() ); + + // Assert with invalid site. + update_option( 'siteurl', $this->node_1 ); + update_option( 'home', $this->node_1 ); + $error = Incoming_Post::get_payload_error( $payload ); + $this->assertTrue( is_wp_error( $error ) ); + $this->assertSame( 'not_distributed_to_site', $error->get_error_code() ); + + // Assert invalid config. + $payload['config'] = 'invalid'; + $error = Incoming_Post::get_payload_error( $payload ); + $this->assertTrue( is_wp_error( $error ) ); + $this->assertSame( 'not_distributed', $error->get_error_code() ); + } + + /** + * Test insert linked post. + */ + public function test_insert() { + $this->assertEmpty( $this->incoming_post->ID ); + + $post_id = $this->incoming_post->insert(); + + $this->assertNotEmpty( $this->incoming_post->ID ); + + $this->assertFalse( is_wp_error( $post_id ) ); + $this->assertGreaterThan( 0, $post_id ); + + $payload = $this->get_sample_payload(); + $this->assertSame( $payload['post_data']['date_gmt'], get_the_date( 'Y-m-d H:i:s', $post_id ) ); + $this->assertSame( $payload['post_data']['title'], get_the_title( $post_id ) ); + $this->assertSame( $payload['post_data']['raw_content'], get_post_field( 'post_content', $post_id ) ); + $this->assertNotEmpty( get_post_thumbnail_id( $post_id ) ); + + // Assert taxonomy terms. + $terms = wp_get_post_terms( $post_id, [ 'category', 'post_tag' ] ); + $this->assertSame( [ 'Category 1', 'Category 2', 'Tag 1', 'Tag 2' ], wp_list_pluck( $terms, 'name' ) ); + $this->assertSame( [ 'category-1', 'category-2', 'tag-1', 'tag-2' ], wp_list_pluck( $terms, 'slug' ) ); + } + + /** + * Test instantiation with post ID. + */ + public function test_instantiation_with_post_id() { + $this->incoming_post->insert(); + + $incoming_post = new Incoming_Post( $this->incoming_post->ID ); + + $this->assertInstanceOf( Incoming_Post::class, $incoming_post ); + $this->assertSame( $this->incoming_post->ID, $incoming_post->ID ); + } + + /** + * Test insert existing linked post. + */ + public function test_insert_existing_post() { + // Insert the linked post for the first time. + $post_id = $this->incoming_post->insert(); + + // Modify the post payload to simulate an update. + $payload = $this->get_sample_payload(); + $payload['post_data']['title'] = 'Updated Title'; + $payload['post_data']['content'] = 'Updated Content'; + $payload['post_data']['raw_content'] = 'Updated Content'; + + // Insert the updated linked post. + $new_linked_post = new Incoming_Post( $payload ); + $updated_post_id = $new_linked_post->insert(); + + // Assert that the updated post has the same ID as the original post. + $this->assertSame( $post_id, $updated_post_id ); + + // Assert that the updated post has the updated title and content. + $incoming_post = get_post( $post_id ); + $this->assertSame( 'Updated Title', $incoming_post->post_title ); + $this->assertSame( 'Updated Content', $incoming_post->post_content ); + } + + /** + * Test insert post when unlinked. + */ + public function test_insert_post_when_unlinked() { + $post_id = $this->incoming_post->insert(); + + // Unlink the post. + $this->incoming_post->set_unlinked(); + + // Update linked post with custom content. + $this->factory->post->update_object( + $post_id, + [ + 'post_title' => 'Custom Title', + 'post_content' => 'Custom Content', + ] + ); + + // Modify the post payload for an update. + $payload = $this->get_sample_payload(); + $payload['post_data']['title'] = 'Updated Title'; + $payload['post_data']['content'] = 'Updated Content'; + $payload['post_data']['raw_content'] = 'Updated Content'; + + // Insert the updated linked post. + $this->incoming_post->insert( $payload ); + + // Assert that the custom content was preserved. + $incoming_post = get_post( $post_id ); + $this->assertSame( 'Custom Title', $incoming_post->post_title ); + $this->assertSame( 'Custom Content', $incoming_post->post_content ); + } + + /** + * Test relink post. + */ + public function test_relink_post() { + $post_id = $this->incoming_post->insert(); + + // Unlink the post. + $this->incoming_post->set_unlinked(); + + // Update linked post with custom content. + $this->factory->post->update_object( + $post_id, + [ + 'post_title' => 'Custom Title', + 'post_content' => 'Custom Content', + ] + ); + + // Relink the post. + $this->incoming_post->set_unlinked( false ); + + // Assert that the post is linked and distributed content restored. + $payload = $this->get_sample_payload(); + $this->assertSame( $payload['post_data']['title'], get_the_title( $post_id ) ); + $this->assertSame( $payload['post_data']['raw_content'], get_post_field( 'post_content', $post_id ) ); + } + + /** + * Test insert post with old modified date. + */ + public function test_insert_post_with_old_modified_date() { + // Insert the linked post for the first time. + $post_id = $this->incoming_post->insert(); + + // Modify the post payload to simulate an update with an old modified date. + $payload = $this->get_sample_payload(); + $payload['post_data']['title'] = 'Old Title'; + $payload['post_data']['modified_gmt'] = '2020-01-01 00:00:00'; + + // Insert the updated linked post. + $error = $this->incoming_post->insert( $payload ); + + // Assert that the insertion returned an error. + $this->assertTrue( is_wp_error( $error ) ); + $this->assertSame( 'old_modified_date', $error->get_error_code() ); + + // Assert that the linked post kept the most recent title. + $incoming_post = get_post( $post_id ); + $this->assertSame( 'Title', $incoming_post->post_title ); + } + + /** + * Test update post thumbnail. + */ + public function test_update_post_thumbnail() { + $post_id = $this->incoming_post->insert(); + + $thumbnail_id = get_post_thumbnail_id( $post_id ); + + // Set a different thumbnail URL. + $payload = $this->get_sample_payload(); + $payload['post_data']['thumbnail_url'] = 'https://picsum.photos/id/2/300/300.jpg'; + + // Insert the linked post with the updated thumbnail. + $this->incoming_post->insert( $payload ); + + // Assert that the thumbnail was updated. + $new_thumbnail_id = get_post_thumbnail_id( $post_id ); + + $this->assertNotEmpty( $new_thumbnail_id ); + $this->assertNotEquals( $thumbnail_id, $new_thumbnail_id ); + } + + /** + * Test remove post thumbnail. + */ + public function test_remove_post_thumbnail() { + $post_id = $this->incoming_post->insert(); + + // Remove the thumbnail. + $payload = $this->get_sample_payload(); + $payload['post_data']['thumbnail_url'] = false; + + // Insert the linked post with the removed thumbnail. + $this->incoming_post->insert( $payload ); + + // Assert that the thumbnail was removed. + $thumbnail_id = get_post_thumbnail_id( $post_id ); + $this->assertEmpty( $thumbnail_id ); + } +} diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php new file mode 100644 index 00000000..f7802440 --- /dev/null +++ b/tests/unit-tests/test-outgoing-post.php @@ -0,0 +1,126 @@ +factory->post->create_and_get( [ 'post_type' => 'post' ] ); + $this->outgoing_post = new Outgoing_Post( $post ); + $this->outgoing_post->set_config( [ $this->node_url ] ); + } + + /** + * Test set post distribution configuration. + */ + public function test_set_config() { + $result = $this->outgoing_post->set_config( [ $this->node_url ] ); + $this->assertFalse( is_wp_error( $result ) ); + } + + /** + * Test get config. + */ + public function test_get_config() { + $config = $this->outgoing_post->get_config(); + $this->assertSame( [ $this->node_url ], $config['site_urls'] ); + $this->assertSame( 32, strlen( $config['network_post_id'] ) ); + } + + /** + * Test get config for non-distributed. + */ + public function test_get_config_for_non_distributed() { + $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); + $outgoing_post = new Outgoing_Post( $post ); + $config = $outgoing_post->get_config(); + $this->assertEmpty( $config['site_urls'] ); + $this->assertEmpty( $config['network_post_id'] ); + } + + /** + * Test set post distribution persists the network post ID. + */ + public function test_set_config_persists_network_post_id() { + $result = $this->outgoing_post->set_config( [ $this->node_url ] ); + $config = $this->outgoing_post->get_config(); + + // Update the post distribution. + $result = $this->outgoing_post->set_config( [ 'https://other-node.test' ] ); + $new_config = $this->outgoing_post->get_config(); + + $this->assertSame( $config['network_post_id'], $new_config['network_post_id'] ); + } + + /** + * Test is distributed. + */ + public function test_is_distributed() { + $this->assertTrue( $this->outgoing_post->is_distributed() ); + + // Update the post distribution. + $result = $this->outgoing_post->set_config( [] ); + $this->assertFalse( $this->outgoing_post->is_distributed() ); + + // Assert regular post. + $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); + $outgoing_post = new Outgoing_Post( $post ); + $this->assertFalse( $outgoing_post->is_distributed() ); + } + + /** + * Test get payload. + */ + public function test_get_payload() { + $payload = $this->outgoing_post->get_payload(); + $this->assertNotEmpty( $payload ); + + $config = $this->outgoing_post->get_config(); + + $this->assertSame( get_bloginfo( 'url' ), $payload['site_url'] ); + $this->assertSame( $this->outgoing_post->get_post()->ID, $payload['post_id'] ); + $this->assertEquals( $config, $payload['config'] ); + + // Assert that 'post_data' only contains the expected keys. + $post_data_keys = [ + 'title', + 'date_gmt', + 'modified_gmt', + 'slug', + 'post_type', + 'raw_content', + 'content', + 'excerpt', + 'thumbnail_url', + 'taxonomy', + ]; + $this->assertEmpty( array_diff( $post_data_keys, array_keys( $payload['post_data'] ) ) ); + $this->assertEmpty( array_diff( array_keys( $payload['post_data'] ), $post_data_keys ) ); + } +} From 353a3d880077f9060544c8e764f780535d1ba6b8 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 12 Dec 2024 15:35:59 -0300 Subject: [PATCH 03/20] feat(content-distribution): sync post meta (#163) --- includes/class-content-distribution.php | 35 ++++++++++++++ .../class-incoming-post.php | 34 +++++++++++++ .../class-outgoing-post.php | 48 +++++++++++++++++++ tests/unit-tests/test-incoming-post.php | 35 +++++++++++++- tests/unit-tests/test-outgoing-post.php | 31 ++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index 991d2c83..dbfcb31c 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -8,6 +8,7 @@ namespace Newspack_Network; use Newspack_Network\Content_Distribution\Outgoing_Post; +use Newspack_Network\Content_Distribution\Incoming_Post; use Newspack\Data_Events; use WP_Post; use WP_Error; @@ -109,6 +110,40 @@ public static function get_distributed_post_types() { return apply_filters( 'newspack_network_distributed_post_types', [ 'post' ] ); } + /** + * Get post meta keys that should be ignored on content distribution. + * + * @return string[] The reserved post meta keys. + */ + public static function get_reserved_post_meta_keys() { + $reserved_keys = [ + '_edit_lock', + '_edit_last', + '_thumbnail_id', + '_yoast_wpseo_primary_category', + ]; + + /** + * Filters the reserved post meta keys that should not be distributed. + * + * @param string[] $reserved_keys The reserved post meta keys. + * @param WP_Post $post The post object. + */ + $reserved_keys = apply_filters( 'newspack_network_content_distribution_reserved_post_meta_keys', $reserved_keys ); + + // Always preserve content distribution post meta. + return array_merge( + $reserved_keys, + [ + Outgoing_Post::DISTRIBUTED_POST_META, + Incoming_Post::NETWORK_POST_ID_META, + Incoming_Post::PAYLOAD_META, + Incoming_Post::UNLINKED_META, + Incoming_Post::ATTACHMENT_META, + ] + ); + } + /** * Get a distributed post. * diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index 9c09539a..a53cde79 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -211,6 +211,37 @@ public function is_linked() { return $this->ID && ! $this->is_unlinked(); } + /** + * Update the post meta for a linked post. + * + * @return void + */ + protected function update_post_meta() { + $reserved_keys = Content_Distribution::get_reserved_post_meta_keys(); + + // Clear existing post meta. + $post_meta = get_post_meta( $this->ID ); + foreach ( $post_meta as $meta_key => $meta_value ) { + if ( ! in_array( $meta_key, $reserved_keys, true ) ) { + delete_post_meta( $this->ID, $meta_key ); + } + } + + $data = $this->payload['post_data']['post_meta']; + + if ( empty( $data ) ) { + return; + } + + foreach ( $data as $meta_key => $meta_value ) { + if ( ! in_array( $meta_key, $reserved_keys, true ) ) { + foreach ( $meta_value as $value ) { + add_post_meta( $this->ID, $meta_key, $value ); + } + } + } + } + /** * Upload the thumbnail for a linked post. */ @@ -361,6 +392,9 @@ public function insert( $payload = [] ) { $this->ID = $post_id; $this->post = get_post( $this->ID ); + // Handle post meta. + $this->update_post_meta(); + // Handle thumbnail. $thumbnail_url = $post_data['thumbnail_url']; if ( $thumbnail_url ) { diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index ee259872..e76bc9ce 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -140,6 +140,7 @@ public function get_payload() { 'excerpt' => $this->post->post_excerpt, 'taxonomy' => $this->get_post_taxonomy_terms(), 'thumbnail_url' => get_the_post_thumbnail_url( $this->post->ID, 'full' ), + 'post_meta' => $this->get_post_meta(), ], ]; } @@ -189,4 +190,51 @@ function( $term ) { } return $data; } + + /** + * Get post meta for distribution. + * + * @return array The post meta data. + */ + protected function get_post_meta() { + $reserved_keys = Content_Distribution::get_reserved_post_meta_keys(); + + $meta = get_post_meta( $this->post->ID ); + + if ( empty( $meta ) ) { + return []; + } + + $meta = array_filter( + $meta, + function( $value, $key ) use ( $reserved_keys ) { + // Filter out reserved keys. + return ! in_array( $key, $reserved_keys, true ); + }, + ARRAY_FILTER_USE_BOTH + ); + + // Unserialize meta values and reformat the array. + $meta = array_reduce( + array_keys( $meta ), + function( $carry, $key ) use ( $meta ) { + $carry[ $key ] = array_map( + function( $v ) { + return maybe_unserialize( $v ); + }, + $meta[ $key ] + ); + return $carry; + }, + [] + ); + + /** + * Filters the post meta data for distribution. + * + * @param array $meta The post meta data. + * @param WP_Post $post The post object. + */ + return apply_filters( 'newspack_network_distributed_post_meta', $meta, $this->post ); + } } diff --git a/tests/unit-tests/test-incoming-post.php b/tests/unit-tests/test-incoming-post.php index b946c74c..44eb68f0 100644 --- a/tests/unit-tests/test-incoming-post.php +++ b/tests/unit-tests/test-incoming-post.php @@ -10,7 +10,7 @@ /** * Test the Incoming_Post class. */ -class TestLinkedPost extends WP_UnitTestCase { +class TestIncomingPost extends WP_UnitTestCase { /** * URL for node that distributes posts. * @@ -76,6 +76,11 @@ private function get_sample_payload() { ], ], ], + 'post_meta' => [ + 'single' => [ 'value' ], + 'array' => [ [ 'a' => 'b', 'c' => 'd' ] ], // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound + 'multiple' => [ 'value 1', 'value 2' ], + ], ], ]; } @@ -134,15 +139,24 @@ public function test_insert() { $this->assertGreaterThan( 0, $post_id ); $payload = $this->get_sample_payload(); + + // Assert post data. $this->assertSame( $payload['post_data']['date_gmt'], get_the_date( 'Y-m-d H:i:s', $post_id ) ); $this->assertSame( $payload['post_data']['title'], get_the_title( $post_id ) ); $this->assertSame( $payload['post_data']['raw_content'], get_post_field( 'post_content', $post_id ) ); + + // Assert featured image. $this->assertNotEmpty( get_post_thumbnail_id( $post_id ) ); // Assert taxonomy terms. $terms = wp_get_post_terms( $post_id, [ 'category', 'post_tag' ] ); $this->assertSame( [ 'Category 1', 'Category 2', 'Tag 1', 'Tag 2' ], wp_list_pluck( $terms, 'name' ) ); $this->assertSame( [ 'category-1', 'category-2', 'tag-1', 'tag-2' ], wp_list_pluck( $terms, 'slug' ) ); + + // Assert post meta. + $this->assertSame( 'value', get_post_meta( $post_id, 'single', true ) ); + $this->assertSame( [ 'a' => 'b', 'c' => 'd' ], get_post_meta( $post_id, 'array', true ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound + $this->assertSame( [ 'value 1', 'value 2' ], get_post_meta( $post_id, 'multiple' ) ); } /** @@ -306,4 +320,23 @@ public function test_remove_post_thumbnail() { $thumbnail_id = get_post_thumbnail_id( $post_id ); $this->assertEmpty( $thumbnail_id ); } + + /** + * Test post meta sync. + */ + public function test_post_meta_sync() { + $post_id = $this->incoming_post->insert(); + + // Unlink the post. + $this->incoming_post->set_unlinked(); + + // Update the post meta. + update_post_meta( $post_id, 'custom', 'new value' ); + + // Relink the post. + $this->incoming_post->set_unlinked( false ); + + // Assert that the custom post meta was removed on relink. + $this->assertEmpty( get_post_meta( $post_id, 'custom', true ) ); + } } diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index f7802440..28552aaf 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -119,8 +119,39 @@ public function test_get_payload() { 'excerpt', 'thumbnail_url', 'taxonomy', + 'post_meta', ]; $this->assertEmpty( array_diff( $post_data_keys, array_keys( $payload['post_data'] ) ) ); $this->assertEmpty( array_diff( array_keys( $payload['post_data'] ), $post_data_keys ) ); } + + /** + * Test post meta. + */ + public function test_post_meta() { + $post = $this->outgoing_post->get_post(); + $meta_key = 'test_meta_key'; + $meta_value = 'test_meta_value'; + update_post_meta( $post->ID, $meta_key, $meta_value ); + + $arr_meta_key = 'test_arr_meta_key'; + $arr_meta_value = [ 1, 2, 3 ]; + update_post_meta( $post->ID, $arr_meta_key, $arr_meta_value ); + + $multiple_meta_key = 'test_multiple_meta_key'; + add_post_meta( $post->ID, $multiple_meta_key, 'a' ); + add_post_meta( $post->ID, $multiple_meta_key, 'b' ); + + $payload = $this->outgoing_post->get_payload(); + $this->assertArrayHasKey( $meta_key, $payload['post_data']['post_meta'] ); + + $this->assertSame( $meta_value, $payload['post_data']['post_meta'][ $meta_key ][0] ); + + $this->assertArrayHasKey( $arr_meta_key, $payload['post_data']['post_meta'] ); + $this->assertSame( $arr_meta_value, $payload['post_data']['post_meta'][ $arr_meta_key ][0] ); + + $this->assertArrayHasKey( $multiple_meta_key, $payload['post_data']['post_meta'] ); + $this->assertSame( 'a', $payload['post_data']['post_meta'][ $multiple_meta_key ][0] ); + $this->assertSame( 'b', $payload['post_data']['post_meta'][ $multiple_meta_key ][1] ); + } } From 7a43b863cd11eadade73aabd060110c27576c6d4 Mon Sep 17 00:00:00 2001 From: Camilla Krag Jensen Date: Thu, 12 Dec 2024 20:58:08 +0100 Subject: [PATCH 04/20] feat(content-distribution): add CLI command for distribute post (#159) * feat: content distribution class * feat: insert linked post * refactor: use site url instead of ID * chore: lint * feat: return errors on post insert * chore: lint * feat: support webhooks request priority * feat: unlink functionality and unit tests * chore: lint * feat: introduce 'linked_post_inserted' hook and listener * chore: lint * fix: listener hook name * fix: typo * chore: better docblocks * chore: remove redundant code * test: persist post hash * test: remove unnecessary assertion * refactor: distribute_post() to use handle_post_updated() method * refactor: post hash is now network post id * chore: update comment * chore: Add CLI command for distribute * feat: content distribution class (#155) * feat: content distribution class * feat: insert linked post * refactor: use site url instead of ID * chore: lint * feat: return errors on post insert * chore: lint * feat: support webhooks request priority * feat: unlink functionality and unit tests * chore: lint * feat: introduce 'linked_post_inserted' hook and listener * chore: lint * fix: listener hook name * fix: typo * chore: better docblocks * chore: remove redundant code * test: persist post hash * test: remove unnecessary assertion * refactor: distribute_post() to use handle_post_updated() method * refactor: post hash is now network post id * chore: update comment * feat(content-distribution): prevent older content updating linked posts (#156) * feat(content-distribution): handle post thumbnail (#157) * Add network util class * Almost done except for a few todos * refactor: OOP for distributed and linked posts (#160) * chore: Require php 8.1 * chore: Update to use util class Also use new incoming/outgoing classes * fix: Include correct classname The old classname "Linked_Post" was still used instead of "Incoming_Post" causing sync to fail. ## How to test Probably eyeballing is enough * chore: Add validation of outgoing post Also move inclusion of the content distribution CLI class to better respect the flag we are introdocuing * chore: phpcs * fix(content-distribution): debug post update and remove deprecated config (#165) * fix: Don't return payload on all posts * fix: post handling * chore: move try-catch * chore: Move check for networked urls * Move more * Move things again * chore: Add a mock networked node * chore: add one more node to the mock nodes * Add a test --------- Co-authored-by: Miguel Peixe --- includes/class-content-distribution.php | 19 ++-- includes/content-distribution/class-cli.php | 104 ++++++++++++++++++ .../class-outgoing-post.php | 42 ++++++- tests/unit-tests/test-outgoing-post.php | 69 ++++++++---- 4 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 includes/content-distribution/class-cli.php diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index dbfcb31c..c9f791f9 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -7,11 +7,11 @@ namespace Newspack_Network; -use Newspack_Network\Content_Distribution\Outgoing_Post; -use Newspack_Network\Content_Distribution\Incoming_Post; use Newspack\Data_Events; +use Newspack_Network\Content_Distribution\CLI; +use Newspack_Network\Content_Distribution\Incoming_Post; +use Newspack_Network\Content_Distribution\Outgoing_Post; use WP_Post; -use WP_Error; /** * Main class for content distribution @@ -26,6 +26,7 @@ public static function init() { if ( ! defined( 'NEWPACK_NETWORK_CONTENT_DISTRIBUTION' ) || ! NEWPACK_NETWORK_CONTENT_DISTRIBUTION ) { return; } + CLI::init(); add_action( 'init', [ __CLASS__, 'register_listeners' ] ); add_filter( 'newspack_webhooks_request_priority', [ __CLASS__, 'webhooks_request_priority' ], 10, 2 ); } @@ -73,6 +74,8 @@ public static function handle_post_updated( $post ) { if ( $post ) { return $post->get_payload(); } + + return null; } /** @@ -149,14 +152,16 @@ public static function get_reserved_post_meta_keys() { * * @param WP_Post|int $post The post object or ID. * - * @return Outgoing_Post|null The distributed post or null if not found. + * @return Outgoing_Post|null The distributed post or null if not found, or we couldn't create one. */ public static function get_distributed_post( $post ) { - $outgoing_post = new Outgoing_Post( $post ); - if ( ! $outgoing_post->is_distributed() ) { + try { + $outgoing_post = new Outgoing_Post( $post ); + } catch ( \InvalidArgumentException ) { return null; } - return $outgoing_post; + + return $outgoing_post->is_distributed() ? $outgoing_post : null; } /** diff --git a/includes/content-distribution/class-cli.php b/includes/content-distribution/class-cli.php new file mode 100644 index 00000000..3780bc43 --- /dev/null +++ b/includes/content-distribution/class-cli.php @@ -0,0 +1,104 @@ + __( 'Distribute a post to all the network or the specified sites' ), + 'synopsis' => [ + [ + 'type' => 'positional', + 'name' => 'post-id', + 'description' => sprintf( + 'The ID of the post to distribute. Supported post types are: %s', + implode( + ', ', + Content_Distribution::get_distributed_post_types() + ) + ), + 'optional' => false, + 'repeating' => false, + ], + [ + 'type' => 'assoc', + 'name' => 'sites', + 'description' => __( "Networked site url(s) comma separated to distribute the post to – or 'all' to distribute to all sites in the network." ), + 'optional' => false, + ], + ], + ] + ); + } + + /** + * Callback for the `newspack-network distribute post` command. + * + * @param array $pos_args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @throws ExitException If something goes wrong. + */ + public function cmd_distribute_post( array $pos_args, array $assoc_args ): void { + $post_id = $pos_args[0]; + if ( ! is_numeric( $post_id ) ) { + WP_CLI::error( 'Post ID must be a number.' ); + } + + if ( 'all' === $assoc_args['sites'] ) { + $sites = Network::get_networked_urls(); + } else { + $sites = array_map( + fn( $site ) => untrailingslashit( trim( $site ) ), + explode( ',', $assoc_args['sites'] ) + ); + } + + try { + $outgoing_post = Content_Distribution::get_distributed_post( $post_id ) ?? new Outgoing_Post( $post_id ); + $config = $outgoing_post->set_config( $sites ); + if ( is_wp_error( $config ) ) { + WP_CLI::error( $config->get_error_message() ); + } + + Content_Distribution::distribute_post( $outgoing_post ); + WP_CLI::success( sprintf( 'Post with ID %d is distributed to %d sites: %s', $post_id, count( $config['site_urls'] ), implode( ', ', $config['site_urls'] ) ) ); + + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } +} diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index e76bc9ce..a02feabe 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -8,8 +8,9 @@ namespace Newspack_Network\Content_Distribution; use Newspack_Network\Content_Distribution; -use WP_Post; +use Newspack_Network\Utils\Network; use WP_Error; +use WP_Post; /** * Outgoing Post Class. @@ -40,6 +41,11 @@ public function __construct( $post ) { throw new \InvalidArgumentException( esc_html( __( 'Invalid post.', 'newspack-network' ) ) ); } + if ( ! in_array( $post->post_type, Content_Distribution::get_distributed_post_types() ) ) { + /* translators: unsupported post type for content distribution */ + throw new \InvalidArgumentException( esc_html( sprintf( __( 'Post type %s is not supported as a distributed outgoing post.', 'newspack-network' ), $post->post_type ) ) ); + } + $this->post = $post; } @@ -57,9 +63,27 @@ public function get_post() { * * @param int[] $site_urls Array of site URLs to distribute the post to. * - * @return void|WP_Error Void on success, WP_Error on failure. + * @return array|WP_Error Config array on success, WP_Error on failure. */ - public function set_config( $site_urls = [] ) { + public function set_config( $site_urls ) { + if ( empty( $site_urls ) ) { + return new WP_Error( 'config_no_site_urls', __( 'No site URLs provided.', 'newspack-network' ) ); + } + + $networked_urls = Network::get_networked_urls(); + $urls_not_in_network = array_diff( $site_urls, $networked_urls ); + if ( ! empty( $urls_not_in_network ) ) { + return new WP_Error( + 'config_non_networked_urls', + sprintf( + /* translators: %s: list of non-networked URLs */ + __( 'Non-networked URLs were passed to config: %s', 'newspack-network' ), + implode( ', ', $urls_not_in_network ) + ) + ); + } + + $config = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); if ( ! is_array( $config ) ) { $config = []; @@ -68,8 +92,18 @@ public function set_config( $site_urls = [] ) { if ( empty( $config['network_post_id'] ) ) { $config['network_post_id'] = md5( $this->post->ID . get_bloginfo( 'url' ) ); } - $config['site_urls'] = $site_urls; + + if ( empty( $config['site_urls'] ) ) { + $config['site_urls'] = []; + } + + // If there are urls not already in the config, add them. Note that we don't support + // removing urls from the config. + $config['site_urls'] = array_unique( array_merge( $config['site_urls'], $site_urls ) ); + update_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, $config ); + + return $config; } /** diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index 28552aaf..964d1f2d 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -6,17 +6,31 @@ */ use Newspack_Network\Content_Distribution\Outgoing_Post; +use Newspack_Network\Hub\Node as Hub_Node; /** * Test the Outgoing_Post class. */ -class TestOutgoingPoist extends WP_UnitTestCase { +class TestOutgoingPost extends WP_UnitTestCase { + + /** - * URL for node that receives posts. + * "Mocked" network nodes. * - * @var string + * @var array */ - protected $node_url = 'https://node.test'; + protected $network = [ + [ + 'id' => 1234, + 'title' => 'Test Node', + 'url' => 'https://node.test', + ], + [ + 'id' => 5678, + 'title' => 'Test Node 2', + 'url' => 'https://other-node.test', + ], + ]; /** * A distributed post. @@ -31,16 +45,36 @@ class TestOutgoingPoist extends WP_UnitTestCase { public function set_up() { parent::set_up(); - $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); + // "Mock" the network node(s). + update_option( Hub_Node::HUB_NODES_SYNCED_OPTION, $this->network ); + $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); $this->outgoing_post = new Outgoing_Post( $post ); - $this->outgoing_post->set_config( [ $this->node_url ] ); + $this->outgoing_post->set_config( [ $this->network[0]['url'] ] ); + } + + /** + * Test adding a site URL to the config after already having added one. + */ + public function test_add_site_url() { + $config = $this->outgoing_post->get_config(); + $this->assertTrue( in_array( $this->network[0]['url'], $config['site_urls'], true ) ); + $this->assertEquals( 1, count( $config['site_urls'] ) ); + + // Now add one more site URL. + $this->outgoing_post->set_config( [ $this->network[1]['url'] ] ); + $config = $this->outgoing_post->get_config(); + // Check that both urls are there. + $this->assertTrue( in_array( $this->network[0]['url'], $config['site_urls'], true ) ); + $this->assertTrue( in_array( $this->network[1]['url'], $config['site_urls'], true ) ); + // But no more than that. + $this->assertEquals( 2, count( $config['site_urls'] ) ); } /** * Test set post distribution configuration. */ public function test_set_config() { - $result = $this->outgoing_post->set_config( [ $this->node_url ] ); + $result = $this->outgoing_post->set_config( [ $this->network[0]['url'] ] ); $this->assertFalse( is_wp_error( $result ) ); } @@ -49,7 +83,7 @@ public function test_set_config() { */ public function test_get_config() { $config = $this->outgoing_post->get_config(); - $this->assertSame( [ $this->node_url ], $config['site_urls'] ); + $this->assertSame( [ $this->network[0]['url'] ], $config['site_urls'] ); $this->assertSame( 32, strlen( $config['network_post_id'] ) ); } @@ -57,9 +91,9 @@ public function test_get_config() { * Test get config for non-distributed. */ public function test_get_config_for_non_distributed() { - $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); + $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); $outgoing_post = new Outgoing_Post( $post ); - $config = $outgoing_post->get_config(); + $config = $outgoing_post->get_config(); $this->assertEmpty( $config['site_urls'] ); $this->assertEmpty( $config['network_post_id'] ); } @@ -68,11 +102,12 @@ public function test_get_config_for_non_distributed() { * Test set post distribution persists the network post ID. */ public function test_set_config_persists_network_post_id() { - $result = $this->outgoing_post->set_config( [ $this->node_url ] ); + $horse = ''; + $this->outgoing_post->set_config( [ $this->network[0]['url'] ] ); $config = $this->outgoing_post->get_config(); - // Update the post distribution. - $result = $this->outgoing_post->set_config( [ 'https://other-node.test' ] ); + // Update the post distribution with one more node. + $this->outgoing_post->set_config( [ $this->network[1]['url'] ] ); $new_config = $this->outgoing_post->get_config(); $this->assertSame( $config['network_post_id'], $new_config['network_post_id'] ); @@ -82,14 +117,8 @@ public function test_set_config_persists_network_post_id() { * Test is distributed. */ public function test_is_distributed() { - $this->assertTrue( $this->outgoing_post->is_distributed() ); - - // Update the post distribution. - $result = $this->outgoing_post->set_config( [] ); - $this->assertFalse( $this->outgoing_post->is_distributed() ); - // Assert regular post. - $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); + $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); $outgoing_post = new Outgoing_Post( $post ); $this->assertFalse( $outgoing_post->is_distributed() ); } From 01fb89ca5c97da18a3e0eb772e4b20afb24e8db7 Mon Sep 17 00:00:00 2001 From: Derrick Koo Date: Mon, 16 Dec 2024 06:01:57 -0700 Subject: [PATCH 05/20] fix: load text domain on init hook (#171) --- newspack-network.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/newspack-network.php b/newspack-network.php index d728a8c1..c6ee3cf2 100644 --- a/newspack-network.php +++ b/newspack-network.php @@ -31,7 +31,12 @@ define( 'NEWSPACK_NETWORK_READER_ROLE', 'network_reader' ); // Load language files. -load_plugin_textdomain( 'newspack-network', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); +add_action( + 'init', + function () { + load_plugin_textdomain( 'newspack-network', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); + } +); require_once __DIR__ . '/vendor/autoload.php'; From e76a2dc8d4c097d7e56943f2a904b4841020c62a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 16 Dec 2024 18:52:59 -0300 Subject: [PATCH 06/20] feat(content-distribution): control distribution meta and prevent multiple dispatches (#170) --- includes/class-content-distribution.php | 158 +++++++++++++++--- includes/content-distribution/class-cli.php | 8 +- .../class-incoming-post.php | 32 ++-- .../class-outgoing-post.php | 92 +++++----- .../class-network-post-updated.php | 2 +- includes/utils/class-network.php | 22 +++ tests/bootstrap.php | 2 + .../unit-tests/test-content-distribution.php | 66 ++++++++ tests/unit-tests/test-incoming-post.php | 19 +-- tests/unit-tests/test-outgoing-post.php | 64 +++---- 10 files changed, 330 insertions(+), 135 deletions(-) create mode 100644 tests/unit-tests/test-content-distribution.php diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index c9f791f9..c61e7e65 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -17,31 +17,62 @@ * Main class for content distribution */ class Content_Distribution { + /** + * Queued network post updates. + * + * @var array Post IDs to update. + */ + private static $queued_post_updates = []; + /** * Initialize this class and register hooks * * @return void */ public static function init() { - if ( ! defined( 'NEWPACK_NETWORK_CONTENT_DISTRIBUTION' ) || ! NEWPACK_NETWORK_CONTENT_DISTRIBUTION ) { + // Place content distribution behind a constant but run under unit tests. + if ( + ! ( defined( 'IS_TEST_ENV' ) && IS_TEST_ENV ) && + ( ! defined( 'NEWPACK_NETWORK_CONTENT_DISTRIBUTION' ) || ! NEWPACK_NETWORK_CONTENT_DISTRIBUTION ) + ) { return; } - CLI::init(); - add_action( 'init', [ __CLASS__, 'register_listeners' ] ); + + add_action( 'init', [ __CLASS__, 'register_data_event_actions' ] ); + add_action( 'shutdown', [ __CLASS__, 'distribute_queued_posts' ] ); add_filter( 'newspack_webhooks_request_priority', [ __CLASS__, 'webhooks_request_priority' ], 10, 2 ); + add_filter( 'update_post_metadata', [ __CLASS__, 'maybe_short_circuit_distributed_meta' ], 10, 4 ); + add_action( 'updated_postmeta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 ); + add_action( 'newspack_network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ], 10, 3 ); + + CLI::init(); } /** - * Register the listeners to the Newspack Data Events API + * Register the data event actions for content distribution. * * @return void */ - public static function register_listeners() { + public static function register_data_event_actions() { if ( ! class_exists( 'Newspack\Data_Events' ) ) { return; } - Data_Events::register_listener( 'wp_after_insert_post', 'network_post_updated', [ __CLASS__, 'handle_post_updated' ] ); - Data_Events::register_listener( 'newspack_network_incoming_post_inserted', 'network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ] ); + Data_Events::register_action( 'network_post_updated' ); + Data_Events::register_action( 'network_incoming_post_inserted' ); + } + + /** + * Distribute queued posts. + */ + public static function distribute_queued_posts() { + $post_ids = array_unique( self::$queued_post_updates ); + foreach ( $post_ids as $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + continue; + } + self::distribute_post( $post ); + } } /** @@ -61,21 +92,83 @@ public static function webhooks_request_priority( $priority, $action_name ) { } /** - * Post update listener callback. + * Validate whether an update to DISTRIBUTED_POST_META is allowed. * - * @param Outgoing_Post|WP_Post|int $post The post object or ID. + * @param null|bool $check Whether to allow updating metadata for the given type. Default null. + * @param int $object_id Object ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Metadata value. + */ + public static function maybe_short_circuit_distributed_meta( $check, $object_id, $meta_key, $meta_value ) { + if ( Outgoing_Post::DISTRIBUTED_POST_META !== $meta_key ) { + return $check; + } + + // Ensure the post type can be distributed. + $post_types = self::get_distributed_post_types(); + if ( ! in_array( get_post_type( $object_id ), $post_types, true ) ) { + return false; + } + + if ( is_wp_error( Outgoing_Post::validate_distribution( $meta_value ) ) ) { + return false; + } + + // Prevent removing existing distributions. + $current_value = get_post_meta( $object_id, $meta_key, true ); + if ( ! empty( array_diff( empty( $current_value ) ? [] : $current_value, $meta_value ) ) ) { + return false; + } + + return $check; + } + + /** + * Distribute post on postmeta update. + * + * @param int $meta_id Meta ID. + * @param int $object_id Object ID. + * @param string $meta_key Meta key. * - * @return array|null The post payload or null if the post is not distributed. + * @return void */ - public static function handle_post_updated( $post ) { - if ( ! $post instanceof Outgoing_Post ) { - $post = self::get_distributed_post( $post ); + public static function handle_postmeta_update( $meta_id, $object_id, $meta_key ) { + if ( ! $object_id ) { + return; } - if ( $post ) { - return $post->get_payload(); + $post = get_post( $object_id ); + if ( ! $post ) { + return; } + if ( ! self::is_post_distributed( $post ) ) { + return; + } + // Ignore reserved keys but run if the meta is setting the distribution. + if ( + Outgoing_Post::DISTRIBUTED_POST_META !== $meta_key && + in_array( $meta_key, self::get_reserved_post_meta_keys(), true ) + ) { + return; + } + self::$queued_post_updates[] = $object_id; + } - return null; + /** + * Distribute post on post updated. + * + * @param WP_Post|int $post The post object or ID. + * + * @return void + */ + public static function handle_post_updated( $post ) { + $post = get_post( $post ); + if ( ! $post ) { + return; + } + if ( ! self::is_post_distributed( $post ) ) { + return; + } + self::$queued_post_updates[] = $post->ID; } /** @@ -86,7 +179,10 @@ public static function handle_post_updated( $post ) { * @param array $post_payload The post payload. */ public static function handle_incoming_post_inserted( $post_id, $is_linked, $post_payload ) { - return [ + if ( ! class_exists( 'Newspack\Data_Events' ) ) { + return; + } + $data = [ 'origin' => [ 'site_url' => $post_payload['site_url'], 'post_id' => $post_payload['post_id'], @@ -97,6 +193,7 @@ public static function handle_incoming_post_inserted( $post_id, $is_linked, $pos 'is_linked' => $is_linked, ], ]; + Data_Events::dispatch( 'network_incoming_post_inserted', $data ); } /** @@ -147,6 +244,17 @@ public static function get_reserved_post_meta_keys() { ); } + /** + * Whether a given post is distributed. + * + * @param WP_Post|int $post The post object or ID. + * + * @return bool Whether the post is distributed. + */ + public static function is_post_distributed( $post ) { + return (bool) self::get_distributed_post( $post ); + } + /** * Get a distributed post. * @@ -160,21 +268,27 @@ public static function get_distributed_post( $post ) { } catch ( \InvalidArgumentException ) { return null; } - return $outgoing_post->is_distributed() ? $outgoing_post : null; } /** - * Manually trigger post distribution. + * Trigger post distribution. * * @param WP_Post|Outgoing_Post|int $post The post object or ID. * * @return void */ public static function distribute_post( $post ) { - $data = self::handle_post_updated( $post ); - if ( $data ) { - Data_Events::dispatch( 'network_post_updated', $data ); + if ( ! class_exists( 'Newspack\Data_Events' ) ) { + return; + } + if ( $post instanceof Outgoing_Post ) { + $distributed_post = $post; + } else { + $distributed_post = self::get_distributed_post( $post ); + } + if ( $distributed_post ) { + Data_Events::dispatch( 'network_post_updated', $distributed_post->get_payload() ); } } } diff --git a/includes/content-distribution/class-cli.php b/includes/content-distribution/class-cli.php index 3780bc43..bbb3c4ea 100644 --- a/includes/content-distribution/class-cli.php +++ b/includes/content-distribution/class-cli.php @@ -89,13 +89,13 @@ public function cmd_distribute_post( array $pos_args, array $assoc_args ): void try { $outgoing_post = Content_Distribution::get_distributed_post( $post_id ) ?? new Outgoing_Post( $post_id ); - $config = $outgoing_post->set_config( $sites ); - if ( is_wp_error( $config ) ) { - WP_CLI::error( $config->get_error_message() ); + $sites = $outgoing_post->set_distribution( $sites ); + if ( is_wp_error( $sites ) ) { + WP_CLI::error( $sites->get_error_message() ); } Content_Distribution::distribute_post( $outgoing_post ); - WP_CLI::success( sprintf( 'Post with ID %d is distributed to %d sites: %s', $post_id, count( $config['site_urls'] ), implode( ', ', $config['site_urls'] ) ) ); + WP_CLI::success( sprintf( 'Post with ID %d is distributed to %d sites: %s', $post_id, count( $sites ), implode( ', ', $sites ) ) ); } catch ( \Exception $e ) { WP_CLI::error( $e->getMessage() ); diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index a53cde79..dac553e4 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -85,7 +85,7 @@ public function __construct( $payload ) { } $this->payload = $payload; - $this->network_post_id = $payload['config']['network_post_id']; + $this->network_post_id = $payload['network_post_id']; $post = $this->query_post(); if ( $post ) { @@ -105,20 +105,19 @@ public static function get_payload_error( $payload ) { if ( ! is_array( $payload ) || empty( $payload['post_id'] ) || - empty( $payload['config'] ) || + empty( $payload['network_post_id'] ) || + empty( $payload['sites'] ) || empty( $payload['post_data'] ) ) { return new WP_Error( 'invalid_post', __( 'Invalid post payload.', 'newspack-network' ) ); } - $config = $payload['config']; - - if ( empty( $config['network_post_id'] ) || empty( $config['site_urls'] ) ) { + if ( empty( $payload['sites'] ) ) { return new WP_Error( 'not_distributed', __( 'Post is not configured for distribution.', 'newspack-network' ) ); } $site_url = get_bloginfo( 'url' ); - if ( ! in_array( $site_url, $config['site_urls'], true ) ) { + if ( ! in_array( $site_url, $payload['sites'], true ) ) { return new WP_Error( 'not_distributed_to_site', __( 'Post is not configured for distribution on this site.', 'newspack-network' ) ); } } @@ -217,26 +216,33 @@ public function is_linked() { * @return void */ protected function update_post_meta() { + $data = $this->payload['post_data']['post_meta']; + $reserved_keys = Content_Distribution::get_reserved_post_meta_keys(); - // Clear existing post meta. + // Clear existing post meta that are not in the payload. $post_meta = get_post_meta( $this->ID ); foreach ( $post_meta as $meta_key => $meta_value ) { - if ( ! in_array( $meta_key, $reserved_keys, true ) ) { + if ( + ! in_array( $meta_key, $reserved_keys, true ) && + ! array_key_exists( $meta_key, $data ) + ) { delete_post_meta( $this->ID, $meta_key ); } } - $data = $this->payload['post_data']['post_meta']; - if ( empty( $data ) ) { return; } foreach ( $data as $meta_key => $meta_value ) { if ( ! in_array( $meta_key, $reserved_keys, true ) ) { - foreach ( $meta_value as $value ) { - add_post_meta( $this->ID, $meta_key, $value ); + if ( 1 === count( $meta_value ) ) { + update_post_meta( $this->ID, $meta_key, $meta_value[0] ); + } else { + foreach ( $meta_value as $value ) { + add_post_meta( $this->ID, $meta_key, $value ); + } } } } @@ -317,7 +323,7 @@ protected function update_payload( $payload ) { } // Do not update if network post ID mismatches. - if ( $this->network_post_id !== $payload['config']['network_post_id'] ) { + if ( $this->network_post_id !== $payload['network_post_id'] ) { return new WP_Error( 'mismatched_post_id', __( 'Mismatched post ID.', 'newspack-network' ) ); } diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index a02feabe..40c6e76c 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -17,9 +17,9 @@ */ class Outgoing_Post { /** - * The post meta key for the distributed post configuration. + * The post meta key for the sites the post is distributed to. */ - const DISTRIBUTED_POST_META = 'newspack_network_distributed'; + const DISTRIBUTED_POST_META = 'newspack_network_distributed_sites'; /** * The post object. @@ -59,22 +59,34 @@ public function get_post() { } /** - * Set the distribution configuration for a given post. + * Get network post ID. * - * @param int[] $site_urls Array of site URLs to distribute the post to. + * @return string The network post ID. + */ + public function get_network_post_id() { + return md5( $this->post->ID . get_bloginfo( 'url' ) ); + } + + /** + * Validate URLs for distribution. * - * @return array|WP_Error Config array on success, WP_Error on failure. + * @param string[] $urls Array of site URLs to distribute the post to. + * + * @return true|WP_Error True on success, WP_Error on failure. */ - public function set_config( $site_urls ) { - if ( empty( $site_urls ) ) { - return new WP_Error( 'config_no_site_urls', __( 'No site URLs provided.', 'newspack-network' ) ); + public static function validate_distribution( $urls ) { + if ( empty( $urls ) ) { + return new WP_Error( 'no_site_urls', __( 'No site URLs provided.', 'newspack-network' ) ); + } + + if ( in_array( get_bloginfo( 'url' ), $urls, true ) ) { + return new WP_Error( 'no_own_site', __( 'Cannot distribute to own site.', 'newspack-network' ) ); } - $networked_urls = Network::get_networked_urls(); - $urls_not_in_network = array_diff( $site_urls, $networked_urls ); + $urls_not_in_network = Network::get_non_networked_urls_from_list( $urls ); if ( ! empty( $urls_not_in_network ) ) { return new WP_Error( - 'config_non_networked_urls', + 'non_networked_urls', sprintf( /* translators: %s: list of non-networked URLs */ __( 'Non-networked URLs were passed to config: %s', 'newspack-network' ), @@ -83,27 +95,34 @@ public function set_config( $site_urls ) { ); } + return true; + } - $config = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); - if ( ! is_array( $config ) ) { - $config = []; - } - // Set post network ID. - if ( empty( $config['network_post_id'] ) ) { - $config['network_post_id'] = md5( $this->post->ID . get_bloginfo( 'url' ) ); + /** + * Set the distribution configuration for a given post. + * + * @param int[] $site_urls Array of site URLs to distribute the post to. + * + * @return array|WP_Error Config array on success, WP_Error on failure. + */ + public function set_distribution( $site_urls ) { + $error = self::validate_distribution( $site_urls ); + if ( is_wp_error( $error ) ) { + return $error; } - if ( empty( $config['site_urls'] ) ) { - $config['site_urls'] = []; + $distribution = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); + if ( ! is_array( $distribution ) ) { + $distribution = []; } // If there are urls not already in the config, add them. Note that we don't support // removing urls from the config. - $config['site_urls'] = array_unique( array_merge( $config['site_urls'], $site_urls ) ); + $distribution = array_unique( array_merge( $distribution, $site_urls ) ); - update_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, $config ); + update_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, $distribution ); - return $config; + return $distribution; } /** @@ -120,35 +139,28 @@ public function is_distributed( $site_url = null ) { return false; } - $config = $this->get_config(); - if ( empty( $config['site_urls'] ) ) { + $distribution = $this->get_distribution(); + if ( empty( $distribution ) ) { return false; } if ( ! empty( $site_url ) ) { - return in_array( $site_url, $config['site_urls'], true ); + return in_array( $site_url, $distribution, true ); } return true; } /** - * Get the distribution configuration for a given post. + * Get the sites the post is distributed to. * * @return array The distribution configuration. */ - public function get_config() { + public function get_distribution() { $config = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); if ( ! is_array( $config ) ) { $config = []; } - $config = wp_parse_args( - $config, - [ - 'site_urls' => [], - 'network_post_id' => '', - ] - ); return $config; } @@ -158,12 +170,12 @@ public function get_config() { * @return array|WP_Error The post payload or WP_Error if the post is invalid. */ public function get_payload() { - $config = self::get_config(); return [ - 'site_url' => get_bloginfo( 'url' ), - 'post_id' => $this->post->ID, - 'config' => $config, - 'post_data' => [ + 'site_url' => get_bloginfo( 'url' ), + 'post_id' => $this->post->ID, + 'network_post_id' => $this->get_network_post_id(), + 'sites' => $this->get_distribution(), + 'post_data' => [ 'title' => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ), 'date_gmt' => $this->post->post_date_gmt, 'modified_gmt' => $this->post->post_modified_gmt, diff --git a/includes/incoming-events/class-network-post-updated.php b/includes/incoming-events/class-network-post-updated.php index 3ddd8ca5..76ddf445 100644 --- a/includes/incoming-events/class-network-post-updated.php +++ b/includes/incoming-events/class-network-post-updated.php @@ -38,7 +38,7 @@ public function process_in_node() { protected function process_post_updated() { $payload = (array) $this->get_data(); - Debugger::log( 'Processing network_post_updated ' . wp_json_encode( $payload['config'] ) ); + Debugger::log( 'Processing network_post_updated ' . wp_json_encode( $payload['sites'] ) ); $error = Incoming_Post::get_payload_error( $payload ); if ( is_wp_error( $error ) ) { diff --git a/includes/utils/class-network.php b/includes/utils/class-network.php index 28416413..7903dc3a 100644 --- a/includes/utils/class-network.php +++ b/includes/utils/class-network.php @@ -45,4 +45,26 @@ public static function get_networked_urls(): array { public static function is_networked_url( string $url ): bool { return in_array( untrailingslashit( $url ), self::get_networked_urls(), true ); } + + /** + * Get list of network URLs given a list of URLs. + * + * @param string[] $urls Array of URLs. + * + * @return string[] Array of networked URLs. + */ + public static function get_networked_urls_from_list( array $urls ): array { + return array_intersect( array_map( 'untrailingslashit', $urls ), self::get_networked_urls() ); + } + + /** + * Get list of URLs that don't belong in the network given a list of URLs. + * + * @param string[] $urls Array of URLs. + * + * @return string[] Array of networked URLs. + */ + public static function get_non_networked_urls_from_list( array $urls ): array { + return array_diff( array_map( 'untrailingslashit', $urls ), self::get_networked_urls() ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 879a4223..fe978af5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,6 +16,8 @@ exit( 1 ); } +define( 'IS_TEST_ENV', 1 ); + // Give access to tests_add_filter() function. require_once "{$newspack_network_hub_test_dir}/includes/functions.php"; diff --git a/tests/unit-tests/test-content-distribution.php b/tests/unit-tests/test-content-distribution.php new file mode 100644 index 00000000..2bf72b9f --- /dev/null +++ b/tests/unit-tests/test-content-distribution.php @@ -0,0 +1,66 @@ + 1234, + 'title' => 'Test Node', + 'url' => 'https://node.test', + ], + [ + 'id' => 5678, + 'title' => 'Test Node 2', + 'url' => 'https://other-node.test', + ], + ]; + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + // "Mock" the network node(s). + update_option( Hub_Node::HUB_NODES_SYNCED_OPTION, $this->network ); + } + + /** + * Test update distributed post meta. + */ + public function test_update_distributed_post_meta() { + $post_id = $this->factory->post->create(); + + // Assert that you're not allowed to update the meta with a non-network site. + $result = update_post_meta( $post_id, Outgoing_Post::DISTRIBUTED_POST_META, [ 'http://non-network-site.com' ] ); + $this->assertFalse( $result ); + + // Assert that you're allowed to update the meta with a network site. + $result = update_post_meta( $post_id, Outgoing_Post::DISTRIBUTED_POST_META, [ 'https://node.test' ] ); + $this->assertNotFalse( $result ); + + // Assert that you can't remove a site from distribution. + $result = update_post_meta( $post_id, Outgoing_Post::DISTRIBUTED_POST_META, [ 'https://other-node.test' ] ); + $this->assertFalse( $result ); + + // Assert that you can add a site to distribution. + $result = update_post_meta( $post_id, Outgoing_Post::DISTRIBUTED_POST_META, [ 'https://node.test', 'https://other-node.test' ] ); + $this->assertNotFalse( $result ); + } +} diff --git a/tests/unit-tests/test-incoming-post.php b/tests/unit-tests/test-incoming-post.php index 44eb68f0..13e253fb 100644 --- a/tests/unit-tests/test-incoming-post.php +++ b/tests/unit-tests/test-incoming-post.php @@ -37,14 +37,11 @@ class TestIncomingPost extends WP_UnitTestCase { */ private function get_sample_payload() { return [ - 'site_url' => $this->node_1, - 'post_id' => 1, - 'config' => [ - 'enabled' => true, - 'site_urls' => [ $this->node_2 ], - 'network_post_id' => '1234567890abcdef1234567890abcdef', - ], - 'post_data' => [ + 'site_url' => $this->node_1, + 'post_id' => 1, + 'network_post_id' => '1234567890abcdef1234567890abcdef', + 'sites' => [ $this->node_2 ], + 'post_data' => [ 'title' => 'Title', 'date_gmt' => '2021-01-01 00:00:00', 'modified_gmt' => '2021-01-01 00:00:00', @@ -117,12 +114,6 @@ public function test_validate_payload() { $error = Incoming_Post::get_payload_error( $payload ); $this->assertTrue( is_wp_error( $error ) ); $this->assertSame( 'not_distributed_to_site', $error->get_error_code() ); - - // Assert invalid config. - $payload['config'] = 'invalid'; - $error = Incoming_Post::get_payload_error( $payload ); - $this->assertTrue( is_wp_error( $error ) ); - $this->assertSame( 'not_distributed', $error->get_error_code() ); } /** diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index 964d1f2d..59362fe9 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -12,8 +12,6 @@ * Test the Outgoing_Post class. */ class TestOutgoingPost extends WP_UnitTestCase { - - /** * "Mocked" network nodes. * @@ -49,68 +47,51 @@ public function set_up() { update_option( Hub_Node::HUB_NODES_SYNCED_OPTION, $this->network ); $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); $this->outgoing_post = new Outgoing_Post( $post ); - $this->outgoing_post->set_config( [ $this->network[0]['url'] ] ); + $this->outgoing_post->set_distribution( [ $this->network[0]['url'] ] ); } /** * Test adding a site URL to the config after already having added one. */ public function test_add_site_url() { - $config = $this->outgoing_post->get_config(); - $this->assertTrue( in_array( $this->network[0]['url'], $config['site_urls'], true ) ); - $this->assertEquals( 1, count( $config['site_urls'] ) ); + $distribution = $this->outgoing_post->get_distribution(); + $this->assertTrue( in_array( $this->network[0]['url'], $distribution, true ) ); + $this->assertEquals( 1, count( $distribution ) ); // Now add one more site URL. - $this->outgoing_post->set_config( [ $this->network[1]['url'] ] ); - $config = $this->outgoing_post->get_config(); + $this->outgoing_post->set_distribution( [ $this->network[1]['url'] ] ); + $distribution = $this->outgoing_post->get_distribution(); // Check that both urls are there. - $this->assertTrue( in_array( $this->network[0]['url'], $config['site_urls'], true ) ); - $this->assertTrue( in_array( $this->network[1]['url'], $config['site_urls'], true ) ); + $this->assertTrue( in_array( $this->network[0]['url'], $distribution, true ) ); + $this->assertTrue( in_array( $this->network[1]['url'], $distribution, true ) ); // But no more than that. - $this->assertEquals( 2, count( $config['site_urls'] ) ); + $this->assertEquals( 2, count( $distribution ) ); } /** - * Test set post distribution configuration. + * Test set post distribution. */ - public function test_set_config() { - $result = $this->outgoing_post->set_config( [ $this->network[0]['url'] ] ); + public function test_set_distribution() { + $result = $this->outgoing_post->set_distribution( [ $this->network[0]['url'] ] ); $this->assertFalse( is_wp_error( $result ) ); } /** - * Test get config. + * Test get post distribution. */ - public function test_get_config() { - $config = $this->outgoing_post->get_config(); - $this->assertSame( [ $this->network[0]['url'] ], $config['site_urls'] ); - $this->assertSame( 32, strlen( $config['network_post_id'] ) ); + public function test_get_distribution() { + $distribution = $this->outgoing_post->get_distribution(); + $this->assertSame( [ $this->network[0]['url'] ], $distribution ); } /** - * Test get config for non-distributed. + * Test get distribution for non-distributed. */ - public function test_get_config_for_non_distributed() { + public function test_get_distribution_for_non_distributed() { $post = $this->factory->post->create_and_get( [ 'post_type' => 'post' ] ); $outgoing_post = new Outgoing_Post( $post ); - $config = $outgoing_post->get_config(); - $this->assertEmpty( $config['site_urls'] ); - $this->assertEmpty( $config['network_post_id'] ); - } - - /** - * Test set post distribution persists the network post ID. - */ - public function test_set_config_persists_network_post_id() { - $horse = ''; - $this->outgoing_post->set_config( [ $this->network[0]['url'] ] ); - $config = $this->outgoing_post->get_config(); - - // Update the post distribution with one more node. - $this->outgoing_post->set_config( [ $this->network[1]['url'] ] ); - $new_config = $this->outgoing_post->get_config(); - - $this->assertSame( $config['network_post_id'], $new_config['network_post_id'] ); + $distribution = $outgoing_post->get_distribution(); + $this->assertEmpty( $distribution ); } /** @@ -130,11 +111,12 @@ public function test_get_payload() { $payload = $this->outgoing_post->get_payload(); $this->assertNotEmpty( $payload ); - $config = $this->outgoing_post->get_config(); + $distribution = $this->outgoing_post->get_distribution(); $this->assertSame( get_bloginfo( 'url' ), $payload['site_url'] ); $this->assertSame( $this->outgoing_post->get_post()->ID, $payload['post_id'] ); - $this->assertEquals( $config, $payload['config'] ); + $this->assertSame( 32, strlen( $payload['network_post_id'] ) ); + $this->assertEquals( $distribution, $payload['sites'] ); // Assert that 'post_data' only contains the expected keys. $post_data_keys = [ From 48df13c65c06f3edabd88122c5a6816c006b70f6 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 18 Dec 2024 10:17:50 -0300 Subject: [PATCH 07/20] fix(content-distribution): post insertion hook and additional meta for incoming post event (#173) --- includes/class-content-distribution.php | 8 ++++++-- includes/content-distribution/class-outgoing-post.php | 1 + tests/unit-tests/test-outgoing-post.php | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index c61e7e65..420f0f08 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -42,6 +42,7 @@ public static function init() { add_action( 'shutdown', [ __CLASS__, 'distribute_queued_posts' ] ); add_filter( 'newspack_webhooks_request_priority', [ __CLASS__, 'webhooks_request_priority' ], 10, 2 ); add_filter( 'update_post_metadata', [ __CLASS__, 'maybe_short_circuit_distributed_meta' ], 10, 4 ); + add_action( 'wp_after_insert_post', [ __CLASS__, 'handle_post_updated' ] ); add_action( 'updated_postmeta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 ); add_action( 'newspack_network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ], 10, 3 ); @@ -183,13 +184,16 @@ public static function handle_incoming_post_inserted( $post_id, $is_linked, $pos return; } $data = [ - 'origin' => [ + 'network_post_id' => $post_payload['network_post_id'], + 'outgoing' => [ 'site_url' => $post_payload['site_url'], 'post_id' => $post_payload['post_id'], + 'post_url' => $post_payload['post_url'], ], - 'destination' => [ + 'incoming' => [ 'site_url' => get_bloginfo( 'url' ), 'post_id' => $post_id, + 'post_url' => get_permalink( $post_id ), 'is_linked' => $is_linked, ], ]; diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index 40c6e76c..3f9b707d 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -173,6 +173,7 @@ public function get_payload() { return [ 'site_url' => get_bloginfo( 'url' ), 'post_id' => $this->post->ID, + 'post_url' => get_permalink( $this->post->ID ), 'network_post_id' => $this->get_network_post_id(), 'sites' => $this->get_distribution(), 'post_data' => [ diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index 59362fe9..23f8f047 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -115,6 +115,7 @@ public function test_get_payload() { $this->assertSame( get_bloginfo( 'url' ), $payload['site_url'] ); $this->assertSame( $this->outgoing_post->get_post()->ID, $payload['post_id'] ); + $this->assertSame( get_permalink( $this->outgoing_post->get_post()->ID ), $payload['post_url'] ); $this->assertSame( 32, strlen( $payload['network_post_id'] ) ); $this->assertEquals( $distribution, $payload['sites'] ); From a2c54d2f702f197b77c27fad7bde7dddaadafd5f Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Wed, 18 Dec 2024 10:48:42 -0300 Subject: [PATCH 08/20] feat(content-distribution): reserved taxonomies (#174) --- includes/class-content-distribution.php | 18 +++++++++++++ .../class-incoming-post.php | 10 +++++--- .../class-outgoing-post.php | 8 ++++-- tests/unit-tests/test-incoming-post.php | 25 +++++++++++++++++++ tests/unit-tests/test-outgoing-post.php | 15 +++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index 420f0f08..1de79dc1 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -248,6 +248,24 @@ public static function get_reserved_post_meta_keys() { ); } + /** + * Get taxonomies that should not be distributed. + * + * @return string[] The reserved taxonomies. + */ + public static function get_reserved_taxonomies() { + $reserved_taxonomies = [ + 'author', // Co-Authors Plus 'author' taxonomy should be ignored as it requires custom handling. + ]; + + /** + * Filters the reserved taxonomies that should not be distributed. + * + * @param string[] $reserved_taxonomies The reserved taxonomies. + */ + return apply_filters( 'newspack_network_content_distribution_reserved_taxonomies', $reserved_taxonomies ); + } + /** * Whether a given post is distributed. * diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index dac553e4..dfd45b52 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -288,16 +288,20 @@ protected function upload_thumbnail() { * @return void */ protected function update_taxonomy_terms() { - $data = $this->payload['post_data']['taxonomy']; + $reserved_taxonomies = Content_Distribution::get_reserved_taxonomies(); + $data = $this->payload['post_data']['taxonomy']; foreach ( $data as $taxonomy => $terms ) { + if ( in_array( $taxonomy, $reserved_taxonomies, true ) ) { + continue; + } if ( ! taxonomy_exists( $taxonomy ) ) { continue; } $term_ids = []; foreach ( $terms as $term_data ) { - $term = get_term_by( 'slug', $term_data['slug'], $taxonomy, ARRAY_A ); + $term = get_term_by( 'name', $term_data['name'], $taxonomy, ARRAY_A ); if ( ! $term ) { - $term = wp_insert_term( $term_data['name'], $taxonomy, [ 'slug' => $term_data['slug'] ] ); + $term = wp_insert_term( $term_data['name'], $taxonomy ); if ( is_wp_error( $term ) ) { continue; } diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index 3f9b707d..57ec01a7 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -215,9 +215,13 @@ protected function get_processed_post_content() { * @return array The taxonomy term data. */ protected function get_post_taxonomy_terms() { - $taxonomies = get_object_taxonomies( $this->post->post_type, 'objects' ); - $data = []; + $reserved_taxonomies = Content_Distribution::get_reserved_taxonomies(); + $taxonomies = get_object_taxonomies( $this->post->post_type, 'objects' ); + $data = []; foreach ( $taxonomies as $taxonomy ) { + if ( in_array( $taxonomy->name, $reserved_taxonomies, true ) ) { + continue; + } if ( ! $taxonomy->public ) { continue; } diff --git a/tests/unit-tests/test-incoming-post.php b/tests/unit-tests/test-incoming-post.php index 13e253fb..0f279670 100644 --- a/tests/unit-tests/test-incoming-post.php +++ b/tests/unit-tests/test-incoming-post.php @@ -330,4 +330,29 @@ public function test_post_meta_sync() { // Assert that the custom post meta was removed on relink. $this->assertEmpty( get_post_meta( $post_id, 'custom', true ) ); } + + /** + * Test reserved taxonomies. + */ + public function test_reserved_taxonomies() { + $payload = $this->get_sample_payload(); + $taxonomy = 'author'; + + // Register a reserved taxonomy. + register_taxonomy( $taxonomy, 'post', [ 'public' => true ] ); + + $payload['post_data']['taxonomy']['author'] = [ + [ + 'name' => 'Author 1', + 'slug' => 'author-1', + ], + ]; + + // Insert the linked post. + $post_id = $this->incoming_post->insert( $payload ); + + // Assert that the post does not have the reserved taxonomy term. + $terms = wp_get_post_terms( $post_id, $taxonomy ); + $this->assertEmpty( $terms ); + } } diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index 23f8f047..73d5612a 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -166,4 +166,19 @@ public function test_post_meta() { $this->assertSame( 'a', $payload['post_data']['post_meta'][ $multiple_meta_key ][0] ); $this->assertSame( 'b', $payload['post_data']['post_meta'][ $multiple_meta_key ][1] ); } + + /** + * Test reserved taxonomies. + */ + public function test_reserved_taxonomies() { + $post = $this->outgoing_post->get_post(); + $taxonomy = 'author'; + register_taxonomy( $taxonomy, 'post', [ 'public' => true ] ); + + $term = $this->factory->term->create( [ 'taxonomy' => $taxonomy ] ); + wp_set_post_terms( $post->ID, [ $term ], $taxonomy ); + + $payload = $this->outgoing_post->get_payload(); + $this->assertTrue( empty( $payload['post_data']['taxonomy'][ $taxonomy ] ) ); + } } From 4af5da1b0edbfcdf5330878cceed0349f91cc36e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 20 Dec 2024 11:42:25 -0300 Subject: [PATCH 09/20] feat(content-distribution): handle status changes (#166) --- includes/class-accepted-actions.php | 2 + includes/class-content-distribution.php | 26 +++++- .../class-incoming-post.php | 32 +++++++- .../class-outgoing-post.php | 1 + .../class-network-post-deleted.php | 51 ++++++++++++ tests/unit-tests/test-incoming-post.php | 80 +++++++++++++++++++ tests/unit-tests/test-outgoing-post.php | 1 + 7 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 includes/incoming-events/class-network-post-deleted.php diff --git a/includes/class-accepted-actions.php b/includes/class-accepted-actions.php index 767b8d44..3e6525e4 100644 --- a/includes/class-accepted-actions.php +++ b/includes/class-accepted-actions.php @@ -41,6 +41,7 @@ class Accepted_Actions { 'network_nodes_synced' => 'Nodes_Synced', 'newspack_network_membership_plan_updated' => 'Membership_Plan_Updated', 'network_post_updated' => 'Network_Post_Updated', + 'network_post_deleted' => 'Network_Post_Deleted', ]; /** @@ -63,5 +64,6 @@ class Accepted_Actions { 'newspack_node_subscription_changed', 'newspack_network_membership_plan_updated', 'network_post_updated', + 'network_post_deleted', ]; } diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index 1de79dc1..12e96b04 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -44,6 +44,7 @@ public static function init() { add_filter( 'update_post_metadata', [ __CLASS__, 'maybe_short_circuit_distributed_meta' ], 10, 4 ); add_action( 'wp_after_insert_post', [ __CLASS__, 'handle_post_updated' ] ); add_action( 'updated_postmeta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 ); + add_action( 'before_delete_post', [ __CLASS__, 'handle_post_deleted' ] ); add_action( 'newspack_network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ], 10, 3 ); CLI::init(); @@ -59,6 +60,7 @@ public static function register_data_event_actions() { return; } Data_Events::register_action( 'network_post_updated' ); + Data_Events::register_action( 'network_post_deleted' ); Data_Events::register_action( 'network_incoming_post_inserted' ); } @@ -77,8 +79,8 @@ public static function distribute_queued_posts() { } /** - * Filter the webhooks request priority so `network_post_updated` is - * prioritized. + * Filter the webhooks request priority so `network_post_updated` and + * `network_post_deleted` are prioritized. * * @param int $priority The request priority. * @param string $action_name The action name. @@ -86,7 +88,7 @@ public static function distribute_queued_posts() { * @return int The request priority. */ public static function webhooks_request_priority( $priority, $action_name ) { - if ( 'network_post_updated' === $action_name ) { + if ( in_array( $action_name, [ 'network_post_updated', 'network_post_deleted' ], true ) ) { return 1; } return $priority; @@ -172,6 +174,24 @@ public static function handle_post_updated( $post ) { self::$queued_post_updates[] = $post->ID; } + /** + * Distribute post deletion. + * + * @param int $post_id The post ID. + * + * @return @void + */ + public static function handle_post_deleted( $post_id ) { + if ( ! class_exists( 'Newspack\Data_Events' ) ) { + return; + } + $post = self::get_distributed_post( $post_id ); + if ( ! $post ) { + return; + } + Data_Events::dispatch( 'network_post_deleted', $post->get_payload() ); + } + /** * Incoming post inserted listener callback. * diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index dfd45b52..dcbc6734 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -334,6 +334,30 @@ protected function update_payload( $payload ) { $this->payload = $payload; } + /** + * Handle the distributed post deletion. + * + * If the post is linked, it'll be trashed. + * + * The distributed post payload will be removed so the unlinked post can be + * treated as a regular standalone post. + * + * We'll keep the network post ID and unlinked meta in case the original post + * gets restored from a backup. + * + * @return void + */ + public function delete() { + // Bail if there's no post to delete. + if ( ! $this->ID ) { + return; + } + if ( $this->is_linked() ) { + wp_trash_post( $this->ID ); + } + delete_post_meta( $this->ID, self::PAYLOAD_META ); + } + /** * Insert the incoming post. * @@ -377,13 +401,19 @@ public function insert( $payload = [] ) { 'post_type' => $post_type, ]; - // New post, set post status. + // The default status for a new post is 'draft'. if ( ! $this->ID ) { $postarr['post_status'] = 'draft'; } // Insert the post if it's linked or a new post. if ( ! $this->ID || $this->is_linked() ) { + + // If the post is moving to non-publish statuses, always update the status. + if ( in_array( $post_data['post_status'], [ 'draft', 'trash', 'pending', 'private' ] ) ) { + $postarr['post_status'] = $post_data['post_status']; + } + // Remove filters that may alter content updates. remove_all_filters( 'content_save_pre' ); diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index 57ec01a7..90ac22e0 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -178,6 +178,7 @@ public function get_payload() { 'sites' => $this->get_distribution(), 'post_data' => [ 'title' => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ), + 'post_status' => $this->post->post_status, 'date_gmt' => $this->post->post_date_gmt, 'modified_gmt' => $this->post->post_modified_gmt, 'slug' => $this->post->post_name, diff --git a/includes/incoming-events/class-network-post-deleted.php b/includes/incoming-events/class-network-post-deleted.php new file mode 100644 index 00000000..51ccbdff --- /dev/null +++ b/includes/incoming-events/class-network-post-deleted.php @@ -0,0 +1,51 @@ +process_post_deleted(); + } + + /** + * Process event in Node + * + * @return void + */ + public function process_in_node() { + $this->process_post_deleted(); + } + + /** + * Process post deleted + */ + protected function process_post_deleted() { + $payload = (array) $this->get_data(); + + Debugger::log( 'Processing network_post_deleted ' . wp_json_encode( $payload['config'] ) ); + + $error = Incoming_Post::get_payload_error( $payload ); + if ( is_wp_error( $error ) ) { + Debugger::log( 'Error processing network_post_deleted: ' . $error->get_error_message() ); + return; + } + $incoming_post = new Incoming_Post( $payload ); + $incoming_post->delete(); + } +} diff --git a/tests/unit-tests/test-incoming-post.php b/tests/unit-tests/test-incoming-post.php index 0f279670..c315f39d 100644 --- a/tests/unit-tests/test-incoming-post.php +++ b/tests/unit-tests/test-incoming-post.php @@ -43,6 +43,7 @@ private function get_sample_payload() { 'sites' => [ $this->node_2 ], 'post_data' => [ 'title' => 'Title', + 'post_status' => 'publish', 'date_gmt' => '2021-01-01 00:00:00', 'modified_gmt' => '2021-01-01 00:00:00', 'slug' => 'slug', @@ -331,6 +332,85 @@ public function test_post_meta_sync() { $this->assertEmpty( get_post_meta( $post_id, 'custom', true ) ); } + /** + * Test status changes. + */ + public function test_status_changes() { + $post_id = $this->incoming_post->insert(); + + // Assert that the default post status is draft. + $this->assertSame( 'draft', get_post_status( $post_id ) ); + + // Publish the linked post. + wp_update_post( + [ + 'ID' => $post_id, + 'post_status' => 'publish', + ] + ); + + $payload = $this->get_sample_payload(); + + // Assert that the post status updates to draft. + $payload['post_data']['post_status'] = 'draft'; + $this->incoming_post->insert( $payload ); + $this->assertSame( 'draft', get_post_status( $post_id ) ); + + // Assert that the post status does NOT update to publish. + $payload['post_data']['post_status'] = 'publish'; + $this->incoming_post->insert( $payload ); + $this->assertSame( 'draft', get_post_status( $post_id ) ); + + // Assert that the post status updates to trash. + $payload['post_data']['post_status'] = 'trash'; + $this->incoming_post->insert( $payload ); + $this->assertSame( 'trash', get_post_status( $post_id ) ); + } + + /** + * Test delete. + */ + public function test_delete() { + $post_id = $this->incoming_post->insert(); + + $this->incoming_post->delete(); + + // Assert that the post was trashed and the payload was removed. + $this->assertSame( 'trash', get_post_status( $post_id ) ); + $this->assertEmpty( get_post_meta( $post_id, Incoming_Post::PAYLOAD_META, true ) ); + } + + /** + * Test delete trashed post. + */ + public function test_delete_trashed_post() { + $post_id = $this->incoming_post->insert(); + + wp_trash_post( $post_id ); + + $this->incoming_post->delete(); + + // Assert that the post remained trashed and the payload was removed. + $this->assertSame( 'trash', get_post_status( $post_id ) ); + $this->assertEmpty( get_post_meta( $post_id, Incoming_Post::PAYLOAD_META, true ) ); + } + + /** + * Test delete unlinked. + */ + public function test_delete_unlinked() { + $post_id = $this->incoming_post->insert(); + + $this->assertNotEmpty( get_post( $post_id ) ); + + $this->incoming_post->set_unlinked(); + $this->incoming_post->delete(); + + // Assert that the post remained as draft and the payload was removed. + $this->assertSame( 'draft', get_post_status( $post_id ) ); + $this->assertEmpty( get_post_meta( $post_id, Incoming_Post::PAYLOAD_META, true ) ); + } + /** * Test reserved taxonomies. */ diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index 73d5612a..ef55f56f 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -122,6 +122,7 @@ public function test_get_payload() { // Assert that 'post_data' only contains the expected keys. $post_data_keys = [ 'title', + 'post_status', 'date_gmt', 'modified_gmt', 'slug', From e10aef43ce7842df1c48cafc17037700b7b9f49a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 20 Dec 2024 11:44:57 -0300 Subject: [PATCH 10/20] feat(content-distribution): editor plugin for distribution (#167) --- includes/class-content-distribution.php | 24 ++ includes/content-distribution/class-api.php | 77 ++++++ .../content-distribution/class-editor.php | 99 +++++++ .../class-outgoing-post.php | 10 +- package.json | 7 +- src/content-distribution/distribute/index.js | 244 ++++++++++++++++++ .../distribute/style.scss | 46 ++++ tests/unit-tests/test-outgoing-post.php | 13 +- webpack.config.js | 16 ++ 9 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 includes/content-distribution/class-api.php create mode 100644 includes/content-distribution/class-editor.php create mode 100644 src/content-distribution/distribute/index.js create mode 100644 src/content-distribution/distribute/style.scss create mode 100644 webpack.config.js diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index 12e96b04..a2c9bace 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -9,6 +9,8 @@ use Newspack\Data_Events; use Newspack_Network\Content_Distribution\CLI; +use Newspack_Network\Content_Distribution\API; +use Newspack_Network\Content_Distribution\Editor; use Newspack_Network\Content_Distribution\Incoming_Post; use Newspack_Network\Content_Distribution\Outgoing_Post; use WP_Post; @@ -48,6 +50,8 @@ public static function init() { add_action( 'newspack_network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ], 10, 3 ); CLI::init(); + API::init(); + Editor::init(); } /** @@ -297,6 +301,26 @@ public static function is_post_distributed( $post ) { return (bool) self::get_distributed_post( $post ); } + /** + * Whether a given post is an incoming post. This will also return true if + * the post is unlinked. + * + * Since the Incoming_Post object queries the post by post meta on + * instantiation, this method is more efficient for checking if a post is + * incoming. + * + * @param WP_Post|int $post The post object or ID. + * + * @return bool Whether the post is an incoming post. + */ + public static function is_post_incoming( $post ) { + $post = get_post( $post ); + if ( ! $post ) { + return false; + } + return (bool) get_post_meta( $post->ID, Incoming_Post::PAYLOAD_META, true ); + } + /** * Get a distributed post. * diff --git a/includes/content-distribution/class-api.php b/includes/content-distribution/class-api.php new file mode 100644 index 00000000..bcad93f2 --- /dev/null +++ b/includes/content-distribution/class-api.php @@ -0,0 +1,77 @@ +\d+)', + [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'distribute' ], + 'args' => [ + 'urls' => [ + 'type' => 'array', + 'required' => true, + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'permission_callback' => function() { + return current_user_can( 'edit_posts' ); // @TODO Custom capability. + }, + ] + ); + } + + /** + * Distribute a post to the network. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|WP_Error The REST response or error. + */ + public static function distribute( $request ) { + $post_id = $request->get_param( 'post_id' ); + $urls = $request->get_param( 'urls' ); + + try { + $outgoing_post = new Outgoing_Post( $post_id ); + } catch ( \InvalidArgumentException $e ) { + return new WP_Error( 'newspack_network_content_distribution_error', $e->getMessage(), [ 'status' => 400 ] ); + } + + $distribution = $outgoing_post->set_distribution( $urls ); + + if ( is_wp_error( $distribution ) ) { + return new WP_Error( 'newspack_network_content_distribution_error', $distribution->get_error_message(), [ 'status' => 400 ] ); + } + + Content_Distribution::distribute_post( $outgoing_post ); + + return rest_ensure_response( $distribution ); + } +} diff --git a/includes/content-distribution/class-editor.php b/includes/content-distribution/class-editor.php new file mode 100644 index 00000000..4f7b5ace --- /dev/null +++ b/includes/content-distribution/class-editor.php @@ -0,0 +1,99 @@ + true, + 'type' => 'array', + 'show_in_rest' => [ + 'schema' => [ + 'context' => [ 'edit' ], + 'type' => 'array', + 'default' => [], + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'auth_callback' => function() { + return current_user_can( 'edit_posts' ); // @TODO Custom capability. + }, + ] + ); + } + } + + /** + * Enqueue block editor assets. + * + * @return void + */ + public static function enqueue_block_editor_assets() { + $screen = get_current_screen(); + if ( ! in_array( $screen->post_type, Content_Distribution::get_distributed_post_types(), true ) ) { + return; + } + + $post = get_post(); + + // Don't enqueue the script for incoming posts. + if ( Content_Distribution::is_post_incoming( $post ) ) { + return; + } + + wp_enqueue_script( + 'newspack-network-distribute', + plugins_url( '../../dist/distribute.js', __FILE__ ), + [], + filemtime( NEWSPACK_NETWORK_PLUGIN_DIR . 'dist/distribute.js' ), + true + ); + wp_register_style( + 'newspack-network-distribute', + plugins_url( '../../dist/distribute.css', __FILE__ ), + [], + filemtime( NEWSPACK_NETWORK_PLUGIN_DIR . 'dist/distribute.css' ), + ); + wp_style_add_data( 'newspack-network-distribute', 'rtl', 'replace' ); + wp_enqueue_style( 'newspack-network-distribute' ); + + wp_localize_script( + 'newspack-network-distribute', + 'newspack_network_distribute', + [ + 'network_sites' => Network::get_networked_urls(), + 'distributed_meta' => Outgoing_Post::DISTRIBUTED_POST_META, + 'post_type_label' => get_post_type_labels( get_post_type_object( $screen->post_type ) )->singular_name, + ] + ); + } +} diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index 90ac22e0..55e649fa 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -41,6 +41,10 @@ public function __construct( $post ) { throw new \InvalidArgumentException( esc_html( __( 'Invalid post.', 'newspack-network' ) ) ); } + if ( 'publish' !== $post->post_status ) { + throw new \InvalidArgumentException( esc_html( __( 'Only published post are allowed to be distributed.', 'newspack-network' ) ) ); + } + if ( ! in_array( $post->post_type, Content_Distribution::get_distributed_post_types() ) ) { /* translators: unsupported post type for content distribution */ throw new \InvalidArgumentException( esc_html( sprintf( __( 'Post type %s is not supported as a distributed outgoing post.', 'newspack-network' ), $post->post_type ) ) ); @@ -120,7 +124,11 @@ public function set_distribution( $site_urls ) { // removing urls from the config. $distribution = array_unique( array_merge( $distribution, $site_urls ) ); - update_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, $distribution ); + $updated = update_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, $distribution ); + + if ( ! $updated ) { + return new WP_Error( 'update_failed', __( 'Failed to update post distribution.', 'newspack-network' ) ); + } return $distribution; } diff --git a/package.json b/package.json index 6c5854eb..7ce52a91 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "scripts": { "cm": "git-cz", "semantic-release": "newspack-scripts release --files=newspack-network.php", - "start": "npm ci", - "build": "echo 'No build step necessary for Newspack Network.'", - "watch": "echo 'No build step necessary for Newspack Network.'", + "clean": "rm -rf dist", + "start": "npm ci --legacy-peer-deps && npm run watch", + "build": "npm run clean && newspack-scripts wp-scripts build", + "watch": "npm run clean && newspack-scripts wp-scripts start", "test": "echo 'No JS unit tests in this repository.'", "lint": "npm run lint:scss && npm run lint:js", "lint:js": "echo 'No JS files in this repository.'", diff --git a/src/content-distribution/distribute/index.js b/src/content-distribution/distribute/index.js new file mode 100644 index 00000000..e0078fd4 --- /dev/null +++ b/src/content-distribution/distribute/index.js @@ -0,0 +1,244 @@ +/* globals newspack_network_distribute */ + +/** + * WordPress dependencies. + */ +import apiFetch from '@wordpress/api-fetch'; +import { sprintf, __, _n } from '@wordpress/i18n'; +import { useState, useEffect } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { PluginSidebar } from '@wordpress/editor'; +import { Panel, PanelBody, CheckboxControl, TextControl, Button } from '@wordpress/components'; +import { globe } from '@wordpress/icons'; +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies. + */ +import './style.scss'; + +const networkSites = newspack_network_distribute.network_sites; +const distributedMetaKey = newspack_network_distribute.distributed_meta; +const postTypeLabel = newspack_network_distribute.post_type_label; + +function Distribute() { + const [ search, setSearch ] = useState( '' ); + const [ isDistributing, setIsDistributing ] = useState( false ); + const [ distribution, setDistribution ] = useState( [] ); + const [ siteSelection, setSiteSelection ] = useState( [] ); + + const { postId, postStatus, savedUrls, hasChangedContent, isSavingPost, isCleanNewPost } = useSelect( select => { + const { + getCurrentPostId, + getCurrentPostAttribute, + hasChangedContent, + isSavingPost, + isCleanNewPost, + } = select( 'core/editor' ); + return { + postId: getCurrentPostId(), + postStatus: getCurrentPostAttribute( 'status' ), + savedUrls: getCurrentPostAttribute( 'meta' )?.[ distributedMetaKey ] || [], + hasChangedContent: hasChangedContent(), + isSavingPost: isSavingPost(), + isCleanNewPost: isCleanNewPost(), + }; + } ); + + useEffect( () => { + setSiteSelection( [] ); + }, [ postId ] ); + + useEffect( () => { + setDistribution( savedUrls ); + // Create notice if the post has been distributed. + if ( savedUrls.length > 0 ) { + createNotice( + 'warning', + sprintf( + _n( + 'This %s is distributed to one network site.', + 'This %s is distributed to %d network sites.', + savedUrls.length, + 'newspack-network' + ), + postTypeLabel.toLowerCase(), + savedUrls.length + ), + { + id: 'newspack-network-distributed-notice', + } + ); + } + }, [ savedUrls ] ); + + const { savePost } = useDispatch( 'core/editor' ); + const { createNotice } = useDispatch( 'core/notices' ); + + const sites = networkSites.filter( url => url.includes( search ) ); + + const selectableSites = networkSites.filter( url => ! distribution.includes( url ) ); + + const isUnpublished = postStatus !== 'publish'; + + const isDisabled = isUnpublished || isSavingPost || isDistributing || isCleanNewPost; + + const getFormattedSite = site => { + const url = new URL( site ); + return url.hostname; + } + + const distribute = () => { + setIsDistributing( true ); + apiFetch( { + path: `newspack-network/v1/content-distribution/distribute/${ postId }`, + method: 'POST', + data: { + urls: siteSelection, + }, + } ).then( urls => { + setDistribution( urls ); + setSiteSelection( [] ); + createNotice( + 'info', + sprintf( + _n( + '%s distributed to one network site.', + '%s distributed to %d network sites.', + urls.length, + 'newspack-network' + ), + postTypeLabel, + urls.length + ), + { + type: 'snackbar', + isDismissible: true, + } + ); + } ).catch( error => { + createNotice( 'error', error.message ); + } ).finally( () => { + setIsDistributing( false ); + } ); + } + + return ( + + + + { ! distribution.length ? ( +

+ { isUnpublished ? ( + sprintf( __( 'This %s has not been published yet. Please publish the %s before distributing it to any network sites.', 'newspack-network' ), postTypeLabel.toLowerCase(), postTypeLabel.toLowerCase() ) + ) : networkSites.length === 1 ? + sprintf( __( 'This %s has not been distributed to your network site yet.', 'newspack-network' ), postTypeLabel.toLowerCase() ) : + sprintf( __( 'This %s has not been distributed to any network sites yet.', 'newspack-network' ), postTypeLabel.toLowerCase() ) + } +

+ ) : ( +

+ { sprintf( + _n( + 'This %s has been distributed to one network site.', + 'This %s has been distributed to %d network sites.', + distribution.length, + 'newspack-network' + ), + postTypeLabel.toLowerCase(), + distribution.length + ) } +

+ ) } + { networkSites.length > 5 && ( + + ) } +
+ + { networkSites.length > 1 && selectableSites.length !== 0 && sites.length === networkSites.length && ( + 0 && siteSelection.length < selectableSites.length } + onChange={ checked => { + setSiteSelection( checked ? selectableSites : [] ); + } } + /> + ) } + { sites.map( siteUrl => ( + { + const urls = checked ? [ ...siteSelection, siteUrl ] : siteSelection.filter( url => siteUrl !== url ); + setSiteSelection( urls ); + } } + /> + ) ) } + + + { siteSelection.length > 0 && ( +

+ { sprintf( + _n( + 'One network site selected.', + '%d network sites selected.', + siteSelection.length, + 'newspack-network' + ), + siteSelection.length + ) } +

+ ) } + { siteSelection.length > 0 && ( + + ) } + +
+
+
+ ); +} + +registerPlugin( 'newspack-network-distribute', { + render: Distribute, + icon: globe, +} ); diff --git a/src/content-distribution/distribute/style.scss b/src/content-distribution/distribute/style.scss new file mode 100644 index 00000000..37a1424f --- /dev/null +++ b/src/content-distribution/distribute/style.scss @@ -0,0 +1,46 @@ +.newspack-network-distribute { + height: calc(100% - 47px); + .components-panel { + height: 100%; + display: flex; + flex-direction: column; + } + .components-panel__body { + border: 0; + &.distribute-header { + > *:last-child, + .components-base-control__field { + margin: 0; + } + } + &.distribute-body { + flex: 1 1 100%; + overflow-y: auto; + padding-top: 0; + .components-checkbox-control { + padding: 12px 0; + .components-base-control__field { + margin-bottom: 0; + } + label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + &:has( input:disabled ) { + opacity: 0.5; + } + &:has( input[name="select-all"] ) label { + font-weight: 600; + } + } + } + &.distribute-footer { + button { + display: block; + width: 100%; + margin-top: 1em; + } + } + } +} diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/test-outgoing-post.php index ef55f56f..854172cc 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/test-outgoing-post.php @@ -72,10 +72,21 @@ public function test_add_site_url() { * Test set post distribution. */ public function test_set_distribution() { - $result = $this->outgoing_post->set_distribution( [ $this->network[0]['url'] ] ); + $result = $this->outgoing_post->set_distribution( [ $this->network[1]['url'] ] ); $this->assertFalse( is_wp_error( $result ) ); } + /** + * Test non-published post. + */ + public function test_non_published_post() { + $post = $this->factory->post->create_and_get( [ 'post_type' => 'post', 'post_status' => 'draft' ] ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound + // Assert the instantiating an Outgoing_Post throws an exception. + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Only published post are allowed to be distributed.' ); + new Outgoing_Post( $post ); + } + /** * Test get post distribution. */ diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 00000000..2cdcf652 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,16 @@ +/** + **** WARNING: No ES6 modules here. Not transpiled! **** + */ +/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable @typescript-eslint/no-var-requires */ + +const getBaseWebpackConfig = require( 'newspack-scripts/config/getWebpackConfig' ); +const path = require( 'path' ); + +module.exports = getBaseWebpackConfig( + { + entry: { + distribute: path.join( __dirname, 'src', 'content-distribution', 'distribute' ), + }, + } +); From deb268310406d4dfa42caa7b4c32a2927980ce62 Mon Sep 17 00:00:00 2001 From: leogermani Date: Fri, 20 Dec 2024 12:40:12 -0300 Subject: [PATCH 11/20] feat: limit purchase of a network membership (#169) * feat: limit purchase of a network membership WIP * fix: validate checkout for logged out readers * feat: improve error message * fix: update copy --------- Co-authored-by: Rasmy Nguyen --- .../class-limit-purchase.php | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/includes/woocommerce-memberships/class-limit-purchase.php b/includes/woocommerce-memberships/class-limit-purchase.php index e9cccbdb..c145886d 100644 --- a/includes/woocommerce-memberships/class-limit-purchase.php +++ b/includes/woocommerce-memberships/class-limit-purchase.php @@ -20,26 +20,36 @@ class Limit_Purchase { public static function init() { add_filter( 'woocommerce_subscription_is_purchasable', [ __CLASS__, 'restrict_network_subscriptions' ], 10, 2 ); add_filter( 'woocommerce_cart_product_cannot_be_purchased_message', [ __CLASS__, 'woocommerce_cart_product_cannot_be_purchased_message' ], 10, 2 ); + + // Also limit purchase for logged out users, inferring their IDs from the email. + add_action( 'woocommerce_after_checkout_validation', [ __CLASS__, 'validate_network_subscription' ], 10, 2 ); } /** - * Restricts subscription purchasing from a network-synchronized plan to one. + * Restricts subscription purchasing from a network-synchronized plan to one for logged in readers. * * @param bool $purchasable Whether the subscription product is purchasable. * @param \WC_Product_Subscription|\WC_Product_Subscription_Variation $subscription_product The subscription product. * @return bool */ public static function restrict_network_subscriptions( $purchasable, $subscription_product ) { + if ( ! is_user_logged_in() ) { + return $purchasable; + } return self::get_network_equivalent_subscription_for_current_user( $subscription_product ) ? false : $purchasable; } /** - * Given a product, check if the current user has an active subscription in another site that gives access to the same membership. + * Given a product, check if the user has an active subscription in another site that gives access to the same membership. * * @param \WC_Product $product Product data. + * @param int|null $user_id User ID, defaults to the current user. */ - private static function get_network_equivalent_subscription_for_current_user( \WC_Product $product ) { - $user_id = get_current_user_id(); + private static function get_network_equivalent_subscription_for_current_user( \WC_Product $product, $user_id = null ) { + if ( is_null( $user_id ) ) { + $user_id = self::get_user_id_from_email(); + } + if ( ! $user_id ) { return; } @@ -105,4 +115,44 @@ public static function woocommerce_cart_product_cannot_be_purchased_message( $me } return $message; } + + /** + * Get user from email. + * + * @return false|int User ID if found by email address, false otherwise. + */ + private static function get_user_id_from_email() { + $billing_email = filter_input( INPUT_POST, 'billing_email', FILTER_SANITIZE_EMAIL ); + if ( $billing_email ) { + $customer = \get_user_by( 'email', $billing_email ); + if ( $customer ) { + return $customer->ID; + } + } + return false; + } + + /** + * Validate network subscription for logged out readers. + * + * @param array $data Checkout data. + * @param WC_Errors $errors Checkout errors. + */ + public static function validate_network_subscription( $data, $errors ) { + if ( is_user_logged_in() || ! function_exists( 'WC' ) ) { + return; + } + $id_from_email = self::get_user_id_from_email(); + if ( $id_from_email ) { + $cart_items = WC()->cart->get_cart(); + foreach ( $cart_items as $cart_item ) { + $product = $cart_item['data']; + $network_active_subscription = self::get_network_equivalent_subscription_for_current_user( $product, $id_from_email ); + if ( $network_active_subscription ) { + $error_message = __( 'Oops! You already have a subscription on another site in this network that grants you access to this site as well. Please log in using the same email address.', 'newspack-network' ); + $errors->add( 'network_subscription', $error_message ); + } + } + } + } } From 52852851909b02f2876c737ec351f77ee263ea05 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 23 Dec 2024 09:31:18 -0300 Subject: [PATCH 12/20] feat(content-distribution): capability and admin page (#176) --- includes/class-content-distribution.php | 2 + includes/content-distribution/class-admin.php | 321 ++++++++++++++++++ includes/content-distribution/class-api.php | 2 +- .../content-distribution/class-editor.php | 6 +- includes/hub/class-distributor-settings.php | 4 + .../test-content-distribution-admin.php | 59 ++++ 6 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 includes/content-distribution/class-admin.php create mode 100644 tests/unit-tests/test-content-distribution-admin.php diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index a2c9bace..c3929872 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -9,6 +9,7 @@ use Newspack\Data_Events; use Newspack_Network\Content_Distribution\CLI; +use Newspack_Network\Content_Distribution\Admin; use Newspack_Network\Content_Distribution\API; use Newspack_Network\Content_Distribution\Editor; use Newspack_Network\Content_Distribution\Incoming_Post; @@ -49,6 +50,7 @@ public static function init() { add_action( 'before_delete_post', [ __CLASS__, 'handle_post_deleted' ] ); add_action( 'newspack_network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ], 10, 3 ); + Admin::init(); CLI::init(); API::init(); Editor::init(); diff --git a/includes/content-distribution/class-admin.php b/includes/content-distribution/class-admin.php new file mode 100644 index 00000000..2e0be3f2 --- /dev/null +++ b/includes/content-distribution/class-admin.php @@ -0,0 +1,321 @@ + self::CANONICAL_NODE_OPTION_NAME, + 'label' => esc_html__( 'Node the Canonical URLs should point to', 'newspack-network' ), + 'callback' => [ __CLASS__, 'canonical_node_callback' ], + ]; + } + + $settings[] = [ + 'key' => self::CAPABILITY_ROLES_OPTION_NAME, + 'label' => esc_html__( 'Roles Allowed to Distribute', 'newspack-network' ), + 'callback' => [ __CLASS__, 'capability_roles_callback' ], + ]; + + foreach ( $settings as $setting ) { + add_settings_field( + $setting['key'], + $setting['label'], + $setting['callback'], + self::PAGE_SLUG, + self::SETTINGS_SECTION + ); + register_setting( + self::PAGE_SLUG, + $setting['key'], + $setting['args'] ?? [] + ); + } + } + + /** + * The canonical node setting callback + * + * @return void + */ + public static function canonical_node_callback() { + $current = self::get_canonical_node(); + + Nodes::nodes_dropdown( $current, self::CANONICAL_NODE_OPTION_NAME, __( 'Default', 'newspack-network' ) ); + + printf( + '
%1$s', + esc_html__( 'By default, canonical URLs will point to the site where the post was created. Modify this setting if you want them to point to one of the nodes.', 'newspack-network' ) + ); + printf( + '
%1$s', + esc_html__( 'Note: This assumes that all sites use the same permalink structure for posts.', 'newspack-network' ) + ); + } + + /** + * The distribute capability roles setting callback + * + * @return void + */ + public static function capability_roles_callback() { + global $wp_roles; + + foreach ( $wp_roles->roles as $role_key => $role ) { + $role_obj = get_role( $role_key ); + + // Bail if role can't edit posts. + if ( ! $role_obj->has_cap( 'edit_posts' ) ) { + continue; + } + + $role_name = $role['name']; + $role_key = $role_obj->name; + + $checked = ''; + if ( $role_obj->has_cap( self::CAPABILITY ) || 'administrator' === $role_key ) { + $checked = 'checked'; + } + + $disabled = ''; + if ( 'administrator' === $role_key ) { + $disabled = 'disabled'; + } + + printf( + '

', + esc_attr( self::CAPABILITY_ROLES_OPTION_NAME ), + esc_attr( $role_key ), + esc_attr( $checked ), + esc_attr( $disabled ), + esc_html( $role_name ) + ); + } + + printf( + '
%1$s', + esc_html__( 'Select the roles of users on this site that will be allowed to distribute content to sites in the network.', 'newspack-network' ) + ); + } + + /** + * Renders the settings page + * + * @return void + */ + public static function render() { + ?> +
+ +
+ +

+ ' /> +

+
+
+ has_cap( self::CAPABILITY ) ) { + $role_obj->add_cap( self::CAPABILITY ); + } + } + $all_roles = wp_roles(); + foreach ( $all_roles->roles as $role_key => $role ) { + $role_obj = get_role( $role_key ); + if ( ! in_array( $role_key, $selected_roles, true ) && $role_obj->has_cap( self::CAPABILITY ) ) { + $role_obj->remove_cap( self::CAPABILITY ); + } + } + } + + /** + * Update option callback + * + * @param mixed $old_value The old value. + * @param mixed $value The new value. + * @param string $option The option name. + * @return array + */ + public static function dispatch_canonical_url_updated_event( $old_value, $value, $option ) { + if ( '0' === (string) $value ) { + return [ + 'url' => get_bloginfo( 'url' ), + ]; + } + $node = new Node( $value ); + $node_url = $node->get_url(); + if ( ! $node_url ) { + $node_url = ''; + } + + return [ + 'url' => $node_url, + ]; + } +} diff --git a/includes/content-distribution/class-api.php b/includes/content-distribution/class-api.php index bcad93f2..38004493 100644 --- a/includes/content-distribution/class-api.php +++ b/includes/content-distribution/class-api.php @@ -41,7 +41,7 @@ public static function register_routes() { ], ], 'permission_callback' => function() { - return current_user_can( 'edit_posts' ); // @TODO Custom capability. + return current_user_can( Admin::CAPABILITY ); }, ] ); diff --git a/includes/content-distribution/class-editor.php b/includes/content-distribution/class-editor.php index 4f7b5ace..a40a8a7e 100644 --- a/includes/content-distribution/class-editor.php +++ b/includes/content-distribution/class-editor.php @@ -45,7 +45,7 @@ public static function register_meta() { ], ], 'auth_callback' => function() { - return current_user_can( 'edit_posts' ); // @TODO Custom capability. + return current_user_can( Admin::CAPABILITY ); }, ] ); @@ -63,6 +63,10 @@ public static function enqueue_block_editor_assets() { return; } + if ( ! current_user_can( Admin::CAPABILITY ) ) { + return; + } + $post = get_post(); // Don't enqueue the script for incoming posts. diff --git a/includes/hub/class-distributor-settings.php b/includes/hub/class-distributor-settings.php index d0c924c7..a8f74669 100644 --- a/includes/hub/class-distributor-settings.php +++ b/includes/hub/class-distributor-settings.php @@ -37,6 +37,10 @@ class Distributor_Settings { * @return void */ public static function init() { + // Bail if Content Distribution is enabled. + if ( defined( 'NEWPACK_NETWORK_CONTENT_DISTRIBUTION' ) && NEWPACK_NETWORK_CONTENT_DISTRIBUTION ) { + return; + } add_action( 'admin_init', [ __CLASS__, 'register_settings' ] ); add_action( 'admin_menu', [ __CLASS__, 'add_menu' ] ); add_filter( 'allowed_options', [ __CLASS__, 'allowed_options' ] ); diff --git a/tests/unit-tests/test-content-distribution-admin.php b/tests/unit-tests/test-content-distribution-admin.php new file mode 100644 index 00000000..17afd2aa --- /dev/null +++ b/tests/unit-tests/test-content-distribution-admin.php @@ -0,0 +1,59 @@ +assertNotEmpty( $roles ); + $this->assertContains( 'administrator', $roles ); + $this->assertContains( 'editor', $roles ); + $this->assertContains( 'author', $roles ); + } + + /** + * Test default roles capability. + */ + public function test_default_roles_capability() { + $default_roles = get_option( Admin::CAPABILITY_ROLES_OPTION_NAME ); + $all_roles = wp_roles(); + foreach ( $all_roles->roles as $role_key => $role ) { + $role_obj = get_role( $role_key ); + if ( in_array( $role_key, $default_roles, true ) ) { + $this->assertTrue( $role_obj->has_cap( Admin::CAPABILITY ) ); + } else { + $this->assertFalse( $role_obj->has_cap( Admin::CAPABILITY ) ); + } + } + } + + /** + * Test updating roles. + */ + public function test_update_roles() { + $roles = get_option( Admin::CAPABILITY_ROLES_OPTION_NAME ); + $roles[] = 'contributor'; + update_option( Admin::CAPABILITY_ROLES_OPTION_NAME, $roles ); + + $role_obj = get_role( 'contributor' ); + $this->assertTrue( $role_obj->has_cap( Admin::CAPABILITY ) ); + + $roles = get_option( Admin::CAPABILITY_ROLES_OPTION_NAME ); + $roles = array_diff( $roles, [ 'contributor' ] ); + update_option( Admin::CAPABILITY_ROLES_OPTION_NAME, $roles ); + + $role_obj = get_role( 'contributor' ); + $this->assertFalse( $role_obj->has_cap( Admin::CAPABILITY ) ); + } +} From 067118191ebcc439a663c4258d1a95ef32eec8f8 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 23 Dec 2024 12:34:51 -0300 Subject: [PATCH 13/20] feat(event-log): collapse data --- includes/hub/admin/class-event-log-list-table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/hub/admin/class-event-log-list-table.php b/includes/hub/admin/class-event-log-list-table.php index e9e779f8..8f142147 100644 --- a/includes/hub/admin/class-event-log-list-table.php +++ b/includes/hub/admin/class-event-log-list-table.php @@ -142,7 +142,7 @@ public function column_default( $item, $column_name ) { case 'action_name': return $item->get_action_name(); case 'data': - return '' . $item->get_raw_data() . ''; + return ''; default: return ''; } From 876351a67e62e7e2097f4a8eb82bc84328229ab2 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 23 Dec 2024 12:35:32 -0300 Subject: [PATCH 14/20] Revert "feat(event-log): collapse data" This reverts commit 067118191ebcc439a663c4258d1a95ef32eec8f8. --- includes/hub/admin/class-event-log-list-table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/hub/admin/class-event-log-list-table.php b/includes/hub/admin/class-event-log-list-table.php index 8f142147..e9e779f8 100644 --- a/includes/hub/admin/class-event-log-list-table.php +++ b/includes/hub/admin/class-event-log-list-table.php @@ -142,7 +142,7 @@ public function column_default( $item, $column_name ) { case 'action_name': return $item->get_action_name(); case 'data': - return ''; + return '' . $item->get_raw_data() . ''; default: return ''; } From 5ca60cee132f811c5efadc8d91778410a562880d Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 6 Jan 2025 09:37:16 -0300 Subject: [PATCH 15/20] feat(content-distribution): canonical url (#177) --- includes/class-content-distribution.php | 3 + .../class-canonical-url.php | 91 +++++++++++++++++++ .../class-incoming-post.php | 26 +++++- .../class-canonical-url-updated.php | 2 +- .../content-distribution/test-admin.php | 61 +++++++++++++ .../test-canonical-url.php | 43 +++++++++ .../test-content-distribution.php | 4 +- .../test-incoming-post.php | 70 +++++--------- .../test-outgoing-post.php | 6 +- .../unit-tests/content-distribution/util.php | 71 +++++++++++++++ 10 files changed, 326 insertions(+), 51 deletions(-) create mode 100644 includes/content-distribution/class-canonical-url.php create mode 100644 tests/unit-tests/content-distribution/test-admin.php create mode 100644 tests/unit-tests/content-distribution/test-canonical-url.php rename tests/unit-tests/{ => content-distribution}/test-content-distribution.php (94%) rename tests/unit-tests/{ => content-distribution}/test-incoming-post.php (89%) rename tests/unit-tests/{ => content-distribution}/test-outgoing-post.php (97%) create mode 100644 tests/unit-tests/content-distribution/util.php diff --git a/includes/class-content-distribution.php b/includes/class-content-distribution.php index c3929872..4b69dc0b 100644 --- a/includes/class-content-distribution.php +++ b/includes/class-content-distribution.php @@ -12,6 +12,7 @@ use Newspack_Network\Content_Distribution\Admin; use Newspack_Network\Content_Distribution\API; use Newspack_Network\Content_Distribution\Editor; +use Newspack_Network\Content_Distribution\Canonical_Url; use Newspack_Network\Content_Distribution\Incoming_Post; use Newspack_Network\Content_Distribution\Outgoing_Post; use WP_Post; @@ -54,6 +55,8 @@ public static function init() { CLI::init(); API::init(); Editor::init(); + + Canonical_Url::init(); } /** diff --git a/includes/content-distribution/class-canonical-url.php b/includes/content-distribution/class-canonical-url.php new file mode 100644 index 00000000..2e0eab89 --- /dev/null +++ b/includes/content-distribution/class-canonical-url.php @@ -0,0 +1,91 @@ +ID ); + + if ( ! $incoming_post->is_linked() ) { + return $canonical_url; + } + + $canonical_url = $incoming_post->get_original_post_url(); + + $base_url = get_option( self::OPTION_NAME, '' ); + if ( $base_url ) { + $canonical_url = str_replace( $incoming_post->get_original_site_url(), $base_url, $canonical_url ); + } + + return $canonical_url; + } + + /** + * Handles the canonical URL change for distributed content when Yoast SEO is in use. + * + * @param string $canonical_url The Yoast WPSEO deduced canonical URL. + * + * @return string $canonical_url The updated distributor friendly URL. + */ + public static function wpseo_canonical_url( $canonical_url ) { + + // Return as is if not on a singular page - taken from rel_canonical(). + if ( ! is_singular() ) { + return $canonical_url; + } + + $id = get_queried_object_id(); + + // Return as is if we do not have a object id for context - taken from rel_canonical(). + if ( 0 === $id ) { + return $canonical_url; + } + + $post = get_post( $id ); + + // Return as is if we don't have a valid post object - taken from wp_get_canonical_url(). + if ( ! $post ) { + return $canonical_url; + } + + // Return as is if current post is not published - taken from wp_get_canonical_url(). + if ( 'publish' !== $post->post_status ) { + return $canonical_url; + } + + return self::filter_canonical_url( $canonical_url, $post ); + } +} diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index dcbc6734..cb9f002e 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -74,7 +74,10 @@ class Incoming_Post { * not configured for distribution. */ public function __construct( $payload ) { + $post = null; + if ( is_numeric( $payload ) ) { + $post = get_post( $payload ); $payload = get_post_meta( $payload, self::PAYLOAD_META, true ); } @@ -87,7 +90,10 @@ public function __construct( $payload ) { $this->payload = $payload; $this->network_post_id = $payload['network_post_id']; - $post = $this->query_post(); + if ( ! $post ) { + $post = $this->query_post(); + } + if ( $post ) { $this->ID = $post->ID; $this->post = $post; @@ -134,6 +140,24 @@ protected function get_post_payload() { return get_post_meta( $this->ID, self::PAYLOAD_META, true ); } + /** + * Get the post original URL. + * + * @return string The post original post URL. Empty string if not found. + */ + public function get_original_post_url() { + return $this->payload['post_url'] ?? ''; + } + + /** + * Get the post original site URL. + * + * @return string The post original site URL. Empty string if not found. + */ + public function get_original_site_url() { + return $this->payload['site_url'] ?? ''; + } + /** * Find the post from the payload's network post ID. * diff --git a/includes/incoming-events/class-canonical-url-updated.php b/includes/incoming-events/class-canonical-url-updated.php index 0a108a90..c68cfb6e 100644 --- a/includes/incoming-events/class-canonical-url-updated.php +++ b/includes/incoming-events/class-canonical-url-updated.php @@ -7,7 +7,7 @@ namespace Newspack_Network\Incoming_Events; -use Newspack_Network\Distributor_Customizations\Canonical_Url; +use Newspack_Network\Content_Distribution\Canonical_Url; /** * Class to handle the Canonical Url Updated Event diff --git a/tests/unit-tests/content-distribution/test-admin.php b/tests/unit-tests/content-distribution/test-admin.php new file mode 100644 index 00000000..7a48b31a --- /dev/null +++ b/tests/unit-tests/content-distribution/test-admin.php @@ -0,0 +1,61 @@ +assertNotEmpty( $roles ); + $this->assertContains( 'administrator', $roles ); + $this->assertContains( 'editor', $roles ); + $this->assertContains( 'author', $roles ); + } + + /** + * Test default roles capability. + */ + public function test_default_roles_capability() { + $default_roles = get_option( Admin::CAPABILITY_ROLES_OPTION_NAME ); + $all_roles = wp_roles(); + foreach ( $all_roles->roles as $role_key => $role ) { + $role_obj = get_role( $role_key ); + if ( in_array( $role_key, $default_roles, true ) ) { + $this->assertTrue( $role_obj->has_cap( Admin::CAPABILITY ) ); + } else { + $this->assertFalse( $role_obj->has_cap( Admin::CAPABILITY ) ); + } + } + } + + /** + * Test updating roles. + */ + public function test_update_roles() { + $roles = get_option( Admin::CAPABILITY_ROLES_OPTION_NAME ); + $roles[] = 'contributor'; + update_option( Admin::CAPABILITY_ROLES_OPTION_NAME, $roles ); + + $role_obj = get_role( 'contributor' ); + $this->assertTrue( $role_obj->has_cap( Admin::CAPABILITY ) ); + + $roles = get_option( Admin::CAPABILITY_ROLES_OPTION_NAME ); + $roles = array_diff( $roles, [ 'contributor' ] ); + update_option( Admin::CAPABILITY_ROLES_OPTION_NAME, $roles ); + + $role_obj = get_role( 'contributor' ); + $this->assertFalse( $role_obj->has_cap( Admin::CAPABILITY ) ); + } +} diff --git a/tests/unit-tests/content-distribution/test-canonical-url.php b/tests/unit-tests/content-distribution/test-canonical-url.php new file mode 100644 index 00000000..3d190e22 --- /dev/null +++ b/tests/unit-tests/content-distribution/test-canonical-url.php @@ -0,0 +1,43 @@ +insert( $payload ); + + wp_publish_post( $post_id ); + + $this->assertEquals( $payload['post_url'], wp_get_canonical_url( get_post( $post_id ) ) ); + } + + /** + * Test custom canonical URL base. + */ + public function test_custom_canonical_url() { + update_option( 'newspack_network_canonical_url', 'https://custom.test' ); + + $payload = get_sample_payload( '', get_bloginfo( 'url' ) ); + $incoming_post = new Incoming_Post( $payload ); + $post_id = $incoming_post->insert( $payload ); + + wp_publish_post( $post_id ); + + $this->assertEquals( 'https://custom.test/2021/01/slug', wp_get_canonical_url( get_post( $post_id ) ) ); + } +} diff --git a/tests/unit-tests/test-content-distribution.php b/tests/unit-tests/content-distribution/test-content-distribution.php similarity index 94% rename from tests/unit-tests/test-content-distribution.php rename to tests/unit-tests/content-distribution/test-content-distribution.php index 2bf72b9f..d05bc06e 100644 --- a/tests/unit-tests/test-content-distribution.php +++ b/tests/unit-tests/content-distribution/test-content-distribution.php @@ -5,6 +5,8 @@ * @package Newspack_Network */ +namespace Test\Content_Distribution; + use Newspack_Network\Content_Distribution; use Newspack_Network\Content_Distribution\Outgoing_Post; use Newspack_Network\Hub\Node as Hub_Node; @@ -12,7 +14,7 @@ /** * Test the Content_Distribution class. */ -class TestContentDistribution extends WP_UnitTestCase { +class TestContentDistribution extends \WP_UnitTestCase { /** * "Mocked" network nodes. * diff --git a/tests/unit-tests/test-incoming-post.php b/tests/unit-tests/content-distribution/test-incoming-post.php similarity index 89% rename from tests/unit-tests/test-incoming-post.php rename to tests/unit-tests/content-distribution/test-incoming-post.php index c315f39d..27ad1bce 100644 --- a/tests/unit-tests/test-incoming-post.php +++ b/tests/unit-tests/content-distribution/test-incoming-post.php @@ -5,12 +5,14 @@ * @package Newspack_Network */ +namespace Test\Content_Distribution; + use Newspack_Network\Content_Distribution\Incoming_Post; /** * Test the Incoming_Post class. */ -class TestIncomingPost extends WP_UnitTestCase { +class TestIncomingPost extends \WP_UnitTestCase { /** * URL for node that distributes posts. * @@ -36,51 +38,7 @@ class TestIncomingPost extends WP_UnitTestCase { * Get sample post payload. */ private function get_sample_payload() { - return [ - 'site_url' => $this->node_1, - 'post_id' => 1, - 'network_post_id' => '1234567890abcdef1234567890abcdef', - 'sites' => [ $this->node_2 ], - 'post_data' => [ - 'title' => 'Title', - 'post_status' => 'publish', - 'date_gmt' => '2021-01-01 00:00:00', - 'modified_gmt' => '2021-01-01 00:00:00', - 'slug' => 'slug', - 'post_type' => 'post', - 'raw_content' => 'Content', - 'content' => '

Content

', - 'excerpt' => 'Excerpt', - 'thumbnail_url' => 'https://picsum.photos/id/1/300/300.jpg', - 'taxonomy' => [ - 'category' => [ - [ - 'name' => 'Category 1', - 'slug' => 'category-1', - ], - [ - 'name' => 'Category 2', - 'slug' => 'category-2', - ], - ], - 'post_tag' => [ - [ - 'name' => 'Tag 1', - 'slug' => 'tag-1', - ], - [ - 'name' => 'Tag 2', - 'slug' => 'tag-2', - ], - ], - ], - 'post_meta' => [ - 'single' => [ 'value' ], - 'array' => [ [ 'a' => 'b', 'c' => 'd' ] ], // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound - 'multiple' => [ 'value 1', 'value 2' ], - ], - ], - ]; + return get_sample_payload( $this->node_1, $this->node_2 ); } /** @@ -222,6 +180,26 @@ public function test_insert_post_when_unlinked() { $this->assertSame( 'Custom Content', $incoming_post->post_content ); } + /** + * Test get original post URL. + */ + public function test_get_original_post_url() { + $post_id = $this->incoming_post->insert(); + $original_url = $this->incoming_post->get_original_post_url(); + $payload = $this->get_sample_payload(); + $this->assertSame( $payload['post_url'], $original_url ); + } + + /** + * Test get original site URL. + */ + public function test_get_original_site_url() { + $post_id = $this->incoming_post->insert(); + $original_url = $this->incoming_post->get_original_site_url(); + $payload = $this->get_sample_payload(); + $this->assertSame( $payload['site_url'], $original_url ); + } + /** * Test relink post. */ diff --git a/tests/unit-tests/test-outgoing-post.php b/tests/unit-tests/content-distribution/test-outgoing-post.php similarity index 97% rename from tests/unit-tests/test-outgoing-post.php rename to tests/unit-tests/content-distribution/test-outgoing-post.php index 854172cc..0e58f57b 100644 --- a/tests/unit-tests/test-outgoing-post.php +++ b/tests/unit-tests/content-distribution/test-outgoing-post.php @@ -5,13 +5,15 @@ * @package Newspack_Network */ +namespace Test\Content_Distribution; + use Newspack_Network\Content_Distribution\Outgoing_Post; use Newspack_Network\Hub\Node as Hub_Node; /** * Test the Outgoing_Post class. */ -class TestOutgoingPost extends WP_UnitTestCase { +class TestOutgoingPost extends \WP_UnitTestCase { /** * "Mocked" network nodes. * @@ -82,7 +84,7 @@ public function test_set_distribution() { public function test_non_published_post() { $post = $this->factory->post->create_and_get( [ 'post_type' => 'post', 'post_status' => 'draft' ] ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound // Assert the instantiating an Outgoing_Post throws an exception. - $this->expectException( Exception::class ); + $this->expectException( \Exception::class ); $this->expectExceptionMessage( 'Only published post are allowed to be distributed.' ); new Outgoing_Post( $post ); } diff --git a/tests/unit-tests/content-distribution/util.php b/tests/unit-tests/content-distribution/util.php new file mode 100644 index 00000000..f145a190 --- /dev/null +++ b/tests/unit-tests/content-distribution/util.php @@ -0,0 +1,71 @@ + $origin, + 'post_id' => 1, + 'post_url' => $origin . '/2021/01/slug', + 'network_post_id' => '1234567890abcdef1234567890abcdef', + 'sites' => [ $destination ], + 'post_data' => [ + 'title' => 'Title', + 'post_status' => 'publish', + 'date_gmt' => '2021-01-01 00:00:00', + 'modified_gmt' => '2021-01-01 00:00:00', + 'slug' => 'slug', + 'post_type' => 'post', + 'raw_content' => 'Content', + 'content' => '

Content

', + 'excerpt' => 'Excerpt', + 'thumbnail_url' => 'https://picsum.photos/id/1/300/300.jpg', + 'taxonomy' => [ + 'category' => [ + [ + 'name' => 'Category 1', + 'slug' => 'category-1', + ], + [ + 'name' => 'Category 2', + 'slug' => 'category-2', + ], + ], + 'post_tag' => [ + [ + 'name' => 'Tag 1', + 'slug' => 'tag-1', + ], + [ + 'name' => 'Tag 2', + 'slug' => 'tag-2', + ], + ], + ], + 'post_meta' => [ + 'single' => [ 'value' ], + 'array' => [ [ 'a' => 'b', 'c' => 'd' ] ], // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound + 'multiple' => [ 'value 1', 'value 2' ], + ], + ], + ]; +} From 90c5425a5d5e52172a5de2e26003199112e8dd22 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 6 Jan 2025 12:00:53 -0300 Subject: [PATCH 16/20] feat(content-distribution): sync comment and ping statuses (#179) --- .../class-incoming-post.php | 16 +++++++----- .../class-outgoing-post.php | 26 ++++++++++--------- .../test-incoming-post.php | 25 ++++++++++++++++++ .../test-outgoing-post.php | 2 ++ .../unit-tests/content-distribution/util.php | 26 ++++++++++--------- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index cb9f002e..dda3ccbc 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -414,15 +414,17 @@ public function insert( $payload = [] ) { } $postarr = [ - 'ID' => $this->ID, - 'post_date_gmt' => $post_data['date_gmt'], - 'post_title' => $post_data['title'], - 'post_name' => $post_data['slug'], - 'post_content' => use_block_editor_for_post_type( $post_type ) ? + 'ID' => $this->ID, + 'post_date_gmt' => $post_data['date_gmt'], + 'post_title' => $post_data['title'], + 'post_name' => $post_data['slug'], + 'post_content' => use_block_editor_for_post_type( $post_type ) ? $post_data['raw_content'] : $post_data['content'], - 'post_excerpt' => $post_data['excerpt'], - 'post_type' => $post_type, + 'post_excerpt' => $post_data['excerpt'], + 'post_type' => $post_type, + 'comment_status' => $post_data['comment_status'], + 'ping_status' => $post_data['ping_status'], ]; // The default status for a new post is 'draft'. diff --git a/includes/content-distribution/class-outgoing-post.php b/includes/content-distribution/class-outgoing-post.php index 55e649fa..40e253ba 100644 --- a/includes/content-distribution/class-outgoing-post.php +++ b/includes/content-distribution/class-outgoing-post.php @@ -185,18 +185,20 @@ public function get_payload() { 'network_post_id' => $this->get_network_post_id(), 'sites' => $this->get_distribution(), 'post_data' => [ - 'title' => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ), - 'post_status' => $this->post->post_status, - 'date_gmt' => $this->post->post_date_gmt, - 'modified_gmt' => $this->post->post_modified_gmt, - 'slug' => $this->post->post_name, - 'post_type' => $this->post->post_type, - 'raw_content' => $this->post->post_content, - 'content' => $this->get_processed_post_content(), - 'excerpt' => $this->post->post_excerpt, - 'taxonomy' => $this->get_post_taxonomy_terms(), - 'thumbnail_url' => get_the_post_thumbnail_url( $this->post->ID, 'full' ), - 'post_meta' => $this->get_post_meta(), + 'title' => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ), + 'post_status' => $this->post->post_status, + 'date_gmt' => $this->post->post_date_gmt, + 'modified_gmt' => $this->post->post_modified_gmt, + 'slug' => $this->post->post_name, + 'post_type' => $this->post->post_type, + 'raw_content' => $this->post->post_content, + 'content' => $this->get_processed_post_content(), + 'excerpt' => $this->post->post_excerpt, + 'comment_status' => $this->post->comment_status, + 'ping_status' => $this->post->ping_status, + 'taxonomy' => $this->get_post_taxonomy_terms(), + 'thumbnail_url' => get_the_post_thumbnail_url( $this->post->ID, 'full' ), + 'post_meta' => $this->get_post_meta(), ], ]; } diff --git a/tests/unit-tests/content-distribution/test-incoming-post.php b/tests/unit-tests/content-distribution/test-incoming-post.php index 27ad1bce..708c6f97 100644 --- a/tests/unit-tests/content-distribution/test-incoming-post.php +++ b/tests/unit-tests/content-distribution/test-incoming-post.php @@ -413,4 +413,29 @@ public function test_reserved_taxonomies() { $terms = wp_get_post_terms( $post_id, $taxonomy ); $this->assertEmpty( $terms ); } + + /** + * Test comment and ping statuses. + */ + public function test_comment_and_ping_statuses() { + $payload = $this->get_sample_payload(); + + // Insert the linked post with comment and ping statuses. + $post_id = $this->incoming_post->insert( $payload ); + + // Assert that the post has the comment and ping statuses. + $this->assertSame( 'open', get_post_field( 'comment_status', $post_id ) ); + $this->assertSame( 'open', get_post_field( 'ping_status', $post_id ) ); + + // Update the comment and ping statuses. + $payload['post_data']['comment_status'] = 'closed'; + $payload['post_data']['ping_status'] = 'closed'; + + // Insert the updated linked post. + $this->incoming_post->insert( $payload ); + + // Assert that the post has the updated comment and ping statuses. + $this->assertSame( 'closed', get_post_field( 'comment_status', $post_id ) ); + $this->assertSame( 'closed', get_post_field( 'ping_status', $post_id ) ); + } } diff --git a/tests/unit-tests/content-distribution/test-outgoing-post.php b/tests/unit-tests/content-distribution/test-outgoing-post.php index 0e58f57b..6ca81296 100644 --- a/tests/unit-tests/content-distribution/test-outgoing-post.php +++ b/tests/unit-tests/content-distribution/test-outgoing-post.php @@ -143,6 +143,8 @@ public function test_get_payload() { 'raw_content', 'content', 'excerpt', + 'comment_status', + 'ping_status', 'thumbnail_url', 'taxonomy', 'post_meta', diff --git a/tests/unit-tests/content-distribution/util.php b/tests/unit-tests/content-distribution/util.php index f145a190..5b2806b5 100644 --- a/tests/unit-tests/content-distribution/util.php +++ b/tests/unit-tests/content-distribution/util.php @@ -29,17 +29,19 @@ function get_sample_payload( $origin = '', $destination = '' ) { 'network_post_id' => '1234567890abcdef1234567890abcdef', 'sites' => [ $destination ], 'post_data' => [ - 'title' => 'Title', - 'post_status' => 'publish', - 'date_gmt' => '2021-01-01 00:00:00', - 'modified_gmt' => '2021-01-01 00:00:00', - 'slug' => 'slug', - 'post_type' => 'post', - 'raw_content' => 'Content', - 'content' => '

Content

', - 'excerpt' => 'Excerpt', - 'thumbnail_url' => 'https://picsum.photos/id/1/300/300.jpg', - 'taxonomy' => [ + 'title' => 'Title', + 'post_status' => 'publish', + 'date_gmt' => '2021-01-01 00:00:00', + 'modified_gmt' => '2021-01-01 00:00:00', + 'slug' => 'slug', + 'post_type' => 'post', + 'raw_content' => 'Content', + 'content' => '

Content

', + 'excerpt' => 'Excerpt', + 'thumbnail_url' => 'https://picsum.photos/id/1/300/300.jpg', + 'comment_status' => 'open', + 'ping_status' => 'open', + 'taxonomy' => [ 'category' => [ [ 'name' => 'Category 1', @@ -61,7 +63,7 @@ function get_sample_payload( $origin = '', $destination = '' ) { ], ], ], - 'post_meta' => [ + 'post_meta' => [ 'single' => [ 'value' ], 'array' => [ [ 'a' => 'b', 'c' => 'd' ] ], // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound 'multiple' => [ 'value 1', 'value 2' ], From 8e076407f6a837201e8773cf88635ee405311a4d Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 6 Jan 2025 13:19:19 -0300 Subject: [PATCH 17/20] feat(content-distribution): posts column (#178) --- .../content-distribution/class-editor.php | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/includes/content-distribution/class-editor.php b/includes/content-distribution/class-editor.php index a40a8a7e..b5b48b3c 100644 --- a/includes/content-distribution/class-editor.php +++ b/includes/content-distribution/class-editor.php @@ -20,6 +20,9 @@ class Editor { public static function init() { add_action( 'init', [ __CLASS__, 'register_meta' ] ); add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] ); + add_filter( 'manage_posts_columns', [ __CLASS__, 'add_distribution_column' ], 10, 2 ); + add_action( 'manage_posts_custom_column', [ __CLASS__, 'render_distribution_column' ], 10, 2 ); + add_action( 'admin_footer', [ __CLASS__, 'add_posts_column_styles' ] ); } /** @@ -100,4 +103,124 @@ public static function enqueue_block_editor_assets() { ] ); } + + /** + * Add distribution column to the posts list. + * + * @param array $columns Columns. + * @param string $post_type Post type. + * + * @return array + */ + public static function add_distribution_column( $columns, $post_type ) { + if ( ! in_array( $post_type, Content_Distribution::get_distributed_post_types(), true ) ) { + return $columns; + } + $columns['content_distribution'] = sprintf( + '%2$s %1$s', + esc_attr__( 'Content Distribution', 'newspack-network' ), + '' + ); + return $columns; + } + + /** + * Render the distribution column. + * + * @param string $column Column. + * @param int $post_id Post ID. + * + * @return void + */ + public static function render_distribution_column( $column, $post_id ) { + if ( 'content_distribution' !== $column ) { + return; + } + + $is_incoming = Content_Distribution::is_post_incoming( $post_id ); + $is_outgoing = Content_Distribution::is_post_distributed( $post_id ); + + if ( ! $is_incoming && ! $is_outgoing ) { + return; + } + + $original_url = ''; + $original_site_url = ''; + + if ( $is_incoming ) { + try { + $incoming_post = new Incoming_Post( $post_id ); + $linked = $incoming_post->is_linked(); + $original_url = $incoming_post->get_original_post_url(); + $original_site_url = $incoming_post->get_original_site_url(); + } catch ( \Exception $e ) { + $linked = false; + } + printf( + $original_url ? + '%4$s%2$s %3$s' : + '%4$s%2$s', + esc_url( $original_url ), + $linked ? + sprintf( + // translators: %s is the original site URL. + esc_html__( 'Originally posted and linked to %s.', 'newspack-network' ), + esc_url( $original_site_url ) + ) : + sprintf( + // translators: %s is the original site URL. + esc_html__( 'Originally posted in %s and currently unlinked.', 'newspack-network' ), + esc_url( $original_site_url ) + ), + $original_url ? esc_html__( 'Click to visit the original post.', 'newspack-network' ) : '', + $linked ? + '' : + '' + ); + } else { + $outgoing_post = new Outgoing_Post( $post_id ); + $distribution_count = count( $outgoing_post->get_distribution() ); + printf( + '%1$s%2$s', + '', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + sprintf( + esc_html( + // translators: %s is the number of network sites the post has been distributed to. + _n( + 'This post has been distributed to %d network site.', + 'This post has been distributed to %d network sites.', + $distribution_count, + 'newspack-network' + ) + ), + esc_attr( number_format_i18n( $distribution_count ) ) + ) + ); + } + } + + /** + * Add posts columns styles. + */ + public static function add_posts_column_styles() { + $screen = get_current_screen(); + if ( ! in_array( $screen->post_type, Content_Distribution::get_distributed_post_types(), true ) ) { + return; + } + ?> + + Date: Tue, 7 Jan 2025 17:28:23 -0300 Subject: [PATCH 18/20] feat(event-log): collapse data (#180) --- .../hub/admin/class-event-log-list-table.php | 24 ++++++++++- includes/hub/admin/class-event-log.php | 20 +++++++++ includes/hub/admin/css/event-log.css | 43 ++++++++++++++++++- includes/hub/admin/js/event-log.js | 25 +++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 includes/hub/admin/js/event-log.js diff --git a/includes/hub/admin/class-event-log-list-table.php b/includes/hub/admin/class-event-log-list-table.php index e9e779f8..49619b69 100644 --- a/includes/hub/admin/class-event-log-list-table.php +++ b/includes/hub/admin/class-event-log-list-table.php @@ -122,6 +122,28 @@ protected function extra_tablenav( $which ) { get_data(), JSON_PRETTY_PRINT ); + if ( 300 > strlen( $str ) ) { + return sprintf( + '
%1$s
', + $str + ); + } + return sprintf( + '
', + $str, + esc_html__( 'Copy to clipboard', 'newspack-network' ) + ); + } + /** * Get the value for each column * @@ -142,7 +164,7 @@ public function column_default( $item, $column_name ) { case 'action_name': return $item->get_action_name(); case 'data': - return '' . $item->get_raw_data() . ''; + return $this->get_data_column( $item ); default: return ''; } diff --git a/includes/hub/admin/class-event-log.php b/includes/hub/admin/class-event-log.php index cc68ff7c..04f12b12 100644 --- a/includes/hub/admin/class-event-log.php +++ b/includes/hub/admin/class-event-log.php @@ -43,6 +43,26 @@ public static function admin_enqueue_scripts() { return; } + wp_enqueue_script( + 'newspack-network-event-log', + plugins_url( 'js/event-log.js', __FILE__ ), + [], + filemtime( NEWSPACK_NETWORK_PLUGIN_DIR . '/includes/hub/admin/js/event-log.js' ), + [ + 'in_footer' => true, + ] + ); + + wp_localize_script( + 'newspack-network-event-log', + 'newspackNetworkEventLogLabels', + [ + 'copy' => __( 'Copy to clipboard', 'newspack-network' ), + 'copying' => __( 'Copying...', 'newspack-network' ), + 'copied' => __( 'Copied ✓', 'newspack-network' ), + ] + ); + wp_enqueue_style( 'newspack-network-event-log', plugins_url( 'css/event-log.css', __FILE__ ), diff --git a/includes/hub/admin/css/event-log.css b/includes/hub/admin/css/event-log.css index 65144013..1f2ca974 100644 --- a/includes/hub/admin/css/event-log.css +++ b/includes/hub/admin/css/event-log.css @@ -1,3 +1,42 @@ .wp-list-table .column-id { - width: 40px; -} \ No newline at end of file + width: 40px; +} + +.newspack-network-data-column { + display: flex; + align-items: flex-end; + justify-content: flex-end; + position: relative; + min-height: 80px; +} +.newspack-network-data-column pre { + margin: 0; + flex: 1 1 100%; + background: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(220, 220, 222, 0.75); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); + color: rgba(44, 51, 56, 0.8); + border-radius: 4px; +} +.newspack-network-data-column pre code { + font-size: 11px; + background: transparent; +} +.newspack-network-data-column textarea { + width: 100%; + height: 80px; + overflow: hidden; + font-size: 11px; + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} +/* Add a fade effect to the textarea */ +.newspack-network-data-column textarea { + background-color: rgba(255, 255, 255, 0.5); + transition: background-color 0.5s; +} +.newspack-network-data-column button { + z-index: 2; +} diff --git a/includes/hub/admin/js/event-log.js b/includes/hub/admin/js/event-log.js new file mode 100644 index 00000000..1937c3c6 --- /dev/null +++ b/includes/hub/admin/js/event-log.js @@ -0,0 +1,25 @@ +/* globals newspackNetworkEventLogLabels */ +( function( $ ) { + $( document ).ready( function() { + const dataColumns = document.querySelectorAll( '.newspack-network-data-column' ); + dataColumns.forEach( function( column ) { + const button = column.querySelector( 'button' ); + const text = column.querySelector( 'textarea' ).value; + button.addEventListener( 'click', function( ev ) { + ev.preventDefault(); + button.textContent = newspackNetworkEventLogLabels.copying; + button.disabled = true; + navigator.clipboard.writeText( text ).then( function() { + button.textContent = newspackNetworkEventLogLabels.copied; + setTimeout( function() { + button.textContent = newspackNetworkEventLogLabels.copy; + button.disabled = false; + }, 1000 ); + } ).catch( function( err ) { + console.error( 'Failed to copy: ', err ); + button.disabled = false; + } ); + } ); + } ); + } ); +} )( jQuery ); From a694795de969959bd434861eb15be80f75021dbb Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 10 Jan 2025 14:21:06 -0300 Subject: [PATCH 19/20] test(content-distribution): post content (#183) --- .../post-content/classic.html | 14 ++ .../post-content/cover.html | 20 +++ .../post-content/gallery.html | 19 +++ .../test-post-content.php | 121 ++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 tests/unit-tests/content-distribution/post-content/classic.html create mode 100644 tests/unit-tests/content-distribution/post-content/cover.html create mode 100644 tests/unit-tests/content-distribution/post-content/gallery.html create mode 100644 tests/unit-tests/content-distribution/test-post-content.php diff --git a/tests/unit-tests/content-distribution/post-content/classic.html b/tests/unit-tests/content-distribution/post-content/classic.html new file mode 100644 index 00000000..d7e58dfb --- /dev/null +++ b/tests/unit-tests/content-distribution/post-content/classic.html @@ -0,0 +1,14 @@ +

Heading 2

+ +Strong paragraph +

Align middle

+

Align right

+
    +
  • List Item #1
  • +
  • List Item #2
  • +
+
    +
  1. Ordered List Item #1
  2. +
  3. Ordered List Item #2
  4. +
+Link diff --git a/tests/unit-tests/content-distribution/post-content/cover.html b/tests/unit-tests/content-distribution/post-content/cover.html new file mode 100644 index 00000000..31432876 --- /dev/null +++ b/tests/unit-tests/content-distribution/post-content/cover.html @@ -0,0 +1,20 @@ + +
+ +
+ +

Cover Title

+ +
+
+ diff --git a/tests/unit-tests/content-distribution/post-content/gallery.html b/tests/unit-tests/content-distribution/post-content/gallery.html new file mode 100644 index 00000000..f4dbcef5 --- /dev/null +++ b/tests/unit-tests/content-distribution/post-content/gallery.html @@ -0,0 +1,19 @@ + + + diff --git a/tests/unit-tests/content-distribution/test-post-content.php b/tests/unit-tests/content-distribution/test-post-content.php new file mode 100644 index 00000000..f75a91b2 --- /dev/null +++ b/tests/unit-tests/content-distribution/test-post-content.php @@ -0,0 +1,121 @@ +node_2 ); + update_option( 'home', $this->node_2 ); + } + + /** + * Get outgoing post payload with content. + * + * @param string $content The post content. + * + * @return array The outgoing post payload. + */ + private function get_outgoing_post_payload_with_content( $content ) { + $outgoing_post = $this->factory->post->create_and_get( [ 'post_content' => $content ] ); + $payload = ( new Outgoing_Post( $outgoing_post->ID ) )->get_payload(); + + // Mock distribution for the post. + $payload['site_url'] = $this->node_1; + $payload['sites'] = [ $this->node_2 ]; + + return $payload; + } + + /** + * Data provider for content. + */ + public function content() { + $files = scandir( __DIR__ . '/post-content' ); + $files = array_diff( $files, [ '.', '..' ] ); + return array_map( + function ( $file ) { + return [ pathinfo( $file, PATHINFO_FILENAME ) ]; + }, + $files + ); + } + + /** + * Assert that two contents are equal. + * + * @param string $expected The expected content. + * @param string $actual The actual content. + */ + private function assertEqualContent( $expected, $actual ) { + $expected = trim( $expected ); + $actual = trim( $actual ); + + /** + * Remove classes from tags to make comparison easier for blocks that uses + * wp_unique_id(). + */ + $expected = preg_replace( '/ class="[^"]+"/', '', $expected ); + $actual = preg_replace( '/ class="[^"]+"/', '', $actual ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * Test classic editor content. + * + * @param string $type The content type. + * + * @dataProvider content + */ + public function test_content( $type ) { + if ( 'classic' === $type ) { + add_filter( 'use_block_editor_for_post_type', '__return_false' ); + } + + $content = file_get_contents( __DIR__ . '/post-content/' . $type . '.html' ); + $payload = $this->get_outgoing_post_payload_with_content( $content ); + + $incoming_post = new Incoming_Post( $payload ); + $post_id = $incoming_post->insert(); + + $this->assertEqualContent( + apply_filters( 'the_content', get_post_field( 'post_content', $payload['post_id'] ) ), + apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ) + ); + + if ( 'classic' === $type ) { + remove_filter( 'use_block_editor_for_post_type', '__return_false' ); + } + } +} From 74c9119435f32370838fd0b249a2dcec1189ae26 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 10 Jan 2025 14:29:08 -0300 Subject: [PATCH 20/20] feat(content-distribution): log incoming post errors (#182) --- .../class-incoming-post.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index dda3ccbc..1a8b28f1 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -100,6 +100,21 @@ public function __construct( $payload ) { } } + /** + * Log a message. + * + * @param string $message The message to log. + * + * @return void + */ + protected function log( $message ) { + $prefix = '[Incoming Post]'; + if ( ! empty( $this->payload ) ) { + $prefix .= ' ' . $this->payload['network_post_id']; + } + Debugger::log( $prefix . ' ' . $message ); + } + /** * Validate a payload. * @@ -297,7 +312,7 @@ protected function upload_thumbnail() { $attachment_id = media_sideload_image( $thumbnail_url, $this->ID, '', 'id' ); if ( is_wp_error( $attachment_id ) ) { - Debugger::log( 'Failed to upload featured image for post ' . $this->ID . ' with message: ' . $attachment_id->get_error_message() ); + self::log( 'Failed to upload featured image for post ' . $this->ID . ' with message: ' . $attachment_id->get_error_message() ); return; } @@ -327,6 +342,7 @@ protected function update_taxonomy_terms() { if ( ! $term ) { $term = wp_insert_term( $term_data['name'], $taxonomy ); if ( is_wp_error( $term ) ) { + self::log( 'Failed to insert term ' . $term_data['name'] . ' for taxonomy ' . $taxonomy . ' with message: ' . $term->get_error_message() ); continue; } $term = get_term_by( 'id', $term['term_id'], $taxonomy, ARRAY_A ); @@ -395,6 +411,7 @@ public function insert( $payload = [] ) { if ( ! empty( $payload ) ) { $error = $this->update_payload( $payload ); if ( is_wp_error( $error ) ) { + self::log( 'Failed to update payload: ' . $error->get_error_message() ); return $error; } } @@ -410,6 +427,7 @@ public function insert( $payload = [] ) { ! empty( $current_payload ) && $current_payload['post_data']['modified_gmt'] > $post_data['modified_gmt'] ) { + self::log( 'Linked post content is newer than the post payload.' ); return new WP_Error( 'old_modified_date', __( 'Linked post content is newer than the post payload.', 'newspack-network' ) ); } @@ -446,11 +464,13 @@ public function insert( $payload = [] ) { $post_id = wp_insert_post( $postarr, true ); if ( is_wp_error( $post_id ) ) { + self::log( 'Failed to insert post with message: ' . $post_id->get_error_message() ); return $post_id; } // The wp_insert_post() function might return `0` on failure. if ( ! $post_id ) { + self::log( 'Failed to insert post.' ); return new WP_Error( 'insert_error', __( 'Error inserting post.', 'newspack-network' ) ); }