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. diff --git a/includes/class-accepted-actions.php b/includes/class-accepted-actions.php index b3ae46ac..3e6525e4 100644 --- a/includes/class-accepted-actions.php +++ b/includes/class-accepted-actions.php @@ -40,6 +40,8 @@ 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', + 'network_post_deleted' => 'Network_Post_Deleted', ]; /** @@ -61,5 +63,7 @@ class Accepted_Actions { 'network_nodes_synced', '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 new file mode 100644 index 00000000..4b69dc0b --- /dev/null +++ b/includes/class-content-distribution.php @@ -0,0 +1,365 @@ +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. + * + * @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 ) { + if ( ! class_exists( 'Newspack\Data_Events' ) ) { + return; + } + $data = [ + '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'], + ], + 'incoming' => [ + 'site_url' => get_bloginfo( 'url' ), + 'post_id' => $post_id, + 'post_url' => get_permalink( $post_id ), + 'is_linked' => $is_linked, + ], + ]; + Data_Events::dispatch( 'network_incoming_post_inserted', $data ); + } + + /** + * 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 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 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. + * + * @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 ); + } + + /** + * 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. + * + * @param WP_Post|int $post The post object or ID. + * + * @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 ) { + try { + $outgoing_post = new Outgoing_Post( $post ); + } catch ( \InvalidArgumentException ) { + return null; + } + return $outgoing_post->is_distributed() ? $outgoing_post : null; + } + + /** + * Trigger post distribution. + * + * @param WP_Post|Outgoing_Post|int $post The post object or ID. + * + * @return void + */ + public static function distribute_post( $post ) { + 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/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-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 new file mode 100644 index 00000000..38004493 --- /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( Admin::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-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-cli.php b/includes/content-distribution/class-cli.php new file mode 100644 index 00000000..bbb3c4ea --- /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 ); + $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( $sites ), implode( ', ', $sites ) ) ); + + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + } +} diff --git a/includes/content-distribution/class-editor.php b/includes/content-distribution/class-editor.php new file mode 100644 index 00000000..b5b48b3c --- /dev/null +++ b/includes/content-distribution/class-editor.php @@ -0,0 +1,226 @@ + true, + 'type' => 'array', + 'show_in_rest' => [ + 'schema' => [ + 'context' => [ 'edit' ], + 'type' => 'array', + 'default' => [], + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'auth_callback' => function() { + return current_user_can( Admin::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; + } + + if ( ! current_user_can( Admin::CAPABILITY ) ) { + 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, + ] + ); + } + + /** + * 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; + } + ?> + + get_error_message() ) ); + } + + $this->payload = $payload; + $this->network_post_id = $payload['network_post_id']; + + if ( ! $post ) { + $post = $this->query_post(); + } + + if ( $post ) { + $this->ID = $post->ID; + $this->post = $post; + } + } + + /** + * 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. + * + * @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['network_post_id'] ) || + empty( $payload['sites'] ) || + empty( $payload['post_data'] ) + ) { + return new WP_Error( 'invalid_post', __( 'Invalid post payload.', 'newspack-network' ) ); + } + + 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, $payload['sites'], 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 ); + } + + /** + * 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. + * + * @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(); + } + + /** + * Update the post meta for a linked post. + * + * @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 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 ) && + ! array_key_exists( $meta_key, $data ) + ) { + delete_post_meta( $this->ID, $meta_key ); + } + } + + if ( empty( $data ) ) { + return; + } + + foreach ( $data as $meta_key => $meta_value ) { + if ( ! in_array( $meta_key, $reserved_keys, true ) ) { + 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 ); + } + } + } + } + } + + /** + * 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 ) ) { + self::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() { + $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( 'name', $term_data['name'], $taxonomy, ARRAY_A ); + 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 ); + } + $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['network_post_id'] ) { + return new WP_Error( 'mismatched_post_id', __( 'Mismatched post ID.', 'newspack-network' ) ); + } + + $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. + * + * 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 ) ) { + self::log( 'Failed to update payload: ' . $error->get_error_message() ); + 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'] + ) { + 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' ) ); + } + + $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, + 'comment_status' => $post_data['comment_status'], + 'ping_status' => $post_data['ping_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' ); + + $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' ) ); + } + + // Update the object. + $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 ) { + $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..40e253ba --- /dev/null +++ b/includes/content-distribution/class-outgoing-post.php @@ -0,0 +1,302 @@ +ID ) ) { + 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 ) ) ); + } + + $this->post = $post; + } + + /** + * Get the post object. + * + * @return WP_Post The post object. + */ + public function get_post() { + return $this->post; + } + + /** + * Get network post ID. + * + * @return string The network post ID. + */ + public function get_network_post_id() { + return md5( $this->post->ID . get_bloginfo( 'url' ) ); + } + + /** + * Validate URLs for distribution. + * + * @param string[] $urls Array of site URLs to distribute the post to. + * + * @return true|WP_Error True on success, WP_Error on failure. + */ + 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' ) ); + } + + $urls_not_in_network = Network::get_non_networked_urls_from_list( $urls ); + if ( ! empty( $urls_not_in_network ) ) { + return new WP_Error( + '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 ) + ) + ); + } + + return true; + } + + /** + * 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; + } + + $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. + $distribution = array_unique( array_merge( $distribution, $site_urls ) ); + + $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; + } + + /** + * 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; + } + + $distribution = $this->get_distribution(); + if ( empty( $distribution ) ) { + return false; + } + + if ( ! empty( $site_url ) ) { + return in_array( $site_url, $distribution, true ); + } + + return true; + } + + /** + * Get the sites the post is distributed to. + * + * @return array The distribution configuration. + */ + public function get_distribution() { + $config = get_post_meta( $this->post->ID, self::DISTRIBUTED_POST_META, true ); + if ( ! is_array( $config ) ) { + $config = []; + } + 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() { + 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' => [ + '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(), + ], + ]; + } + + /** + * 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() { + $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; + } + $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; + } + + /** + * 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/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 ); 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/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/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/includes/incoming-events/class-network-post-updated.php b/includes/incoming-events/class-network-post-updated.php new file mode 100644 index 00000000..76ddf445 --- /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['sites'] ) ); + + $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..7903dc3a --- /dev/null +++ b/includes/utils/class-network.php @@ -0,0 +1,70 @@ + 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 ); + } + + /** + * 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/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 ); + } + } + } + } } diff --git a/newspack-network.php b/newspack-network.php index a921e669..ce28cf0a 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/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/phpcs.xml b/phpcs.xml index 6a6550f7..33246845 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -19,7 +19,7 @@ - + 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/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/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-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/content-distribution/test-content-distribution.php b/tests/unit-tests/content-distribution/test-content-distribution.php new file mode 100644 index 00000000..d05bc06e --- /dev/null +++ b/tests/unit-tests/content-distribution/test-content-distribution.php @@ -0,0 +1,68 @@ + 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/content-distribution/test-incoming-post.php b/tests/unit-tests/content-distribution/test-incoming-post.php new file mode 100644 index 00000000..708c6f97 --- /dev/null +++ b/tests/unit-tests/content-distribution/test-incoming-post.php @@ -0,0 +1,441 @@ +node_1, $this->node_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() ); + } + + /** + * 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(); + + // 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' ) ); + } + + /** + * 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 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. + */ + 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 ); + } + + /** + * 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 ) ); + } + + /** + * 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. + */ + 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 ); + } + + /** + * 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 new file mode 100644 index 00000000..6ca81296 --- /dev/null +++ b/tests/unit-tests/content-distribution/test-outgoing-post.php @@ -0,0 +1,200 @@ + 1234, + 'title' => 'Test Node', + 'url' => 'https://node.test', + ], + [ + 'id' => 5678, + 'title' => 'Test Node 2', + 'url' => 'https://other-node.test', + ], + ]; + + /** + * A distributed post. + * + * @var Outgoing_Post + */ + protected $outgoing_post; + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + // "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_distribution( [ $this->network[0]['url'] ] ); + } + + /** + * Test adding a site URL to the config after already having added one. + */ + public function test_add_site_url() { + $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_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'], $distribution, true ) ); + $this->assertTrue( in_array( $this->network[1]['url'], $distribution, true ) ); + // But no more than that. + $this->assertEquals( 2, count( $distribution ) ); + } + + /** + * Test set post distribution. + */ + public function test_set_distribution() { + $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. + */ + public function test_get_distribution() { + $distribution = $this->outgoing_post->get_distribution(); + $this->assertSame( [ $this->network[0]['url'] ], $distribution ); + } + + /** + * Test get distribution 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 ); + $distribution = $outgoing_post->get_distribution(); + $this->assertEmpty( $distribution ); + } + + /** + * Test is distributed. + */ + public function test_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 ); + + $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->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'] ); + + // Assert that 'post_data' only contains the expected keys. + $post_data_keys = [ + 'title', + 'post_status', + 'date_gmt', + 'modified_gmt', + 'slug', + 'post_type', + 'raw_content', + 'content', + 'excerpt', + 'comment_status', + 'ping_status', + '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] ); + } + + /** + * 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 ] ) ); + } +} 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' ); + } + } +} diff --git a/tests/unit-tests/content-distribution/util.php b/tests/unit-tests/content-distribution/util.php new file mode 100644 index 00000000..5b2806b5 --- /dev/null +++ b/tests/unit-tests/content-distribution/util.php @@ -0,0 +1,73 @@ + $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', + 'comment_status' => 'open', + 'ping_status' => 'open', + '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' ], + ], + ], + ]; +} 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 ) ); + } +} 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' ), + }, + } +);