# speechkit/7.2.0/src/posts-list/class-bulk-edit.php

BeyondWords – AI audio for publishers, version 7.2.0. 346 lines.

- Page: https://pluginprobe.com/plugins/speechkit/7.2.0/code/src/posts-list/class-bulk-edit.php
- Raw: https://pluginprobe.com/plugins/speechkit/7.2.0/raw/src/posts-list/class-bulk-edit.php
- Modified: 2026-09-25T15:14:58+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/speechkit/7.2.0/code/src/posts-list/class-bulk-edit.php#L10-L20`.

```php
<?php
/**
 * Bulk Edit handler for the posts list screen: generate/delete audio for many posts.
 *
 * @package BeyondWords\PostsList
 * @since   3.0.0
 * @since   7.0.0 Refactored to BeyondWords namespace with snake_case methods.
 */

declare( strict_types = 1 );

namespace BeyondWords\PostsList;

defined( 'ABSPATH' ) || exit;

/**
 * Bulk-edit support for the BeyondWords column.
 *
 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
 */
class BulkEdit {

	/**
	 * Register WordPress hooks.
	 */
	public static function init(): void {
		add_action( 'bulk_edit_custom_box', [ self::class, 'bulk_edit_custom_box' ], 10, 2 );
		add_action( 'wp_ajax_save_bulk_edit_beyondwords', [ self::class, 'save_bulk_edit' ] );

		add_action(
			'wp_loaded',
			static function (): void {
				$post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();

				if ( ! is_array( $post_types ) ) {
					return;
				}

				foreach ( $post_types as $post_type ) {
					add_filter( "bulk_actions-edit-{$post_type}", [ self::class, 'bulk_actions_edit' ] );
					add_filter( "handle_bulk_actions-edit-{$post_type}", [ self::class, 'handle_bulk_delete_action' ], 10, 3 );
					add_filter( "handle_bulk_actions-edit-{$post_type}", [ self::class, 'handle_bulk_generate_action' ], 10, 3 );
				}
			}
		);
	}

	/**
	 * Render the bulk-edit fieldset for the BeyondWords column.
	 *
	 * @param string $column_name Column slug being rendered.
	 * @param string $post_type   Current post-type screen.
	 */
	public static function bulk_edit_custom_box( $column_name, $post_type ): void {
		if ( 'beyondwords' !== $column_name ) {
			return;
		}

		if ( ! in_array( $post_type, \BeyondWords\Settings\Utils::get_compatible_post_types(), true ) ) {
			return;
		}

		wp_nonce_field( 'beyondwords_bulk_edit_nonce', 'beyondwords_bulk_edit' );
		?>
		<fieldset class="inline-edit-col-right">
			<div class="inline-edit-col">
				<div class="inline-edit-group wp-clearfix">
					<label class="alignleft">
						<span class="title"><?php esc_html_e( 'BeyondWords', 'speechkit' ); ?></span>
						<select name="beyondwords_generate_audio">
							<option value="-1"><?php esc_html_e( '— No change —', 'speechkit' ); ?></option>
							<option value="generate"><?php esc_html_e( 'Generate audio', 'speechkit' ); ?></option>
							<option value="delete"><?php esc_html_e( 'Delete audio', 'speechkit' ); ?></option>
						</select>
					</label>
				</div>
			</div>
		</fieldset>
		<?php
	}

	/**
	 * AJAX handler (`wp_ajax_save_bulk_edit_beyondwords`) for the inline bulk edit.
	 *
	 * The nonce is CSRF protection only; any logged-in user can reach a
	 * `wp_ajax_*` callback, so capabilities must additionally gate the action.
	 */
	public static function save_bulk_edit(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if (
			! isset( $_POST['beyondwords_bulk_edit_nonce'] )
			|| ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['beyondwords_bulk_edit_nonce'] ) ), 'beyondwords_bulk_edit' )
		) {
			wp_nonce_ays( '' );
		}

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_send_json_error(
				[ 'message' => __( '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<string,string> $bulk_array Existing bulk actions.
	 *
	 * @return array<string,string>
	 */
	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 );
	}
}

```
