prefix) ? $wpdb->prefix : 'wp_'; $site_hash = substr(md5($db_identifier . '|' . $table_prefix), 0, 8); // Remove content_version from key for stability - route_id is already unique per page return sprintf('ait:v4:%s:%s:%s', $site_hash, $lang, $route_id); } /** * Get artifact from cache (filesystem fallback). * * @param string $key * @return string|false */ public static function get($key) { $file = self::file_path($key); if (is_file($file)) { return self::validate_and_read_cache($file, $key); } // Backward compatibility: in shared-cache mode, older installs may still have // files under /cache/{domain}/{lang}/pages/ while current path is /cache/{lang}/pages/. if (!AI_Translate_Core::is_multi_domain()) { $parts = explode(':', (string) $key); $lang = isset($parts[3]) ? sanitize_key($parts[3]) : ''; if ($lang !== '') { $hash = md5($key); $uploads = wp_upload_dir(); $base = trailingslashit($uploads['basedir']) . 'ai-translate/cache/'; $domain_dirs = glob($base . '*/', GLOB_ONLYDIR); if (is_array($domain_dirs)) { foreach ($domain_dirs as $domain_dir) { $candidate = trailingslashit($domain_dir) . $lang . '/pages/' . substr($hash, 0, 2) . '/' . $hash . '.html'; if (is_file($candidate)) { return self::validate_and_read_cache($candidate, $key); } } } } } return false; } /** * Delete a cached file by key. * * @param string $key * @return bool True if file was deleted */ public static function delete($key) { $file = self::file_path($key); if (is_file($file)) { wp_delete_file($file); return !file_exists($file); } return false; } /** * Validate cache file and read contents if valid. * * @param string $file * @param string $key * @return string|false */ private static function validate_and_read_cache($file, $key) { // Revalidate based on admin setting (cache_expiration in hours) // Admin validation ensures minimum 14 days, so we respect the setting directly $expiry_seconds = AI_Translate_Core::cache_expiration_hours() * HOUR_IN_SECONDS; $mtime = @filemtime($file); if ($mtime) { $age_seconds = time() - (int) $mtime; if ($age_seconds > $expiry_seconds) { return false; // expired → force refresh via API } } $contents = @file_get_contents($file); return $contents !== false ? $contents : false; } /** * Store artifact to cache (filesystem). * * @param string $key * @param string $html * @return void */ public static function set($key, $html) { $file = self::file_path($key); $dir = dirname($file); if (!is_dir($dir)) { wp_mkdir_p($dir); } $result = false; $tmp = $file . '.tmp.' . uniqid('', true); // Atomic write: write temp then rename so readers never see a partial HTML file. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- local cache write under uploads if (@file_put_contents($tmp, $html) !== false) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic replace of cache artifact if (@rename($tmp, $file)) { $result = true; } else { // Fallback if rename fails across filesystems // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $result = (@file_put_contents($file, $html) !== false); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink @unlink($tmp); } } if (!$result) { return; } // Track cache metadata for admin table // Extract post_id from route_id in cache key: ait:v4:site:lang:route_id $parts = explode(':', (string) $key); if (count($parts) >= 5) { // route_id itself contains ":" (e.g. "post:123" or "path:md5"), so reconstruct it $route_id = implode(':', array_slice($parts, 4)); $post_id = null; // Check if route_id is in format 'post:123' if (strpos($route_id, 'post:') === 0) { $post_id = (int) substr($route_id, 5); } // Check if route_id is path-based and represents homepage (path:md5(/)) elseif ($route_id === ('path:' . md5('/'))) { // Use post_id = 0 for homepage (blog listing) $post_id = 0; } // Check if route_id is the 404 page (path:md5(/404)) elseif ($route_id === ('path:' . md5('/404'))) { // Use post_id = -1 for 404 page $post_id = -1; } if ($post_id !== null) { // Extract language from key $lang = isset($parts[3]) ? sanitize_key($parts[3]) : ''; if ($lang !== '') { // Insert metadata $cache_hash = md5($key); AI_Cache_Meta::insert($post_id, $lang, $file, $cache_hash); } } } } /** * Delegate to centralized get_site_cache_dir in AI_Translate_Core. * * @return string */ private static function get_site_cache_dir() { return \AITranslate\AI_Translate_Core::get_site_cache_dir(); } /** * Get file path for a cache key (public method for metadata tracking) * * @param string $key * @param string|null $site_dir Optional site subdirectory. Null uses the active domain. * @return string */ public static function get_file_path($key, $site_dir = null) { return self::file_path($key, $site_dir); } /** * Map key to file path under uploads. * * @param string $key * @param string|null $site_dir Optional site subdirectory. Null uses the active domain. * @return string */ private static function file_path($key, $site_dir = null) { $uploads = wp_upload_dir(); $base = trailingslashit($uploads['basedir']) . 'ai-translate/cache/'; if ($site_dir === null) { $site_dir = self::get_site_cache_dir(); } if ($site_dir !== '') { $base = trailingslashit($base) . $site_dir . '/'; } // Extract language from key: // - ait:v4:site:lang:route (v4 = database-based site identifier, no content_version) // - ait:v3:site:lang:route (v3 = domain-based site identifier, no content_version) // - ait:v2:site:lang:route:ver (v2 = old format with content_version, for backward compatibility) $parts = explode(':', (string) $key); $lang = isset($parts[3]) ? sanitize_key($parts[3]) : 'xx'; $dir = $base . $lang . '/pages/'; $hash = md5($key); return $dir . substr($hash, 0, 2) . '/' . $hash . '.html'; } }