__( '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 ); } }