0, 'misses' => 0, 'sets' => 0, 'deletes' => 0, 'flushes' => 0, ); /** * Cache groups for organized invalidation * * @var array */ private static array $cache_groups = array( 'audio' => 'Audio generation and processing', 'settings' => 'Plugin settings and configuration', 'api' => 'API responses and data', 'assets' => 'Asset optimization and minification', 'posts' => 'Post content and metadata', 'cron' => 'Cron job data and status', ); /** * Cache backend preference * * @var string */ private string $backend = 'transient'; /** * Whether object cache is available * * @var bool */ private bool $object_cache_available; /** * Constructor */ public function __construct() { $this->object_cache_available = wp_using_ext_object_cache(); $this->backend = $this->determine_best_backend(); // Initialize cache groups in WordPress object cache. if ( $this->object_cache_available ) { foreach ( array_keys( self::$cache_groups ) as $group ) { wp_cache_add_global_groups( array( self::CACHE_PREFIX . $group ) ); } } } /** * Get cached value with fallback callback. * * @param string $key Cache key. * @param callable|null $callback Fallback function to generate value. * @param int $expiration Cache expiration in seconds. * @param string $group Cache group. * @return mixed Cached or generated value. */ public function get( string $key, ?callable $callback = null, int $expiration = self::DEFAULT_EXPIRATION, string $group = 'default' ): mixed { $cache_key = $this->build_cache_key( $key, $group ); // Try to get from cache. $value = $this->get_from_cache( $cache_key, $group ); if ( false !== $value ) { ++self::$stats['hits']; accessibility_audio_tts_debug_log( "Cache hit: {$cache_key}" ); return $value; } ++self::$stats['misses']; accessibility_audio_tts_debug_log( "Cache miss: {$cache_key}" ); // Generate value if callback provided. if ( null !== $callback ) { try { $value = $callback(); $this->set( $key, $value, $expiration, $group ); return $value; } catch ( \Throwable $e ) { accessibility_audio_tts_error_log( "Error generating cached value for {$cache_key}: " . $e->getMessage() ); return false; } } return false; } /** * Set cache value. * * @param string $key Cache key. * @param mixed $value Value to cache. * @param int $expiration Cache expiration in seconds. * @param string $group Cache group. * @return bool True on success. */ public function set( string $key, mixed $value, int $expiration = self::DEFAULT_EXPIRATION, string $group = 'default' ): bool { $cache_key = $this->build_cache_key( $key, $group ); $success = $this->set_in_cache( $cache_key, $value, $expiration, $group ); if ( $success ) { ++self::$stats['sets']; accessibility_audio_tts_debug_log( "Cache set: {$cache_key} (expires in {$expiration}s)" ); } else { accessibility_audio_tts_error_log( "Failed to set cache: {$cache_key}" ); } return $success; } /** * Delete cache value. * * @param string $key Cache key. * @param string $group Cache group. * @return bool True on success. */ public function delete( string $key, string $group = 'default' ): bool { $cache_key = $this->build_cache_key( $key, $group ); $success = $this->delete_from_cache( $cache_key, $group ); if ( $success ) { ++self::$stats['deletes']; accessibility_audio_tts_debug_log( "Cache deleted: {$cache_key}" ); } return $success; } /** * Check if cache key exists. * * @param string $key Cache key. * @param string $group Cache group. * @return bool True if exists. */ public function exists( string $key, string $group = 'default' ): bool { $cache_key = $this->build_cache_key( $key, $group ); return false !== $this->get_from_cache( $cache_key, $group ); } /** * Invalidate entire cache group. * * @param string $group Cache group to invalidate. * @return bool True on success. */ public function invalidate_group( string $group ): bool { if ( ! isset( self::$cache_groups[ $group ] ) ) { accessibility_audio_tts_error_log( "Unknown cache group: {$group}" ); return false; } $success = false; switch ( $this->backend ) { case 'object_cache': // WordPress object cache doesn't support group invalidation. // This is a limitation we need to work around. $success = $this->invalidate_group_object_cache( $group ); break; case 'transient': $success = $this->invalidate_group_transients( $group ); break; case 'file': $success = $this->invalidate_group_files( $group ); break; } if ( $success ) { accessibility_audio_tts_debug_log( "Invalidated cache group: {$group}" ); } return $success; } /** * Clear all plugin cache. * * @return bool True on success. */ public function flush_all(): bool { $success = true; foreach ( array_keys( self::$cache_groups ) as $group ) { if ( ! $this->invalidate_group( $group ) ) { $success = false; } } if ( $success ) { ++self::$stats['flushes']; accessibility_audio_tts_debug_log( 'Flushed all plugin cache' ); } return $success; } /** * Get cache statistics * * @return array Cache statistics */ public function get_stats(): array { $total_requests = self::$stats['hits'] + self::$stats['misses']; $hit_ratio = $total_requests > 0 ? round( ( self::$stats['hits'] / $total_requests ) * 100, 2 ) : 0; return array_merge( self::$stats, array( 'total_requests' => $total_requests, 'hit_ratio' => $hit_ratio, 'backend' => $this->backend, 'object_cache_available' => $this->object_cache_available, 'groups' => array_keys( self::$cache_groups ), ) ); } /** * Get cache size information * * @return array Cache size data */ public function get_cache_size(): array { $size_data = array( 'total_keys' => 0, 'estimated_size' => 0, 'groups' => array(), ); foreach ( array_keys( self::$cache_groups ) as $group ) { $group_size = $this->get_group_size( $group ); $size_data['groups'][ $group ] = $group_size; $size_data['total_keys'] += $group_size['keys']; $size_data['estimated_size'] += $group_size['size']; } return $size_data; } /** * Warm up cache with commonly used data * * @param array $keys Keys to warm up. * @return int Number of keys warmed up */ public function warm_up( array $keys = array() ): int { if ( empty( $keys ) ) { $keys = $this->get_warmup_keys(); } $warmed = 0; foreach ( $keys as $key_data ) { $key = $key_data['key']; $group = $key_data['group'] ?? 'default'; $callback = $key_data['callback'] ?? null; $expiration = $key_data['expiration'] ?? self::DEFAULT_EXPIRATION; if ( $callback && ! $this->exists( $key, $group ) ) { try { $value = $callback(); if ( $this->set( $key, $value, $expiration, $group ) ) { ++$warmed; } } catch ( \Throwable $e ) { accessibility_audio_tts_error_log( "Error warming up cache key {$key}: " . $e->getMessage() ); } } } if ( function_exists( 'accessibility_audio_tts_debug_log' ) ) { accessibility_audio_tts_debug_log( "Warmed up {$warmed} cache keys" ); } return $warmed; } /** * Build cache key with prefix and group * * @param string $key Original key. * @param string $group Cache group. * @return string Full cache key */ private function build_cache_key( string $key, string $group ): string { return self::CACHE_PREFIX . $group . '_' . $key; } /** * Get value from cache backend * * @param string $cache_key Full cache key. * @param string $group Cache group. * @return mixed Cached value or false. */ private function get_from_cache( string $cache_key, string $group ): mixed { switch ( $this->backend ) { case 'object_cache': return wp_cache_get( $cache_key, self::CACHE_PREFIX . $group ); case 'transient': return get_transient( $cache_key ); case 'file': return $this->get_from_file_cache( $cache_key ); default: return false; } } /** * Set value in cache backend * * @param string $cache_key Full cache key. * @param mixed $value Value to cache. * @param int $expiration Expiration time. * @param string $group Cache group. * @return bool Success. */ private function set_in_cache( string $cache_key, mixed $value, int $expiration, string $group ): bool { switch ( $this->backend ) { case 'object_cache': return wp_cache_set( $cache_key, $value, self::CACHE_PREFIX . $group, $expiration ); case 'transient': return set_transient( $cache_key, $value, $expiration ); case 'file': return $this->set_in_file_cache( $cache_key, $value, $expiration ); default: return false; } } /** * Delete value from cache backend * * @param string $cache_key Full cache key. * @param string $group Cache group. * @return bool Success. */ private function delete_from_cache( string $cache_key, string $group ): bool { switch ( $this->backend ) { case 'object_cache': return wp_cache_delete( $cache_key, self::CACHE_PREFIX . $group ); case 'transient': return delete_transient( $cache_key ); case 'file': return $this->delete_from_file_cache( $cache_key ); default: return false; } } /** * Determine best cache backend * * @return string Backend name. */ private function determine_best_backend(): string { // Prefer object cache if available. if ( $this->object_cache_available ) { return 'object_cache'; } // Fall back to transients. return 'transient'; } /** * Invalidate object cache group * * @param string $group Group name. * @return bool Success. */ private function invalidate_group_object_cache( string $group ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter reserved for future use. // WordPress object cache doesn't support group invalidation. // This is a limitation we need to work around. return wp_cache_flush(); } /** * Invalidate transient group * * @param string $group Group name. * @return bool Success. */ private function invalidate_group_transients( string $group ): bool { global $wpdb; $prefix = self::CACHE_PREFIX . $group . '_'; $transient_prefix = '_transient_' . $prefix; $timeout_prefix = '_transient_timeout_' . $prefix; // Delete transients and their timeouts. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk invalidation of transients by prefix; prepared values. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_prefix . '%', $timeout_prefix . '%' ) ); return false !== $deleted; } /** * Invalidate file cache group. * * @param string $group Group name. * @return bool Success. */ private function invalidate_group_files( string $group ): bool { $cache_dir = $this->get_file_cache_dir(); if ( ! is_dir( $cache_dir ) ) { return true; // Nothing to delete. } $pattern = $cache_dir . '/' . self::CACHE_PREFIX . $group . '_*'; $files = glob( $pattern ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_glob $success = true; foreach ( $files as $file ) { if ( ! wp_delete_file( $file ) ) { $success = false; } } return $success; } /** * Get file cache directory. * * @return string Cache directory path. */ private function get_file_cache_dir(): string { $upload_dir = wp_upload_dir(); $cache_dir = $upload_dir['basedir'] . '/accessibility-audio-tts-cache'; if ( ! is_dir( $cache_dir ) ) { wp_mkdir_p( $cache_dir ); // Add .htaccess to prevent direct access. $htaccess = $cache_dir . '/.htaccess'; if ( ! file_exists( $htaccess ) ) { file_put_contents( $htaccess, "Deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } return $cache_dir; } /** * Get value from file cache * * @param string $cache_key Cache key. * @return mixed Cached value or false. */ private function get_from_file_cache( string $cache_key ): mixed { $file_path = $this->get_file_cache_dir() . '/' . $cache_key . '.cache'; if ( ! file_exists( $file_path ) ) { return false; } $data = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local cache file read, not remote URL. if ( false === $data ) { return false; } $cache_data = unserialize( $data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Internal cache format. if ( false === $cache_data ) { return false; } // Check expiration. if ( $cache_data['expires'] > 0 && $cache_data['expires'] < time() ) { wp_delete_file( $file_path ); return false; } return $cache_data['value']; } /** * Set value in file cache * * @param string $cache_key Cache key. * @param mixed $value Value to cache. * @param int $expiration Expiration time. * @return bool Success. */ private function set_in_file_cache( string $cache_key, mixed $value, int $expiration ): bool { $file_path = $this->get_file_cache_dir() . '/' . $cache_key . '.cache'; $cache_data = array( 'value' => $value, 'expires' => $expiration > 0 ? time() + $expiration : 0, 'created' => time(), ); $data = serialize( $cache_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Internal cache format. return file_put_contents( $file_path, $data, LOCK_EX ) !== false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } /** * Delete value from file cache * * @param string $cache_key Cache key. * @return bool Success. */ private function delete_from_file_cache( string $cache_key ): bool { $file_path = $this->get_file_cache_dir() . '/' . $cache_key . '.cache'; if ( file_exists( $file_path ) ) { return (bool) wp_delete_file( $file_path ); } return true; } /** * Get cache group size * * @param string $group Group name. * @return array Size information. */ private function get_group_size( string $group ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter reserved for future use. // This is an approximation since we can't easily count cache entries. return array( 'keys' => 0, 'size' => 0, ); } /** * Get keys for cache warmup * * @return array Warmup keys configuration. */ private function get_warmup_keys(): array { return array( array( 'key' => 'plugin_settings', 'group' => 'settings', 'callback' => function () { return get_option( 'accessibility_audio_tts_settings', array() ); }, 'expiration' => 3600, ), ); } /** * Clean up expired cache entries * * @return int Number of entries cleaned. */ public function cleanup_expired(): int { $cleaned = 0; if ( 'file' === $this->backend ) { $cache_dir = $this->get_file_cache_dir(); $files = glob( $cache_dir . '/*.cache' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_glob foreach ( $files as $file ) { if ( is_file( $file ) && basename( $file ) !== '.htaccess' ) { if ( time() - filemtime( $file ) > $max_age ) { // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested if ( wp_delete_file( $file ) ) { ++$cleaned; } } } } } if ( function_exists( 'accessibility_audio_tts_debug_log' ) ) { accessibility_audio_tts_debug_log( "Cleaned up {$cleaned} expired cache entries" ); } return $cleaned; } /** * Reset cache statistics * * @return void */ public static function reset_stats(): void { self::$stats = array( 'hits' => 0, 'misses' => 0, 'sets' => 0, 'deletes' => 0, 'flushes' => 0, ); } }