\d+)', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'get_player_data' ), 'permission_callback' => array( __CLASS__, 'check_read_permission' ), 'args' => array( 'id' => array( 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, 'sanitize_callback' => 'absint', ), ), ) ); // Audio generation status endpoint (requires edit capability). register_rest_route( self::NAMESPACE, '/status/(?P\d+)', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'get_audio_status' ), 'permission_callback' => array( __CLASS__, 'check_edit_permission' ), 'args' => array( 'id' => array( 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, 'sanitize_callback' => 'absint', ), ), ) ); // Generate audio endpoint. register_rest_route( self::NAMESPACE, '/generate', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'generate_audio' ), 'permission_callback' => array( __CLASS__, 'check_edit_permission' ), 'args' => array( 'post_id' => array( 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, 'sanitize_callback' => 'absint', ), 'force' => array( 'required' => false, 'default' => false, 'validate_callback' => function ( $param ) { return is_bool( $param ) || in_array( $param, array( 'true', 'false', '1', '0' ), true ); }, 'sanitize_callback' => function ( $param ) { return filter_var( $param, FILTER_VALIDATE_BOOLEAN ); }, ), ), ) ); // Statistics endpoint. register_rest_route( self::NAMESPACE, '/stats', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'get_statistics' ), 'permission_callback' => array( __CLASS__, 'check_manage_permission' ), 'args' => array( 'period' => array( 'required' => false, 'default' => 'month', 'validate_callback' => function ( $param ) { return in_array( $param, array( 'day', 'week', 'month', 'year' ), true ); }, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); // Cache management endpoint. register_rest_route( self::NAMESPACE, '/cache', array( array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'get_cache_status' ), 'permission_callback' => array( __CLASS__, 'check_manage_permission' ), ), array( 'methods' => 'DELETE', 'callback' => array( __CLASS__, 'clear_cache' ), 'permission_callback' => array( __CLASS__, 'check_manage_permission' ), ), ) ); // Settings endpoint. register_rest_route( self::NAMESPACE, '/settings', array( array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'get_settings' ), 'permission_callback' => array( __CLASS__, 'check_manage_permission' ), ), array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'update_settings' ), 'permission_callback' => array( __CLASS__, 'check_manage_permission' ), 'args' => array( 'settings' => array( 'required' => true, 'validate_callback' => function ( $param ) { return is_array( $param ); }, ), ), ), ) ); } /** * Get player data for a post. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error Response object. * @since 1.0.6 */ public static function get_player_data( $request ) { $post_id = $request->get_param( 'id' ); // Check if post exists. $post = get_post( $post_id ); if ( ! $post ) { return new \WP_Error( 'post_not_found', __( 'Post not found.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 404 ) ); } // Apply the same password/private-content policy as HTML and file delivery. if ( ! \Accessibility_Audio_TTS\Core\Audio_Access::can_read( $post ) ) { return new \WP_Error( 'post_not_published', __( 'Post is not published.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 403 ) ); } // Get audio data. try { $file = \Accessibility_Audio_TTS\Core\Audio_Access::resolve_audio_file( $post_id ); } catch ( \Throwable $error ) { return new \WP_Error( 'audio_storage_unavailable', __( 'Audio is temporarily unavailable.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 503 ) ); } if ( ! \Accessibility_Audio_TTS\Core\Audio_Access::can_read( $post ) ) { return new \WP_Error( 'audio_access_denied', __( 'Audio access denied.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 403 ) ); } $audio_url = $file['url'] ?? ''; $audio_generated = get_post_meta( $post_id, 'accessibility_audio_tts_generated', true ); $generation_date = get_post_meta( $post_id, 'accessibility_audio_tts_generation_date', true ); $data = array( 'post_id' => $post_id, 'post_title' => $post->post_title, 'post_url' => get_permalink( $post_id ), 'has_audio' => ! empty( $audio_url ), 'audio_url' => $audio_url ? esc_url_raw( $audio_url ) : null, 'audio_generated' => (bool) $audio_generated, 'generation_date' => $generation_date ? $generation_date : null, 'player_html' => self::get_player_html( $post_id, $audio_url ), ); return rest_ensure_response( $data ); } /** * Get audio generation status. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error Response object. * @since 1.0.6 */ public static function get_audio_status( $request ) { $post_id = $request->get_param( 'id' ); // Check if post exists. if ( ! get_post( $post_id ) ) { return new \WP_Error( 'post_not_found', __( 'Post not found.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 404 ) ); } $status = array( 'post_id' => $post_id, 'status' => 'unknown', 'progress' => 0, 'message' => '', 'audio_url' => null, 'error' => null, ); // Check generation status. $audio_url = get_post_meta( $post_id, 'accessibility_audio_tts_url', true ); $generation_status = get_post_meta( $post_id, 'accessibility_audio_tts_generation_status', true ); $generation_progress = get_post_meta( $post_id, 'accessibility_audio_tts_generation_progress', true ); $generation_error = get_post_meta( $post_id, 'accessibility_audio_tts_generation_error', true ); // A previous recording remains playable while a forced replacement is queued, // running, or failed. It does not describe the state of that newer job. $status['audio_url'] = $audio_url ? esc_url_raw( $audio_url ) : null; if ( 'queued' === $generation_status ) { $status['status'] = 'queued'; $status['message'] = __( 'Audio generation is queued.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } elseif ( 'processing' === $generation_status ) { $status['status'] = 'processing'; $status['progress'] = $generation_progress ? intval( $generation_progress ) : 0; $status['message'] = __( 'Audio generation in progress.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } elseif ( in_array( $generation_status, array( 'failed', 'cancelled' ), true ) ) { $status['status'] = $generation_status; $status['message'] = 'cancelled' === $generation_status ? __( 'Audio generation was cancelled.', 'accessibility-audio-tts-text-to-speech-for-articles' ) : __( 'Audio generation failed.', 'accessibility-audio-tts-text-to-speech-for-articles' ); $status['error'] = $generation_error ? sanitize_text_field( $generation_error ) : null; } elseif ( ! empty( $audio_url ) ) { $status['status'] = 'completed'; $status['progress'] = 100; $status['message'] = __( 'Audio generation completed.', 'accessibility-audio-tts-text-to-speech-for-articles' ); $status['audio_url'] = esc_url_raw( $audio_url ); } elseif ( 'failed' === $generation_status || ! empty( $generation_error ) ) { $status['status'] = 'failed'; $status['message'] = __( 'Audio generation failed.', 'accessibility-audio-tts-text-to-speech-for-articles' ); $status['error'] = $generation_error ? sanitize_text_field( $generation_error ) : null; } else { $status['status'] = 'not_started'; $status['message'] = __( 'Audio generation not started.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } return rest_ensure_response( $status ); } /** * Generate audio for a post. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error Response object. * @since 1.0.6 */ public static function generate_audio( $request ) { $post_id = $request->get_param( 'post_id' ); $force = $request->get_param( 'force' ); // Rate limiting. if ( ! Security_Manager::rate_limit( 'generate_audio', 5, 300 ) ) { return new \WP_Error( 'rate_limit_exceeded', __( 'Rate limit exceeded. Please try again later.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 429 ) ); } // Check if post exists. $post = get_post( $post_id ); if ( ! $post ) { return new \WP_Error( 'post_not_found', __( 'Post not found.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 404 ) ); } // Check if audio already exists and force is not set. if ( ! $force && get_post_meta( $post_id, 'accessibility_audio_tts_url', true ) ) { return new \WP_Error( 'audio_exists', __( 'Audio already exists for this post. Use force=true to regenerate.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 409 ) ); } // Check if generation is already in progress. $generation_status = get_post_meta( $post_id, 'accessibility_audio_tts_generation_status', true ); $started_at = (int) get_post_meta( $post_id, 'accessibility_audio_tts_generation_started_at', true ); if ( \Accessibility_Audio_TTS\Core\Generation_Lease::is_busy( (int) $post_id ) || ( in_array( $generation_status, array( 'queued', 'processing' ), true ) && $started_at > time() - 900 ) ) { return new \WP_Error( 'generation_in_progress', __( 'Audio generation is already in progress for this post.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 409 ) ); } if ( ! \Accessibility_Audio_TTS\Admin\Conversion_Processor::background_processing_enabled() ) { return new \WP_Error( 'background_disabled', __( 'Enable background processing before scheduling audio generation.', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'status' => 409 ) ); } // Queued is distinct from running and can be recovered after a stale job. update_post_meta( $post_id, 'accessibility_audio_tts_generation_status', 'queued' ); update_post_meta( $post_id, 'accessibility_audio_tts_generation_started_at', time() ); update_post_meta( $post_id, 'accessibility_audio_tts_generation_progress', 0 ); delete_post_meta( $post_id, 'accessibility_audio_tts_generation_error' ); // Schedule background generation. // Replace stale events, including the previous one-argument hook format. wp_clear_scheduled_hook( 'accessibility_audio_tts_generate_single', array( $post_id ) ); wp_clear_scheduled_hook( 'accessibility_audio_tts_generate_single', array( $post_id, false ) ); wp_clear_scheduled_hook( 'accessibility_audio_tts_generate_single', array( $post_id, true ) ); $scheduled = wp_schedule_single_event( time(), 'accessibility_audio_tts_generate_single', array( $post_id, $force ), true ); if ( is_wp_error( $scheduled ) || ! $scheduled ) { $message = is_wp_error( $scheduled ) ? $scheduled->get_error_message() : __( 'Could not schedule audio generation.', 'accessibility-audio-tts-text-to-speech-for-articles' ); update_post_meta( $post_id, 'accessibility_audio_tts_generation_status', 'failed' ); update_post_meta( $post_id, 'accessibility_audio_tts_generation_error', $message ); return new \WP_Error( 'generation_schedule_failed', $message, array( 'status' => 500 ) ); } return rest_ensure_response( array( 'post_id' => $post_id, 'status' => 'queued', 'message' => __( 'Audio generation started.', 'accessibility-audio-tts-text-to-speech-for-articles' ), ) ); } /** * Get plugin statistics. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response object. * @since 1.0.6 */ public static function get_statistics( $request ) { $period = $request->get_param( 'period' ); // Get statistics based on period. $stats = array( 'period' => $period, 'total_posts_with_audio' => 0, 'total_listens' => 0, 'total_listen_time' => 0, 'average_listen_duration' => 0, 'top_posts' => array(), 'recent_activity' => array(), ); // Calculate date range. $date_range = self::get_date_range( $period ); // Get posts with audio. $posts_with_audio = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- normalized for consistency. array( 'key' => 'accessibility_audio_tts_url', 'compare' => 'EXISTS', ), ), 'numberposts' => -1, 'date_query' => array( array( 'after' => $date_range['start'], 'before' => $date_range['end'], 'inclusive' => true, ), ), 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $stats['total_posts_with_audio'] = count( $posts_with_audio ); // Get listening statistics if available. if ( class_exists( '\\Accessibility_Audio_TTS\\Admin\\Listening_Statistics' ) ) { $listening_stats = new \Accessibility_Audio_TTS\Admin\Listening_Statistics(); $listen_data = $listening_stats->get_period_stats( $period ); $stats['total_listens'] = $listen_data['total_listens'] ?? 0; $stats['total_listen_time'] = $listen_data['total_time'] ?? 0; $stats['average_listen_duration'] = $listen_data['average_duration'] ?? 0; $stats['top_posts'] = $listen_data['top_posts'] ?? array(); } return rest_ensure_response( $stats ); } /** * Get cache status. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response object. * @since 1.0.6 */ public static function get_cache_status( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $cache_status = array( 'enabled' => true, 'size' => 0, 'files' => 0, 'last_cleared' => get_option( 'accessibility_audio_tts_cache_last_cleared' ), 'types' => array( 'audio' => array( 'size' => 0, 'files' => 0, ), 'static' => array( 'size' => 0, 'files' => 0, ), ), ); // Calculate cache size (simplified). $upload_dir = wp_upload_dir(); $audio_dir = $upload_dir['basedir'] . '/accessibility-audio-tts/'; if ( is_dir( $audio_dir ) ) { $cache_status['types']['audio'] = self::get_directory_stats( $audio_dir ); $cache_status['size'] += $cache_status['types']['audio']['size']; $cache_status['files'] += $cache_status['types']['audio']['files']; } return rest_ensure_response( $cache_status ); } /** * Clear cache. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response object. * @since 1.0.6 */ public static function clear_cache( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found // Clear WordPress object cache. wp_cache_flush(); // Clear plugin-specific transients. delete_transient( 'accessibility_audio_tts_stats' ); delete_transient( 'accessibility_audio_tts_settings' ); // Update last cleared timestamp. update_option( 'accessibility_audio_tts_cache_last_cleared', current_time( 'mysql' ) ); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Cache cleared successfully.', 'accessibility-audio-tts-text-to-speech-for-articles' ), 'timestamp' => current_time( 'mysql' ), ) ); } /** * Get plugin settings. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response object. * @since 1.0.6 */ public static function get_settings( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $settings = get_option( 'accessibility_audio_tts_settings', array() ); // Remove sensitive data. unset( $settings['api_key'] ); // Sanitize recursively. $settings = self::sanitize_settings_array( $settings ); return rest_ensure_response( $settings ); } /** * Update plugin settings. * * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error Response object. * @since 1.0.6 */ public static function update_settings( $request ) { $new_settings = $request->get_param( 'settings' ); // Validate settings. $validated_settings = self::validate_settings( $new_settings ); if ( is_wp_error( $validated_settings ) ) { return $validated_settings; } $current_settings = get_option( 'accessibility_audio_tts_settings', array() ); // Merge settings. $updated_settings = array_merge( $current_settings, $validated_settings ); // Update unified settings. update_option( 'accessibility_audio_tts_settings', $updated_settings ); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Settings updated successfully.', 'accessibility-audio-tts-text-to-speech-for-articles' ), 'settings' => $updated_settings, ) ); } /** * Recursively sanitize settings array for safe REST output. * * @param mixed $value Value to sanitize. * @return mixed Sanitized value. */ private static function sanitize_settings_array( $value ) { if ( is_array( $value ) ) { $sanitized = array(); foreach ( $value as $k => $v ) { $sanitized_key = is_string( $k ) ? sanitize_key( $k ) : $k; $sanitized[ $sanitized_key ] = self::sanitize_settings_array( $v ); } return $sanitized; } if ( is_string( $value ) ) { return sanitize_text_field( $value ); } if ( is_bool( $value ) || is_numeric( $value ) || null === $value ) { return $value; } return sanitize_text_field( wp_json_encode( $value ) ); } /** * Check read permission for public endpoints (player data). * * Intentionally public — the player endpoint serves audio for published posts * on the frontend. The callback itself verifies post_status === 'publish'. * * @param \WP_REST_Request $request Request object. * @return bool Always true for public access. * @since 1.0.6 */ public static function check_read_permission( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found return true; } /** * Check edit permission. * * @param \WP_REST_Request $request Request object. * @return bool True if user has permission. * @since 1.0.6 */ public static function check_edit_permission( $request ) { $post_id = $request->get_param( 'post_id' ) ?? $request->get_param( 'id' ); if ( $post_id ) { return current_user_can( 'edit_post', (int) $post_id ); } return current_user_can( 'edit_posts' ); } /** * Check manage permission. * * @param \WP_REST_Request $request Request object. * @return bool True if user has permission. * @since 1.0.6 */ public static function check_manage_permission( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found return current_user_can( 'manage_options' ); } /** * Get player HTML for a post. * * @param int $post_id Post ID. * @param string $audio_url Audio URL. * @return string Player HTML. * @since 1.0.6 */ private static function get_player_html( $post_id, $audio_url ) { if ( empty( $audio_url ) ) { return ''; } if ( class_exists( '\\Accessibility_Audio_TTS\\Frontend\\Player_Renderer' ) ) { $renderer = new \Accessibility_Audio_TTS\Frontend\Player_Renderer(); return $renderer->render_player( $post_id, $audio_url ); } return ''; } /** * Get date range for period. * * @param string $period Period (day, week, month, year). * @return array Start and end dates. * @since 1.0.6 */ private static function get_date_range( $period ) { $end = current_time( 'mysql' ); switch ( $period ) { case 'day': $start = gmdate( 'Y-m-d H:i:s', strtotime( '-1 day' ) ); break; case 'week': $start = gmdate( 'Y-m-d H:i:s', strtotime( '-1 week' ) ); break; case 'year': $start = gmdate( 'Y-m-d H:i:s', strtotime( '-1 year' ) ); break; case 'month': default: $start = gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) ); break; } return array( 'start' => $start, 'end' => $end, ); } /** * Get directory statistics. * * @param string $dir Directory path. * @return array Directory stats. * @since 1.0.6 */ private static function get_directory_stats( $dir ) { $stats = array( 'size' => 0, 'files' => 0, ); if ( ! is_dir( $dir ) ) { return $stats; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ) ); foreach ( $iterator as $file ) { if ( $file->isFile() ) { $stats['size'] += $file->getSize(); ++$stats['files']; } } return $stats; } /** * Validate settings. * * @param array $settings Settings to validate. * @return array|\WP_Error Validated settings or error. * @since 1.0.6 */ private static function validate_settings( $settings ) { $validated = array(); $errors = array(); // Define allowed settings and their validation rules. $allowed_settings = array( 'post_types' => 'array', 'auto_generate' => 'boolean', 'voice_id' => 'string', 'voice_speed' => 'float', 'voice_stability' => 'float', 'cache_enabled' => 'boolean', 'offline_enabled' => 'boolean', ); foreach ( $settings as $key => $value ) { if ( ! array_key_exists( $key, $allowed_settings ) ) { continue; // Skip unknown settings. } $type = $allowed_settings[ $key ]; switch ( $type ) { case 'array': if ( is_array( $value ) ) { $validated[ $key ] = array_map( 'sanitize_text_field', $value ); } else { /* translators: auto */ $errors[] = sprintf( __( 'Setting %s must be an array.', 'accessibility-audio-tts-text-to-speech-for-articles' ), $key ); } break; case 'boolean': $validated[ $key ] = (bool) $value; break; case 'float': $float_value = floatval( $value ); if ( $float_value >= 0 && $float_value <= 1 ) { $validated[ $key ] = $float_value; } else { /* translators: auto */ $errors[] = sprintf( __( 'Setting %s must be between 0 and 1.', 'accessibility-audio-tts-text-to-speech-for-articles' ), $key ); } break; case 'string': default: $validated[ $key ] = sanitize_text_field( $value ); break; } } if ( ! empty( $errors ) ) { return new \WP_Error( 'invalid_settings', implode( ' ', $errors ), array( 'status' => 400 ) ); } return $validated; } }