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
+ ) }
+