__( 'Sorry, you are not allowed to bulk edit BeyondWords audio.', 'speechkit' ) ],
403
);
}
if ( ! isset( $_POST['beyondwords_bulk_edit'] ) || ! isset( $_POST['post_ids'] ) || ! is_array( $_POST['post_ids'] ) ) {
wp_send_json_error(
[ 'message' => __( 'Missing bulk-edit action or selected posts.', 'speechkit' ) ],
400
);
}
$post_ids = array_filter( array_map( 'absint', wp_unslash( $_POST['post_ids'] ) ) );
$action = sanitize_text_field( wp_unslash( $_POST['beyondwords_bulk_edit'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( 'generate' !== $action && 'delete' !== $action ) {
wp_send_json_error(
[ 'message' => __( 'Unrecognised bulk-edit action.', 'speechkit' ) ],
400
);
}
// Only operate on posts the current user is actually allowed to edit, so a
// crafted request cannot mutate or delete audio on out-of-reach posts.
$post_ids = array_values(
array_filter(
$post_ids,
static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
)
);
// A capable user may have selected only posts they cannot edit; treat that
// as a clean no-op rather than the delete helper's empty-batch error.
if ( empty( $post_ids ) ) {
wp_send_json_success( [] );
}
try {
$updated_post_ids = ( 'generate' === $action )
? self::generate_audio_for_posts( $post_ids )
: self::delete_audio_for_posts( $post_ids );
} catch ( \Exception $e ) {
wp_send_json_error(
[ 'message' => $e->getMessage() ],
500
);
}
wp_send_json_success( $updated_post_ids );
}
/**
* Mark each post for audio generation, skipping ones that already have content.
*
* @param int[]|null $post_ids Posts to process.
*
* @return int[] IDs of posts updated.
*/
public static function generate_audio_for_posts( ?array $post_ids ): array {
if ( ! is_array( $post_ids ) ) {
return [];
}
$updated_post_ids = [];
foreach ( $post_ids as $post_id ) {
if ( ! get_post_meta( $post_id, 'beyondwords_content_id', true ) ) {
update_post_meta( $post_id, 'beyondwords_generate_audio', '1' );
}
$updated_post_ids[] = $post_id;
}
return $updated_post_ids;
}
/**
* Delete BeyondWords audio for each post and clear the related post meta.
*
* @param int[]|null $post_ids Posts to process.
*
* @return int[] IDs of posts updated.
*
* @throws \Exception When the BeyondWords API does not return a deletable batch.
*/
public static function delete_audio_for_posts( ?array $post_ids ): array {
if ( ! is_array( $post_ids ) ) {
return [];
}
$response = \BeyondWords\Post\Sync::batch_delete_audio_for_posts( $post_ids );
if ( ! $response ) {
throw new \Exception(
esc_html__( 'Error while bulk deleting audio. Please contact support with reference BULK-NO-RESPONSE.', 'speechkit' )
);
}
$keys = \BeyondWords\Core\Utils::get_post_meta_keys( 'all' );
$updated_post_ids = [];
foreach ( $response as $post_id ) {
foreach ( $keys as $key ) {
delete_post_meta( $post_id, $key );
}
$updated_post_ids[] = $post_id;
}
return $updated_post_ids;
}
/**
* Add BeyondWords actions to the bulk-action dropdown.
*
* @param array $bulk_array Existing bulk actions.
*
* @return array
*/
public static function bulk_actions_edit( $bulk_array ) {
$bulk_array['beyondwords_generate_audio'] = __( 'Generate audio', 'speechkit' );
$bulk_array['beyondwords_delete_audio'] = __( 'Delete audio', 'speechkit' );
return $bulk_array;
}
/**
* Handle the "Generate audio" bulk action.
*
* @param string $redirect Redirect URL the bulk handler will use.
* @param string $doaction Selected bulk action.
* @param int[] $object_ids Post IDs in the bulk selection.
*/
public static function handle_bulk_generate_action( $redirect, $doaction, $object_ids ) {
if ( 'beyondwords_generate_audio' !== $doaction ) {
return $redirect;
}
$redirect = remove_query_arg(
[
'beyondwords_bulk_generated',
'beyondwords_bulk_deferred',
'beyondwords_bulk_deleted',
'beyondwords_bulk_failed',
'beyondwords_bulk_skipped',
'beyondwords_bulk_error',
],
$redirect
);
// Core routes custom bulk actions here after only a coarse edit_posts check,
// so guard per-post — else a crafted request could trigger billable generation.
$object_ids = array_values(
array_filter(
$object_ids,
static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
)
);
// Nothing editable selected: a clean no-op reporting a zero count.
if ( empty( $object_ids ) ) {
$redirect = add_query_arg( 'beyondwords_bulk_generated', 0, $redirect );
$redirect = add_query_arg( 'beyondwords_bulk_failed', 0, $redirect );
$nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
}
// Sync offloads to cron on VIP or runs a hard-capped synchronous batch
// off VIP; it also normalises and sorts the IDs, so no sort() here.
try {
$counts = \BeyondWords\Post\Sync::bulk_generate_audio_for_posts( $object_ids );
$redirect = add_query_arg( 'beyondwords_bulk_generated', $counts['generated'], $redirect );
$redirect = add_query_arg( 'beyondwords_bulk_failed', $counts['failed'], $redirect );
if ( $counts['skipped'] > 0 ) {
$redirect = add_query_arg( 'beyondwords_bulk_skipped', $counts['skipped'], $redirect );
}
if ( $counts['deferred'] > 0 ) {
$redirect = add_query_arg( 'beyondwords_bulk_deferred', $counts['deferred'], $redirect );
}
} catch ( \Exception $e ) {
$redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect );
}
$nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
}
/**
* Handle the "Delete audio" bulk action.
*
* @param string $redirect Redirect URL the bulk handler will use.
* @param string $doaction Selected bulk action.
* @param int[] $object_ids Post IDs in the bulk selection.
*/
public static function handle_bulk_delete_action( $redirect, $doaction, $object_ids ) {
if ( 'beyondwords_delete_audio' !== $doaction ) {
return $redirect;
}
$redirect = remove_query_arg(
[
'beyondwords_bulk_generated',
'beyondwords_bulk_deferred',
'beyondwords_bulk_deleted',
'beyondwords_bulk_failed',
'beyondwords_bulk_skipped',
'beyondwords_bulk_error',
],
$redirect
);
// Core routes custom bulk actions here after only a coarse edit_posts check,
// so guard per-post — else a crafted request could wipe another author's audio.
$object_ids = array_values(
array_filter(
$object_ids,
static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
)
);
// Bail before the remote batch-delete so an empty selection can't hit the API.
if ( empty( $object_ids ) ) {
$redirect = add_query_arg( 'beyondwords_bulk_deleted', 0, $redirect );
$nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
}
sort( $object_ids );
try {
$result = self::delete_audio_for_posts( $object_ids );
$redirect = add_query_arg( 'beyondwords_bulk_deleted', count( $result ), $redirect );
} catch ( \Exception $e ) {
$redirect = add_query_arg( 'beyondwords_bulk_error', $e->getMessage(), $redirect );
}
$nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );
return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
}
}