*/ private const PROVIDER_LABELS = [ 'deepl' => 'DeepL', 'google' => 'Google', ]; /** * Cached metadata from the translate/providers endpoint. * * @var array> */ private static array $translationProviderMetaCache = []; /** * Tracks which providers already emitted a debug log about the language source. * * @var array */ private static array $languageSourceLogged = []; /** * Return the currently selected translation provider slug. */ public static function get_translation_provider(): string { $stored = get_option('aigude_translation_provider', self::DEFAULT_PROVIDER); $provider = self::normalize_translation_provider($stored); $meta = self::get_translation_providers_metadata(); if (!isset($meta[$provider])) { return self::DEFAULT_PROVIDER; } return $provider; } /** * Human readable label for a provider slug. */ public static function get_translation_provider_label(?string $provider = null): string { $key = $provider !== null ? self::normalize_translation_provider($provider) : self::get_translation_provider(); return self::PROVIDER_LABELS[$key] ?? ucfirst($key); } /** * Return the available provider slugs. * * @return string[] */ public static function get_available_translation_providers(): array { $meta = self::get_translation_providers_metadata(); return array_keys($meta); } /** * Fetch translation provider metadata from Credits API (cached). * * @param bool $force If true, bypass transient + static cache. * @return array> */ public static function get_translation_providers_metadata(bool $force = false): array { if (!$force && !empty(self::$translationProviderMetaCache)) { return self::$translationProviderMetaCache; } if (!$force) { $cached = get_transient('aigude_translation_providers_meta'); if (is_array($cached)) { self::$translationProviderMetaCache = $cached; return $cached; } } $providers = []; $apiKey = AIGUDE_Tools_Plugin::get_translation_api_key(); if (!empty($apiKey)) { $response = wp_remote_get(self::get_translate_providers_url(), [ 'headers' => ['apikey' => $apiKey], 'timeout' => 30, ]); if (!is_wp_error($response)) { $body = wp_remote_retrieve_body($response); $json = json_decode($body, true); if (is_array($json)) { if (isset($json['providers']) && is_array($json['providers'])) { $providers = $json['providers']; } elseif (!empty($json)) { $providers = $json; } } } } $normalized = []; foreach ((array) $providers as $key => $data) { $norm = self::normalize_translation_provider((string) $key); if (!is_array($data)) { continue; } $normalized[$norm] = $data; } $defaultDeepl = self::get_default_deepl_metadata(); if (!isset($normalized['deepl'])) { $normalized['deepl'] = $defaultDeepl; } else { $deepl = $normalized['deepl']; $deepl['canonical_codes'] = array_values(array_unique(array_map('strval', isset($deepl['canonical_codes']) && is_array($deepl['canonical_codes']) ? $deepl['canonical_codes'] : []))); if (empty($deepl['canonical_codes'])) { $deepl['canonical_codes'] = $defaultDeepl['canonical_codes']; } else { $deepl['canonical_codes'] = array_values(array_unique(array_merge($deepl['canonical_codes'], $defaultDeepl['canonical_codes']))); } $deepl['display_names'] = isset($deepl['display_names']) && is_array($deepl['display_names']) ? $deepl['display_names'] + $defaultDeepl['display_names'] : $defaultDeepl['display_names']; $deepl['aliases'] = isset($deepl['aliases']) && is_array($deepl['aliases']) ? $deepl['aliases'] : []; $normalized['deepl'] = $deepl; } foreach ($normalized as $prov => &$info) { if (!isset($info['canonical_codes']) || !is_array($info['canonical_codes'])) { $info['canonical_codes'] = []; } if (!isset($info['display_names']) || !is_array($info['display_names'])) { $info['display_names'] = []; } if (!isset($info['aliases']) || !is_array($info['aliases'])) { $info['aliases'] = []; } $region = isset($info['region']) ? strtolower(trim((string) $info['region'])) : ''; $info['region'] = $region === 'eu' ? 'eu' : 'global'; } unset($info); set_transient('aigude_translation_providers_meta', $normalized, HOUR_IN_SECONDS); self::$translationProviderMetaCache = $normalized; return $normalized; } /** * Resolve translate/providers endpoint, allowing overrides via constant, env, or filter. */ private static function get_translate_providers_url(): string { if (defined('AIGUDE_TRANSLATE_PROVIDERS_URL') && is_string(AIGUDE_TRANSLATE_PROVIDERS_URL) && AIGUDE_TRANSLATE_PROVIDERS_URL !== '') { return AIGUDE_TRANSLATE_PROVIDERS_URL; } $env = getenv('AIGUDE_TRANSLATE_PROVIDERS_URL'); if (is_string($env) && trim($env) !== '') { return trim($env); } if (function_exists('apply_filters')) { $filtered = apply_filters('aigude_translate_providers_url', self::API_URL_TRANSLATE_PROVIDERS); if (is_string($filtered) && trim($filtered) !== '') { return trim($filtered); } } return self::API_URL_TRANSLATE_PROVIDERS; } /** * Return the language list (code => label) for the active provider. * * @return array */ public static function get_translation_languages(?string $provider = null): array { $provider = $provider !== null ? self::normalize_translation_provider($provider) : self::get_translation_provider(); $meta = self::get_translation_providers_metadata(); $info = $meta[$provider] ?? null; if (!is_array($info)) { self::log_language_source($provider, false, 0); return []; } $codes = array_map('strval', $info['canonical_codes'] ?? []); $labels = array_map('strval', $info['display_names'] ?? []); if (empty($codes)) { self::log_language_source($provider, false, 0); return []; } $languages = []; foreach ($codes as $code) { $label = $labels[$code] ?? $labels[self::normalize_lang_key($code)] ?? self::humanize_language_code($code); $languages[$code] = $label; } asort($languages, SORT_NATURAL | SORT_FLAG_CASE); self::log_language_source($provider, true, count($languages)); return $languages; } /** Backwards-compat helper for legacy calls expecting DeepL languages. */ public static function get_deepl_languages(): array { return self::get_translation_languages(); } /** * Default metadata for DeepL (used when API call fails or omits details). * * @return array{canonical_codes: array, display_names: array, aliases: array, supports_auto_source: bool, region: string} */ private static function get_default_deepl_metadata(): array { return [ 'canonical_codes' => [], 'display_names' => [], 'aliases' => [], 'supports_auto_source'=> true, 'region' => 'eu', ]; } /** * Normalize arbitrary provider slug to our canonical lowercase form. */ public static function normalize_translation_provider(?string $provider): string { if (!is_string($provider)) { return self::DEFAULT_PROVIDER; } $norm = strtolower(trim($provider)); return $norm !== '' ? $norm : self::DEFAULT_PROVIDER; } /** * Normalize arbitrary language code string to comparison key (uppercase, hyphen). */ private static function normalize_lang_key(string $code): string { $c = strtoupper(trim($code)); return str_replace('_', '-', $c); } /** * Fallback label for language codes when provider metadata lacks one. */ private static function humanize_language_code(string $code): string { $norm = self::normalize_lang_key($code); if (strpos($norm, '-') !== false) { $parts = array_map('strtoupper', explode('-', $norm)); return sprintf('%s (%s)', $parts[0], implode('-', array_slice($parts, 1))); } return strtoupper($norm); } /** * Emit a debug log describing where the language list originated. */ private static function log_language_source(string $provider, bool $fromApi, int $count): void { if (!AIGUDE_Tools_Plugin::debug_enabled()) { return; } if (isset(self::$languageSourceLogged[$provider])) { return; } self::$languageSourceLogged[$provider] = true; $source = $fromApi ? 'API metadata' : 'fallback list'; AIGUDE_Tools_Plugin::log( sprintf( 'Translation languages for provider "%s" loaded from %s (%d entries).', $provider, $source, $count ) ); } /** * Resolve the best site language for the given provider. * * @return array{code:string,label:string,supported:bool,source:string} */ public static function describe_site_language(?string $provider = null): array { $provider = $provider !== null ? self::normalize_translation_provider($provider) : self::get_translation_provider(); $languages = self::get_translation_languages($provider); $locale = get_locale(); $candidates = self::get_locale_candidates($locale); foreach ($candidates as $candidate) { if ($candidate !== '' && isset($languages[$candidate])) { return [ 'code' => $candidate, 'label' => $languages[$candidate], 'supported' => true, 'source' => 'site', ]; } } $fallback = $candidates[0] ?? self::normalize_lang_key($locale); return [ 'code' => $fallback, 'label' => self::humanize_language_code($fallback), 'supported' => false, 'source' => 'site', ]; } /** * Resolve the language preference saved in settings and ensure it is supported. * * @return array{code:string,label:string,supported:bool,source:string} */ public static function describe_language_preference(?string $provider = null): array { $provider = $provider !== null ? self::normalize_translation_provider($provider) : self::get_translation_provider(); $languages = self::get_translation_languages($provider); $saved = get_option('aigude_target_language', 'default'); if ($saved !== '' && $saved !== 'default') { $normalized = self::normalize_language_for_provider($saved, $provider); if ($normalized !== '') { return [ 'code' => $normalized, 'label' => $languages[$normalized] ?? self::humanize_language_code($normalized), 'source' => 'user', 'supported' => isset($languages[$normalized]), ]; } } return self::describe_site_language($provider); } /** * Normalize user input into a provider specific language code. */ private static function normalize_language_for_provider(string $code, string $provider): string { if ($provider === 'deepl') { return self::normalize_deepl_lang_code($code, 'target'); } return self::normalize_provider_lang_code_generic($code, $provider, 'target'); } /** * Build a priority list of locale codes to try for matching languages. * * @return string[] */ private static function get_locale_candidates(string $locale): array { $locale = trim($locale); if ($locale === '') { return []; } $candidates = []; $normalized = self::normalize_lang_key($locale); if ($normalized !== '') { $candidates[] = $normalized; } if (strpos($normalized, '-') !== false) { $candidates[] = substr($normalized, 0, 2); } $candidates[] = strtoupper(substr($locale, 0, 2)); $candidates = array_values(array_unique(array_filter($candidates))); return $candidates; } /** * Normalize a provided language string to whatever the current provider expects. */ public static function resolve_target_lang_code(string $code, ?string $provider = null): string { $provider = $provider !== null ? self::normalize_translation_provider($provider) : self::get_translation_provider(); $meta = self::get_translation_providers_metadata(); if (!isset($meta[$provider])) { $provider = self::get_translation_provider(); } if ($code === 'default') { $code = self::map_wp_locale_to_provider(get_locale(), $provider); } if ($provider === 'deepl') { $norm = self::normalize_deepl_lang_code($code, 'target'); } else { $norm = self::normalize_provider_lang_code_generic($code, $provider, 'target'); } if ($norm === '' || $norm === 'auto') { $norm = self::map_wp_locale_to_provider(get_locale(), $provider); } return $norm !== '' ? $norm : 'EN'; } /** * Normalize arbitrary input to a DeepL code. Allows 'en', 'EN', 'en-us', etc. */ public static function normalize_deepl_lang_code(string $code, string $context = 'target'): string { return self::normalize_provider_lang_code_generic($code, 'deepl', $context); } /** * Map WordPress locale (e.g., en_US) to a DeepL target code. */ public static function map_wp_locale_to_deepl(string $locale): string { return self::map_wp_locale_to_provider($locale, 'deepl'); } /** * Map WP locale to the currently active provider code. */ public static function map_wp_locale_to_provider(string $locale, ?string $provider = null): string { $provider = $provider !== null ? self::normalize_translation_provider($provider) : self::get_translation_provider(); return self::map_locale_to_available_code($locale, $provider); } /** * Map a locale to the closest available code for the provider using metadata. */ private static function map_locale_to_available_code(string $locale, string $provider): string { $meta = self::get_translation_providers_metadata(); $info = $meta[$provider] ?? []; $canon = isset($info['canonical_codes']) && is_array($info['canonical_codes']) ? array_map('strval', $info['canonical_codes']) : []; if (empty($canon)) { return 'EN'; } $lookup = []; foreach ($canon as $code) { $lookup[self::normalize_lang_key($code)] = $code; } $variants = []; $variants[] = self::normalize_lang_key($locale); $variants[] = substr(self::normalize_lang_key($locale), 0, 2); $variants[] = strtoupper(substr((string) $locale, 0, 2)); foreach ($variants as $variant) { $key = self::normalize_lang_key($variant); if (isset($lookup[$key])) { return $lookup[$key]; } } foreach (['EN-US', 'EN-GB', 'EN'] as $fallback) { $fKey = self::normalize_lang_key($fallback); if (isset($lookup[$fKey])) { return $lookup[$fKey]; } } $first = reset($lookup); return $first !== false ? $first : 'EN'; } /** * Normalize language code for providers other than DeepL. */ public static function normalize_provider_lang_code_generic(string $code, string $provider, string $context = 'target'): string { if ($code === '') { return ''; } $trim = trim($code); if ($trim === '') { return ''; } $upper = strtoupper($trim); if ($upper === 'DEFAULT') { return self::map_wp_locale_to_provider(get_locale(), $provider); } if ($upper === 'AUTO') { return 'auto'; } $meta = self::get_translation_providers_metadata(); $info = $meta[$provider] ?? null; if (!is_array($info)) { return ''; } $canon = isset($info['canonical_codes']) && is_array($info['canonical_codes']) ? $info['canonical_codes'] : []; $canonMap = []; foreach ($canon as $canonCode) { $canonMap[self::normalize_lang_key((string) $canonCode)] = (string) $canonCode; } if (empty($canonMap)) { return ''; } $key = self::normalize_lang_key($trim); if (isset($canonMap[$key])) { return $canonMap[$key]; } $aliases = isset($info['aliases']) && is_array($info['aliases']) ? $info['aliases'] : []; foreach ($aliases as $alias => $target) { $aliasKey = self::normalize_lang_key((string) $alias); if ($aliasKey !== $key) { continue; } $targetKey = self::normalize_lang_key((string) $target); if (isset($canonMap[$targetKey])) { return $canonMap[$targetKey]; } return (string) $target; } $two = substr($key, 0, 2); foreach ($canonMap as $norm => $original) { if (substr($norm, 0, 2) === $two) { return $original; } } return ''; } }