Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion includes/admin/class-rop-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,9 @@ public function rop_publish_now_metabox_html() {
public function publish_now_attributes( $default ) {
global $post;

$default['action'] = 'no' !== get_post_meta( $post->ID, 'rop_publish_now', true );
$meta_value = get_post_meta( $post->ID, 'rop_publish_now', true );
// Already-published posts only pre-check the box for a still-pending share; the instant-share default is for new posts.
$default['action'] = 'publish' === get_post_status( $post ) ? 'yes' === $meta_value : 'no' !== $meta_value;
$default['page_active_accounts'] = get_post_meta( $post->ID, 'rop_publish_now_accounts', true );

return $default;
Expand All @@ -910,6 +912,39 @@ public function transition_post_status( $new_status, $old_status, $post ) {
$this->maybe_publish_now( $post->ID, true );
}

/**
* Publish now on post save, hooked to `wp_after_insert_post`.
*
* Routine edits of an already-published post must not re-queue shares from
* leftover meta; on those saves only an explicit metabox submission counts
* as sharing intent. Re-sharing from the Block Editor goes through the
* REST `share` endpoint instead, which forces the share.
*
* @param int $post_id The post ID.
* @param WP_Post $post The post object.
* @param bool $update Whether this is an update.
* @param WP_Post|null $post_before The post object before the update, null for new posts.
*
* @return void
*/
public function maybe_publish_now_after_insert( $post_id, $post, $update, $post_before ) {
if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) {
if ( empty( $_POST['publish_now'] ) ) {
return;
}

// The Classic metabox keeps the box checked while a share is still pending, so every
// ordinary save of such a post submits `publish_now`. Re-queueing here would refresh
// the history timestamp and let a long-stalled entry escape the expiration cutoff,
// so leave a request that is already queued exactly as it is.
if ( 'queued' === get_post_meta( $post_id, 'rop_publish_now_status', true ) ) {
return;
}
}

$this->maybe_publish_now( $post_id );
}

/**
* Publish now, if enabled.
*
Expand Down
19 changes: 18 additions & 1 deletion includes/admin/models/class-rop-posts-selector-model.php
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,23 @@ public function update_buffer( $account_id, $post_id, $refresh = false ) {
return $this->set( 'posts_buffer', $this->buffer, $refresh );
}

/**
* How many publish now entries a single drain may consume.
*/
const PUBLISH_NOW_BATCH_SIZE = 300;

/**
* Get the number of publish now entries a single drain may consume.
*
* @access public
* @return int
*/
public static function get_publish_now_batch_size() {
$batch_size = (int) apply_filters( 'rop_publish_now_batch_size', self::PUBLISH_NOW_BATCH_SIZE );

return $batch_size > 0 ? $batch_size : self::PUBLISH_NOW_BATCH_SIZE;
}

/**
* Get posts to be published now.
*
Expand All @@ -828,7 +845,7 @@ public function get_publish_now_posts() {
'value' => 'queued',
),
),
'numberposts' => 300,
'posts_per_page' => self::get_publish_now_batch_size(), // NOTE: WP_Query ignores `numberposts`; without this the batch silently fell back to the site's `posts_per_page` option.
'orderby' => 'modified',
'order' => 'ASC',
'fields' => 'ids',
Expand Down
78 changes: 77 additions & 1 deletion includes/admin/models/class-rop-queue-model.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,10 +366,21 @@ public function build_queue_publish_now() {
return $normalized_queue;
}

$expiration = apply_filters( 'rop_publish_now_expiration', DAY_IN_SECONDS );

$index = 0;
foreach ( $posts as $post_id ) {
$accounts = get_post_meta( $post_id, 'rop_publish_now_accounts', true );
if ( ! $accounts ) {
if ( ! $accounts || ! is_array( $accounts ) ) {
// NOTE: retire it through the same path as an expired entry. Clearing only the
// status would leave `queued` history rows behind, and the editor treats those as
// an active share, so it would spin on "Posting to social media…" forever.
$this->expire_publish_now( $post_id, 'has no accounts left to share to' );
continue;
}

if ( $this->is_publish_now_expired( $post_id, $expiration ) ) {
$this->expire_publish_now( $post_id );
continue;
}

Expand All @@ -385,9 +396,74 @@ public function build_queue_publish_now() {
$index ++;
}

// A full batch means more entries are still waiting. The drain runs on a single event, so
// without another pass a freshly published post sitting behind a large backlog would wait
// for an unrelated share event. Each pass clears its own batch, so this always terminates.
if ( count( $posts ) >= Rop_Posts_Selector_Model::get_publish_now_batch_size() ) {
( new Rop_Cron_Helper() )->manage_cron( array( 'action' => 'publish-now' ) );
}

return $normalized_queue;
}

/**
* Check whether a publish now request is too old to still be shared.
*
* A stalled cron must not blast months-old queue entries to the accounts
* once it recovers. The queue time is the newest "queued" entry in the
* sharing history; entries without one predate the history meta and are
* stale by definition.
*
* @param int $post_id The post ID.
* @param int $expiration Maximum age, in seconds, of a queue entry.
*
* @return bool
*/
private function is_publish_now_expired( $post_id, $expiration ) {
$queued_at = 0;
$history = get_post_meta( $post_id, 'rop_publish_now_history', true );

if ( is_array( $history ) ) {
foreach ( $history as $item ) {
if (
is_array( $item ) &&
isset( $item['status'], $item['timestamp'] ) &&
'queued' === $item['status'] &&
is_numeric( $item['timestamp'] )
) {
$queued_at = max( $queued_at, (int) $item['timestamp'] );
}
}
}

return ( time() - $queued_at ) > $expiration;
}

/**
* Drop a publish now request without sharing it.
*
* @param int $post_id The post ID.
* @param string $reason Why the request was dropped, for the log line.
*
* @return void
*/
private function expire_publish_now( $post_id, $reason = 'expired before it could be shared' ) {
delete_post_meta( $post_id, 'rop_publish_now_accounts' );
delete_post_meta( $post_id, 'rop_publish_now_status' );

$history = get_post_meta( $post_id, 'rop_publish_now_history', true );
if ( is_array( $history ) ) {
foreach ( $history as $i => $item ) {
if ( is_array( $item ) && isset( $item['status'] ) && 'queued' === $item['status'] ) {
$history[ $i ]['status'] = 'expired';
}
}
update_post_meta( $post_id, 'rop_publish_now_history', $history );
}

$this->logger->info( sprintf( 'Publish now request for post %d %s, skipping.', $post_id, $reason ) );
}

/**
* Method to build the queue according to the timeline.
*
Expand Down
2 changes: 1 addition & 1 deletion includes/class-rop.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function ( $message ) {
$this->loader->add_action( 'rop_cron_job_publish_now', $plugin_admin, 'rop_cron_job_publish_now' );

$this->loader->add_action( 'add_meta_boxes', $plugin_admin, 'rop_publish_now_metabox' );
$this->loader->add_action( 'wp_after_insert_post', $plugin_admin, 'maybe_publish_now' );
$this->loader->add_action( 'wp_after_insert_post', $plugin_admin, 'maybe_publish_now_after_insert', 10, 4 );
$this->loader->add_action( 'transition_post_status', $plugin_admin, 'transition_post_status', 10, 3 );
$this->loader->add_action( 'rop_publish_now_instant_share', $plugin_admin, 'maybe_publish_now', 10, 2 );
$this->loader->add_filter( 'rop_publish_now_attributes', $plugin_admin, 'publish_now_attributes' );
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<testsuite name="queue">
<directory>./tests/test-queue.php</directory>
</testsuite>
<testsuite name="publish-now">
<directory>./tests/test-publish-now.php</directory>
</testsuite>
<testsuite name="selector">
<directory>./tests/test-selector.php</directory>
</testsuite>
Expand Down
6 changes: 6 additions & 0 deletions tests/e2e/fixtures/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export const test = base.extend({
runPublishNow: (postId) => call('publish-now', { postId }),
getRequests: () =>
call('requests').then((result) => result.requests),
seedQueuedPost: (title, ageSeconds = 0) =>
call('queued-post', { title, ageSeconds }).then(
(result) => result.postId
),
getPublishNowState: (postId) =>
call('publish-now-state', { postId }),
});
},
});
Expand Down
79 changes: 79 additions & 0 deletions tests/e2e/mu-plugins/rop-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function rop_e2e_reset() {
delete_metadata( 'post', 0, 'rop_publish_now', '', true );
delete_metadata( 'post', 0, 'rop_publish_now_status', '', true );
delete_metadata( 'post', 0, 'rop_publish_now_accounts', '', true );
delete_metadata( 'post', 0, 'rop_publish_now_history', '', true );

return rest_ensure_response( array( 'ok' => true ) );
}
Expand Down Expand Up @@ -128,6 +129,82 @@ function rop_e2e_run_publish_now( WP_REST_Request $request ) {
return rest_ensure_response( array( 'ok' => true ) );
}

/**
* Seed a post already sitting in the instant-share queue, optionally aged.
*
* Reproduces the #1102 backlog: entries queued while cron was dead. Aging it
* through the API is the only way to get a months-old entry inside a test.
*/
function rop_e2e_seed_queued_post( WP_REST_Request $request ) {
$loaded = rop_e2e_activate_plugin();
if ( is_wp_error( $loaded ) ) {
return $loaded;
}
if ( ! $loaded ) {
return new WP_Error( 'rop_e2e_plugin_missing', 'Revive Old Posts is not loaded.', array( 'status' => 500 ) );
}

$accounts = ( new Rop_Services_Model() )->get_active_accounts();
$account_id = array_key_first( $accounts );
if ( ! $account_id ) {
return new WP_Error( 'rop_e2e_no_account', 'Seed an account first.', array( 'status' => 400 ) );
}

$age = absint( $request->get_param( 'ageSeconds' ) );
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$post_id = wp_insert_post(
array(
'post_title' => $title ? $title : 'Backlog Post',
'post_status' => 'publish',
),
true
);

if ( is_wp_error( $post_id ) ) {
return $post_id;
}

update_post_meta( $post_id, 'rop_publish_now', 'yes' );
update_post_meta( $post_id, 'rop_publish_now_status', 'queued' );
update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) );
update_post_meta(
$post_id,
'rop_publish_now_history',
array(
array(
'account' => $account_id,
'service' => $accounts[ $account_id ]['service'],
'timestamp' => time() - $age,
'status' => 'queued',
),
)
);

return rest_ensure_response(
array(
'ok' => true,
'postId' => $post_id,
)
);
}

/**
* Read back the publish-now meta of a post so tests can assert on queue state.
*/
function rop_e2e_get_publish_now_state( WP_REST_Request $request ) {
$post_id = absint( $request->get_param( 'postId' ) );
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error( 'rop_e2e_post_missing', 'A valid postId is required.', array( 'status' => 400 ) );
}

return rest_ensure_response(
array(
'status' => get_post_meta( $post_id, 'rop_publish_now_status', true ),
'history' => get_post_meta( $post_id, 'rop_publish_now_history', true ),
)
);
}

function rop_e2e_get_requests() {
$state = get_option( ROP_E2E_STATE_OPTION, array() );

Expand Down Expand Up @@ -175,5 +252,7 @@ function () {
register_rest_route( ROP_E2E_NAMESPACE, '/account', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_seed_account' ) );
register_rest_route( ROP_E2E_NAMESPACE, '/publish-now', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_run_publish_now' ) );
register_rest_route( ROP_E2E_NAMESPACE, '/requests', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_get_requests' ) );
register_rest_route( ROP_E2E_NAMESPACE, '/queued-post', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_seed_queued_post' ) );
register_rest_route( ROP_E2E_NAMESPACE, '/publish-now-state', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_get_publish_now_state' ) );
}
);
Loading
Loading