Skip to content
Merged
87 changes: 78 additions & 9 deletions includes/class-content-distribution.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class Content_Distribution {
*
* @var array Post IDs to update.
*/
private static $queued_post_updates = [];
private static $queued_distributions = [];

/**
* Initialize this class and register hooks
Expand All @@ -51,7 +51,9 @@ public static function init() {
add_filter( 'newspack_webhooks_request_priority', [ __CLASS__, 'webhooks_request_priority' ], 10, 2 );
add_filter( 'update_post_metadata', [ __CLASS__, 'maybe_short_circuit_distributed_meta' ], 10, 4 );
add_action( 'wp_after_insert_post', [ __CLASS__, 'handle_post_updated' ] );
add_action( 'updated_postmeta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 );
add_action( 'updated_post_meta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 );
add_action( 'added_post_meta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 );
add_action( 'deleted_post_meta', [ __CLASS__, 'handle_postmeta_update' ], 10, 3 );
add_action( 'before_delete_post', [ __CLASS__, 'handle_post_deleted' ] );
add_action( 'newspack_network_incoming_post_inserted', [ __CLASS__, 'handle_incoming_post_inserted' ], 10, 3 );

Expand All @@ -77,22 +79,60 @@ public static function register_data_event_actions() {
Data_Events::register_action( 'network_incoming_post_inserted' );
}

/**
* Queue post distribution to run on PHP shutdown.
*
* @param int $post_id The post ID.
* @param null|string $post_data_key The post data key to update.
* Default is null (entire post payload).
*
* @return void
*/
public static function queue_post_distribution( $post_id, $post_data_key = null ) {
// Bail if the post is already queued for a full update.
if ( isset( self::$queued_distributions[ $post_id ] ) && self::$queued_distributions[ $post_id ] === true ) {
return;
}

// Queue for a full post update.
if ( empty( $post_data_key ) ) {
self::$queued_distributions[ $post_id ] = true;
return;
}

// Queue for a partial update.
if ( ! isset( self::$queued_distributions[ $post_id ] ) ) {
self::$queued_distributions[ $post_id ] = [];
}
self::$queued_distributions[ $post_id ][] = $post_data_key;
}

/**
* Get queued post distributions.
*/
public static function get_queued_distributions() {
return self::$queued_distributions;
}

/**
* Distribute queued posts.
*/
public static function distribute_queued_posts() {
if ( empty( self::$queued_post_updates ) ) {
if ( empty( self::$queued_distributions ) ) {
return;
}
$post_ids = array_unique( self::$queued_post_updates );
foreach ( $post_ids as $post_id ) {
foreach ( self::$queued_distributions as $post_id => $post_data_keys ) {
$post = get_post( $post_id );
if ( ! $post ) {
continue;
}
self::distribute_post( $post );
if ( is_array( $post_data_keys ) ) {
self::distribute_post_partial( $post, array_unique( $post_data_keys ) );
} else {
self::distribute_post( $post );
}
}
self::$queued_post_updates = [];
self::$queued_distributions = [];
}

/**
Expand Down Expand Up @@ -170,7 +210,7 @@ public static function handle_postmeta_update( $meta_id, $object_id, $meta_key )
) {
return;
}
self::$queued_post_updates[] = $object_id;
self::queue_post_distribution( $post->ID, 'post_meta' );
}

/**
Expand All @@ -191,7 +231,7 @@ public static function handle_post_updated( $post ) {
if ( ! self::is_post_distributed( $post ) ) {
return;
}
self::$queued_post_updates[] = $post->ID;
self::queue_post_distribution( $post->ID );
}

/**
Expand Down Expand Up @@ -388,4 +428,33 @@ public static function distribute_post( $post, $status_on_create = 'draft' ) {
update_post_meta( $post->ID, self::PAYLOAD_HASH_META, $payload_hash );
}
}

/**
* Trigger a partial post distribution.
*
* @param WP_Post|Outgoing_Post|int $post The post object or ID.
* @param string[] $post_data_keys The post data keys to update.
*
* @return void|WP_Error The error if the payload is invalid.
*/
public static function distribute_post_partial( $post, $post_data_keys ) {
if ( ! class_exists( 'Newspack\Data_Events' ) ) {
return;
}
if ( is_string( $post_data_keys ) ) {
$post_data_keys = [ $post_data_keys ];
}
if ( $post instanceof Outgoing_Post ) {
$distributed_post = $post;
} else {
$distributed_post = self::get_distributed_post( $post );
}
if ( $distributed_post ) {
$payload = $distributed_post->get_partial_payload( $post_data_keys );
if ( is_wp_error( $payload ) ) {
return $payload;
}
Data_Events::dispatch( 'network_post_updated', $payload );
}
}
}
46 changes: 44 additions & 2 deletions includes/content-distribution/class-incoming-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,17 @@ public function __construct( $payload ) {
}

if ( $post ) {
$this->ID = $post->ID;
$this->post = $post;
$this->ID = $post->ID;
$this->post = $post;
}

// Handle partial payload.
if ( ! empty( $payload['partial'] ) ) {
$payload = $this->get_payload_from_partial( $payload );
if ( is_wp_error( $payload ) ) {
throw new \InvalidArgumentException( esc_html( $payload->get_error_message() ) );
}
$this->payload = $payload;
}
}

Expand Down Expand Up @@ -173,6 +182,31 @@ protected function get_post_payload() {
return get_post_meta( $this->ID, self::PAYLOAD_META, true );
}

/**
* Get payload from partial.
*
* @param array $payload The partial payload.
*
* @return array|WP_Error The full payload or WP_Error on failure.
*/
protected function get_payload_from_partial( $payload ) {
if ( ! $this->ID ) {
return new WP_Error( 'missing_post', __( 'Partial payload requires an existing post.', 'newspack-network' ) );
}

$current_payload = $this->get_post_payload();
$current_payload_error = self::get_payload_error( $current_payload );
if ( is_wp_error( $current_payload_error ) ) {
return $current_payload_error;
}

$payload['post_data'] = array_merge( $current_payload['post_data'], $payload['post_data'] );

unset( $payload['partial'] );

return $payload;
}

/**
* Get the post's original site URL.
*
Expand Down Expand Up @@ -399,6 +433,14 @@ protected function update_payload( $payload ) {
return new WP_Error( 'mismatched_post_id', __( 'Mismatched post ID.', 'newspack-network' ) );
}

// Handle partial payload.
if ( ! empty( $payload['partial'] ) ) {
$payload = $this->get_payload_from_partial( $payload );
if ( is_wp_error( $payload ) ) {
return $payload;
}
}

$this->payload = $payload;
}

Expand Down
37 changes: 37 additions & 0 deletions includes/content-distribution/class-outgoing-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,43 @@ public function get_payload( $status_on_create = 'draft' ) {
];
}

/**
* Get a partial payload for distribution.
*
* @param string[] $post_data_keys Keys in the post_data array to include in
* the partial payload.
*
* @return array|WP_Error The partial payload or WP_Error if any of the keys were not found.
*/
public function get_partial_payload( $post_data_keys ) {
if ( is_string( $post_data_keys ) ) {
$post_data_keys = [ $post_data_keys ];
}

$payload = $this->get_payload();
foreach ( $post_data_keys as $post_data_key ) {
if ( ! isset( $payload['post_data'][ $post_data_key ] ) ) {
return new WP_Error( 'key_not_found', __( 'Key not found in payload.', 'newspack-network' ) );
}
}

// Mark the payload as partial.
$payload['partial'] = true;

$post_data = [];
foreach ( $post_data_keys as $post_data_key ) {
$post_data[ $post_data_key ] = $payload['post_data'][ $post_data_key ];
}

// Always add the date and modified date to the partial payload.
$post_data['date_gmt'] = $payload['post_data']['date_gmt'];
$post_data['modified_gmt'] = $payload['post_data']['modified_gmt'];

$payload['post_data'] = $post_data;

return $payload;
}

/**
* Get the processed post content for distribution.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace Test\Content_Distribution;

use Newspack_Network\Content_Distribution as Content_Distribution_Class;
use Newspack_Network\Content_Distribution\Outgoing_Post;
use Newspack_Network\Hub\Node as Hub_Node;

Expand Down Expand Up @@ -64,4 +65,29 @@ public function test_update_distributed_post_meta() {
$result = update_post_meta( $post_id, Outgoing_Post::DISTRIBUTED_POST_META, [ 'https://node.test', 'https://other-node.test' ] );
$this->assertNotFalse( $result );
}

/**
* Test queue post distribution.
*/
public function test_queue_post_distribution() {
$post_id = $this->factory->post->create();

// Queue post meta for distribution.
Content_Distribution_Class::queue_post_distribution( $post_id, 'post_meta' );
$queue = Content_Distribution_Class::get_queued_distributions();
$this->assertArrayHasKey( $post_id, $queue );
$this->assertSame( [ 'post_meta' ], $queue[ $post_id ] );

// Queue full post for distribution.
Content_Distribution_Class::queue_post_distribution( $post_id );
$queue = Content_Distribution_Class::get_queued_distributions();
// Assert that the post is queued for full distribution (= true).
$this->assertTrue( $queue[ $post_id ] );

// Queue another attribute for distribution.
Content_Distribution_Class::queue_post_distribution( $post_id, 'post_meta' );
$queue = Content_Distribution_Class::get_queued_distributions();
// Assert that the post is still queued for full distribution.
$this->assertTrue( $queue[ $post_id ] );
}
}
65 changes: 65 additions & 0 deletions tests/unit-tests/content-distribution/test-incoming-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -481,4 +481,69 @@ public function test_status_on_create() {
$this->incoming_post->insert( $payload );
$this->assertSame( 'draft', get_post_status( $post_id ) );
}

/**
* Test partial post payload on insert.
*/
public function test_partial_payload_insert() {
$post_id = $this->incoming_post->insert();

// Make the payload a partial.
$payload = $this->get_sample_payload();
$payload['partial'] = true;
$payload['post_data'] = [
'title' => 'Updated Title',
'date_gmt' => $payload['post_data']['date_gmt'],
'modified_gmt' => $payload['post_data']['modified_gmt'],
];

$this->incoming_post->insert( $payload );

// Assert that the post title was updated and the content was not.
$this->assertSame( 'Updated Title', get_the_title( $post_id ) );
$this->assertSame( 'Content', get_post_field( 'post_content', $post_id ) );
}

/**
* Test partial post payload on instantiation.
*/
public function test_partial_payload_instantiation() {
$post_id = $this->incoming_post->insert();

// Make the payload a partial.
$payload = $this->get_sample_payload();
$payload['partial'] = true;
$payload['post_data'] = [
'title' => 'Updated Title',
'date_gmt' => $payload['post_data']['date_gmt'],
'modified_gmt' => $payload['post_data']['modified_gmt'],
];

$incoming_post = new Incoming_Post( $payload );
$incoming_post->insert();

// Assert that the post title was updated and the content was not.
$this->assertSame( 'Updated Title', get_the_title( $post_id ) );
$this->assertSame( 'Content', get_post_field( 'post_content', $post_id ) );
}

/**
* Test partial payload on missing post.
*/
public function test_partial_payload_missing_post() {
$payload = $this->get_sample_payload();

// Make the payload a partial.
$payload['partial'] = true;
$payload['post_data'] = [
'title' => 'Updated Title',
'date_gmt' => $payload['post_data']['date_gmt'],
'modified_gmt' => $payload['post_data']['modified_gmt'],
];

// Assert that instantiating a partial payload will throw an exception.
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'Partial payload requires an existing post.' );
new Incoming_Post( $payload );
}
}
34 changes: 34 additions & 0 deletions tests/unit-tests/content-distribution/test-outgoing-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,38 @@ public function test_ignored_taxonomies() {
$payload = $this->outgoing_post->get_payload();
$this->assertTrue( empty( $payload['post_data']['taxonomy'][ $taxonomy ] ) );
}

/**
* Test get partial payload.
*/
public function test_get_partial_payload() {
$partial_payload = $this->outgoing_post->get_partial_payload( 'post_meta' );

$payload = $this->outgoing_post->get_payload();
$this->assertTrue( $partial_payload['partial'] );
$this->assertSame( $payload['network_post_id'], $partial_payload['network_post_id'] );
$this->assertSame( $payload['post_data']['post_meta'], $partial_payload['post_data']['post_meta'] );
$this->assertSame( $payload['post_data']['date_gmt'], $partial_payload['post_data']['date_gmt'] );
$this->assertSame( $payload['post_data']['modified_gmt'], $partial_payload['post_data']['modified_gmt'] );
$this->assertArrayNotHasKey( 'title', $partial_payload['post_data'] );
$this->assertArrayNotHasKey( 'content', $partial_payload['post_data'] );
$this->assertArrayNotHasKey( 'taxonomy', $partial_payload['post_data'] );
}

/**
* Test get partial payload multiple keys.
*/
public function test_get_partial_payload_multiple_keys() {
$partial_payload = $this->outgoing_post->get_partial_payload( [ 'post_meta', 'taxonomy' ] );

$payload = $this->outgoing_post->get_payload();
$this->assertTrue( $partial_payload['partial'] );
$this->assertSame( $payload['network_post_id'], $partial_payload['network_post_id'] );
$this->assertSame( $payload['post_data']['post_meta'], $partial_payload['post_data']['post_meta'] );
$this->assertSame( $payload['post_data']['taxonomy'], $partial_payload['post_data']['taxonomy'] );
$this->assertSame( $payload['post_data']['date_gmt'], $partial_payload['post_data']['date_gmt'] );
$this->assertSame( $payload['post_data']['modified_gmt'], $partial_payload['post_data']['modified_gmt'] );
$this->assertArrayNotHasKey( 'title', $partial_payload['post_data'] );
$this->assertArrayNotHasKey( 'content', $partial_payload['post_data'] );
}
}