feat(database): 添加用户角色权限系统及相关监控功能

- 创建用户表(users)包含基本信息和认证字段
- 创建角色表(roles)用于权限控制
- 创建权限表(permissions)定义系统权限
- 创建用户角色关联表(user_roles)建立用户与角色关系
- 创建角色权限关联表(role_permissions)建立角色与权限关系
- 创建迁移记录表(migrations)追踪数据库变更
- 添加AdminController提供管理员面板功能
- 实现系统监控、配置管理、缓存清理等功能
- 添加AOP切面编程支持的各种通知类型
- 实现告警管理AlertManager支持多渠道告警
- 添加文档注解接口规范
This commit is contained in:
Lawson
2026-04-08 17:00:28 +08:00
commit 2782d765fb
270 changed files with 107192 additions and 0 deletions

View File

@@ -0,0 +1,824 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Config;
use Fendx\I18n\Config\Loader\ConfigLoader;
use Fendx\I18n\Config\Validator\ConfigValidator;
use Fendx\I18n\Config\Cache\ConfigCache;
class I18nConfigManager
{
protected ConfigLoader $loader;
protected ConfigValidator $validator;
protected ConfigCache $cache;
protected array $config = [];
protected array $defaults = [];
protected array $environments = [];
protected string $currentEnvironment = 'production';
protected bool $loaded = false;
public function __construct(array $config = [])
{
$this->defaults = $this->getDefaultConfig();
$this->config = array_merge($this->defaults, $config);
$this->loader = new ConfigLoader($this->config);
$this->validator = new ConfigValidator($this->config);
$this->cache = new ConfigCache($this->config);
$this->currentEnvironment = $this->config['environment'] ?? $this->detectEnvironment();
}
/**
* Load I18n configuration.
*/
public function load(): void
{
if ($this->loaded) {
return;
}
// Load base configuration
$baseConfig = $this->loader->loadBase();
// Load environment-specific configuration
$envConfig = $this->loader->loadEnvironment($this->currentEnvironment);
// Load local configuration
$localConfig = $this->loader->loadLocal();
// Merge configurations
$this->config = array_merge_recursive(
$this->defaults,
$baseConfig,
$envConfig,
$localConfig
);
// Validate configuration
$validation = $this->validator->validate($this->config);
if (!$validation['valid']) {
throw new \InvalidArgumentException(
'Invalid I18n configuration: ' . implode(', ', $validation['errors'])
);
}
// Process configuration
$this->processConfig();
$this->loaded = true;
}
/**
* Get configuration value.
*/
public function get(string $key, mixed $default = null): mixed
{
$this->ensureLoaded();
return $this->getNestedValue($this->config, $key, $default);
}
/**
* Set configuration value.
*/
public function set(string $key, mixed $value): void
{
$this->ensureLoaded();
$this->setNestedValue($this->config, $key, $value);
$this->cache->clear();
}
/**
* Check if configuration key exists.
*/
public function has(string $key): bool
{
$this->ensureLoaded();
return $this->hasNestedValue($this->config, $key);
}
/**
* Get all configuration.
*/
public function all(): array
{
$this->ensureLoaded();
return $this->config;
}
/**
* Get supported languages.
*/
public function getSupportedLanguages(): array
{
return $this->get('supported_languages', []);
}
/**
* Get default language.
*/
public function getDefaultLanguage(): string
{
return $this->get('default_language', 'en');
}
/**
* Get fallback language.
*/
public function getFallbackLanguage(): string
{
return $this->get('fallback_language', 'en');
}
/**
* Get current language.
*/
public function getCurrentLanguage(): string
{
return $this->get('current_language', $this->getDefaultLanguage());
}
/**
* Set current language.
*/
public function setCurrentLanguage(string $language): void
{
if (!in_array($language, $this->getSupportedLanguages())) {
throw new \InvalidArgumentException("Language '{$language}' is not supported");
}
$this->set('current_language', $language);
}
/**
* Get timezone configuration.
*/
public function getTimezoneConfig(): array
{
return $this->get('timezone', [
'default' => 'UTC',
'allow_user_override' => true,
'supported_timezones' => []
]);
}
/**
* Get currency configuration.
*/
public function getCurrencyConfig(): array
{
return $this->get('currency', [
'default' => 'USD',
'allow_user_override' => true,
'supported_currencies' => [],
'precision' => 2,
'decimal_separator' => '.',
'thousands_separator' => ','
]);
}
/**
* Get date/time format configuration.
*/
public function getDateTimeConfig(): array
{
return $this->get('datetime', [
'date_format' => 'Y-m-d',
'time_format' => 'H:i:s',
'datetime_format' => 'Y-m-d H:i:s',
'timezone' => 'UTC',
'locale' => 'en'
]);
}
/**
* Get number format configuration.
*/
public function getNumberConfig(): array
{
return $this->get('number', [
'decimal_separator' => '.',
'thousands_separator' => ',',
'precision' => 2
]);
}
/**
* Get translation paths.
*/
public function getTranslationPaths(): array
{
return $this->get('translation_paths', []);
}
/**
* Get cache configuration.
*/
public function getCacheConfig(): array
{
return $this->get('cache', [
'enabled' => true,
'driver' => 'file',
'prefix' => 'i18n',
'ttl' => 3600
]);
}
/**
* Get language switcher configuration.
*/
public function getSwitcherConfig(): array
{
return $this->get('switcher', [
'strategies' => ['url', 'parameter', 'header', 'session', 'cookie'],
'url_parameter' => 'lang',
'cookie_name' => 'language',
'cookie_expires' => 86400 * 30
]);
}
/**
* Get fallback configuration.
*/
public function getFallbackConfig(): array
{
return $this->get('fallback', [
'enabled' => true,
'chains' => [],
'max_depth' => 5
]);
}
/**
* Get validation configuration.
*/
public function getValidationConfig(): array
{
return $this->get('validation', [
'strict_mode' => false,
'log_missing' => true,
'throw_on_missing' => false
]);
}
/**
* Add supported language.
*/
public function addSupportedLanguage(string $code, string $name, array $options = []): void
{
$languages = $this->getSupportedLanguages();
$languages[$code] = array_merge([
'name' => $name,
'native_name' => $name,
'direction' => 'ltr',
'enabled' => true
], $options);
$this->set('supported_languages', $languages);
}
/**
* Remove supported language.
*/
public function removeSupportedLanguage(string $code): void
{
$languages = $this->getSupportedLanguages();
unset($languages[$code]);
$this->set('supported_languages', $languages);
}
/**
* Add translation path.
*/
public function addTranslationPath(string $path, int $priority = 0): void
{
$paths = $this->getTranslationPaths();
$paths[] = ['path' => $path, 'priority' => $priority];
// Sort by priority (higher first)
usort($paths, fn($a, $b) => $b['priority'] - $a['priority']);
$this->set('translation_paths', $paths);
}
/**
* Remove translation path.
*/
public function removeTranslationPath(string $path): void
{
$paths = $this->getTranslationPaths();
$paths = array_filter($paths, fn($p) => $p['path'] !== $path);
$this->set('translation_paths', array_values($paths));
}
/**
* Set timezone configuration.
*/
public function setTimezoneConfig(array $config): void
{
$this->set('timezone', array_merge($this->getTimezoneConfig(), $config));
}
/**
* Set currency configuration.
*/
public function setCurrencyConfig(array $config): void
{
$this->set('currency', array_merge($this->getCurrencyConfig(), $config));
}
/**
* Set date/time format configuration.
*/
public function setDateTimeConfig(array $config): void
{
$this->set('datetime', array_merge($this->getDateTimeConfig(), $config));
}
/**
* Set number format configuration.
*/
public function setNumberConfig(array $config): void
{
$this->set('number', array_merge($this->getNumberConfig(), $config));
}
/**
* Enable/disable cache.
*/
public function setCacheEnabled(bool $enabled): void
{
$this->set('cache.enabled', $enabled);
}
/**
* Set cache TTL.
*/
public function setCacheTtl(int $ttl): void
{
$this->set('cache.ttl', $ttl);
}
/**
* Enable/disable strict validation.
*/
public function setStrictValidation(bool $strict): void
{
$this->set('validation.strict_mode', $strict);
}
/**
* Get environment-specific configuration.
*/
public function getEnvironmentConfig(string $environment): array
{
return $this->environments[$environment] ?? [];
}
/**
* Set environment-specific configuration.
*/
public function setEnvironmentConfig(string $environment, array $config): void
{
$this->environments[$environment] = $config;
}
/**
* Get current environment.
*/
public function getCurrentEnvironment(): string
{
return $this->currentEnvironment;
}
/**
* Set current environment.
*/
public function setCurrentEnvironment(string $environment): void
{
$this->currentEnvironment = $environment;
$this->loaded = false; // Force reload with new environment
}
/**
* Detect current environment.
*/
protected function detectEnvironment(): string
{
// Check environment variable
$env = getenv('APP_ENV') ?: getenv('ENVIRONMENT');
if ($env) {
return $env;
}
// Check server variable
if (isset($_SERVER['APP_ENV'])) {
return $_SERVER['APP_ENV'];
}
// Check for common indicators
if (isset($_SERVER['SERVER_NAME'])) {
$serverName = $_SERVER['SERVER_NAME'];
if (str_contains($serverName, 'localhost') || str_contains($serverName, 'dev')) {
return 'development';
} elseif (str_contains($serverName, 'staging') || str_contains($serverName, 'test')) {
return 'staging';
}
}
// Default to production
return 'production';
}
/**
* Process configuration after loading.
*/
protected function processConfig(): void
{
// Set default timezone
$timezone = $this->get('timezone.default', 'UTC');
date_default_timezone_set($timezone);
// Process supported languages
$languages = $this->getSupportedLanguages();
foreach ($languages as $code => $info) {
if (!isset($info['name'])) {
$languages[$code]['name'] = $code;
}
if (!isset($info['native_name'])) {
$languages[$code]['native_name'] = $info['name'];
}
if (!isset($info['direction'])) {
$languages[$code]['direction'] = 'ltr';
}
if (!isset($info['enabled'])) {
$languages[$code]['enabled'] = true;
}
}
$this->set('supported_languages', $languages);
// Process translation paths
$paths = $this->getTranslationPaths();
foreach ($paths as $index => $path) {
if (is_string($path)) {
$paths[$index] = ['path' => $path, 'priority' => 0];
}
}
usort($paths, fn($a, $b) => $b['priority'] - $a['priority']);
$this->set('translation_paths', $paths);
// Validate current language
$currentLanguage = $this->getCurrentLanguage();
if (!in_array($currentLanguage, array_keys($languages))) {
$this->setCurrentLanguage($this->getDefaultLanguage());
}
}
/**
* Get nested value from array.
*/
protected function getNestedValue(array $array, string $key, mixed $default = null): mixed
{
$keys = explode('.', $key);
$current = $array;
foreach ($keys as $k) {
if (!is_array($current) || !array_key_exists($k, $current)) {
return $default;
}
$current = $current[$k];
}
return $current;
}
/**
* Set nested value in array.
*/
protected function setNestedValue(array &$array, string $key, mixed $value): void
{
$keys = explode('.', $key);
$current = &$array;
foreach ($keys as $k) {
if (!is_array($current)) {
$current = [];
}
if (!array_key_exists($k, $current)) {
$current[$k] = [];
}
$current = &$current[$k];
}
$current = $value;
}
/**
* Check if nested value exists.
*/
protected function hasNestedValue(array $array, string $key): bool
{
$keys = explode('.', $key);
$current = $array;
foreach ($keys as $k) {
if (!is_array($current) || !array_key_exists($k, $current)) {
return false;
}
$current = $current[$k];
}
return true;
}
/**
* Ensure configuration is loaded.
*/
protected function ensureLoaded(): void
{
if (!$this->loaded) {
$this->load();
}
}
/**
* Reload configuration.
*/
public function reload(): void
{
$this->loaded = false;
$this->cache->clear();
$this->load();
}
/**
* Save configuration to file.
*/
public function save(string $filename = null): bool
{
$this->ensureLoaded();
$filename = $filename ?? $this->get('config_file', 'i18n.php');
$content = '<?php return ' . var_export($this->config, true) . ';';
return file_put_contents($filename, $content) !== false;
}
/**
* Export configuration.
*/
public function export(string $format = 'php'): string
{
$this->ensureLoaded();
return match ($format) {
'php' => '<?php return ' . var_export($this->config, true) . ';',
'json' => json_encode($this->config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
'yaml' => $this->toYaml($this->config),
default => throw new \InvalidArgumentException("Unsupported export format: {$format}")
};
}
/**
* Import configuration.
*/
public function import(string $data, string $format = 'php'): void
{
$config = match ($format) {
'php' => include 'data://text/plain,' . urlencode($data),
'json' => json_decode($data, true),
'yaml' => $this->fromYaml($data),
default => throw new \InvalidArgumentException("Unsupported import format: {$format}")
};
if (!is_array($config)) {
throw new \InvalidArgumentException('Invalid configuration data');
}
$this->config = array_merge($this->defaults, $config);
$this->processConfig();
$this->cache->clear();
}
/**
* Convert to YAML.
*/
protected function toYaml(array $data, int $depth = 0): string
{
$yaml = '';
$indent = str_repeat(' ', $depth);
foreach ($data as $key => $value) {
if (is_array($value)) {
$yaml .= "{$indent}{$key}:\n";
$yaml .= $this->toYaml($value, $depth + 1);
} else {
$escapedValue = str_replace(["\n", "\r"], ['\\n', '\\r'], (string) $value);
$yaml .= "{$indent}{$key}: \"{$escapedValue}\"\n";
}
}
return $yaml;
}
/**
* Parse from YAML.
*/
protected function fromYaml(string $yaml): array
{
$data = [];
$lines = explode("\n", $yaml);
$stack = [&$data];
$currentIndent = 0;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
$indent = strlen($line) - strlen(ltrim($line));
$line = trim($line);
if (preg_match('/^(\w+):\s*$/', $line, $matches)) {
$key = $matches[1];
$newArray = [];
// Adjust stack depth
while ($indent < $currentIndent) {
array_pop($stack);
$currentIndent -= 2;
}
$current = &$stack[count($stack) - 1];
$current[$key] = &$newArray;
$stack[] = &$newArray;
$currentIndent = $indent;
} elseif (preg_match('/^(\w+):\s*"(.*)"$/', $line, $matches)) {
$key = $matches[1];
$value = stripslashes($matches[2]);
// Adjust stack depth
while ($indent < $currentIndent) {
array_pop($stack);
$currentIndent -= 2;
}
$current = &$stack[count($stack) - 1];
$current[$key] = $value;
}
}
return $data;
}
/**
* Validate configuration.
*/
public function validate(): array
{
$this->ensureLoaded();
return $this->validator->validate($this->config);
}
/**
* Get configuration summary.
*/
public function getSummary(): array
{
$this->ensureLoaded();
return [
'environment' => $this->currentEnvironment,
'default_language' => $this->getDefaultLanguage(),
'current_language' => $this->getCurrentLanguage(),
'fallback_language' => $this->getFallbackLanguage(),
'supported_languages' => count($this->getSupportedLanguages()),
'translation_paths' => count($this->getTranslationPaths()),
'cache_enabled' => $this->get('cache.enabled', false),
'timezone' => $this->get('timezone.default', 'UTC'),
'currency' => $this->get('currency.default', 'USD')
];
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'environment' => 'production',
'default_language' => 'en',
'fallback_language' => 'en',
'current_language' => 'en',
'supported_languages' => [
'en' => [
'name' => 'English',
'native_name' => 'English',
'direction' => 'ltr',
'enabled' => true
]
],
'translation_paths' => [
['path' => 'resources/lang', 'priority' => 100]
],
'cache' => [
'enabled' => true,
'driver' => 'file',
'prefix' => 'i18n',
'ttl' => 3600
],
'switcher' => [
'strategies' => ['url', 'parameter', 'header', 'session', 'cookie'],
'url_parameter' => 'lang',
'cookie_name' => 'language',
'cookie_expires' => 86400 * 30,
'track_switches' => true
],
'fallback' => [
'enabled' => true,
'chains' => [],
'max_depth' => 5
],
'timezone' => [
'default' => 'UTC',
'allow_user_override' => true,
'supported_timezones' => []
],
'currency' => [
'default' => 'USD',
'allow_user_override' => true,
'supported_currencies' => [],
'precision' => 2,
'decimal_separator' => '.',
'thousands_separator' => ','
],
'datetime' => [
'date_format' => 'Y-m-d',
'time_format' => 'H:i:s',
'datetime_format' => 'Y-m-d H:i:s',
'timezone' => 'UTC',
'locale' => 'en'
],
'number' => [
'decimal_separator' => '.',
'thousands_separator' => ',',
'precision' => 2
],
'validation' => [
'strict_mode' => false,
'log_missing' => true,
'throw_on_missing' => false
]
];
}
/**
* Create I18n config manager instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for development environment.
*/
public static function forDevelopment(): self
{
return new self([
'environment' => 'development',
'cache' => ['enabled' => false],
'validation' => ['strict_mode' => true, 'log_missing' => true]
]);
}
/**
* Create for testing environment.
*/
public static function forTesting(): self
{
return new self([
'environment' => 'testing',
'cache' => ['enabled' => false],
'validation' => ['strict_mode' => true]
]);
}
/**
* Create for production environment.
*/
public static function forProduction(): self
{
return new self([
'environment' => 'production',
'cache' => ['enabled' => true, 'ttl' => 7200],
'validation' => ['strict_mode' => false, 'log_missing' => false]
]);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,723 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Locale\Fallback;
use Fendx\I18n\Locale\Fallback\Resolver\FallbackResolver;
use Fendx\I18n\Locale\Fallback\Cache\FallbackCache;
class FallbackManager
{
protected FallbackResolver $resolver;
protected FallbackCache $cache;
protected array $config = [];
protected array $fallbackChains = [];
protected array $translations = [];
protected string $defaultFallback = 'en';
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->resolver = new FallbackResolver($this->config);
$this->cache = new FallbackCache($this->config);
$this->defaultFallback = $this->config['default_fallback'] ?? 'en';
$this->initializeFallbackChains();
}
/**
* Initialize fallback chains.
*/
protected function initializeFallbackChains(): void
{
$this->fallbackChains = $this->config['fallback_chains'] ?? $this->generateDefaultChains();
}
/**
* Generate default fallback chains.
*/
protected function generateDefaultChains(): array
{
return [
// English fallbacks
'en-US' => ['en-US', 'en'],
'en-GB' => ['en-GB', 'en'],
'en-AU' => ['en-AU', 'en'],
'en-CA' => ['en-CA', 'en'],
// Spanish fallbacks
'es-ES' => ['es-ES', 'es'],
'es-MX' => ['es-MX', 'es'],
'es-AR' => ['es-AR', 'es'],
'es-CO' => ['es-CO', 'es'],
// French fallbacks
'fr-FR' => ['fr-FR', 'fr'],
'fr-CA' => ['fr-CA', 'fr'],
'fr-BE' => ['fr-BE', 'fr'],
'fr-CH' => ['fr-CH', 'fr'],
// German fallbacks
'de-DE' => ['de-DE', 'de'],
'de-AT' => ['de-AT', 'de'],
'de-CH' => ['de-CH', 'de'],
// Portuguese fallbacks
'pt-BR' => ['pt-BR', 'pt'],
'pt-PT' => ['pt-PT', 'pt'],
// Chinese fallbacks
'zh-CN' => ['zh-CN', 'zh'],
'zh-TW' => ['zh-TW', 'zh'],
'zh-HK' => ['zh-HK', 'zh'],
// Dutch fallbacks
'nl-NL' => ['nl-NL', 'nl'],
'nl-BE' => ['nl-BE', 'nl'],
];
}
/**
* Translate using fallback chain.
*/
public function translate(string $key, array $parameters = [], string $language = null): ?string
{
$language = $language ?? $this->defaultFallback;
$chain = $this->getFallbackChain($language);
// Check cache first
$cacheKey = $this->generateCacheKey($key, $language, $parameters);
if ($this->cache->isEnabled() && $cached = $this->cache->get($cacheKey)) {
return $cached;
}
// Try each language in the fallback chain
foreach ($chain as $lang) {
if (isset($this->translations[$lang])) {
$translation = $this->findTranslation($this->translations[$lang], $key);
if ($translation !== null) {
// Replace parameters
$result = $this->replaceParameters($translation, $parameters);
// Cache the result
if ($this->cache->isEnabled()) {
$this->cache->set($cacheKey, $result);
}
return $result;
}
}
}
return null;
}
/**
* Check if translation exists in fallback chain.
*/
public function has(string $key, string $language = null): bool
{
$language = $language ?? $this->defaultFallback;
$chain = $this->getFallbackChain($language);
foreach ($chain as $lang) {
if (isset($this->translations[$lang])) {
if ($this->findTranslation($this->translations[$lang], $key) !== null) {
return true;
}
}
}
return false;
}
/**
* Get fallback chain for a language.
*/
public function getFallbackChain(string $language): array
{
// Check if we have a predefined chain
if (isset($this->fallbackChains[$language])) {
return $this->fallbackChains[$language];
}
// Generate dynamic chain
return $this->resolver->resolve($language, $this->defaultFallback);
}
/**
* Set fallback chain for a language.
*/
public function setFallbackChain(string $language, array $chain): void
{
$this->fallbackChains[$language] = $chain;
$this->cache->clearLanguage($language);
}
/**
* Add language to fallback chain.
*/
public function addToFallbackChain(string $language, string $fallbackLanguage): void
{
if (!isset($this->fallbackChains[$language])) {
$this->fallbackChains[$language] = [$language];
}
if (!in_array($fallbackLanguage, $this->fallbackChains[$language])) {
$this->fallbackChains[$language][] = $fallbackLanguage;
}
$this->cache->clearLanguage($language);
}
/**
* Remove language from fallback chain.
*/
public function removeFromFallbackChain(string $language, string $fallbackLanguage): void
{
if (isset($this->fallbackChains[$language])) {
$this->fallbackChains[$language] = array_filter(
$this->fallbackChains[$language],
fn($lang) => $lang !== $fallbackLanguage
);
$this->cache->clearLanguage($language);
}
}
/**
* Set translations for a language.
*/
public function setTranslations(string $language, array $translations): void
{
$this->translations[$language] = $translations;
$this->cache->clearLanguage($language);
}
/**
* Add translations for a language.
*/
public function addTranslations(string $language, array $translations): void
{
if (!isset($this->translations[$language])) {
$this->translations[$language] = [];
}
$this->translations[$language] = array_merge_recursive(
$this->translations[$language],
$translations
);
$this->cache->clearLanguage($language);
}
/**
* Get translations for a language.
*/
public function getTranslations(string $language): array
{
return $this->translations[$language] ?? [];
}
/**
* Remove translations for a language.
*/
public function removeTranslations(string $language): void
{
unset($this->translations[$language]);
$this->cache->clearLanguage($language);
}
/**
* Get all fallback chains.
*/
public function getAllFallbackChains(): array
{
return $this->fallbackChains;
}
/**
* Get languages that fallback to the specified language.
*/
public function getLanguagesThatFallbackTo(string $language): array
{
$languages = [];
foreach ($this->fallbackChains as $lang => $chain) {
if (in_array($language, $chain) && $lang !== $language) {
$languages[] = $lang;
}
}
return $languages;
}
/**
* Check if language A falls back to language B.
*/
public function doesFallbackTo(string $fromLanguage, string $toLanguage): bool
{
$chain = $this->getFallbackChain($fromLanguage);
return in_array($toLanguage, $chain);
}
/**
* Get fallback distance between two languages.
*/
public function getFallbackDistance(string $fromLanguage, string $toLanguage): int
{
$chain = $this->getFallbackChain($fromLanguage);
$position = array_search($toLanguage, $chain);
return $position !== false ? $position : -1;
}
/**
* Find the best fallback language for a key.
*/
public function findBestFallback(string $key, string $language): ?string
{
$chain = $this->getFallbackChain($language);
foreach ($chain as $lang) {
if (isset($this->translations[$lang])) {
if ($this->findTranslation($this->translations[$lang], $key) !== null) {
return $lang;
}
}
}
return null;
}
/**
* Get missing translations for a language.
*/
public function getMissingTranslations(string $language, string $referenceLanguage = null): array
{
$referenceLanguage = $referenceLanguage ?? $this->defaultFallback;
$referenceTranslations = $this->getTranslations($referenceLanguage);
$currentTranslations = $this->getTranslations($language);
$missing = [];
foreach ($referenceTranslations as $group => $translations) {
$missingInGroup = $this->findMissingKeys($translations, $currentTranslations[$group] ?? []);
if (!empty($missingInGroup)) {
$missing[$group] = $missingInGroup;
}
}
return $missing;
}
/**
* Get extra translations for a language (translations that don't exist in reference).
*/
public function getExtraTranslations(string $language, string $referenceLanguage = null): array
{
$referenceLanguage = $referenceLanguage ?? $this->defaultFallback;
$referenceTranslations = $this->getTranslations($referenceLanguage);
$currentTranslations = $this->getTranslations($language);
$extra = [];
foreach ($currentTranslations as $group => $translations) {
$extraInGroup = $this->findMissingKeys($translations, $referenceTranslations[$group] ?? []);
if (!empty($extraInGroup)) {
$extra[$group] = $extraInGroup;
}
}
return $extra;
}
/**
* Find missing keys between two translation arrays.
*/
protected function findMissingKeys(array $reference, array $current): array
{
$missing = [];
foreach ($reference as $key => $value) {
if (is_array($value)) {
if (!isset($current[$key]) || !is_array($current[$key])) {
$missing[$key] = $value;
} else {
$nestedMissing = $this->findMissingKeys($value, $current[$key]);
if (!empty($nestedMissing)) {
$missing[$key] = $nestedMissing;
}
}
} else {
if (!isset($current[$key])) {
$missing[$key] = $value;
}
}
}
return $missing;
}
/**
* Find translation in nested array.
*/
protected function findTranslation(array $translations, string $key): ?string
{
$parts = explode('.', $key);
$current = $translations;
foreach ($parts as $part) {
if (!is_array($current) || !isset($current[$part])) {
return null;
}
$current = $current[$part];
}
return is_string($current) ? $current : null;
}
/**
* Replace parameters in translation.
*/
protected function replaceParameters(string $translation, array $parameters): string
{
foreach ($parameters as $placeholder => $value) {
$translation = str_replace(':' . $placeholder, (string) $value, $translation);
}
return $translation;
}
/**
* Generate cache key.
*/
protected function generateCacheKey(string $key, string $language, array $parameters): string
{
$paramHash = empty($parameters) ? '' : '_' . md5(serialize($parameters));
return "fallback:{$language}:{$key}{$paramHash}";
}
/**
* Set default fallback language.
*/
public function setDefaultFallback(string $language): void
{
$this->defaultFallback = $language;
$this->cache->clear();
}
/**
* Get default fallback language.
*/
public function getDefaultFallback(): string
{
return $this->defaultFallback;
}
/**
* Optimize fallback chains.
*/
public function optimizeChains(): void
{
foreach ($this->fallbackChains as $language => $chain) {
$this->fallbackChains[$language] = $this->resolver->optimize($chain);
}
$this->cache->clear();
}
/**
* Validate fallback chains.
*/
public function validateChains(): array
{
$issues = [];
foreach ($this->fallbackChains as $language => $chain) {
// Check for circular references
if ($this->hasCircularReference($language, $chain)) {
$issues[] = "Circular reference detected in fallback chain for '{$language}'";
}
// Check if all languages in chain exist
foreach ($chain as $lang) {
if (!isset($this->translations[$lang]) && $lang !== $this->defaultFallback) {
$issues[] = "Language '{$lang}' in fallback chain for '{$language}' has no translations";
}
}
// Check if chain is too long
if (count($chain) > $this->config['max_chain_length']) {
$issues[] = "Fallback chain for '{$language}' is too long (" . count($chain) . " > " . $this->config['max_chain_length'] . ")";
}
}
return $issues;
}
/**
* Check for circular references in fallback chain.
*/
protected function hasCircularReference(string $language, array $chain): bool
{
$visited = [];
$current = $language;
while (true) {
if (isset($visited[$current])) {
return true; // Circular reference found
}
$visited[$current] = true;
if (!isset($this->fallbackChains[$current])) {
break;
}
$nextChain = $this->fallbackChains[$current];
if (empty($nextChain) || end($nextChain) === $current) {
break;
}
$current = end($nextChain);
}
return false;
}
/**
* Get fallback statistics.
*/
public function getStatistics(): array
{
$stats = [
'total_languages' => count($this->translations),
'total_chains' => count($this->fallbackChains),
'default_fallback' => $this->defaultFallback,
'average_chain_length' => 0,
'max_chain_length' => 0,
'min_chain_length' => PHP_INT_MAX,
'chains' => []
];
if (!empty($this->fallbackChains)) {
$totalLength = 0;
foreach ($this->fallbackChains as $language => $chain) {
$length = count($chain);
$totalLength += $length;
$stats['max_chain_length'] = max($stats['max_chain_length'], $length);
$stats['min_chain_length'] = min($stats['min_chain_length'], $length);
$stats['chains'][$language] = [
'length' => $length,
'chain' => $chain,
'has_translations' => isset($this->translations[$language])
];
}
$stats['average_chain_length'] = $totalLength / count($this->fallbackChains);
}
return $stats;
}
/**
* Export fallback configuration.
*/
public function export(string $format = 'json'): string
{
$data = [
'default_fallback' => $this->defaultFallback,
'fallback_chains' => $this->fallbackChains,
'statistics' => $this->getStatistics()
];
return match ($format) {
'json' => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
'php' => '<?php return ' . var_export($data, true) . ';',
'yaml' => $this->toYaml($data),
default => throw new \InvalidArgumentException("Unsupported export format: {$format}")
};
}
/**
* Import fallback configuration.
*/
public function import(string $data, string $format = 'json'): void
{
$config = match ($format) {
'json' => json_decode($data, true),
'php' => include 'data://text/plain,' . urlencode($data),
'yaml' => $this->fromYaml($data),
default => throw new \InvalidArgumentException("Unsupported import format: {$format}")
};
if (!is_array($config)) {
throw new \InvalidArgumentException('Invalid configuration data');
}
if (isset($config['default_fallback'])) {
$this->setDefaultFallback($config['default_fallback']);
}
if (isset($config['fallback_chains'])) {
$this->fallbackChains = $config['fallback_chains'];
}
$this->cache->clear();
}
/**
* Convert to YAML format.
*/
protected function toYaml(array $data): string
{
$yaml = '';
if (isset($data['default_fallback'])) {
$yaml .= "default_fallback: {$data['default_fallback']}\n\n";
}
if (isset($data['fallback_chains'])) {
$yaml .= "fallback_chains:\n";
foreach ($data['fallback_chains'] as $language => $chain) {
$yaml .= " {$language}: [" . implode(', ', $chain) . "]\n";
}
}
return $yaml;
}
/**
* Parse from YAML format.
*/
protected function fromYaml(string $yaml): array
{
$data = [];
$lines = explode("\n", $yaml);
$currentSection = null;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (str_ends_with($line, ':')) {
$currentSection = substr($line, 0, -1);
$data[$currentSection] = [];
} elseif ($currentSection && preg_match('/^(\w+):\s*\[(.*)\]$/', $line, $matches)) {
$key = $matches[1];
$values = array_map('trim', explode(',', $matches[2]));
$data[$currentSection][$key] = $values;
} elseif (preg_match('/^(\w+):\s*(.+)$/', $line, $matches)) {
$data[$matches[1]] = $matches[2];
}
}
return $data;
}
/**
* Clear cache.
*/
public function clearCache(): void
{
$this->cache->clear();
}
/**
* Reset fallback manager.
*/
public function reset(): void
{
$this->fallbackChains = [];
$this->translations = [];
$this->cache->clear();
$this->initializeFallbackChains();
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'default_fallback' => 'en',
'max_chain_length' => 5,
'cache_enabled' => true,
'cache_ttl' => 3600,
'auto_optimize' => false,
'validate_chains' => true
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Get resolver.
*/
public function getResolver(): FallbackResolver
{
return $this->resolver;
}
/**
* Get cache.
*/
public function getCache(): FallbackCache
{
return $this->cache;
}
/**
* Create fallback manager instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create fallback manager with default chains.
*/
public static function withDefaults(): self
{
return new self([
'auto_optimize' => true,
'validate_chains' => true
]);
}
/**
* Create fallback manager for minimal setup.
*/
public static function minimal(): self
{
return new self([
'max_chain_length' => 3,
'cache_enabled' => false,
'auto_optimize' => false,
'validate_chains' => false
]);
}
}

View File

@@ -0,0 +1,779 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Locale;
use Fendx\I18n\Locale\Loader\LanguageLoader;
use Fendx\I18n\Locale\Translator\Translator;
use Fendx\I18n\Locale\Fallback\FallbackManager;
use Fendx\I18n\Locale\Cache\TranslationCache;
class LanguageManager
{
protected LanguageLoader $loader;
protected Translator $translator;
protected FallbackManager $fallback;
protected TranslationCache $cache;
protected array $config = [];
protected array $loadedLanguages = [];
protected string $currentLanguage = 'en';
protected string $fallbackLanguage = 'en';
protected array $availableLanguages = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->loader = new LanguageLoader($this->config);
$this->translator = new Translator($this->config);
$this->fallback = new FallbackManager($this->config);
$this->cache = new TranslationCache($this->config);
$this->initialize();
}
/**
* Initialize language manager.
*/
protected function initialize(): void
{
$this->availableLanguages = $this->loader->getAvailableLanguages();
$this->fallbackLanguage = $this->config['fallback_language'] ?? 'en';
// Set current language from config or request
$this->setCurrentLanguage($this->detectCurrentLanguage());
}
/**
* Translate a key.
*/
public function translate(string $key, array $parameters = [], string $language = null): string
{
$language = $language ?? $this->currentLanguage;
// Check cache first
$cacheKey = $this->generateCacheKey($key, $language, $parameters);
if ($this->cache->isEnabled() && $cached = $this->cache->get($cacheKey)) {
return $cached;
}
// Try to translate in the requested language
$translation = $this->translator->translate($key, $parameters, $language);
// If not found, try fallback language
if ($translation === null && $language !== $this->fallbackLanguage) {
$translation = $this->translator->translate($key, $parameters, $this->fallbackLanguage);
}
// If still not found, try fallback chain
if ($translation === null) {
$translation = $this->fallback->translate($key, $parameters, $language);
}
// If still not found, return the key itself
if ($translation === null) {
$translation = $this->handleMissingTranslation($key, $parameters, $language);
}
// Cache the result
if ($this->cache->isEnabled()) {
$this->cache->set($cacheKey, $translation);
}
return $translation;
}
/**
* Check if a translation exists.
*/
public function has(string $key, string $language = null): bool
{
$language = $language ?? $this->currentLanguage;
if ($this->translator->has($key, $language)) {
return true;
}
if ($language !== $this->fallbackLanguage && $this->translator->has($key, $this->fallbackLanguage)) {
return true;
}
return $this->fallback->has($key, $language);
}
/**
* Get all translations for a language.
*/
public function getAllTranslations(string $language = null): array
{
$language = $language ?? $this->currentLanguage;
if (!isset($this->loadedLanguages[$language])) {
$this->loadLanguage($language);
}
return $this->loadedLanguages[$language] ?? [];
}
/**
* Get translations for a specific group.
*/
public function getGroup(string $group, string $language = null): array
{
$language = $language ?? $this->currentLanguage;
if (!isset($this->loadedLanguages[$language])) {
$this->loadLanguage($language);
}
return $this->loadedLanguages[$language][$group] ?? [];
}
/**
* Set current language.
*/
public function setCurrentLanguage(string $language): void
{
if (!$this->isLanguageAvailable($language)) {
throw new \InvalidArgumentException("Language '{$language}' is not available");
}
$this->currentLanguage = $language;
// Load language if not already loaded
if (!isset($this->loadedLanguages[$language])) {
$this->loadLanguage($language);
}
// Notify language change
$this->onLanguageChanged($language);
}
/**
* Get current language.
*/
public function getCurrentLanguage(): string
{
return $this->currentLanguage;
}
/**
* Get fallback language.
*/
public function getFallbackLanguage(): string
{
return $this->fallbackLanguage;
}
/**
* Set fallback language.
*/
public function setFallbackLanguage(string $language): void
{
if (!$this->isLanguageAvailable($language)) {
throw new \InvalidArgumentException("Fallback language '{$language}' is not available");
}
$this->fallbackLanguage = $language;
$this->fallback->setFallbackLanguage($language);
}
/**
* Get available languages.
*/
public function getAvailableLanguages(): array
{
return $this->availableLanguages;
}
/**
* Check if a language is available.
*/
public function isLanguageAvailable(string $language): bool
{
return in_array($language, $this->availableLanguages);
}
/**
* Load language translations.
*/
protected function loadLanguage(string $language): void
{
$translations = $this->loader->load($language);
$this->loadedLanguages[$language] = $translations;
// Set translations for translator
$this->translator->setTranslations($language, $translations);
// Set translations for fallback manager
$this->fallback->setTranslations($language, $translations);
}
/**
* Reload language translations.
*/
public function reloadLanguage(string $language = null): void
{
$language = $language ?? $this->currentLanguage;
// Clear cache
$this->cache->clearLanguage($language);
// Reload translations
$this->loadLanguage($language);
}
/**
* Reload all languages.
*/
public function reloadAll(): void
{
$this->cache->clear();
$this->loadedLanguages = [];
$this->availableLanguages = $this->loader->getAvailableLanguages();
// Reload current language
$this->loadLanguage($this->currentLanguage);
}
/**
* Add translation dynamically.
*/
public function addTranslation(string $key, string $value, string $language = null): void
{
$language = $language ?? $this->currentLanguage;
if (!isset($this->loadedLanguages[$language])) {
$this->loadLanguage($language);
}
// Parse key into group and item
$parts = explode('.', $key, 2);
$group = $parts[0];
$item = $parts[1] ?? $key;
// Add to loaded translations
$this->loadedLanguages[$language][$group][$item] = $value;
// Update translator
$this->translator->addTranslation($key, $value, $language);
// Clear relevant cache
$this->cache->clearKey($key, $language);
}
/**
* Add multiple translations.
*/
public function addTranslations(array $translations, string $language = null): void
{
$language = $language ?? $this->currentLanguage;
foreach ($translations as $key => $value) {
$this->addTranslation($key, $value, $language);
}
}
/**
* Remove translation.
*/
public function removeTranslation(string $key, string $language = null): void
{
$language = $language ?? $this->currentLanguage;
// Parse key into group and item
$parts = explode('.', $key, 2);
$group = $parts[0];
$item = $parts[1] ?? $key;
// Remove from loaded translations
unset($this->loadedLanguages[$language][$group][$item]);
// Update translator
$this->translator->removeTranslation($key, $language);
// Clear cache
$this->cache->clearKey($key, $language);
}
/**
* Detect current language from request or environment.
*/
protected function detectCurrentLanguage(): string
{
// Check if language is explicitly set in config
if (isset($this->config['language'])) {
return $this->config['language'];
}
// Check HTTP Accept-Language header
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$preferredLanguage = $this->parseAcceptLanguage($_SERVER['HTTP_ACCEPT_LANGUAGE']);
if ($preferredLanguage && $this->isLanguageAvailable($preferredLanguage)) {
return $preferredLanguage;
}
}
// Check session
if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['language'])) {
$sessionLanguage = $_SESSION['language'];
if ($this->isLanguageAvailable($sessionLanguage)) {
return $sessionLanguage;
}
}
// Check cookie
if (isset($_COOKIE['language'])) {
$cookieLanguage = $_COOKIE['language'];
if ($this->isLanguageAvailable($cookieLanguage)) {
return $cookieLanguage;
}
}
// Return fallback language
return $this->fallbackLanguage;
}
/**
* Parse Accept-Language header.
*/
protected function parseAcceptLanguage(string $header): ?string
{
$languages = [];
// Split the header into language parts
$parts = explode(',', $header);
foreach ($parts as $part) {
$part = trim($part);
// Extract language and quality
if (preg_match('/^([a-z]{1,2}(?:-[A-Z]{2})?)(?:;q=([0-9.]+))?$/', $part, $matches)) {
$lang = $matches[1];
$quality = isset($matches[2]) ? (float) $matches[2] : 1.0;
$languages[$lang] = $quality;
}
}
// Sort by quality (descending)
arsort($languages);
// Return the highest quality language
return array_key_first($languages) ?: null;
}
/**
* Handle missing translation.
*/
protected function handleMissingTranslation(string $key, array $parameters, string $language): string
{
// Log missing translation
if ($this->config['log_missing']) {
error_log("Missing translation: '{$key}' for language '{$language}'");
}
// Return key with parameters replaced
$result = $key;
foreach ($parameters as $placeholder => $value) {
$result = str_replace(':' . $placeholder, (string) $value, $result);
}
return $result;
}
/**
* Generate cache key.
*/
protected function generateCacheKey(string $key, string $language, array $parameters): string
{
$paramHash = empty($parameters) ? '' : '_' . md5(serialize($parameters));
return "{$language}.{$key}{$paramHash}";
}
/**
* Handle language change event.
*/
protected function onLanguageChanged(string $newLanguage): void
{
// Store in session
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION['language'] = $newLanguage;
}
// Set cookie
if ($this->config['set_cookie']) {
setcookie('language', $newLanguage, [
'expires' => time() + (86400 * 30), // 30 days
'path' => $this->config['cookie_path'] ?? '/',
'domain' => $this->config['cookie_domain'] ?? '',
'secure' => $this->config['cookie_secure'] ?? false,
'httponly' => $this->config['cookie_httponly'] ?? true,
'samesite' => $this->config['cookie_samesite'] ?? 'Lax'
]);
}
// Trigger event if available
if (isset($this->config['on_language_changed']) && is_callable($this->config['on_language_changed'])) {
call_user_func($this->config['on_language_changed'], $newLanguage);
}
}
/**
* Get language info.
*/
public function getLanguageInfo(string $language = null): array
{
$language = $language ?? $this->currentLanguage;
return $this->loader->getLanguageInfo($language) ?? [
'code' => $language,
'name' => $language,
'native_name' => $language,
'direction' => 'ltr',
'plural_rules' => []
];
}
/**
* Get plural form for a number.
*/
public function getPluralForm(int $count, string $language = null): int
{
$language = $language ?? $this->currentLanguage;
$info = $this->getLanguageInfo($language);
$rules = $info['plural_rules'] ?? [];
if (empty($rules)) {
// Default English plural rule
return ($count === 1) ? 1 : 0;
}
// Apply plural rules
foreach ($rules as $index => $rule) {
if ($this->evaluatePluralRule($rule, $count)) {
return $index;
}
}
return 0;
}
/**
* Evaluate plural rule.
*/
protected function evaluatePluralRule(string $rule, int $count): bool
{
// Simple rule evaluation (can be extended)
if ($rule === 'n === 1') {
return $count === 1;
} elseif ($rule === 'n > 1') {
return $count > 1;
} elseif ($rule === 'n >= 2 && n <= 4') {
return $count >= 2 && $count <= 4;
}
return false;
}
/**
* Translate with pluralization.
*/
public function translatePlural(string $key, int $count, array $parameters = [], string $language = null): string
{
$language = $language ?? $this->currentLanguage;
$pluralForm = $this->getPluralForm($count, $language);
// Try to find plural translation
$pluralKey = "{$key}.{$pluralForm}";
if ($this->has($pluralKey, $language)) {
$parameters['count'] = $count;
return $this->translate($pluralKey, $parameters, $language);
}
// Fallback to singular form
$parameters['count'] = $count;
return $this->translate($key, $parameters, $language);
}
/**
* Get translation statistics.
*/
public function getStatistics(): array
{
$stats = [
'total_languages' => count($this->availableLanguages),
'loaded_languages' => count($this->loadedLanguages),
'current_language' => $this->currentLanguage,
'fallback_language' => $this->fallbackLanguage,
'cache_enabled' => $this->cache->isEnabled(),
'translations' => []
];
foreach ($this->loadedLanguages as $language => $translations) {
$totalKeys = 0;
foreach ($translations as $group => $keys) {
$totalKeys += count($keys);
}
$stats['translations'][$language] = [
'total_keys' => $totalKeys,
'groups' => count($translations)
];
}
return $stats;
}
/**
* Export translations.
*/
public function export(string $format = 'json', string $language = null): string
{
$language = $language ?? $this->currentLanguage;
$translations = $this->getAllTranslations($language);
return match ($format) {
'json' => json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
'php' => '<?php return ' . var_export($translations, true) . ';',
'yaml' => $this->toYaml($translations),
'po' => $this->toPo($translations, $language),
default => throw new \InvalidArgumentException("Unsupported export format: {$format}")
};
}
/**
* Import translations.
*/
public function import(string $data, string $format = 'json', string $language = null): void
{
$language = $language ?? $this->currentLanguage;
$translations = match ($format) {
'json' => json_decode($data, true),
'php' => include 'data://text/plain,' . urlencode($data),
'yaml' => $this->fromYaml($data),
'po' => $this->fromPo($data),
default => throw new \InvalidArgumentException("Unsupported import format: {$format}")
};
if (!is_array($translations)) {
throw new \InvalidArgumentException('Invalid translation data');
}
$this->addTranslations($translations, $language);
}
/**
* Convert to YAML format.
*/
protected function toYaml(array $data): string
{
$yaml = '';
foreach ($data as $group => $translations) {
$yaml .= "{$group}:\n";
foreach ($translations as $key => $value) {
$escapedValue = str_replace(["\n", "\r"], ['\\n', '\\r'], $value);
$yaml .= " {$key}: \"{$escapedValue}\"\n";
}
}
return $yaml;
}
/**
* Convert to PO format.
*/
protected function toPo(array $data, string $language): string
{
$po = "# Translation for {$language}\n";
$po .= "# Generated on " . date('Y-m-d H:i:s') . "\n\n";
foreach ($data as $group => $translations) {
foreach ($translations as $key => $value) {
$msgid = $group . '.' . $key;
$po .= "msgid \"{$msgid}\"\n";
$po .= "msgstr \"{$value}\"\n\n";
}
}
return $po;
}
/**
* Parse from YAML format.
*/
protected function fromYaml(string $yaml): array
{
// Simple YAML parser (can be replaced with a proper YAML library)
$data = [];
$lines = explode("\n", $yaml);
$currentGroup = null;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (str_ends_with($line, ':')) {
$currentGroup = substr($line, 0, -1);
$data[$currentGroup] = [];
} elseif ($currentGroup && preg_match('/^(\w+):\s*"(.*)"$/', $line, $matches)) {
$data[$currentGroup][$matches[1]] = stripslashes($matches[2]);
}
}
return $data;
}
/**
* Parse from PO format.
*/
protected function fromPo(string $po): array
{
$data = [];
$lines = explode("\n", $po);
$msgid = null;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (preg_match('/^msgid\s+"(.*)"$/', $line, $matches)) {
$msgid = $matches[1];
} elseif (preg_match('/^msgstr\s+"(.*)"$/', $line, $matches) && $msgid) {
$parts = explode('.', $msgid, 2);
if (count($parts) === 2) {
$data[$parts[0]][$parts[1]] = stripslashes($matches[1]);
}
$msgid = null;
}
}
return $data;
}
/**
* Clear cache.
*/
public function clearCache(): void
{
$this->cache->clear();
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'fallback_language' => 'en',
'load_paths' => [
__DIR__ . '/../../../resources/lang'
],
'cache_enabled' => true,
'cache_driver' => 'file',
'cache_prefix' => 'translations',
'log_missing' => true,
'set_cookie' => true,
'cookie_path' => '/',
'cookie_domain' => '',
'cookie_secure' => false,
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'auto_reload' => false
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Get language loader.
*/
public function getLoader(): LanguageLoader
{
return $this->loader;
}
/**
* Get translator.
*/
public function getTranslator(): Translator
{
return $this->translator;
}
/**
* Get fallback manager.
*/
public function getFallback(): FallbackManager
{
return $this->fallback;
}
/**
* Get cache.
*/
public function getCache(): TranslationCache
{
return $this->cache;
}
/**
* Create language manager instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create language manager for web application.
*/
public static function forWeb(): self
{
return new self([
'set_cookie' => true,
'cache_enabled' => true,
'log_missing' => true
]);
}
/**
* Create language manager for API.
*/
public static function forApi(): self
{
return new self([
'set_cookie' => false,
'cache_enabled' => true,
'log_missing' => false
]);
}
/**
* Create language manager for CLI.
*/
public static function forCli(): self
{
return new self([
'set_cookie' => false,
'cache_enabled' => false,
'log_missing' => false
]);
}
}

View File

@@ -0,0 +1,749 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Locale\Organizer;
use Fendx\I18n\Locale\Organizer\Parser\TranslationParser;
use Fendx\I18n\Locale\Organizer\Validator\TranslationValidator;
use Fendx\I18n\Locale\Organizer\Merger\TranslationMerger;
class TranslationOrganizer
{
protected TranslationParser $parser;
protected TranslationValidator $validator;
protected TranslationMerger $merger;
protected array $config = [];
protected array $languages = [];
protected array $groups = [];
protected string $basePath = '';
public function __construct(string $basePath, array $config = [])
{
$this->basePath = rtrim($basePath, '/');
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->parser = new TranslationParser($this->config);
$this->validator = new TranslationValidator($this->config);
$this->merger = new TranslationMerger($this->config);
$this->scanLanguages();
}
/**
* Scan available languages.
*/
protected function scanLanguages(): void
{
$this->languages = [];
$this->groups = [];
if (!is_dir($this->basePath)) {
return;
}
$directories = scandir($this->basePath);
foreach ($directories as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
$langPath = $this->basePath . '/' . $dir;
if (is_dir($langPath) && $this->isValidLanguageCode($dir)) {
$this->languages[$dir] = $this->scanLanguageGroups($langPath);
}
}
}
/**
* Scan groups for a language.
*/
protected function scanLanguageGroups(string $langPath): array
{
$groups = [];
$files = scandir($langPath);
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$filePath = $langPath . '/' . $file;
$groupInfo = pathinfo($file);
if (is_file($filePath) && $this->isTranslationFile($file)) {
$groupName = $groupInfo['filename'];
$groups[$groupName] = [
'file' => $file,
'path' => $filePath,
'format' => $groupInfo['extension'] ?? 'php',
'size' => filesize($filePath),
'modified' => filemtime($filePath)
];
// Track all groups
if (!isset($this->groups[$groupName])) {
$this->groups[$groupName] = [];
}
$this->groups[$groupName][] = $dir;
}
}
return $groups;
}
/**
* Check if language code is valid.
*/
protected function isValidLanguageCode(string $code): bool
{
return preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $code);
}
/**
* Check if file is a translation file.
*/
protected function isTranslationFile(string $filename): bool
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($extension, $this->config['supported_formats']);
}
/**
* Get available languages.
*/
public function getLanguages(): array
{
return array_keys($this->languages);
}
/**
* Get available groups.
*/
public function getGroups(): array
{
return array_keys($this->groups);
}
/**
* Get groups for a language.
*/
public function getLanguageGroups(string $language): array
{
return array_keys($this->languages[$language] ?? []);
}
/**
* Get languages for a group.
*/
public function getGroupLanguages(string $group): array
{
return $this->groups[$group] ?? [];
}
/**
* Load translations for a language and group.
*/
public function loadTranslations(string $language, string $group): array
{
if (!isset($this->languages[$language][$group])) {
return [];
}
$fileInfo = $this->languages[$language][$group];
$filePath = $fileInfo['path'];
$format = $fileInfo['format'];
return $this->parser->parse($filePath, $format);
}
/**
* Save translations for a language and group.
*/
public function saveTranslations(string $language, string $group, array $translations): bool
{
$filePath = $this->getTranslationFilePath($language, $group);
if ($filePath === null) {
return false;
}
$format = $this->languages[$language][$group]['format'] ?? 'php';
return $this->parser->save($filePath, $translations, $format);
}
/**
* Get translation file path.
*/
protected function getTranslationFilePath(string $language, string $group): ?string
{
if (!isset($this->languages[$language][$group])) {
// Create new file if it doesn't exist
$dirPath = $this->basePath . '/' . $language;
if (!is_dir($dirPath)) {
mkdir($dirPath, 0755, true);
}
$format = $this->config['default_format'];
$fileName = $group . '.' . $format;
$filePath = $dirPath . '/' . $fileName;
// Register the new file
$this->languages[$language][$group] = [
'file' => $fileName,
'path' => $filePath,
'format' => $format,
'size' => 0,
'modified' => time()
];
if (!isset($this->groups[$group])) {
$this->groups[$group] = [];
}
$this->groups[$group][] = $language;
return $filePath;
}
return $this->languages[$language][$group]['path'];
}
/**
* Create new language directory.
*/
public function createLanguage(string $language): bool
{
if (!$this->isValidLanguageCode($language)) {
throw new \InvalidArgumentException("Invalid language code: {$language}");
}
if (isset($this->languages[$language])) {
return true; // Already exists
}
$langPath = $this->basePath . '/' . $language;
if (!mkdir($langPath, 0755, true)) {
return false;
}
$this->languages[$language] = [];
return true;
}
/**
* Create new group file.
*/
public function createGroup(string $language, string $group, array $translations = []): bool
{
if (!$this->createLanguage($language)) {
return false;
}
$filePath = $this->getTranslationFilePath($language, $group);
if ($filePath === null) {
return false;
}
return $this->saveTranslations($language, $group, $translations);
}
/**
* Delete language directory.
*/
public function deleteLanguage(string $language): bool
{
if (!isset($this->languages[$language])) {
return true; // Already doesn't exist
}
$langPath = $this->basePath . '/' . $language;
return $this->deleteDirectory($langPath);
}
/**
* Delete group file.
*/
public function deleteGroup(string $language, string $group): bool
{
if (!isset($this->languages[$language][$group])) {
return true; // Already doesn't exist
}
$filePath = $this->languages[$language][$group]['path'];
if (unlink($filePath)) {
unset($this->languages[$language][$group]);
// Update groups tracking
if (isset($this->groups[$group])) {
$this->groups[$group] = array_filter($this->groups[$group], fn($lang) => $lang !== $language);
if (empty($this->groups[$group])) {
unset($this->groups[$group]);
}
}
return true;
}
return false;
}
/**
* Rename language.
*/
public function renameLanguage(string $oldLanguage, string $newLanguage): bool
{
if (!$this->isValidLanguageCode($newLanguage)) {
throw new \InvalidArgumentException("Invalid language code: {$newLanguage}");
}
if (!isset($this->languages[$oldLanguage])) {
return false;
}
if (isset($this->languages[$newLanguage])) {
throw new \InvalidArgumentException("Target language already exists: {$newLanguage}");
}
$oldPath = $this->basePath . '/' . $oldLanguage;
$newPath = $this->basePath . '/' . $newLanguage;
if (!rename($oldPath, $newPath)) {
return false;
}
// Update internal tracking
$this->languages[$newLanguage] = $this->languages[$oldLanguage];
unset($this->languages[$oldLanguage]);
// Update groups tracking
foreach ($this->groups as $group => $languages) {
$key = array_search($oldLanguage, $languages);
if ($key !== false) {
$this->groups[$group][$key] = $newLanguage;
}
}
return true;
}
/**
* Rename group.
*/
public function renameGroup(string $language, string $oldGroup, string $newGroup): bool
{
if (!isset($this->languages[$language][$oldGroup])) {
return false;
}
if (isset($this->languages[$language][$newGroup])) {
throw new \InvalidArgumentException("Target group already exists: {$newGroup}");
}
$oldFileInfo = $this->languages[$language][$oldGroup];
$format = $oldFileInfo['format'];
$dirPath = dirname($oldFileInfo['path']);
$newFileName = $newGroup . '.' . $format;
$newFilePath = $dirPath . '/' . $newFileName;
if (!rename($oldFileInfo['path'], $newFilePath)) {
return false;
}
// Update internal tracking
$this->languages[$language][$newGroup] = [
'file' => $newFileName,
'path' => $newFilePath,
'format' => $format,
'size' => $oldFileInfo['size'],
'modified' => time()
];
unset($this->languages[$language][$oldGroup]);
// Update groups tracking
if (isset($this->groups[$oldGroup])) {
$this->groups[$newGroup] = $this->groups[$oldGroup];
unset($this->groups[$oldGroup]);
}
return true;
}
/**
* Copy translations between languages.
*/
public function copyTranslations(string $fromLanguage, string $toLanguage, array $groups = null): bool
{
if (!isset($this->languages[$fromLanguage])) {
return false;
}
if (!$this->createLanguage($toLanguage)) {
return false;
}
$groupsToCopy = $groups ?? array_keys($this->languages[$fromLanguage]);
foreach ($groupsToCopy as $group) {
if (!isset($this->languages[$fromLanguage][$group])) {
continue;
}
$translations = $this->loadTranslations($fromLanguage, $group);
$this->saveTranslations($toLanguage, $group, $translations);
}
return true;
}
/**
* Merge translations from multiple languages.
*/
public function mergeTranslations(array $languages, string $targetLanguage, array $groups = null): bool
{
if (!$this->createLanguage($targetLanguage)) {
return false;
}
$groupsToMerge = $groups ?? $this->getGroups();
foreach ($groupsToMerge as $group) {
$mergedTranslations = [];
foreach ($languages as $language) {
if (!isset($this->languages[$language][$group])) {
continue;
}
$translations = $this->loadTranslations($language, $group);
$mergedTranslations = $this->merger->merge($mergedTranslations, $translations);
}
if (!empty($mergedTranslations)) {
$this->saveTranslations($targetLanguage, $group, $mergedTranslations);
}
}
return true;
}
/**
* Validate translations.
*/
public function validateTranslations(string $language = null, string $group = null): array
{
$results = [];
$languagesToValidate = $language ? [$language] : $this->getLanguages();
foreach ($languagesToValidate as $lang) {
if (!isset($this->languages[$lang])) {
continue;
}
$groupsToValidate = $group ? [$group] : array_keys($this->languages[$lang]);
foreach ($groupsToValidate as $grp) {
$translations = $this->loadTranslations($lang, $grp);
$validation = $this->validator->validate($translations, $lang, $grp);
$results[$lang][$grp] = $validation;
}
}
return $results;
}
/**
* Get translation statistics.
*/
public function getStatistics(): array
{
$stats = [
'total_languages' => count($this->languages),
'total_groups' => count($this->groups),
'languages' => [],
'groups' => [],
'overall' => [
'total_files' => 0,
'total_keys' => 0,
'total_size' => 0
]
];
foreach ($this->languages as $language => $groups) {
$langStats = [
'groups' => count($groups),
'files' => count($groups),
'keys' => 0,
'size' => 0
];
foreach ($groups as $group => $fileInfo) {
$translations = $this->loadTranslations($language, $group);
$keyCount = $this->countKeys($translations);
$langStats['keys'] += $keyCount;
$langStats['size'] += $fileInfo['size'];
$stats['overall']['total_keys'] += $keyCount;
$stats['overall']['total_size'] += $fileInfo['size'];
}
$stats['languages'][$language] = $langStats;
$stats['overall']['total_files'] += $langStats['files'];
}
foreach ($this->groups as $group => $languages) {
$groupStats = [
'languages' => count($languages),
'keys' => 0
];
foreach ($languages as $language) {
$translations = $this->loadTranslations($language, $group);
$groupStats['keys'] += $this->countKeys($translations);
}
$stats['groups'][$group] = $groupStats;
}
return $stats;
}
/**
* Count keys in translations array.
*/
protected function countKeys(array $translations): int
{
$count = 0;
foreach ($translations as $value) {
if (is_array($value)) {
$count += $this->countKeys($value);
} else {
$count++;
}
}
return $count;
}
/**
* Find missing translations.
*/
public function findMissingTranslations(string $baseLanguage = 'en'): array
{
if (!isset($this->languages[$baseLanguage])) {
throw new \InvalidArgumentException("Base language not found: {$baseLanguage}");
}
$missing = [];
$baseGroups = array_keys($this->languages[$baseLanguage]);
foreach ($this->languages as $language => $groups) {
if ($language === $baseLanguage) {
continue;
}
$missing[$language] = [];
foreach ($baseGroups as $group) {
$baseTranslations = $this->loadTranslations($baseLanguage, $group);
$currentTranslations = $this->loadTranslations($language, $group);
$missingKeys = $this->findMissingKeys($baseTranslations, $currentTranslations);
if (!empty($missingKeys)) {
$missing[$language][$group] = $missingKeys;
}
}
}
return array_filter($missing); // Remove languages with no missing translations
}
/**
* Find missing keys between two translation arrays.
*/
protected function findMissingKeys(array $base, array $current): array
{
$missing = [];
foreach ($base as $key => $value) {
if (is_array($value)) {
if (!isset($current[$key]) || !is_array($current[$key])) {
$missing[$key] = $value;
} else {
$nestedMissing = $this->findMissingKeys($value, $current[$key]);
if (!empty($nestedMissing)) {
$missing[$key] = $nestedMissing;
}
}
} else {
if (!isset($current[$key])) {
$missing[$key] = $value;
}
}
}
return $missing;
}
/**
* Find unused translations.
*/
public function findUnusedTranslations(string $language, array $usedKeys = []): array
{
if (!isset($this->languages[$language])) {
return [];
}
$unused = [];
$allKeys = $this->getAllKeys($language);
foreach ($allKeys as $group => $keys) {
$unusedKeys = array_diff($keys, $usedKeys);
if (!empty($unusedKeys)) {
$unused[$group] = $unusedKeys;
}
}
return $unused;
}
/**
* Get all keys for a language.
*/
protected function getAllKeys(string $language): array
{
$allKeys = [];
foreach ($this->languages[$language] as $group => $fileInfo) {
$translations = $this->loadTranslations($language, $group);
$allKeys[$group] = $this->extractKeys($translations);
}
return $allKeys;
}
/**
* Extract keys from translations array.
*/
protected function extractKeys(array $translations, string $prefix = ''): array
{
$keys = [];
foreach ($translations as $key => $value) {
$fullKey = $prefix ? $prefix . '.' . $key : $key;
if (is_array($value)) {
$keys = array_merge($keys, $this->extractKeys($value, $fullKey));
} else {
$keys[] = $fullKey;
}
}
return $keys;
}
/**
* Delete directory recursively.
*/
protected function deleteDirectory(string $dir): bool
{
if (!is_dir($dir)) {
return true;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
return rmdir($dir);
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'supported_formats' => ['php', 'json', 'yaml', 'yml', 'po'],
'default_format' => 'php',
'auto_create' => true,
'backup_before_change' => true,
'validation_rules' => [
'required_keys' => [],
'max_key_length' => 100,
'max_value_length' => 1000
]
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Get base path.
*/
public function getBasePath(): string
{
return $this->basePath;
}
/**
* Get parser.
*/
public function getParser(): TranslationParser
{
return $this->parser;
}
/**
* Get validator.
*/
public function getValidator(): TranslationValidator
{
return $this->validator;
}
/**
* Get merger.
*/
public function getMerger(): TranslationMerger
{
return $this->merger;
}
/**
* Create organizer instance.
*/
public static function create(string $basePath, array $config = []): self
{
return new self($basePath, $config);
}
}

View File

@@ -0,0 +1,718 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Locale\Switcher;
use Fendx\I18n\Locale\LanguageManager;
use Fendx\I18n\Locale\Switcher\Detector\LanguageDetector;
use Fendx\I18n\Locale\Switcher\Storage\LanguageStorage;
use Fendx\I18n\Locale\Switcher\Url\UrlRewriter;
class LanguageSwitcher
{
protected LanguageManager $languageManager;
protected LanguageDetector $detector;
protected LanguageStorage $storage;
protected UrlRewriter $urlRewriter;
protected array $config = [];
protected array $switchStrategies = [];
public function __construct(LanguageManager $languageManager, array $config = [])
{
$this->languageManager = $languageManager;
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->detector = new LanguageDetector($this->config);
$this->storage = new LanguageStorage($this->config);
$this->urlRewriter = new UrlRewriter($this->config);
$this->initializeStrategies();
}
/**
* Initialize language switching strategies.
*/
protected function initializeStrategies(): void
{
$this->switchStrategies = [
'url' => [$this, 'switchByUrl'],
'parameter' => [$this, 'switchByParameter'],
'header' => [$this, 'switchByHeader'],
'session' => [$this, 'switchBySession'],
'cookie' => [$this, 'switchByCookie'],
'subdomain' => [$this, 'switchBySubdomain'],
'domain' => [$this, 'switchByDomain']
];
}
/**
* Switch language automatically based on configured strategies.
*/
public function autoSwitch(): string
{
$strategies = $this->config['switch_strategies'] ?? ['url', 'parameter', 'header', 'session', 'cookie'];
foreach ($strategies as $strategy) {
if (isset($this->switchStrategies[$strategy])) {
$language = call_user_func($this->switchStrategies[$strategy]);
if ($language && $this->languageManager->isLanguageAvailable($language)) {
$this->switchTo($language);
return $language;
}
}
}
// Fallback to current language
return $this->languageManager->getCurrentLanguage();
}
/**
* Switch to specific language.
*/
public function switchTo(string $language): bool
{
if (!$this->languageManager->isLanguageAvailable($language)) {
return false;
}
$oldLanguage = $this->languageManager->getCurrentLanguage();
// Set language in manager
$this->languageManager->setCurrentLanguage($language);
// Store in configured storage
$this->storage->store($language);
// Trigger switch event
$this->onLanguageSwitched($oldLanguage, $language);
return true;
}
/**
* Switch language by URL path.
*/
protected function switchByUrl(): ?string
{
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
foreach ($this->languageManager->getAvailableLanguages() as $lang) {
$pattern = '/^\/' . preg_quote($lang, '/') . '(\/|$)/';
if (preg_match($pattern, $requestUri)) {
return $lang;
}
}
return null;
}
/**
* Switch language by URL parameter.
*/
protected function switchByParameter(): ?string
{
$parameter = $this->config['url_parameter'] ?? 'lang';
return $_GET[$parameter] ?? null;
}
/**
* Switch language by HTTP header.
*/
protected function switchByHeader(): ?string
{
return $this->detector->detectFromHeader();
}
/**
* Switch language by session.
*/
protected function switchBySession(): ?string
{
return $this->storage->getFromSession();
}
/**
* Switch language by cookie.
*/
protected function switchByCookie(): ?string
{
return $this->storage->getFromCookie();
}
/**
* Switch language by subdomain.
*/
protected function switchBySubdomain(): ?string
{
$host = $_SERVER['HTTP_HOST'] ?? '';
foreach ($this->languageManager->getAvailableLanguages() as $lang) {
$subdomain = $lang . '.';
if (str_starts_with($host, $subdomain)) {
return $lang;
}
}
return null;
}
/**
* Switch language by domain.
*/
protected function switchByDomain(): ?string
{
$host = $_SERVER['HTTP_HOST'] ?? '';
$domainMapping = $this->config['domain_mapping'] ?? [];
return $domainMapping[$host] ?? null;
}
/**
* Get language switch URL.
*/
public function getSwitchUrl(string $language, string $currentUrl = null): string
{
$currentUrl = $currentUrl ?? ($_SERVER['REQUEST_URI'] ?? '/');
return $this->urlRewriter->rewrite($currentUrl, $language, $this->languageManager->getCurrentLanguage());
}
/**
* Get language switch URLs for all available languages.
*/
public function getSwitchUrls(string $currentUrl = null): array
{
$currentUrl = $currentUrl ?? ($_SERVER['REQUEST_URI'] ?? '/');
$urls = [];
foreach ($this->languageManager->getAvailableLanguages() as $language) {
$urls[$language] = $this->getSwitchUrl($language, $currentUrl);
}
return $urls;
}
/**
* Get language selector HTML.
*/
public function getSelectorHtml(array $options = []): string
{
$currentLanguage = $this->languageManager->getCurrentLanguage();
$availableLanguages = $this->languageManager->getAvailableLanguages();
$switchUrls = $this->getSwitchUrls();
$defaultOptions = [
'type' => 'dropdown', // dropdown, links, flags
'show_flag' => true,
'show_name' => true,
'show_native_name' => false,
'class' => 'language-selector',
'current_class' => 'current-language',
'ul_class' => 'language-list',
'li_class' => 'language-item',
'a_class' => 'language-link'
];
$options = array_merge($defaultOptions, $options);
switch ($options['type']) {
case 'dropdown':
return $this->renderDropdownSelector($currentLanguage, $availableLanguages, $switchUrls, $options);
case 'links':
return $this->renderLinksSelector($currentLanguage, $availableLanguages, $switchUrls, $options);
case 'flags':
return $this->renderFlagsSelector($currentLanguage, $availableLanguages, $switchUrls, $options);
default:
return $this->renderDropdownSelector($currentLanguage, $availableLanguages, $switchUrls, $options);
}
}
/**
* Render dropdown selector.
*/
protected function renderDropdownSelector(string $current, array $languages, array $urls, array $options): string
{
$html = '<select class="' . $options['class'] . '" onchange="window.location.href=this.value">';
foreach ($languages as $language) {
$url = $urls[$language];
$info = $this->languageManager->getLanguageInfo($language);
$selected = $language === $current ? ' selected' : '';
$label = '';
if ($options['show_flag']) {
$label .= $this->getFlagEmoji($language) . ' ';
}
if ($options['show_name']) {
$label .= $info['name'];
}
if ($options['show_native_name'] && $info['native_name'] !== $info['name']) {
$label .= ' (' . $info['native_name'] . ')';
}
$html .= '<option value="' . htmlspecialchars($url) . '"' . $selected . '>' . $label . '</option>';
}
$html .= '</select>';
return $html;
}
/**
* Render links selector.
*/
protected function renderLinksSelector(string $current, array $languages, array $urls, array $options): string
{
$html = '<ul class="' . $options['ul_class'] . '">';
foreach ($languages as $language) {
$url = $urls[$language];
$info = $this->languageManager->getLanguageInfo($language);
$currentClass = $language === $current ? ' ' . $options['current_class'] : '';
$html .= '<li class="' . $options['li_class'] . $currentClass . '">';
$html .= '<a href="' . htmlspecialchars($url) . '" class="' . $options['a_class'] . '">';
if ($options['show_flag']) {
$html .= $this->getFlagEmoji($language) . ' ';
}
if ($options['show_name']) {
$html .= $info['name'];
}
if ($options['show_native_name'] && $info['native_name'] !== $info['name']) {
$html .= ' (' . $info['native_name'] . ')';
}
$html .= '</a></li>';
}
$html .= '</ul>';
return $html;
}
/**
* Render flags selector.
*/
protected function renderFlagsSelector(string $current, array $languages, array $urls, array $options): string
{
$html = '<div class="' . $options['class'] . '">';
foreach ($languages as $language) {
$url = $urls[$language];
$currentClass = $language === $current ? ' ' . $options['current_class'] : '';
$html .= '<a href="' . htmlspecialchars($url) . '" class="' . $options['a_class'] . $currentClass . '" title="' . $this->languageManager->getLanguageInfo($language)['name'] . '">';
$html .= $this->getFlagEmoji($language);
$html .= '</a>';
}
$html .= '</div>';
return $html;
}
/**
* Get flag emoji for language.
*/
protected function getFlagEmoji(string $language): string
{
$flagMap = $this->config['flag_map'] ?? [
'en' => '🇺🇸',
'es' => '🇪🇸',
'fr' => '🇫🇷',
'de' => '🇩🇪',
'it' => '🇮🇹',
'pt' => '🇵🇹',
'ru' => '🇷🇺',
'zh' => '🇨🇳',
'ja' => '🇯🇵',
'ko' => '🇰🇷',
'ar' => '🇸🇦',
'hi' => '🇮🇳',
'th' => '🇹🇭',
'vi' => '🇻🇳',
'tr' => '🇹🇷',
'pl' => '🇵🇱',
'nl' => '🇳🇱',
'sv' => '🇸🇪',
'no' => '🇳🇴',
'da' => '🇩🇰',
'fi' => '🇫🇮',
'cs' => '🇨🇿',
'sk' => '🇸🇰',
'hu' => '🇭🇺',
'ro' => '🇷🇴',
'bg' => '🇧🇬',
'hr' => '🇭🇷',
'sr' => '🇷🇸',
'sl' => '🇸🇮',
'et' => '🇪🇪',
'lv' => '🇱🇻',
'lt' => '🇱🇹',
'el' => '🇬🇷',
'he' => '🇮🇱',
'fa' => '🇮🇷',
'ur' => '🇵🇰',
'bn' => '🇧🇩',
'ta' => '🇱🇰',
'te' => '🇮🇳',
'ml' => '🇮🇳',
'kn' => '🇮🇳',
'gu' => '🇮🇳',
'pa' => '🇮🇳',
'mr' => '🇮🇳',
'ne' => '🇳🇵',
'si' => '🇱🇰',
'my' => '🇲🇲',
'km' => '🇰🇭',
'lo' => '🇱🇦',
'ka' => '🇬🇪',
'am' => '🇪🇹',
'sw' => '🇰🇪',
'zu' => '🇿🇦',
'af' => '🇿🇦',
'is' => '🇮🇸',
'mt' => '🇲🇹',
'cy' => '🏴󠁧󠁢󠁷󠁬󠁳󠁿',
'ga' => '🇮🇪',
'gd' => '🏴󠁧󠁢󠁳󠁣󠁴󠁿',
'eu' => '🏴󠁥󠁳󠁰󠁶󠁿',
'ca' => '🏴󠁥󠁳󠁣󠁴󠁿'
];
return $flagMap[$language] ?? '🌐';
}
/**
* Get language preference from user agent.
*/
public function getUserPreference(): ?string
{
return $this->detector->detectFromUserAgent();
}
/**
* Get language preference from geolocation.
*/
public function getGeolocationPreference(): ?string
{
return $this->detector->detectFromGeolocation();
}
/**
* Set language preference.
*/
public function setPreference(string $language, array $options = []): bool
{
if (!$this->languageManager->isLanguageAvailable($language)) {
return false;
}
$storage = $options['storage'] ?? 'cookie';
switch ($storage) {
case 'session':
return $this->storage->storeInSession($language);
case 'cookie':
return $this->storage->storeInCookie($language);
case 'database':
return $this->storage->storeInDatabase($language, $options['user_id'] ?? null);
default:
return $this->storage->store($language);
}
}
/**
* Get language preference.
*/
public function getPreference(string $storage = 'auto'): ?string
{
switch ($storage) {
case 'session':
return $this->storage->getFromSession();
case 'cookie':
return $this->storage->getFromCookie();
case 'database':
return $this->storage->getFromDatabase($this->config['user_id'] ?? null);
case 'auto':
default:
return $this->storage->get();
}
}
/**
* Clear language preference.
*/
public function clearPreference(string $storage = 'all'): bool
{
switch ($storage) {
case 'session':
return $this->storage->clearFromSession();
case 'cookie':
return $this->storage->clearFromCookie();
case 'database':
return $this->storage->clearFromDatabase($this->config['user_id'] ?? null);
case 'all':
default:
return $this->storage->clear();
}
}
/**
* Get language switching history.
*/
public function getHistory(int $limit = 10): array
{
return $this->storage->getHistory($limit);
}
/**
* Track language switch.
*/
protected function trackSwitch(string $fromLanguage, string $toLanguage): void
{
if ($this->config['track_switches']) {
$this->storage->trackSwitch($fromLanguage, $toLanguage);
}
}
/**
* Handle language switched event.
*/
protected function onLanguageSwitched(string $fromLanguage, string $toLanguage): void
{
// Track the switch
$this->trackSwitch($fromLanguage, $toLanguage);
// Trigger custom callback if configured
if (isset($this->config['on_switch']) && is_callable($this->config['on_switch'])) {
call_user_func($this->config['on_switch'], $fromLanguage, $toLanguage);
}
// Log the switch if enabled
if ($this->config['log_switches']) {
error_log("Language switched from '{$fromLanguage}' to '{$toLanguage}'");
}
}
/**
* Get language switching analytics.
*/
public function getAnalytics(array $options = []): array
{
$defaultOptions = [
'period' => '30d',
'group_by' => 'language', // language, date, user
'include_switches' => true,
'include_preferences' => true
];
$options = array_merge($defaultOptions, $options);
return $this->storage->getAnalytics($options);
}
/**
* Get most popular languages.
*/
public function getPopularLanguages(int $limit = 10): array
{
return $this->storage->getPopularLanguages($limit);
}
/**
* Get language switching statistics.
*/
public function getSwitchStatistics(): array
{
return [
'total_switches' => $this->storage->getTotalSwitches(),
'unique_users' => $this->storage->getUniqueUsers(),
'most_switched_to' => $this->storage->getMostSwitchedTo(),
'most_switched_from' => $this->storage->getMostSwitchedFrom(),
'average_switches_per_user' => $this->storage->getAverageSwitchesPerUser()
];
}
/**
* Validate language switch request.
*/
public function validateSwitchRequest(string $language, array $context = []): array
{
$errors = [];
// Check if language is available
if (!$this->languageManager->isLanguageAvailable($language)) {
$errors[] = 'Language not available';
}
// Check rate limiting if configured
if ($this->config['rate_limit'] && !$this->checkRateLimit($context)) {
$errors[] = 'Rate limit exceeded';
}
// Check user permissions if configured
if ($this->config['require_auth'] && !$this->checkUserPermission($context)) {
$errors[] = 'Permission denied';
}
return [
'valid' => empty($errors),
'errors' => $errors
];
}
/**
* Check rate limit.
*/
protected function checkRateLimit(array $context): bool
{
$key = $this->getRateLimitKey($context);
$limit = $this->config['rate_limit']['requests'] ?? 10;
$window = $this->config['rate_limit']['window'] ?? 3600; // 1 hour
return $this->storage->checkRateLimit($key, $limit, $window);
}
/**
* Get rate limit key.
*/
protected function getRateLimitKey(array $context): string
{
$identifier = $context['user_id'] ?? $_SERVER['REMOTE_ADDR'] ?? 'anonymous';
return 'lang_switch_rate:' . $identifier;
}
/**
* Check user permission.
*/
protected function checkUserPermission(array $context): bool
{
// Implement user permission check logic
return true; // Placeholder
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'switch_strategies' => ['url', 'parameter', 'header', 'session', 'cookie'],
'url_parameter' => 'lang',
'domain_mapping' => [],
'flag_map' => [],
'track_switches' => true,
'log_switches' => true,
'on_switch' => null,
'rate_limit' => null,
'require_auth' => false,
'cookie_options' => [
'expires' => 86400 * 30, // 30 days
'path' => '/',
'domain' => '',
'secure' => false,
'httponly' => true,
'samesite' => 'Lax'
],
'session_key' => 'language',
'database_table' => 'user_preferences'
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Get language manager.
*/
public function getLanguageManager(): LanguageManager
{
return $this->languageManager;
}
/**
* Get detector.
*/
public function getDetector(): LanguageDetector
{
return $this->detector;
}
/**
* Get storage.
*/
public function getStorage(): LanguageStorage
{
return $this->storage;
}
/**
* Get URL rewriter.
*/
public function getUrlRewriter(): UrlRewriter
{
return $this->urlRewriter;
}
/**
* Create language switcher instance.
*/
public static function create(LanguageManager $languageManager, array $config = []): self
{
return new self($languageManager, $config);
}
/**
* Create language switcher for web application.
*/
public static function forWeb(LanguageManager $languageManager): self
{
return new self($languageManager, [
'switch_strategies' => ['url', 'parameter', 'header', 'session', 'cookie'],
'track_switches' => true,
'log_switches' => true
]);
}
/**
* Create language switcher for API.
*/
public static function forApi(LanguageManager $languageManager): self
{
return new self($languageManager, [
'switch_strategies' => ['header', 'parameter'],
'track_switches' => false,
'log_switches' => false
]);
}
/**
* Create language switcher for CLI.
*/
public static function forCli(LanguageManager $languageManager): self
{
return new self($languageManager, [
'switch_strategies' => ['parameter'],
'track_switches' => false,
'log_switches' => false
]);
}
}

View File

@@ -0,0 +1,880 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Timezone\Config;
use Fendx\I18n\Timezone\Config\Loader\ConfigLoader;
use Fendx\I18n\Timezone\Config\Validator\ConfigValidator;
use Fendx\I18n\Timezone\Config\Cache\ConfigCache;
class TimezoneConfigManager
{
protected ConfigLoader $loader;
protected ConfigValidator $validator;
protected ConfigCache $cache;
protected array $config = [];
protected array $userPreferences = [];
protected array $environmentConfigs = [];
protected array $defaultConfig = [];
public function __construct(array $config = [])
{
$this->defaultConfig = $this->getDefaultConfig();
$this->config = array_merge($this->defaultConfig, $config);
$this->loader = new ConfigLoader($this->config);
$this->validator = new ConfigValidator($this->config);
$this->cache = new ConfigCache($this->config);
$this->initialize();
}
/**
* Get default timezone.
*/
public function getDefaultTimezone(): string
{
return $this->config['default_timezone'] ?? 'UTC';
}
/**
* Set default timezone.
*/
public function setDefaultTimezone(string $timezone): void
{
if (!$this->validator->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$this->config['default_timezone'] = $timezone;
$this->saveConfig();
}
/**
* Get supported timezones.
*/
public function getSupportedTimezones(): array
{
return $this->config['supported_timezones'] ?? \DateTimeZone::listIdentifiers();
}
/**
* Set supported timezones.
*/
public function setSupportedTimezones(array $timezones): void
{
$validation = $this->validator->validateTimezones($timezones);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid timezones: " . implode(', ', $validation['errors']));
}
$this->config['supported_timezones'] = $timezones;
$this->saveConfig();
}
/**
* Add supported timezone.
*/
public function addSupportedTimezone(string $timezone): void
{
if (!$this->validator->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$supported = $this->getSupportedTimezones();
if (!in_array($timezone, $supported)) {
$supported[] = $timezone;
$this->config['supported_timezones'] = $supported;
$this->saveConfig();
}
}
/**
* Remove supported timezone.
*/
public function removeSupportedTimezone(string $timezone): void
{
$supported = $this->getSupportedTimezones();
$key = array_search($timezone, $supported);
if ($key !== false) {
unset($supported[$key]);
$this->config['supported_timezones'] = array_values($supported);
$this->saveConfig();
}
}
/**
* Get timezone groups.
*/
public function getTimezoneGroups(): array
{
return $this->config['timezone_groups'] ?? $this->generateTimezoneGroups();
}
/**
* Set timezone groups.
*/
public function setTimezoneGroups(array $groups): void
{
$this->config['timezone_groups'] = $groups;
$this->saveConfig();
}
/**
* Add timezone group.
*/
public function addTimezoneGroup(string $name, array $timezones): void
{
$validation = $this->validator->validateTimezones($timezones);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid timezones in group: " . implode(', ', $validation['errors']));
}
$groups = $this->getTimezoneGroups();
$groups[$name] = $timezones;
$this->config['timezone_groups'] = $groups;
$this->saveConfig();
}
/**
* Get timezone by group.
*/
public function getTimezoneByGroup(string $group): array
{
$groups = $this->getTimezoneGroups();
return $groups[$group] ?? [];
}
/**
* Get user timezone preference.
*/
public function getUserTimezone(string $userId = null): string
{
$userId = $userId ?? $this->getCurrentUserId();
if (isset($this->userPreferences[$userId]['timezone'])) {
return $this->userPreferences[$userId]['timezone'];
}
// Try to load from storage
$stored = $this->loadUserPreference($userId, 'timezone');
if ($stored) {
$this->userPreferences[$userId]['timezone'] = $stored;
return $stored;
}
return $this->getDefaultTimezone();
}
/**
* Set user timezone preference.
*/
public function setUserTimezone(string $timezone, string $userId = null): void
{
if (!$this->validator->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$userId = $userId ?? $this->getCurrentUserId();
$this->userPreferences[$userId]['timezone'] = $timezone;
$this->saveUserPreference($userId, 'timezone', $timezone);
}
/**
* Get timezone format preference.
*/
public function getTimezoneFormat(string $userId = null): array
{
$userId = $userId ?? $this->getCurrentUserId();
return $this->userPreferences[$userId]['format'] ?? $this->config['default_format'];
}
/**
* Set timezone format preference.
*/
public function setTimezoneFormat(array $format, string $userId = null): void
{
$validation = $this->validator->validateFormat($format);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid format: " . implode(', ', $validation['errors']));
}
$userId = $userId ?? $this->getCurrentUserId();
$this->userPreferences[$userId]['format'] = $format;
$this->saveUserPreference($userId, 'format', $format);
}
/**
* Get auto-detection settings.
*/
public function getAutoDetectionSettings(): array
{
return $this->config['auto_detection'] ?? [];
}
/**
* Set auto-detection settings.
*/
public function setAutoDetectionSettings(array $settings): void
{
$validation = $this->validator->validateAutoDetectionSettings($settings);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid auto-detection settings: " . implode(', ', $validation['errors']));
}
$this->config['auto_detection'] = array_merge($this->getAutoDetectionSettings(), $settings);
$this->saveConfig();
}
/**
* Enable/disable auto-detection.
*/
public function setAutoDetection(bool $enabled): void
{
$this->config['auto_detection']['enabled'] = $enabled;
$this->saveConfig();
}
/**
* Detect user timezone.
*/
public function detectUserTimezone(string $ipAddress = null, string $userAgent = null): ?string
{
$settings = $this->getAutoDetectionSettings();
if (!$settings['enabled']) {
return null;
}
$detected = null;
// Method 1: IP-based detection
if ($settings['methods']['ip'] && $ipAddress) {
$detected = $this->detectByIP($ipAddress);
}
// Method 2: User-Agent based detection
if (!$detected && $settings['methods']['user_agent'] && $userAgent) {
$detected = $this->detectByUserAgent($userAgent);
}
// Method 3: Geolocation API
if (!$detected && $settings['methods']['geolocation']) {
$detected = $this->detectByGeolocation();
}
// Validate detected timezone
if ($detected && $this->validator->isValidTimezone($detected)) {
return $detected;
}
return null;
}
/**
* Get timezone conversion settings.
*/
public function getConversionSettings(): array
{
return $this->config['conversion'] ?? [];
}
/**
* Set timezone conversion settings.
*/
public function setConversionSettings(array $settings): void
{
$validation = $this->validator->validateConversionSettings($settings);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid conversion settings: " . implode(', ', $validation['errors']));
}
$this->config['conversion'] = array_merge($this->getConversionSettings(), $settings);
$this->saveConfig();
}
/**
* Get DST settings.
*/
public function getDSTSettings(): array
{
return $this->config['dst'] ?? [];
}
/**
* Set DST settings.
*/
public function setDSTSettings(array $settings): void
{
$validation = $this->validator->validateDSTSettings($settings);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid DST settings: " . implode(', ', $validation['errors']));
}
$this->config['dst'] = array_merge($this->getDSTSettings(), $settings);
$this->saveConfig();
}
/**
* Get display settings.
*/
public function getDisplaySettings(): array
{
return $this->config['display'] ?? [];
}
/**
* Set display settings.
*/
public function setDisplaySettings(array $settings): void
{
$this->config['display'] = array_merge($this->getDisplaySettings(), $settings);
$this->saveConfig();
}
/**
* Get environment-specific config.
*/
public function getEnvironmentConfig(string $environment): array
{
if (!isset($this->environmentConfigs[$environment])) {
$this->environmentConfigs[$environment] = $this->loadEnvironmentConfig($environment);
}
return $this->environmentConfigs[$environment];
}
/**
* Set environment-specific config.
*/
public function setEnvironmentConfig(string $environment, array $config): void
{
$this->environmentConfigs[$environment] = $config;
$this->saveEnvironmentConfig($environment, $config);
}
/**
* Get active environment config.
*/
public function getActiveConfig(): array
{
$environment = $this->getCurrentEnvironment();
$envConfig = $this->getEnvironmentConfig($environment);
return array_merge($this->config, $envConfig);
}
/**
* Validate configuration.
*/
public function validateConfig(): array
{
return $this->validator->validateConfig($this->config);
}
/**
* Reset configuration to defaults.
*/
public function resetToDefaults(): void
{
$this->config = $this->defaultConfig;
$this->saveConfig();
}
/**
* Export configuration.
*/
public function exportConfig(string $format = 'json'): string
{
$data = [
'config' => $this->config,
'user_preferences' => $this->userPreferences,
'environment_configs' => $this->environmentConfigs,
'exported_at' => date('Y-m-d H:i:s'),
'version' => $this->config['version'] ?? '1.0'
];
switch ($format) {
case 'json':
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
case 'php':
return '<?php return ' . var_export($data, true) . ';';
case 'yaml':
return $this->arrayToYaml($data);
default:
throw new \InvalidArgumentException("Unsupported export format: {$format}");
}
}
/**
* Import configuration.
*/
public function importConfig(string $data, string $format = 'json'): void
{
switch ($format) {
case 'json':
$imported = json_decode($data, true);
break;
case 'php':
$imported = include 'data://text/plain;base64,' . base64_encode($data);
break;
case 'yaml':
$imported = $this->yamlToArray($data);
break;
default:
throw new \InvalidArgumentException("Unsupported import format: {$format}");
}
if (!$imported) {
throw new \InvalidArgumentException("Invalid configuration data");
}
// Validate imported config
$validation = $this->validator->validateConfig($imported['config'] ?? []);
if (!$validation['valid']) {
throw new \InvalidArgumentException("Invalid configuration: " . implode(', ', $validation['errors']));
}
if (isset($imported['config'])) {
$this->config = array_merge($this->config, $imported['config']);
}
if (isset($imported['user_preferences'])) {
$this->userPreferences = array_merge($this->userPreferences, $imported['user_preferences']);
}
if (isset($imported['environment_configs'])) {
$this->environmentConfigs = array_merge($this->environmentConfigs, $imported['environment_configs']);
}
$this->saveConfig();
}
/**
* Get configuration summary.
*/
public function getSummary(): array
{
return [
'default_timezone' => $this->getDefaultTimezone(),
'supported_timezones_count' => count($this->getSupportedTimezones()),
'timezone_groups_count' => count($this->getTimezoneGroups()),
'user_preferences_count' => count($this->userPreferences),
'environment_configs_count' => count($this->environmentConfigs),
'auto_detection_enabled' => $this->getAutoDetectionSettings()['enabled'] ?? false,
'dst_handling_enabled' => $this->getDSTSettings()['enabled'] ?? true,
'version' => $this->config['version'] ?? '1.0'
];
}
/**
* Get timezone selector configuration.
*/
public function getSelectorConfig(): array
{
return [
'default_timezone' => $this->getDefaultTimezone(),
'supported_timezones' => $this->getSupportedTimezones(),
'timezone_groups' => $this->getTimezoneGroups(),
'show_groups' => $this->getDisplaySettings()['show_groups'] ?? true,
'show_offset' => $this->getDisplaySettings()['show_offset'] ?? true,
'show_current_time' => $this->getDisplaySettings()['show_current_time'] ?? true,
'group_by_region' => $this->getDisplaySettings()['group_by_region'] ?? true,
'sort_by_offset' => $this->getDisplaySettings()['sort_by_offset'] ?? false
];
}
/**
* Get timezone for API response.
*/
public function getTimezoneForAPI(string $timezone = null): array
{
$timezone = $timezone ?? $this->getDefaultTimezone();
if (!$this->validator->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
$now = new \DateTime('now', $tz);
return [
'timezone' => $timezone,
'offset' => $tz->getOffset($now),
'offset_hours' => $tz->getOffset($now) / 3600,
'abbreviation' => $now->format('T'),
'is_dst' => $now->format('I') === '1',
'current_time' => $now->format('Y-m-d H:i:s'),
'formatted_time' => $this->formatTimezone($timezone)
];
}
/**
* Format timezone name for display.
*/
public function formatTimezone(string $timezone, string $format = null): string
{
$format = $format ?? $this->getDisplaySettings()['format'] ?? 'full';
switch ($format) {
case 'short':
return $timezone;
case 'city':
$parts = explode('/', $timezone);
return end($parts);
case 'region_city':
$parts = explode('/', $timezone);
return str_replace('_', ' ', implode(' / ', $parts));
case 'full':
default:
return str_replace('_', ' ', $timezone);
}
}
/**
* Initialize configuration manager.
*/
protected function initialize(): void
{
// Load configuration from file
$this->loadConfig();
// Validate configuration
$validation = $this->validateConfig();
if (!$validation['valid']) {
throw new \RuntimeException("Invalid configuration: " . implode(', ', $validation['errors']));
}
// Initialize cache
if ($this->config['cache_enabled']) {
$this->cache->initialize();
}
}
/**
* Load configuration.
*/
protected function loadConfig(): void
{
if ($this->config['config_file'] && file_exists($this->config['config_file'])) {
$loaded = $this->loader->loadFromFile($this->config['config_file']);
$this->config = array_merge($this->config, $loaded);
}
}
/**
* Save configuration.
*/
protected function saveConfig(): void
{
if ($this->config['config_file']) {
$this->loader->saveToFile($this->config['config_file'], $this->config);
}
if ($this->config['cache_enabled']) {
$this->cache->set('config', $this->config);
}
}
/**
* Load environment configuration.
*/
protected function loadEnvironmentConfig(string $environment): array
{
$envFile = $this->config['config_dir'] . '/' . $environment . '.php';
if (file_exists($envFile)) {
return include $envFile;
}
return [];
}
/**
* Save environment configuration.
*/
protected function saveEnvironmentConfig(string $environment, array $config): void
{
$envFile = $this->config['config_dir'] . '/' . $environment . '.php';
$this->loader->saveToFile($envFile, $config);
}
/**
* Load user preference.
*/
protected function loadUserPreference(string $userId, string $key)
{
if ($this->config['user_preferences_storage'] === 'session') {
return $_SESSION['timezone_preferences'][$userId][$key] ?? null;
} elseif ($this->config['user_preferences_storage'] === 'database') {
// Database implementation would go here
return null;
} elseif ($this->config['user_preferences_storage'] === 'file') {
$file = $this->config['user_preferences_dir'] . '/' . $userId . '.json';
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
return $data[$key] ?? null;
}
}
return null;
}
/**
* Save user preference.
*/
protected function saveUserPreference(string $userId, string $key, $value): void
{
if ($this->config['user_preferences_storage'] === 'session') {
$_SESSION['timezone_preferences'][$userId][$key] = $value;
} elseif ($this->config['user_preferences_storage'] === 'database') {
// Database implementation would go here
} elseif ($this->config['user_preferences_storage'] === 'file') {
$file = $this->config['user_preferences_dir'] . '/' . $userId . '.json';
$data = [];
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
}
$data[$key] = $value;
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
}
/**
* Get current user ID.
*/
protected function getCurrentUserId(): string
{
// This would depend on your authentication system
return $_SESSION['user_id'] ?? 'anonymous';
}
/**
* Get current environment.
*/
protected function getCurrentEnvironment(): string
{
return $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'production';
}
/**
* Detect timezone by IP.
*/
protected function detectByIP(string $ipAddress): ?string
{
// This would integrate with a GeoIP service
// For now, return a basic implementation
return null;
}
/**
* Detect timezone by User-Agent.
*/
protected function detectByUserAgent(string $userAgent): ?string
{
// This would analyze the User-Agent string for timezone hints
// For now, return a basic implementation
return null;
}
/**
* Detect timezone by geolocation.
*/
protected function detectByGeolocation(): ?string
{
// This would use browser geolocation API
// For now, return a basic implementation
return null;
}
/**
* Generate timezone groups.
*/
protected function generateTimezoneGroups(): array
{
$groups = [];
$identifiers = \DateTimeZone::listIdentifiers();
foreach ($identifiers as $timezone) {
$parts = explode('/', $timezone);
$region = $parts[0];
if (!isset($groups[$region])) {
$groups[$region] = [];
}
$groups[$region][] = $timezone;
}
return $groups;
}
/**
* Convert array to YAML.
*/
protected function arrayToYaml(array $array, int $depth = 0): string
{
$yaml = '';
$indent = str_repeat(' ', $depth);
foreach ($array as $key => $value) {
if (is_array($value)) {
$yaml .= "{$indent}{$key}:\n";
$yaml .= $this->arrayToYaml($value, $depth + 1);
} else {
$escapedValue = str_replace(["\n", "\r"], ['\\n', '\\r'], (string) $value);
$yaml .= "{$indent}{$key}: \"{$escapedValue}\"\n";
}
}
return $yaml;
}
/**
* Convert YAML to array.
*/
protected function yamlToArray(string $yaml): array
{
// This would use a proper YAML parser in production
// For now, return a basic implementation
return [];
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'default_timezone' => 'UTC',
'supported_timezones' => \DateTimeZone::listIdentifiers(),
'timezone_groups' => [],
'auto_detection' => [
'enabled' => true,
'methods' => [
'ip' => true,
'user_agent' => false,
'geolocation' => false
],
'fallback_to_default' => true
],
'conversion' => [
'cache_enabled' => true,
'cache_ttl' => 3600,
'handle_dst' => true,
'handle_ambiguous_times' => true
],
'dst' => [
'enabled' => true,
'auto_detect' => true,
'handle_ambiguous' => 'standard',
'handle_nonexistent' => 'forward'
],
'display' => [
'format' => 'full',
'show_groups' => true,
'show_offset' => true,
'show_current_time' => true,
'group_by_region' => true,
'sort_by_offset' => false
],
'default_format' => [
'date' => 'Y-m-d',
'time' => 'H:i:s',
'datetime' => 'Y-m-d H:i:s'
],
'cache_enabled' => true,
'config_file' => null,
'config_dir' => __DIR__ . '/../../../config',
'user_preferences_storage' => 'session',
'user_preferences_dir' => __DIR__ . '/../../../storage/timezone',
'version' => '1.0'
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
$this->saveConfig();
}
/**
* Create config manager instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for web application.
*/
public static function forWeb(): self
{
return new self([
'auto_detection' => [
'enabled' => true,
'methods' => [
'ip' => true,
'user_agent' => true,
'geolocation' => true
]
],
'user_preferences_storage' => 'session'
]);
}
/**
* Create for API application.
*/
public static function forAPI(): self
{
return new self([
'auto_detection' => [
'enabled' => false
],
'user_preferences_storage' => 'database'
]);
}
/**
* Create for CLI application.
*/
public static function forCLI(): self
{
return new self([
'auto_detection' => [
'enabled' => false
],
'user_preferences_storage' => 'file'
]);
}
}

View File

@@ -0,0 +1,753 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Timezone\Converter;
use Fendx\I18n\Timezone\Converter\Engine\DateTimeEngine;
use Fendx\I18n\Timezone\Converter\Engine\TimestampEngine;
use Fendx\I18n\Timezone\Converter\Validator\TimezoneValidator;
class TimezoneConverter
{
protected DateTimeEngine $dateTimeEngine;
protected TimestampEngine $timestampEngine;
protected TimezoneValidator $validator;
protected array $config = [];
protected array $conversionCache = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->dateTimeEngine = new DateTimeEngine($this->config);
$this->timestampEngine = new TimestampEngine($this->config);
$this->validator = new TimezoneValidator($this->config);
}
/**
* Convert DateTime to different timezone.
*/
public function convertDateTime(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): \DateTime
{
if (!$this->validator->isValidTimezone($fromTimezone)) {
throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}");
}
if (!$this->validator->isValidTimezone($toTimezone)) {
throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}");
}
$cacheKey = $this->generateCacheKey($datetime, $fromTimezone, $toTimezone);
if ($this->config['cache_enabled'] && isset($this->conversionCache[$cacheKey])) {
return clone $this->conversionCache[$cacheKey];
}
$result = $this->dateTimeEngine->convert($datetime, $fromTimezone, $toTimezone);
if ($this->config['cache_enabled']) {
$this->conversionCache[$cacheKey] = clone $result;
}
return $result;
}
/**
* Convert timestamp to different timezone.
*/
public function convertTimestamp(int $timestamp, string $fromTimezone, string $toTimezone): \DateTime
{
if (!$this->validator->isValidTimezone($fromTimezone)) {
throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}");
}
if (!$this->validator->isValidTimezone($toTimezone)) {
throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}");
}
$cacheKey = "timestamp_{$timestamp}_{$fromTimezone}_{$toTimezone}";
if ($this->config['cache_enabled'] && isset($this->conversionCache[$cacheKey])) {
return clone $this->conversionCache[$cacheKey];
}
$result = $this->timestampEngine->convert($timestamp, $fromTimezone, $toTimezone);
if ($this->config['cache_enabled']) {
$this->conversionCache[$cacheKey] = clone $result;
}
return $result;
}
/**
* Convert datetime string to different timezone.
*/
public function convertString(string $datetime, string $fromTimezone, string $toTimezone, string $format = null): \DateTime
{
$format = $format ?? $this->config['default_datetime_format'];
try {
$sourceDateTime = \DateTime::createFromFormat($format, $datetime, new \DateTimeZone($fromTimezone));
if (!$sourceDateTime) {
throw new \InvalidArgumentException("Invalid datetime format: {$datetime}");
}
return $this->convertDateTime($sourceDateTime, $fromTimezone, $toTimezone);
} catch (\Exception $e) {
throw new \RuntimeException("Failed to convert datetime string: " . $e->getMessage(), 0, $e);
}
}
/**
* Convert multiple datetimes to different timezone.
*/
public function convertMultiple(array $datetimes, string $fromTimezone, string $toTimezone): array
{
$results = [];
foreach ($datetimes as $key => $datetime) {
try {
if ($datetime instanceof \DateTimeInterface) {
$results[$key] = $this->convertDateTime($datetime, $fromTimezone, $toTimezone);
} elseif (is_int($datetime)) {
$results[$key] = $this->convertTimestamp($datetime, $fromTimezone, $toTimezone);
} elseif (is_string($datetime)) {
$results[$key] = $this->convertString($datetime, $fromTimezone, $toTimezone);
} else {
throw new \InvalidArgumentException("Unsupported datetime type for key '{$key}'");
}
} catch (\Exception $e) {
$results[$key] = $e;
}
}
return $results;
}
/**
* Convert with daylight saving time awareness.
*/
public function convertWithDST(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): \DateTime
{
if (!$this->validator->isValidTimezone($fromTimezone)) {
throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}");
}
if (!$this->validator->isValidTimezone($toTimezone)) {
throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}");
}
$fromTz = new \DateTimeZone($fromTimezone);
$toTz = new \DateTimeZone($toTimezone);
// Create DateTime in source timezone
$sourceDt = new \DateTime($datetime->format('Y-m-d H:i:s'), $fromTz);
// Convert to target timezone (PHP handles DST automatically)
$targetDt = $sourceDt->setTimezone($toTz);
// Add DST information
$dstInfo = $this->getDSTInfo($targetDt, $toTimezone);
$targetDt->dstInfo = $dstInfo;
return $targetDt;
}
/**
* Convert with custom offset (for historical dates).
*/
public function convertWithOffset(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone, int $offsetSeconds = null): \DateTime
{
if ($offsetSeconds === null) {
return $this->convertDateTime($datetime, $fromTimezone, $toTimezone);
}
$fromTz = new \DateTimeZone($fromTimezone);
$toTz = new \DateTimeZone($toTimezone);
// Create DateTime in source timezone
$sourceDt = new \DateTime($datetime->format('Y-m-d H:i:s'), $fromTz);
// Apply custom offset
$sourceDt->modify("{$offsetSeconds} seconds");
// Convert to target timezone
$targetDt = $sourceDt->setTimezone($toTz);
return $targetDt;
}
/**
* Get timezone offset difference.
*/
public function getOffsetDifference(string $timezone1, string $timezone2, \DateTimeInterface $datetime = null): array
{
$datetime = $datetime ?? new \DateTime();
if (!$this->validator->isValidTimezone($timezone1)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone1}");
}
if (!$this->validator->isValidTimezone($timezone2)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone2}");
}
$tz1 = new \DateTimeZone($timezone1);
$tz2 = new \DateTimeZone($timezone2);
$dt1 = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz1);
$dt2 = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz2);
$offset1 = $dt1->getOffset();
$offset2 = $dt2->getOffset();
$difference = $offset2 - $offset1;
return [
'timezone1' => $timezone1,
'timezone2' => $timezone2,
'offset1' => $offset1,
'offset2' => $offset2,
'difference' => $difference,
'difference_hours' => $difference / 3600,
'timezone1_ahead' => $difference > 0,
'datetime' => $datetime->format('Y-m-d H:i:s')
];
}
/**
* Find best conversion path between timezones.
*/
public function findBestPath(string $fromTimezone, string $toTimezone): array
{
if (!$this->validator->isValidTimezone($fromTimezone)) {
throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}");
}
if (!$this->validator->isValidTimezone($toTimezone)) {
throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}");
}
// Direct conversion is always the best path
return [
'path' => [$fromTimezone, $toTimezone],
'steps' => 1,
'direct' => true,
'recommended' => true
];
}
/**
* Convert with business hours consideration.
*/
public function convertWithBusinessHours(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone, array $businessHours = null): array
{
$businessHours = $businessHours ?? $this->config['business_hours'];
$converted = $this->convertDateTime($datetime, $fromTimezone, $toTimezone);
$isBusinessHours = $this->isBusinessHours($converted, $toTimezone, $businessHours);
$nextBusinessHours = $this->getNextBusinessHours($converted, $toTimezone, $businessHours);
return [
'converted_datetime' => $converted,
'is_business_hours' => $isBusinessHours,
'next_business_hours' => $nextBusinessHours,
'business_hours' => $businessHours
];
}
/**
* Batch convert with progress callback.
*/
public function batchConvert(array $items, string $fromTimezone, string $toTimezone, callable $progressCallback = null): array
{
$results = [];
$total = count($items);
$processed = 0;
foreach ($items as $key => $item) {
try {
$results[$key] = $this->convertDateTime($item, $fromTimezone, $toTimezone);
} catch (\Exception $e) {
$results[$key] = $e;
}
$processed++;
if ($progressCallback) {
$progressCallback($processed, $total, $key, $results[$key]);
}
}
return $results;
}
/**
* Convert with locale awareness.
*/
public function convertWithLocale(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone, string $locale = 'en'): array
{
$converted = $this->convertDateTime($datetime, $fromTimezone, $toTimezone);
$localeInfo = $this->getLocaleTimezoneInfo($toTimezone, $locale);
return [
'datetime' => $converted,
'locale' => $locale,
'timezone_name' => $localeInfo['name'],
'timezone_abbreviation' => $localeInfo['abbreviation'],
'formatted_datetime' => $this->formatForLocale($converted, $locale)
];
}
/**
* Get conversion statistics.
*/
public function getConversionStatistics(): array
{
return [
'cache_enabled' => $this->config['cache_enabled'],
'cache_size' => count($this->conversionCache),
'cache_hit_ratio' => $this->calculateCacheHitRatio(),
'total_conversions' => $this->config['total_conversions'] ?? 0,
'error_count' => $this->config['error_count'] ?? 0
];
}
/**
* Clear conversion cache.
*/
public function clearCache(): void
{
$this->conversionCache = [];
}
/**
* Warm up cache with common conversions.
*/
public function warmUpCache(array $commonConversions = null): void
{
$commonConversions = $commonConversions ?? $this->config['common_conversions'];
$now = new \DateTime();
foreach ($commonConversions as $conversion) {
try {
$this->convertDateTime($now, $conversion['from'], $conversion['to']);
} catch (\Exception $e) {
// Ignore errors during warmup
}
}
}
/**
* Validate timezone conversion.
*/
public function validateConversion(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): array
{
$errors = [];
$warnings = [];
// Validate timezones
if (!$this->validator->isValidTimezone($fromTimezone)) {
$errors[] = "Invalid source timezone: {$fromTimezone}";
}
if (!$this->validator->isValidTimezone($toTimezone)) {
$errors[] = "Invalid target timezone: {$toTimezone}";
}
// Check for potential issues
if ($fromTimezone === $toTimezone) {
$warnings[] = "Source and target timezones are the same";
}
// Check for historical date issues
if ($datetime->format('Y') < 1970) {
$warnings[] = "Historical dates may have timezone accuracy issues";
}
// Check for DST transition issues
if ($this->isNearDSTTransition($datetime, $fromTimezone)) {
$warnings[] = "Datetime is near DST transition in source timezone";
}
if ($this->isNearDSTTransition($datetime, $toTimezone)) {
$warnings[] = "Datetime is near DST transition in target timezone";
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings
];
}
/**
* Get DST information.
*/
protected function getDSTInfo(\DateTime $datetime, string $timezone): array
{
$tz = new \DateTimeZone($timezone);
$transitions = $tz->getTransitions($datetime->getTimestamp(), $datetime->getTimestamp());
if (!empty($transitions)) {
$transition = $transitions[0];
return [
'is_dst' => $transition['dst'] ?? false,
'offset' => $transition['offset'] ?? 0,
'abbreviation' => $transition['abbr'] ?? ''
];
}
return [
'is_dst' => false,
'offset' => $tz->getOffset($datetime),
'abbreviation' => $datetime->format('T')
];
}
/**
* Check if datetime is during business hours.
*/
protected function isBusinessHours(\DateTime $datetime, string $timezone, array $businessHours): bool
{
$hour = (int) $datetime->format('H');
$dayOfWeek = (int) $datetime->format('w'); // 0 = Sunday, 6 = Saturday
// Check if it's a weekday
if (!in_array($dayOfWeek, $businessHours['weekdays'])) {
return false;
}
// Check if it's within business hours
return $hour >= $businessHours['start_hour'] && $hour < $businessHours['end_hour'];
}
/**
* Get next business hours.
*/
protected function getNextBusinessHours(\DateTime $datetime, string $timezone, array $businessHours): \DateTime
{
$next = clone $datetime;
$dayOfWeek = (int) $next->format('w');
// Move to next business day if needed
while (!in_array($dayOfWeek, $businessHours['weekdays'])) {
$next->modify('+1 day');
$dayOfWeek = (int) $next->format('w');
}
// Set to start of business hours
$next->setTime($businessHours['start_hour'], 0, 0);
return $next;
}
/**
* Get locale timezone information.
*/
protected function getLocaleTimezoneInfo(string $timezone, string $locale): array
{
$tz = new \DateTimeZone($timezone);
$now = new \DateTime('now', $tz);
// Get localized timezone name
$names = [
'en' => $timezone,
'zh-CN' => $this->getChineseTimezoneName($timezone),
'ja' => $this->getJapaneseTimezoneName($timezone),
'ko' => $this->getKoreanTimezoneName($timezone),
'es' => $this->getSpanishTimezoneName($timezone),
'fr' => $this->getFrenchTimezoneName($timezone),
'de' => $this->getGermanTimezoneName($timezone)
];
return [
'name' => $names[$locale] ?? $timezone,
'abbreviation' => $now->format('T'),
'offset' => $tz->getOffset($now),
'offset_hours' => $tz->getOffset($now) / 3600
];
}
/**
* Get Chinese timezone name.
*/
protected function getChineseTimezoneName(string $timezone): string
{
$names = [
'UTC' => '协调世界时',
'America/New_York' => '美国东部时间',
'America/Chicago' => '美国中部时间',
'America/Denver' => '美国山地时间',
'America/Los_Angeles' => '美国太平洋时间',
'Europe/London' => '格林威治时间',
'Europe/Paris' => '中欧时间',
'Europe/Berlin' => '中欧时间',
'Asia/Shanghai' => '中国标准时间',
'Asia/Tokyo' => '日本标准时间',
'Asia/Seoul' => '韩国标准时间',
'Asia/Hong_Kong' => '香港时间',
'Asia/Singapore' => '新加坡时间'
];
return $names[$timezone] ?? $timezone;
}
/**
* Get Japanese timezone name.
*/
protected function getJapaneseTimezoneName(string $timezone): string
{
$names = [
'UTC' => '協定世界時',
'America/New_York' => '東部標準時',
'America/Chicago' => '中部標準時',
'America/Denver' => '山地標準時',
'America/Los_Angeles' => '太平洋標準時',
'Europe/London' => 'グリニッジ標準時',
'Europe/Paris' => '中央ヨーロッパ時間',
'Europe/Berlin' => '中央ヨーロッパ時間',
'Asia/Shanghai' => '中国標準時',
'Asia/Tokyo' => '日本標準時',
'Asia/Seoul' => '韓国標準時'
];
return $names[$timezone] ?? $timezone;
}
/**
* Get Korean timezone name.
*/
protected function getKoreanTimezoneName(string $timezone): string
{
$names = [
'UTC' => '협정 세계시',
'America/New_York' => '동부 표준시',
'America/Chicago' => '중부 표준시',
'America/Denver' => '산악 표준시',
'America/Los_Angeles' => '태평양 표준시',
'Europe/London' => '그리니치 표준시',
'Europe/Paris' => '중앙 유럽 시간',
'Europe/Berlin' => '중앙 유럽 시간',
'Asia/Shanghai' => '중국 표준시',
'Asia/Tokyo' => '일본 표준시',
'Asia/Seoul' => '한국 표준시'
];
return $names[$timezone] ?? $timezone;
}
/**
* Get Spanish timezone name.
*/
protected function getSpanishTimezoneName(string $timezone): string
{
$names = [
'UTC' => 'Tiempo Universal Coordinado',
'America/New_York' => 'Tiempo del Este',
'America/Chicago' => 'Tiempo del Centro',
'America/Denver' => 'Tiempo de la Montaña',
'America/Los_Angeles' => 'Tiempo del Pacífico',
'Europe/London' => 'Hora de Greenwich',
'Europe/Paris' => 'Hora de Europa Central',
'Europe/Berlin' => 'Hora de Europa Central',
'Asia/Shanghai' => 'Hora de China',
'Asia/Tokyo' => 'Hora de Japón',
'Asia/Seoul' => 'Hora de Corea'
];
return $names[$timezone] ?? $timezone;
}
/**
* Get French timezone name.
*/
protected function getFrenchTimezoneName(string $timezone): string
{
$names = [
'UTC' => 'Temps Universel Coordonné',
'America/New_York' => 'Heure de l\'Est',
'America/Chicago' => 'Heure du Centre',
'America/Denver' => 'Heure des Montagnes',
'America/Los_Angeles' => 'Heure du Pacifique',
'Europe/London' => 'Heure de Greenwich',
'Europe/Paris' => 'Heure d\'Europe Centrale',
'Europe/Berlin' => 'Heure d\'Europe Centrale',
'Asia/Shanghai' => 'Heure de Chine',
'Asia/Tokyo' => 'Heure du Japon',
'Asia/Seoul' => 'Heure de Corée'
];
return $names[$timezone] ?? $timezone;
}
/**
* Get German timezone name.
*/
protected function getGermanTimezoneName(string $timezone): string
{
$names = [
'UTC' => 'Koordinierte Weltzeit',
'America/New_York' => 'Östliche Zeit',
'America/Chicago' => 'Zentrale Zeit',
'America/Denver' => 'Gebirgszeit',
'America/Los_Angeles' => 'Pazifische Zeit',
'Europe/London' => 'Greenwich-Zeit',
'Europe/Paris' => 'Mitteleuropäische Zeit',
'Europe/Berlin' => 'Mitteleuropäische Zeit',
'Asia/Shanghai' => 'Chinesische Zeit',
'Asia/Tokyo' => 'Japanische Zeit',
'Asia/Seoul' => 'Koreanische Zeit'
];
return $names[$timezone] ?? $timezone;
}
/**
* Format datetime for locale.
*/
protected function formatForLocale(\DateTime $datetime, string $locale): string
{
$formats = [
'en' => 'Y-m-d H:i:s T',
'zh-CN' => 'Y年m月d日 H:i:s T',
'ja' => 'Y年m月d日 H:i:s T',
'ko' => 'Y년 m월 d일 H:i:s T',
'es' => 'd/m/Y H:i:s T',
'fr' => 'd/m/Y H:i:s T',
'de' => 'd.m.Y H:i:s T'
];
$format = $formats[$locale] ?? 'Y-m-d H:i:s T';
return $datetime->format($format);
}
/**
* Check if datetime is near DST transition.
*/
protected function isNearDSTTransition(\DateTimeInterface $datetime, string $timezone): bool
{
$tz = new \DateTimeZone($timezone);
$year = (int) $datetime->format('Y');
// Get DST transitions for the year
$transitions = $tz->getTransitions(
strtotime("{$year}-01-01"),
strtotime("{$year}-12-31")
);
$timestamp = $datetime->getTimestamp();
$threshold = 3600; // 1 hour before/after transition
foreach ($transitions as $transition) {
if (abs($timestamp - $transition['ts']) < $threshold) {
return true;
}
}
return false;
}
/**
* Generate cache key.
*/
protected function generateCacheKey(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): string
{
return $datetime->format('Y-m-d H:i:s') . "_{$fromTimezone}_{$toTimezone}";
}
/**
* Calculate cache hit ratio.
*/
protected function calculateCacheHitRatio(): float
{
$total = $this->config['total_conversions'] ?? 1;
$hits = $this->config['cache_hits'] ?? 0;
return $hits / $total;
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'cache_enabled' => true,
'cache_ttl' => 3600,
'default_datetime_format' => 'Y-m-d H:i:s',
'business_hours' => [
'weekdays' => [1, 2, 3, 4, 5], // Monday to Friday
'start_hour' => 9,
'end_hour' => 17
],
'common_conversions' => [
['from' => 'UTC', 'to' => 'America/New_York'],
['from' => 'UTC', 'to' => 'Europe/London'],
['from' => 'UTC', 'to' => 'Asia/Shanghai'],
['from' => 'UTC', 'to' => 'Asia/Tokyo'],
['from' => 'America/New_York', 'to' => 'UTC'],
['from' => 'Europe/London', 'to' => 'UTC'],
['from' => 'Asia/Shanghai', 'to' => 'UTC'],
['from' => 'Asia/Tokyo', 'to' => 'UTC']
],
'total_conversions' => 0,
'cache_hits' => 0,
'error_count' => 0
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create converter instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for high performance.
*/
public static function forPerformance(): self
{
return new self([
'cache_enabled' => true,
'cache_ttl' => 7200,
'common_conversions' => [
// Add more common conversions for performance
['from' => 'America/New_York', 'to' => 'Europe/London'],
['from' => 'Europe/London', 'to' => 'America/New_York'],
['from' => 'Asia/Shanghai', 'to' => 'America/New_York'],
['from' => 'America/New_York', 'to' => 'Asia/Shanghai']
]
]);
}
/**
* Create for accuracy.
*/
public static function forAccuracy(): self
{
return new self([
'cache_enabled' => false, // Disable cache for accuracy
'validate_conversions' => true
]);
}
}

View File

@@ -0,0 +1,896 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Timezone\DST;
use Fendx\I18n\Timezone\DST\Calculator\DSTCalculator;
use Fendx\I18n\Timezone\DST\Detector\DSTDetector;
use Fendx\I18n\Timezone\DST\Transition\DSTTransition;
class DaylightSavingTimeHandler
{
protected DSTCalculator $calculator;
protected DSTDetector $detector;
protected DSTTransition $transition;
protected array $config = [];
protected array $dstRules = [];
protected array $transitionCache = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->calculator = new DSTCalculator($this->config);
$this->detector = new DSTDetector($this->config);
$this->transition = new DSTTransition($this->config);
$this->dstRules = $this->loadDSTRules();
}
/**
* Check if DST is active for timezone at specific datetime.
*/
public function isDSTActive(string $timezone, \DateTimeInterface $datetime = null): bool
{
$datetime = $datetime ?? new \DateTime();
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
return $dt->format('I') === '1';
}
/**
* Get DST information for timezone at specific datetime.
*/
public function getDSTInfo(string $timezone, \DateTimeInterface $datetime = null): array
{
$datetime = $datetime ?? new \DateTime();
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
$isDST = $dt->format('I') === '1';
$offset = $tz->getOffset($dt);
$abbreviation = $dt->format('T');
return [
'timezone' => $timezone,
'datetime' => $datetime->format('Y-m-d H:i:s'),
'is_dst' => $isDST,
'offset' => $offset,
'offset_hours' => $offset / 3600,
'abbreviation' => $abbreviation,
'dst_offset' => $isDST ? $this->getDSTOffset($timezone) : 0,
'standard_offset' => $isDST ? $offset - $this->getDSTOffset($timezone) : $offset
];
}
/**
* Get DST transitions for timezone in specific year.
*/
public function getDSTTransitions(string $timezone, int $year = null): array
{
$year = $year ?? (int) date('Y');
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$cacheKey = "{$timezone}_{$year}";
if (isset($this->transitionCache[$cacheKey])) {
return $this->transitionCache[$cacheKey];
}
$tz = new \DateTimeZone($timezone);
// Get transitions for the entire year
$transitions = $tz->getTransitions(
strtotime("{$year}-01-01"),
strtotime("{$year}-12-31 23:59:59")
);
$dstTransitions = [];
foreach ($transitions as $transition) {
if ($transition['dst'] !== $transition['isdst']) {
$dstTransitions[] = [
'timestamp' => $transition['ts'],
'datetime' => date('Y-m-d H:i:s', $transition['ts']),
'offset' => $transition['offset'],
'is_dst' => $transition['dst'],
'abbreviation' => $transition['abbr'],
'type' => $transition['dst'] ? 'start' : 'end',
'offset_change' => $this->calculateOffsetChange($transition, $tz, $year)
];
}
}
$this->transitionCache[$cacheKey] = $dstTransitions;
return $dstTransitions;
}
/**
* Get next DST transition for timezone.
*/
public function getNextDSTTransition(string $timezone, \DateTimeInterface $datetime = null): ?array
{
$datetime = $datetime ?? new \DateTime();
$transitions = $this->getDSTTransitions($timezone, (int) $datetime->format('Y'));
$timestamp = $datetime->getTimestamp();
foreach ($transitions as $transition) {
if ($transition['timestamp'] > $timestamp) {
return $transition;
}
}
// Check next year if no transitions found this year
$nextYear = (int) $datetime->format('Y') + 1;
$nextYearTransitions = $this->getDSTTransitions($timezone, $nextYear);
return !empty($nextYearTransitions) ? $nextYearTransitions[0] : null;
}
/**
* Get previous DST transition for timezone.
*/
public function getPreviousDSTTransition(string $timezone, \DateTimeInterface $datetime = null): ?array
{
$datetime = $datetime ?? new \DateTime();
$transitions = $this->getDSTTransitions($timezone, (int) $datetime->format('Y'));
$timestamp = $datetime->getTimestamp();
$previousTransition = null;
foreach ($transitions as $transition) {
if ($transition['timestamp'] < $timestamp) {
$previousTransition = $transition;
} else {
break;
}
}
// Check previous year if no transitions found this year
if ($previousTransition === null) {
$previousYear = (int) $datetime->format('Y') - 1;
$previousYearTransitions = $this->getDSTTransitions($timezone, $previousYear);
return !empty($previousYearTransitions) ? end($previousYearTransitions) : null;
}
return $previousTransition;
}
/**
* Get DST offset for timezone.
*/
public function getDSTOffset(string $timezone): int
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
// Most DST offsets are 1 hour (3600 seconds)
// Some exceptions exist (like Lord Howe Island with 30 minutes)
$exceptions = [
'Australia/Lord_Howe' => 1800, // 30 minutes
'Antarctica/Macquarie' => 1800, // 30 minutes
];
return $exceptions[$timezone] ?? 3600;
}
/**
* Check if timezone observes DST.
*/
public function observesDST(string $timezone): bool
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
// Check if timezone has any DST transitions in current year
$transitions = $this->getDSTTransitions($timezone);
return !empty($transitions);
}
/**
* Get DST periods for timezone in specific year.
*/
public function getDSTPeriods(string $timezone, int $year = null): array
{
$year = $year ?? (int) date('Y');
$transitions = $this->getDSTTransitions($timezone, $year);
$periods = [];
$startTransition = null;
foreach ($transitions as $transition) {
if ($transition['type'] === 'start') {
$startTransition = $transition;
} elseif ($transition['type'] === 'end' && $startTransition) {
$periods[] = [
'start' => $startTransition,
'end' => $transition,
'duration' => $transition['timestamp'] - $startTransition['timestamp'],
'duration_hours' => ($transition['timestamp'] - $startTransition['timestamp']) / 3600
];
$startTransition = null;
}
}
return $periods;
}
/**
* Convert datetime with DST awareness.
*/
public function convertWithDST(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): \DateTime
{
if (!$this->isValidTimezone($fromTimezone)) {
throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}");
}
if (!$this->isValidTimezone($toTimezone)) {
throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}");
}
$fromTz = new \DateTimeZone($fromTimezone);
$toTz = new \DateTimeZone($toTimezone);
// Create DateTime in source timezone
$sourceDt = new \DateTime($datetime->format('Y-m-d H:i:s'), $fromTz);
// Convert to target timezone (PHP handles DST automatically)
$targetDt = $sourceDt->setTimezone($toTz);
// Add DST metadata
$targetDt->dstInfo = [
'source_dst' => $this->isDSTActive($fromTimezone, $datetime),
'target_dst' => $this->isDSTActive($toTimezone, $targetDt),
'source_offset' => $fromTz->getOffset($datetime),
'target_offset' => $toTz->getOffset($targetDt),
'offset_change' => $toTz->getOffset($targetDt) - $fromTz->getOffset($datetime)
];
return $targetDt;
}
/**
* Handle ambiguous or non-existent times during DST transitions.
*/
public function handleAmbiguousTime(\DateTimeInterface $datetime, string $timezone, string $preference = 'standard'): \DateTime
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
// Check if this is an ambiguous time (during fall back transition)
$previousTransition = $this->getPreviousDSTTransition($timezone, $datetime);
if ($previousTransition && $previousTransition['type'] === 'end') {
$transitionDateTime = new \DateTime($previousTransition['datetime']);
$transitionDateTime->setTimezone($tz);
// Check if our datetime is within the ambiguous hour
$diff = $datetime->getTimestamp() - $transitionDateTime->getTimestamp();
if ($diff >= 0 && $diff < 3600) { // Within the ambiguous hour
switch ($preference) {
case 'standard':
// Use standard time (first occurrence)
return $dt;
case 'dst':
// Use DST time (second occurrence)
$dt->modify('+1 hour');
return $dt;
case 'earlier':
// Use earlier time
return $dt;
case 'later':
// Use later time
$dt->modify('+1 hour');
return $dt;
default:
throw new \InvalidArgumentException("Invalid preference: {$preference}");
}
}
}
return $dt;
}
/**
* Handle non-existent time during DST transition.
*/
public function handleNonExistentTime(\DateTimeInterface $datetime, string $timezone, string $strategy = 'forward'): \DateTime
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
// Check if this is a non-existent time (during spring forward transition)
$previousTransition = $this->getPreviousDSTTransition($timezone, $datetime);
if ($previousTransition && $previousTransition['type'] === 'start') {
$transitionDateTime = new \DateTime($previousTransition['datetime']);
$transitionDateTime->setTimezone($tz);
// Check if our datetime is within the skipped hour
$diff = $datetime->getTimestamp() - $transitionDateTime->getTimestamp();
if ($diff >= 0 && $diff < 3600) { // Within the skipped hour
switch ($strategy) {
case 'forward':
// Move forward to the next valid time
return new \DateTime($previousTransition['datetime'], $tz);
case 'backward':
// Move backward to the previous valid time
$dt = new \DateTime($previousTransition['datetime'], $tz);
$dt->modify('-1 hour');
return $dt;
case 'adjust':
// Adjust to the nearest valid time
return new \DateTime($previousTransition['datetime'], $tz);
default:
throw new \InvalidArgumentException("Invalid strategy: {$strategy}");
}
}
}
return new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
}
/**
* Get DST rules for timezone.
*/
public function getDSTRules(string $timezone): array
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
return $this->dstRules[$timezone] ?? $this->generateDSTRules($timezone);
}
/**
* Calculate DST-aware time difference.
*/
public function calculateTimeDifference(\DateTimeInterface $start, \DateTimeInterface $end, string $timezone): array
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
$startDt = new \DateTime($start->format('Y-m-d H:i:s'), $tz);
$endDt = new \DateTime($end->format('Y-m-d H:i:s'), $tz);
$interval = $endDt->diff($startDt);
// Check if DST transitions occurred between the two times
$transitions = $this->getDSTTransitions($timezone, (int) $start->format('Y'));
$dstTransitionsInRange = [];
foreach ($transitions as $transition) {
if ($transition['timestamp'] > $start->getTimestamp() &&
$transition['timestamp'] < $end->getTimestamp()) {
$dstTransitionsInRange[] = $transition;
}
}
return [
'interval' => $interval,
'total_seconds' => $end->getTimestamp() - $start->getTimestamp(),
'dst_transitions' => $dstTransitionsInRange,
'dst_adjusted_seconds' => $this->calculateDSTAdjustedDifference($start, $end, $timezone),
'has_dst_transition' => !empty($dstTransitionsInRange)
];
}
/**
* Get DST-aware business hours calculation.
*/
public function calculateBusinessHours(\DateTimeInterface $datetime, string $timezone, array $businessHours = null): array
{
$businessHours = $businessHours ?? $this->config['business_hours'];
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
$isDST = $this->isDSTActive($timezone, $dt);
$hour = (int) $dt->format('H');
$dayOfWeek = (int) $dt->format('w');
// Adjust business hours for DST if needed
$adjustedBusinessHours = $businessHours;
if ($isDST && $this->config['adjust_business_hours_for_dst']) {
$adjustedBusinessHours = $this->adjustBusinessHoursForDST($businessHours, $timezone);
}
$isBusinessHours = in_array($dayOfWeek, $adjustedBusinessHours['weekdays']) &&
$hour >= $adjustedBusinessHours['start_hour'] &&
$hour < $adjustedBusinessHours['end_hour'];
return [
'datetime' => $dt,
'timezone' => $timezone,
'is_dst' => $isDST,
'is_business_hours' => $isBusinessHours,
'business_hours' => $adjustedBusinessHours,
'next_business_hour' => $this->getNextBusinessHour($dt, $timezone, $adjustedBusinessHours),
'previous_business_hour' => $this->getPreviousBusinessHour($dt, $timezone, $adjustedBusinessHours)
];
}
/**
* Validate DST configuration.
*/
public function validateDSTConfig(array $config): array
{
$errors = [];
$warnings = [];
// Validate timezone
if (isset($config['timezone']) && !$this->isValidTimezone($config['timezone'])) {
$errors[] = "Invalid timezone: {$config['timezone']}";
}
// Validate business hours
if (isset($config['business_hours'])) {
$bh = $config['business_hours'];
if (!isset($bh['start_hour']) || !is_int($bh['start_hour']) || $bh['start_hour'] < 0 || $bh['start_hour'] > 23) {
$errors[] = "Invalid start_hour in business_hours";
}
if (!isset($bh['end_hour']) || !is_int($bh['end_hour']) || $bh['end_hour'] < 0 || $bh['end_hour'] > 24) {
$errors[] = "Invalid end_hour in business_hours";
}
if (isset($bh['start_hour']) && isset($bh['end_hour']) && $bh['start_hour'] >= $bh['end_hour']) {
$warnings[] = "Business hours start_hour should be less than end_hour";
}
}
// Validate preference settings
if (isset($config['ambiguous_time_preference'])) {
$validPreferences = ['standard', 'dst', 'earlier', 'later'];
if (!in_array($config['ambiguous_time_preference'], $validPreferences)) {
$errors[] = "Invalid ambiguous_time_preference";
}
}
if (isset($config['non_existent_time_strategy'])) {
$validStrategies = ['forward', 'backward', 'adjust'];
if (!in_array($config['non_existent_time_strategy'], $validStrategies)) {
$errors[] = "Invalid non_existent_time_strategy";
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings
];
}
/**
* Generate DST report for timezone.
*/
public function generateDSTReport(string $timezone, int $year = null): array
{
$year = $year ?? (int) date('Y');
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$transitions = $this->getDSTTransitions($timezone, $year);
$periods = $this->getDSTPeriods($timezone, $year);
$observesDST = $this->observesDST($timezone);
$report = [
'timezone' => $timezone,
'year' => $year,
'observes_dst' => $observesDST,
'total_transitions' => count($transitions),
'total_periods' => count($periods),
'dst_offset' => $this->getDSTOffset($timezone),
'current_status' => $this->getDSTInfo($timezone),
'transitions' => $transitions,
'periods' => $periods
];
if ($observesDST) {
$report['statistics'] = [
'total_dst_hours' => $this->calculateTotalDSTHours($timezone, $year),
'dst_percentage' => $this->calculateDSTPercentage($timezone, $year),
'longest_dst_period' => $this->getLongestDSTPeriod($timezone, $year),
'shortest_dst_period' => $this->getShortestDSTPeriod($timezone, $year)
];
}
return $report;
}
/**
* Check if timezone is valid.
*/
protected function isValidTimezone(string $timezone): bool
{
return in_array($timezone, \DateTimeZone::listIdentifiers());
}
/**
* Load DST rules.
*/
protected function loadDSTRules(): array
{
return [
// US DST rules (historical and current)
'America/New_York' => [
'start_month' => 3,
'start_day' => 'second_sunday',
'start_time' => '02:00',
'end_month' => 11,
'end_day' => 'first_sunday',
'end_time' => '02:00',
'offset' => 3600
],
'America/Chicago' => [
'start_month' => 3,
'start_day' => 'second_sunday',
'start_time' => '02:00',
'end_month' => 11,
'end_day' => 'first_sunday',
'end_time' => '02:00',
'offset' => 3600
],
'America/Denver' => [
'start_month' => 3,
'start_day' => 'second_sunday',
'start_time' => '02:00',
'end_month' => 11,
'end_day' => 'first_sunday',
'end_time' => '02:00',
'offset' => 3600
],
'America/Los_Angeles' => [
'start_month' => 3,
'start_day' => 'second_sunday',
'start_time' => '02:00',
'end_month' => 11,
'end_day' => 'first_sunday',
'end_time' => '02:00',
'offset' => 3600
],
// European DST rules
'Europe/London' => [
'start_month' => 3,
'start_day' => 'last_sunday',
'start_time' => '01:00',
'end_month' => 10,
'end_day' => 'last_sunday',
'end_time' => '01:00',
'offset' => 3600
],
'Europe/Paris' => [
'start_month' => 3,
'start_day' => 'last_sunday',
'start_time' => '01:00',
'end_month' => 10,
'end_day' => 'last_sunday',
'end_time' => '01:00',
'offset' => 3600
],
'Europe/Berlin' => [
'start_month' => 3,
'start_day' => 'last_sunday',
'start_time' => '01:00',
'end_month' => 10,
'end_day' => 'last_sunday',
'end_time' => '01:00',
'offset' => 3600
]
];
}
/**
* Generate DST rules for timezone.
*/
protected function generateDSTRules(string $timezone): array
{
// Try to infer rules from transitions
$transitions = $this->getDSTTransitions($timezone);
if (empty($transitions)) {
return [
'observes_dst' => false
];
}
// Analyze transitions to generate rules
$rules = ['observes_dst' => true];
// This is a simplified rule generation
// In practice, you'd want more sophisticated analysis
foreach ($transitions as $transition) {
$dt = new \DateTime($transition['datetime']);
if ($transition['type'] === 'start') {
$rules['start_month'] = (int) $dt->format('m');
$rules['start_day'] = 'unknown'; // Would need more analysis
$rules['start_time'] = $dt->format('H:i');
} elseif ($transition['type'] === 'end') {
$rules['end_month'] = (int) $dt->format('m');
$rules['end_day'] = 'unknown'; // Would need more analysis
$rules['end_time'] = $dt->format('H:i');
}
}
$rules['offset'] = $this->getDSTOffset($timezone);
return $rules;
}
/**
* Calculate offset change for transition.
*/
protected function calculateOffsetChange(array $transition, \DateTimeZone $tz, int $year): int
{
// Get offset before and after transition
$before = new \DateTime($transition['datetime'], $tz);
$before->modify('-1 second');
$after = new \DateTime($transition['datetime'], $tz);
$after->modify('+1 second');
return $after->getOffset() - $before->getOffset();
}
/**
* Calculate DST-adjusted time difference.
*/
protected function calculateDSTAdjustedDifference(\DateTimeInterface $start, \DateTimeInterface $end, string $timezone): int
{
$tz = new \DateTimeZone($timezone);
$startDt = new \DateTime($start->format('Y-m-d H:i:s'), $tz);
$endDt = new \DateTime($end->format('Y-m-d H:i:s'), $tz);
// Calculate actual difference in seconds
return $endDt->getTimestamp() - $startDt->getTimestamp();
}
/**
* Adjust business hours for DST.
*/
protected function adjustBusinessHoursForDST(array $businessHours, string $timezone): array
{
// Some businesses may want to adjust their hours during DST
// This is a placeholder for more complex logic
return $businessHours;
}
/**
* Get next business hour.
*/
protected function getNextBusinessHour(\DateTime $datetime, string $timezone, array $businessHours): \DateTime
{
$next = clone $datetime;
$dayOfWeek = (int) $next->format('w');
// Move to next business day if needed
while (!in_array($dayOfWeek, $businessHours['weekdays'])) {
$next->modify('+1 day');
$dayOfWeek = (int) $next->format('w');
}
// Set to start of business hours
$next->setTime($businessHours['start_hour'], 0, 0);
return $next;
}
/**
* Get previous business hour.
*/
protected function getPreviousBusinessHour(\DateTime $datetime, string $timezone, array $businessHours): \DateTime
{
$previous = clone $datetime;
$dayOfWeek = (int) $previous->format('w');
// Move to previous business day if needed
while (!in_array($dayOfWeek, $businessHours['weekdays'])) {
$previous->modify('-1 day');
$dayOfWeek = (int) $previous->format('w');
}
// Set to end of business hours
$previous->setTime($businessHours['end_hour'] - 1, 59, 59);
return $previous;
}
/**
* Calculate total DST hours in year.
*/
protected function calculateTotalDSTHours(string $timezone, int $year): int
{
$periods = $this->getDSTPeriods($timezone, $year);
$totalHours = 0;
foreach ($periods as $period) {
$totalHours += $period['duration_hours'];
}
return (int) $totalHours;
}
/**
* Calculate DST percentage for year.
*/
protected function calculateDSTPercentage(string $timezone, int $year): float
{
$totalHours = $this->calculateTotalDSTHours($timezone, $year);
$yearHours = ($this->isLeapYear($year) ? 366 : 365) * 24;
return ($totalHours / $yearHours) * 100;
}
/**
* Check if year is leap year.
*/
protected function isLeapYear(int $year): bool
{
return ($year % 4 === 0 && $year % 100 !== 0) || ($year % 400 === 0);
}
/**
* Get longest DST period.
*/
protected function getLongestDSTPeriod(string $timezone, int $year): ?array
{
$periods = $this->getDSTPeriods($timezone, $year);
if (empty($periods)) {
return null;
}
$longest = $periods[0];
foreach ($periods as $period) {
if ($period['duration_hours'] > $longest['duration_hours']) {
$longest = $period;
}
}
return $longest;
}
/**
* Get shortest DST period.
*/
protected function getShortestDSTPeriod(string $timezone, int $year): ?array
{
$periods = $this->getDSTPeriods($timezone, $year);
if (empty($periods)) {
return null;
}
$shortest = $periods[0];
foreach ($periods as $period) {
if ($period['duration_hours'] < $shortest['duration_hours']) {
$shortest = $period;
}
}
return $shortest;
}
/**
* Clear transition cache.
*/
public function clearCache(): void
{
$this->transitionCache = [];
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'business_hours' => [
'weekdays' => [1, 2, 3, 4, 5], // Monday to Friday
'start_hour' => 9,
'end_hour' => 17
],
'adjust_business_hours_for_dst' => false,
'ambiguous_time_preference' => 'standard',
'non_existent_time_strategy' => 'forward',
'cache_enabled' => true,
'cache_ttl' => 3600
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create DST handler instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for US timezones.
*/
public static function forUS(): self
{
return new self([
'business_hours' => [
'weekdays' => [1, 2, 3, 4, 5],
'start_hour' => 9,
'end_hour' => 17
],
'ambiguous_time_preference' => 'standard',
'non_existent_time_strategy' => 'forward'
]);
}
/**
* Create for European timezones.
*/
public static function forEurope(): self
{
return new self([
'business_hours' => [
'weekdays' => [1, 2, 3, 4, 5],
'start_hour' => 9,
'end_hour' => 17
],
'ambiguous_time_preference' => 'standard',
'non_existent_time_strategy' => 'forward'
]);
}
}

View File

@@ -0,0 +1,793 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Timezone\Database;
use Fendx\I18n\Timezone\Database\Loader\TimezoneLoader;
use Fendx\I18n\Timezone\Database\Cache\TimezoneCache;
use Fendx\I18n\Timezone\Database\Index\TimezoneIndex;
class TimezoneDatabase
{
protected TimezoneLoader $loader;
protected TimezoneCache $cache;
protected TimezoneIndex $index;
protected array $config = [];
protected array $timezoneData = [];
protected array $locationData = [];
protected array $metadata = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->loader = new TimezoneLoader($this->config);
$this->cache = new TimezoneCache($this->config);
$this->index = new TimezoneIndex($this->config);
$this->initializeDatabase();
}
/**
* Get timezone information.
*/
public function getTimezoneInfo(string $timezone): array
{
if (!isset($this->timezoneData[$timezone])) {
$this->loadTimezoneData($timezone);
}
$data = $this->timezoneData[$timezone] ?? [];
if (empty($data)) {
throw new \InvalidArgumentException("Timezone not found: {$timezone}");
}
return $data;
}
/**
* Get timezone location information.
*/
public function getLocation(string $timezone): array
{
if (!isset($this->locationData[$timezone])) {
$this->loadLocationData($timezone);
}
return $this->locationData[$timezone] ?? [];
}
/**
* Get timezone metadata.
*/
public function getMetadata(string $timezone): array
{
if (!isset($this->metadata[$timezone])) {
$this->loadMetadata($timezone);
}
return $this->metadata[$timezone] ?? [];
}
/**
* Search timezones by criteria.
*/
public function search(array $criteria): array
{
return $this->index->search($criteria, $this->timezoneData);
}
/**
* Get timezones by country.
*/
public function getTimezonesByCountry(string $countryCode): array
{
return $this->search(['country' => $countryCode]);
}
/**
* Get timezones by region.
*/
public function getTimezonesByRegion(string $region): array
{
return $this->search(['region' => $region]);
}
/**
* Get timezones by city.
*/
public function getTimezonesByCity(string $city): array
{
return $this->search(['city' => $city]);
}
/**
* Get timezones by coordinates.
*/
public function getTimezonesByCoordinates(float $latitude, float $longitude, float $radius = 50): array
{
return $this->index->searchByCoordinates($latitude, $longitude, $radius, $this->timezoneData);
}
/**
* Get timezone by IP address.
*/
public function getTimezoneByIP(string $ipAddress): ?string
{
$location = $this->getLocationByIP($ipAddress);
if ($location) {
$timezones = $this->getTimezonesByCoordinates(
$location['latitude'],
$location['longitude'],
100
);
return !empty($timezones) ? $timezones[0]['timezone'] : null;
}
return null;
}
/**
* Get location by IP address.
*/
public function getLocationByIP(string $ipAddress): ?array
{
// This would integrate with a GeoIP service
// For now, return a basic implementation
return $this->loader->loadLocationByIP($ipAddress);
}
/**
* Get popular timezones.
*/
public function getPopularTimezones(int $limit = 20): array
{
$popularTimezones = $this->config['popular_timezones'] ?? [];
$result = [];
foreach ($popularTimezones as $timezone) {
if (isset($this->timezoneData[$timezone])) {
$result[] = $this->timezoneData[$timezone];
}
}
return array_slice($result, 0, $limit);
}
/**
* Get timezone offsets for all timezones at specific datetime.
*/
public function getAllOffsets(\DateTimeInterface $datetime = null): array
{
$datetime = $datetime ?? new \DateTime();
$offsets = [];
foreach ($this->timezoneData as $timezone => $data) {
try {
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
$offsets[$timezone] = [
'offset' => $tz->getOffset($dt),
'offset_hours' => $tz->getOffset($dt) / 3600,
'abbreviation' => $dt->format('T'),
'is_dst' => $dt->format('I') === '1'
];
} catch (\Exception $e) {
// Skip invalid timezones
continue;
}
}
return $offsets;
}
/**
* Get timezone conversion matrix.
*/
public function getConversionMatrix(array $timezones = null): array
{
$timezones = $timezones ?? array_keys($this->timezoneData);
$matrix = [];
$now = new \DateTime();
foreach ($timezones as $fromTz) {
$matrix[$fromTz] = [];
foreach ($timezones as $toTz) {
if ($fromTz === $toTz) {
$matrix[$fromTz][$toTz] = [
'offset_difference' => 0,
'offset_hours' => 0,
'time_difference' => '00:00:00'
];
} else {
$fromZone = new \DateTimeZone($fromTz);
$toZone = new \DateTimeZone($toTz);
$fromOffset = $fromZone->getOffset($now);
$toOffset = $toZone->getOffset($now);
$difference = $toOffset - $fromOffset;
$hours = floor(abs($difference) / 3600);
$minutes = floor((abs($difference) % 3600) / 60);
$sign = $difference >= 0 ? '+' : '-';
$matrix[$fromTz][$toTz] = [
'offset_difference' => $difference,
'offset_hours' => $difference / 3600,
'time_difference' => sprintf('%s%02d:%02d:00', $sign, $hours, $minutes)
];
}
}
}
return $matrix;
}
/**
* Get timezone groups.
*/
public function getTimezoneGroups(): array
{
return [
'americas' => $this->getTimezonesByRegion('America'),
'europe' => $this->getTimezonesByRegion('Europe'),
'africa' => $this->getTimezonesByRegion('Africa'),
'asia' => $this->getTimezonesByRegion('Asia'),
'australia' => $this->getTimezonesByRegion('Australia'),
'pacific' => $this->getTimezonesByRegion('Pacific'),
'antarctica' => $this->getTimezonesByRegion('Antarctica'),
'arctic' => $this->getTimezonesByRegion('Arctic'),
'indian' => $this->getTimezonesByRegion('Indian'),
'atlantic' => $this->getTimezonesByRegion('Atlantic')
];
}
/**
* Get timezone by abbreviation.
*/
public function getTimezoneByAbbreviation(string $abbreviation, \DateTimeInterface $datetime = null): array
{
$datetime = $datetime ?? new \DateTime();
$matches = [];
foreach ($this->timezoneData as $timezone => $data) {
try {
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz);
if ($dt->format('T') === $abbreviation) {
$matches[] = [
'timezone' => $timezone,
'offset' => $tz->getOffset($dt),
'is_dst' => $dt->format('I') === '1',
'data' => $data
];
}
} catch (\Exception $e) {
continue;
}
}
return $matches;
}
/**
* Get timezone statistics.
*/
public function getStatistics(): array
{
$stats = [
'total_timezones' => count($this->timezoneData),
'total_countries' => count($this->getCountries()),
'total_regions' => count($this->getRegions()),
'total_cities' => count($this->getCities()),
'dst_observing_timezones' => 0,
'non_dst_observing_timezones' => 0,
'offset_distribution' => [],
'region_distribution' => []
];
$now = new \DateTime();
foreach ($this->timezoneData as $timezone => $data) {
try {
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($now->format('Y-m-d H:i:s'), $tz);
if ($dt->format('I') === '1') {
$stats['dst_observing_timezones']++;
} else {
$stats['non_dst_observing_timezones']++;
}
$offset = $tz->getOffset($dt) / 3600;
$offsetKey = (string) $offset;
$stats['offset_distribution'][$offsetKey] = ($stats['offset_distribution'][$offsetKey] ?? 0) + 1;
$region = explode('/', $timezone)[0];
$stats['region_distribution'][$region] = ($stats['region_distribution'][$region] ?? 0) + 1;
} catch (\Exception $e) {
continue;
}
}
ksort($stats['offset_distribution']);
ksort($stats['region_distribution']);
return $stats;
}
/**
* Validate timezone data.
*/
public function validateTimezone(string $timezone): array
{
$errors = [];
$warnings = [];
try {
$tz = new \DateTimeZone($timezone);
$now = new \DateTime('now', $tz);
// Basic validation
if (!in_array($timezone, \DateTimeZone::listIdentifiers())) {
$errors[] = "Timezone not in PHP's timezone list";
}
// Check for data completeness
if (!isset($this->timezoneData[$timezone])) {
$warnings[] = "No extended data available for timezone";
}
// Check for location data
if (!isset($this->locationData[$timezone])) {
$warnings[] = "No location data available for timezone";
}
// Validate offset
$offset = $tz->getOffset($now);
if ($offset % 900 !== 0) { // Not aligned to 15-minute intervals
$warnings[] = "Timezone offset not aligned to 15-minute intervals";
}
} catch (\Exception $e) {
$errors[] = "Invalid timezone: " . $e->getMessage();
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings
];
}
/**
* Update timezone data.
*/
public function updateTimezoneData(string $timezone, array $data): void
{
$this->timezoneData[$timezone] = array_merge(
$this->timezoneData[$timezone] ?? [],
$data
);
$this->index->updateIndex($timezone, $data);
if ($this->config['cache_enabled']) {
$this->cache->set($timezone, $this->timezoneData[$timezone]);
}
}
/**
* Add custom timezone.
*/
public function addCustomTimezone(string $timezone, array $data): void
{
if (isset($this->timezoneData[$timezone])) {
throw new \InvalidArgumentException("Timezone already exists: {$timezone}");
}
$this->updateTimezoneData($timezone, $data);
}
/**
* Remove timezone.
*/
public function removeTimezone(string $timezone): void
{
unset($this->timezoneData[$timezone]);
unset($this->locationData[$timezone]);
unset($this->metadata[$timezone]);
$this->index->removeFromIndex($timezone);
if ($this->config['cache_enabled']) {
$this->cache->delete($timezone);
}
}
/**
* Export timezone data.
*/
public function exportData(string $format = 'json'): string
{
$data = [
'timezone_data' => $this->timezoneData,
'location_data' => $this->locationData,
'metadata' => $this->metadata,
'statistics' => $this->getStatistics(),
'exported_at' => date('Y-m-d H:i:s'),
'version' => $this->config['data_version'] ?? '1.0'
];
switch ($format) {
case 'json':
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
case 'php':
return '<?php return ' . var_export($data, true) . ';';
case 'csv':
return $this->exportToCSV($data);
default:
throw new \InvalidArgumentException("Unsupported export format: {$format}");
}
}
/**
* Import timezone data.
*/
public function importData(string $data, string $format = 'json'): void
{
switch ($format) {
case 'json':
$imported = json_decode($data, true);
break;
case 'php':
$imported = include 'data://text/plain;base64,' . base64_encode($data);
break;
default:
throw new \InvalidArgumentException("Unsupported import format: {$format}");
}
if (!$imported) {
throw new \InvalidArgumentException("Invalid data format");
}
if (isset($imported['timezone_data'])) {
$this->timezoneData = array_merge($this->timezoneData, $imported['timezone_data']);
}
if (isset($imported['location_data'])) {
$this->locationData = array_merge($this->locationData, $imported['location_data']);
}
if (isset($imported['metadata'])) {
$this->metadata = array_merge($this->metadata, $imported['metadata']);
}
// Rebuild index
$this->index->rebuildIndex($this->timezoneData);
}
/**
* Get database version.
*/
public function getVersion(): string
{
return $this->config['data_version'] ?? '1.0';
}
/**
* Update database.
*/
public function updateDatabase(): void
{
$this->loader->updateDatabase();
$this->initializeDatabase();
}
/**
* Initialize database.
*/
protected function initializeDatabase(): void
{
// Load basic timezone data
$this->loadBasicTimezoneData();
// Build search index
$this->index->buildIndex($this->timezoneData);
// Warm up cache
if ($this->config['cache_enabled'] && $this->config['warmup_cache']) {
$this->warmUpCache();
}
}
/**
* Load basic timezone data.
*/
protected function loadBasicTimezoneData(): void
{
$identifiers = \DateTimeZone::listIdentifiers();
foreach ($identifiers as $timezone) {
$this->timezoneData[$timezone] = [
'timezone' => $timezone,
'identifier' => $timezone,
'region' => explode('/', $timezone)[0],
'city' => $this->extractCity($timezone),
'country' => $this->extractCountry($timezone)
];
}
}
/**
* Load timezone data.
*/
protected function loadTimezoneData(string $timezone): void
{
if ($this->config['cache_enabled']) {
$cached = $this->cache->get($timezone);
if ($cached) {
$this->timezoneData[$timezone] = $cached;
return;
}
}
$data = $this->loader->loadTimezoneData($timezone);
if ($data) {
$this->timezoneData[$timezone] = $data;
if ($this->config['cache_enabled']) {
$this->cache->set($timezone, $data);
}
}
}
/**
* Load location data.
*/
protected function loadLocationData(string $timezone): void
{
$data = $this->loader->loadLocationData($timezone);
if ($data) {
$this->locationData[$timezone] = $data;
}
}
/**
* Load metadata.
*/
protected function loadMetadata(string $timezone): void
{
$data = $this->loader->loadMetadata($timezone);
if ($data) {
$this->metadata[$timezone] = $data;
}
}
/**
* Extract city from timezone.
*/
protected function extractCity(string $timezone): string
{
$parts = explode('/', $timezone);
if (count($parts) >= 2) {
return str_replace('_', ' ', end($parts));
}
return $timezone;
}
/**
* Extract country from timezone.
*/
protected function extractCountry(string $timezone): string
{
// This is a simplified country extraction
// In practice, you'd use a more comprehensive mapping
$countryMap = [
'America' => 'US',
'Europe' => 'EU',
'Asia' => 'AS',
'Africa' => 'AF',
'Australia' => 'AU',
'Pacific' => 'OC'
];
$region = explode('/', $timezone)[0];
return $countryMap[$region] ?? 'Unknown';
}
/**
* Get countries.
*/
protected function getCountries(): array
{
$countries = [];
foreach ($this->timezoneData as $data) {
if (isset($data['country'])) {
$countries[$data['country']] = true;
}
}
return array_keys($countries);
}
/**
* Get regions.
*/
protected function getRegions(): array
{
$regions = [];
foreach ($this->timezoneData as $data) {
if (isset($data['region'])) {
$regions[$data['region']] = true;
}
}
return array_keys($regions);
}
/**
* Get cities.
*/
protected function getCities(): array
{
$cities = [];
foreach ($this->timezoneData as $data) {
if (isset($data['city'])) {
$cities[$data['city']] = true;
}
}
return array_keys($cities);
}
/**
* Export to CSV.
*/
protected function exportToCSV(array $data): string
{
$csv = "Timezone,Region,City,Country,Latitude,Longitude\n";
foreach ($data['timezone_data'] as $timezone => $info) {
$location = $data['location_data'][$timezone] ?? [];
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s\n",
$timezone,
$info['region'] ?? '',
$info['city'] ?? '',
$info['country'] ?? '',
$location['latitude'] ?? '',
$location['longitude'] ?? ''
);
}
return $csv;
}
/**
* Warm up cache.
*/
protected function warmUpCache(): void
{
$popularTimezones = $this->config['popular_timezones'] ?? array_slice(array_keys($this->timezoneData), 0, 50);
foreach ($popularTimezones as $timezone) {
if (isset($this->timezoneData[$timezone])) {
$this->cache->set($timezone, $this->timezoneData[$timezone]);
}
}
}
/**
* Clear cache.
*/
public function clearCache(): void
{
$this->cache->clear();
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'cache_enabled' => true,
'cache_ttl' => 3600,
'warmup_cache' => true,
'data_version' => '1.0',
'popular_timezones' => [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Hong_Kong',
'Asia/Singapore',
'Australia/Sydney',
'Pacific/Auckland'
],
'data_sources' => [
'tz_database' => true,
'geoip' => true,
'custom_data' => true
]
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create database instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for production.
*/
public static function forProduction(): self
{
return new self([
'cache_enabled' => true,
'cache_ttl' => 7200,
'warmup_cache' => true,
'data_sources' => [
'tz_database' => true,
'geoip' => true,
'custom_data' => false
]
]);
}
/**
* Create for development.
*/
public static function forDevelopment(): self
{
return new self([
'cache_enabled' => false,
'warmup_cache' => false,
'data_sources' => [
'tz_database' => true,
'geoip' => false,
'custom_data' => true
]
]);
}
}

View File

@@ -0,0 +1,813 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Timezone;
use Fendx\I18n\Timezone\Detector\TimezoneDetector;
use Fendx\I18n\Timezone\Converter\TimezoneConverter;
use Fendx\I18n\Timezone\Validator\TimezoneValidator;
use Fendx\I18n\Timezone\Database\TimezoneDatabase;
class TimezoneManager
{
protected TimezoneDetector $detector;
protected TimezoneConverter $converter;
protected TimezoneValidator $validator;
protected TimezoneDatabase $database;
protected array $config = [];
protected string $defaultTimezone = 'UTC';
protected string $currentTimezone = 'UTC';
protected array $supportedTimezones = [];
protected array $userTimezones = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->detector = new TimezoneDetector($this->config);
$this->converter = new TimezoneConverter($this->config);
$this->validator = new TimezoneValidator($this->config);
$this->database = new TimezoneDatabase($this->config);
$this->defaultTimezone = $this->config['default_timezone'] ?? 'UTC';
$this->currentTimezone = $this->detectCurrentTimezone();
$this->supportedTimezones = $this->config['supported_timezones'] ?? $this->getAllTimezones();
}
/**
* Get current timezone.
*/
public function getCurrentTimezone(): string
{
return $this->currentTimezone;
}
/**
* Set current timezone.
*/
public function setCurrentTimezone(string $timezone): bool
{
if (!$this->isValidTimezone($timezone)) {
return false;
}
$this->currentTimezone = $timezone;
// Set PHP default timezone
date_default_timezone_set($timezone);
return true;
}
/**
* Get default timezone.
*/
public function getDefaultTimezone(): string
{
return $this->defaultTimezone;
}
/**
* Set default timezone.
*/
public function setDefaultTimezone(string $timezone): bool
{
if (!$this->isValidTimezone($timezone)) {
return false;
}
$this->defaultTimezone = $timezone;
return true;
}
/**
* Detect current timezone.
*/
protected function detectCurrentTimezone(): string
{
// Check if explicitly set in config
if (isset($this->config['current_timezone'])) {
return $this->config['current_timezone'];
}
// Check user preference
if ($this->config['allow_user_override'] && isset($_SESSION['timezone'])) {
$userTimezone = $_SESSION['timezone'];
if ($this->isValidTimezone($userTimezone)) {
return $userTimezone;
}
}
// Check cookie
if ($this->config['allow_user_override'] && isset($_COOKIE['timezone'])) {
$cookieTimezone = $_COOKIE['timezone'];
if ($this->isValidTimezone($cookieTimezone)) {
return $cookieTimezone;
}
}
// Auto-detect from various sources
$detected = $this->detector->detect();
if ($detected && $this->isValidTimezone($detected)) {
return $detected;
}
// Fallback to default
return $this->defaultTimezone;
}
/**
* Convert datetime from one timezone to another.
*/
public function convert(\DateTimeInterface $datetime, string $toTimezone, string $fromTimezone = null): \DateTime
{
$fromTimezone = $fromTimezone ?? $this->currentTimezone;
return $this->converter->convert($datetime, $fromTimezone, $toTimezone);
}
/**
* Convert timestamp to timezone.
*/
public function convertTimestamp(int $timestamp, string $toTimezone, string $fromTimezone = null): \DateTime
{
$fromTimezone = $fromTimezone ?? $this->currentTimezone;
$datetime = new \DateTime("@{$timestamp}");
return $this->convert($datetime, $toTimezone, $fromTimezone);
}
/**
* Convert string datetime to timezone.
*/
public function convertString(string $datetime, string $toTimezone, string $fromTimezone = null, string $format = null): \DateTime
{
$fromTimezone = $fromTimezone ?? $this->currentTimezone;
$format = $format ?? $this->config['datetime_format'] ?? 'Y-m-d H:i:s';
$dt = \DateTime::createFromFormat($format, $datetime, new \DateTimeZone($fromTimezone));
if (!$dt) {
throw new \InvalidArgumentException("Invalid datetime format: {$datetime}");
}
return $this->convert($dt, $toTimezone, $fromTimezone);
}
/**
* Format datetime in specific timezone.
*/
public function format(\DateTimeInterface $datetime, string $format = null, string $timezone = null): string
{
$timezone = $timezone ?? $this->currentTimezone;
$format = $format ?? $this->config['datetime_format'] ?? 'Y-m-d H:i:s';
if ($datetime->getTimezone()->getName() !== $timezone) {
$datetime = $this->convert($datetime, $timezone);
}
return $datetime->format($format);
}
/**
* Format current time in specific timezone.
*/
public function formatNow(string $format = null, string $timezone = null): string
{
$now = new \DateTime();
return $this->format($now, $format, $timezone);
}
/**
* Get timezone offset.
*/
public function getOffset(string $timezone = null): int
{
$timezone = $timezone ?? $this->currentTimezone;
$dt = new \DateTime('now', new \DateTimeZone($timezone));
return $dt->getOffset();
}
/**
* Get timezone offset in hours.
*/
public function getOffsetHours(string $timezone = null): float
{
return $this->getOffset($timezone) / 3600;
}
/**
* Get timezone abbreviation.
*/
public function getAbbreviation(string $timezone = null): string
{
$timezone = $timezone ?? $this->currentTimezone;
$dt = new \DateTime('now', new \DateTimeZone($timezone));
return $dt->format('T');
}
/**
* Check if timezone is valid.
*/
public function isValidTimezone(string $timezone): bool
{
return in_array($timezone, $this->getAllTimezones());
}
/**
* Get all available timezones.
*/
public function getAllTimezones(): array
{
return \DateTimeZone::listIdentifiers();
}
/**
* Get supported timezones.
*/
public function getSupportedTimezones(): array
{
return $this->supportedTimezones;
}
/**
* Set supported timezones.
*/
public function setSupportedTimezones(array $timezones): void
{
$this->supportedTimezones = array_intersect($timezones, $this->getAllTimezones());
}
/**
* Add supported timezone.
*/
public function addSupportedTimezone(string $timezone): bool
{
if (!$this->isValidTimezone($timezone)) {
return false;
}
if (!in_array($timezone, $this->supportedTimezones)) {
$this->supportedTimezones[] = $timezone;
}
return true;
}
/**
* Remove supported timezone.
*/
public function removeSupportedTimezone(string $timezone): bool
{
$key = array_search($timezone, $this->supportedTimezones);
if ($key !== false) {
unset($this->supportedTimezones[$key]);
$this->supportedTimezones = array_values($this->supportedTimezones);
return true;
}
return false;
}
/**
* Get timezones by region.
*/
public function getTimezonesByRegion(string $region): array
{
$allTimezones = $this->getAllTimezones();
$regionTimezones = [];
foreach ($allTimezones as $timezone) {
if (str_starts_with($timezone, $region . '/')) {
$regionTimezones[] = $timezone;
}
}
return $regionTimezones;
}
/**
* Get common timezones.
*/
public function getCommonTimezones(): array
{
return [
'UTC' => 'UTC',
'America/New_York' => 'Eastern Time (US & Canada)',
'America/Chicago' => 'Central Time (US & Canada)',
'America/Denver' => 'Mountain Time (US & Canada)',
'America/Los_Angeles' => 'Pacific Time (US & Canada)',
'Europe/London' => 'London',
'Europe/Paris' => 'Paris',
'Europe/Berlin' => 'Berlin',
'Europe/Moscow' => 'Moscow',
'Asia/Shanghai' => 'Shanghai',
'Asia/Tokyo' => 'Tokyo',
'Asia/Hong_Kong' => 'Hong Kong',
'Asia/Singapore' => 'Singapore',
'Asia/Dubai' => 'Dubai',
'Australia/Sydney' => 'Sydney',
'Pacific/Auckland' => 'Auckland'
];
}
/**
* Get timezone info.
*/
public function getTimezoneInfo(string $timezone): array
{
if (!$this->isValidTimezone($timezone)) {
throw new \InvalidArgumentException("Invalid timezone: {$timezone}");
}
$dt = new \DateTime('now', new \DateTimeZone($timezone));
$tz = $dt->getTimezone();
return [
'timezone' => $timezone,
'offset' => $tz->getOffset(new \DateTime()),
'offset_hours' => $tz->getOffset(new \DateTime()) / 3600,
'abbreviation' => $dt->format('T'),
'name' => $tz->getName(),
'location' => $this->getTimezoneLocation($timezone),
'current_time' => $dt->format('Y-m-d H:i:s'),
'is_dst' => $dt->format('I') === '1',
'dst_offset' => $this->getDstOffset($timezone)
];
}
/**
* Get timezone location information.
*/
protected function getTimezoneLocation(string $timezone): array
{
$location = $this->database->getLocation($timezone);
if ($location) {
return $location;
}
// Fallback to parsing timezone name
$parts = explode('/', $timezone);
if (count($parts) >= 2) {
return [
'country' => $parts[0],
'city' => str_replace('_', ' ', $parts[1])
];
}
return [
'country' => 'Unknown',
'city' => $timezone
];
}
/**
* Get DST offset.
*/
protected function getDstOffset(string $timezone): int
{
$tz = new \DateTimeZone($timezone);
$transitions = $tz->getTransitions();
if (empty($transitions)) {
return 0;
}
$current = $transitions[0];
return $current['dst'] ? 3600 : 0;
}
/**
* Check if DST is active in timezone.
*/
public function isDstActive(string $timezone = null): bool
{
$timezone = $timezone ?? $this->currentTimezone;
$dt = new \DateTime('now', new \DateTimeZone($timezone));
return $dt->format('I') === '1';
}
/**
* Get DST transitions for timezone.
*/
public function getDstTransitions(string $timezone, int $year = null): array
{
$year = $year ?? (int) date('Y');
$tz = new \DateTimeZone($timezone);
$transitions = $tz->getTransitions(
strtotime("{$year}-01-01"),
strtotime("{$year}-12-31")
);
$dstTransitions = [];
foreach ($transitions as $transition) {
if ($transition['dst'] !== $transition['isdst']) {
$dstTransitions[] = [
'timestamp' => $transition['ts'],
'datetime' => date('Y-m-d H:i:s', $transition['ts']),
'offset' => $transition['offset'],
'is_dst' => $transition['dst'],
'abbreviation' => $transition['abbr']
];
}
}
return $dstTransitions;
}
/**
* Get timezone offset for specific date.
*/
public function getOffsetForDate(\DateTimeInterface $date, string $timezone): int
{
$tz = new \DateTimeZone($timezone);
$dt = new \DateTime($date->format('Y-m-d H:i:s'), $tz);
return $dt->getOffset();
}
/**
* Convert between timezones with DST awareness.
*/
public function convertWithDst(\DateTimeInterface $datetime, string $toTimezone, string $fromTimezone = null): \DateTime
{
$fromTimezone = $fromTimezone ?? $this->currentTimezone;
// Create DateTime with source timezone
$sourceDt = new \DateTime($datetime->format('Y-m-d H:i:s'), new \DateTimeZone($fromTimezone));
// Convert to target timezone
$targetDt = $sourceDt->setTimezone(new \DateTimeZone($toTimezone));
return $targetDt;
}
/**
* Get timezone difference between two timezones.
*/
public function getTimezoneDifference(string $timezone1, string $timezone2): array
{
$offset1 = $this->getOffset($timezone1);
$offset2 = $this->getOffset($timezone2);
$difference = $offset2 - $offset1;
return [
'timezone1' => $timezone1,
'timezone2' => $timezone2,
'offset1' => $offset1,
'offset2' => $offset2,
'difference' => $difference,
'difference_hours' => $difference / 3600,
'timezone1_ahead' => $difference > 0
];
}
/**
* Get best timezone for user.
*/
public function getBestTimezoneForUser(string $ipAddress = null, string $languageCode = null): string
{
return $this->detector->detectBestTimezone($ipAddress, $languageCode);
}
/**
* Set user timezone preference.
*/
public function setUserTimezone(string $timezone, string $userId = null): bool
{
if (!$this->isValidTimezone($timezone)) {
return false;
}
if ($userId) {
$this->userTimezones[$userId] = $timezone;
$this->database->saveUserTimezone($userId, $timezone);
} else {
// Store in session
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION['timezone'] = $timezone;
}
// Store in cookie
if ($this->config['set_cookie']) {
setcookie('timezone', $timezone, [
'expires' => time() + (86400 * 30), // 30 days
'path' => $this->config['cookie_path'] ?? '/',
'domain' => $this->config['cookie_domain'] ?? '',
'secure' => $this->config['cookie_secure'] ?? false,
'httponly' => $this->config['cookie_httponly'] ?? true,
'samesite' => $this->config['cookie_samesite'] ?? 'Lax'
]);
}
}
return true;
}
/**
* Get user timezone preference.
*/
public function getUserTimezone(string $userId = null): ?string
{
if ($userId) {
return $this->userTimezones[$userId] ?? $this->database->getUserTimezone($userId);
}
// Check session
if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['timezone'])) {
return $_SESSION['timezone'];
}
// Check cookie
if (isset($_COOKIE['timezone'])) {
return $_COOKIE['timezone'];
}
return null;
}
/**
* Get timezone selector HTML.
*/
public function getSelectorHtml(array $options = []): string
{
$currentTimezone = $this->getCurrentTimezone();
$timezones = $options['timezones'] ?? $this->getCommonTimezones();
$defaultOptions = [
'name' => 'timezone',
'id' => 'timezone-selector',
'class' => 'timezone-selector',
'selected' => $currentTimezone,
'show_offset' => true,
'show_current_time' => false,
'group_by_region' => false
];
$options = array_merge($defaultOptions, $options);
$html = '<select name="' . $options['name'] . '" id="' . $options['id'] . '" class="' . $options['class'] . '">';
if ($options['group_by_region']) {
$groupedTimezones = $this->groupTimezonesByRegion($timezones);
foreach ($groupedTimezones as $region => $regionTimezones) {
$html .= '<optgroup label="' . htmlspecialchars($region) . '">';
foreach ($regionTimezones as $timezone => $label) {
$html .= $this->renderTimezoneOption($timezone, $label, $options);
}
$html .= '</optgroup>';
}
} else {
foreach ($timezones as $timezone => $label) {
$html .= $this->renderTimezoneOption($timezone, $label, $options);
}
}
$html .= '</select>';
return $html;
}
/**
* Render timezone option.
*/
protected function renderTimezoneOption(string $timezone, string $label, array $options): string
{
$selected = $timezone === $options['selected'] ? ' selected' : '';
$displayLabel = $label;
if ($options['show_offset']) {
$offset = $this->getOffsetHours($timezone);
$offsetStr = $offset >= 0 ? '+' . $offset : (string) $offset;
$displayLabel .= ' (UTC' . $offsetStr . ')';
}
if ($options['show_current_time']) {
$currentTime = $this->formatNow('H:i', $timezone);
$displayLabel .= ' - ' . $currentTime;
}
return '<option value="' . htmlspecialchars($timezone) . '"' . $selected . '>' . htmlspecialchars($displayLabel) . '</option>';
}
/**
* Group timezones by region.
*/
protected function groupTimezonesByRegion(array $timezones): array
{
$grouped = [];
foreach ($timezones as $timezone => $label) {
$parts = explode('/', $timezone);
$region = $parts[0];
if (!isset($grouped[$region])) {
$grouped[$region] = [];
}
$grouped[$region][$timezone] = $label;
}
// Sort regions and timezones
ksort($grouped);
foreach ($grouped as $region => &$regionTimezones) {
ksort($regionTimezones);
}
return $grouped;
}
/**
* Validate timezone configuration.
*/
public function validateConfig(): array
{
$errors = [];
// Validate default timezone
if (!$this->isValidTimezone($this->defaultTimezone)) {
$errors[] = "Invalid default timezone: {$this->defaultTimezone}";
}
// Validate current timezone
if (!$this->isValidTimezone($this->currentTimezone)) {
$errors[] = "Invalid current timezone: {$this->currentTimezone}";
}
// Validate supported timezones
foreach ($this->supportedTimezones as $timezone) {
if (!$this->isValidTimezone($timezone)) {
$errors[] = "Invalid supported timezone: {$timezone}";
}
}
return [
'valid' => empty($errors),
'errors' => $errors
];
}
/**
* Get timezone statistics.
*/
public function getStatistics(): array
{
return [
'total_timezones' => count($this->getAllTimezones()),
'supported_timezones' => count($this->supportedTimezones),
'default_timezone' => $this->defaultTimezone,
'current_timezone' => $this->currentTimezone,
'is_dst_active' => $this->isDstActive(),
'user_timezones' => count($this->userTimezones),
'regions' => $this->getRegionCount()
];
}
/**
* Get region count.
*/
protected function getRegionCount(): int
{
$regions = [];
$timezones = $this->getAllTimezones();
foreach ($timezones as $timezone) {
$parts = explode('/', $timezone);
if (isset($parts[0])) {
$regions[$parts[0]] = true;
}
}
return count($regions);
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'default_timezone' => 'UTC',
'allow_user_override' => true,
'set_cookie' => true,
'cookie_path' => '/',
'cookie_domain' => '',
'cookie_secure' => false,
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'datetime_format' => 'Y-m-d H:i:s',
'date_format' => 'Y-m-d',
'time_format' => 'H:i:s',
'auto_detect' => true,
'cache_enabled' => true,
'cache_ttl' => 3600
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
// Update dependent values
if (isset($config['default_timezone'])) {
$this->defaultTimezone = $config['default_timezone'];
}
if (isset($config['supported_timezones'])) {
$this->setSupportedTimezones($config['supported_timezones']);
}
}
/**
* Get detector.
*/
public function getDetector(): TimezoneDetector
{
return $this->detector;
}
/**
* Get converter.
*/
public function getConverter(): TimezoneConverter
{
return $this->converter;
}
/**
* Get validator.
*/
public function getValidator(): TimezoneValidator
{
return $this->validator;
}
/**
* Get database.
*/
public function getDatabase(): TimezoneDatabase
{
return $this->database;
}
/**
* Create timezone manager instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for web application.
*/
public static function forWeb(): self
{
return new self([
'allow_user_override' => true,
'set_cookie' => true,
'auto_detect' => true
]);
}
/**
* Create for API.
*/
public static function forApi(): self
{
return new self([
'allow_user_override' => false,
'set_cookie' => false,
'auto_detect' => false
]);
}
/**
* Create for CLI.
*/
public static function forCli(): self
{
return new self([
'allow_user_override' => false,
'set_cookie' => false,
'auto_detect' => false
]);
}
}

View File

@@ -0,0 +1,941 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Tools\Detector;
use Fendx\I18n\Tools\Detector\Analyzer\CodeAnalyzer;
use Fendx\I18n\Tools\Detector\Analyzer\TemplateAnalyzer;
use Fendx\I18n\Tools\Detector\Comparator\TranslationComparator;
class MissingTranslationDetector
{
protected CodeAnalyzer $codeAnalyzer;
protected TemplateAnalyzer $templateAnalyzer;
protected TranslationComparator $comparator;
protected array $config = [];
protected array $usedKeys = [];
protected array $availableKeys = [];
protected array $missingKeys = [];
protected array $unusedKeys = [];
protected array $analysisResults = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->codeAnalyzer = new CodeAnalyzer($this->config);
$this->templateAnalyzer = new TemplateAnalyzer($this->config);
$this->comparator = new TranslationComparator($this->config);
}
/**
* Detect missing translations from project.
*/
public function detectFromProject(string $projectPath, array $options = []): array
{
$this->reset();
$sourcePaths = $options['source_paths'] ?? $this->config['source_paths'];
$translationPaths = $options['translation_paths'] ?? $this->config['translation_paths'];
$languages = $options['languages'] ?? $this->config['languages'];
$referenceLanguage = $options['reference_language'] ?? $this->config['reference_language'];
// Analyze source code to find used translation keys
$this->analyzeSourceCode($sourcePaths);
// Load available translation keys
$this->loadTranslationKeys($translationPaths, $languages);
// Compare used keys with available keys
$this->compareKeys($referenceLanguage);
return $this->getDetectionResults();
}
/**
* Detect missing translations from specific files.
*/
public function detectFromFiles(array $sourceFiles, array $translationFiles, array $options = []): array
{
$this->reset();
$languages = $options['languages'] ?? $this->config['languages'];
$referenceLanguage = $options['reference_language'] ?? $this->config['reference_language'];
// Analyze source files
$this->analyzeSourceFiles($sourceFiles);
// Load translation files
$this->loadTranslationFiles($translationFiles, $languages);
// Compare keys
$this->compareKeys($referenceLanguage);
return $this->getDetectionResults();
}
/**
* Detect missing translations for specific language.
*/
public function detectForLanguage(string $language, string $referenceLanguage = null): array
{
$this->reset();
$referenceLanguage = $referenceLanguage ?? $this->config['reference_language'];
if (!isset($this->availableKeys[$referenceLanguage])) {
throw new \InvalidArgumentException("Reference language '{$referenceLanguage}' not available");
}
if (!isset($this->availableKeys[$language])) {
throw new \InvalidArgumentException("Language '{$language}' not available");
}
$referenceKeys = $this->availableKeys[$referenceLanguage];
$languageKeys = $this->availableKeys[$language];
// Find missing keys
$missingKeys = array_diff($referenceKeys, $languageKeys);
// Find extra keys
$extraKeys = array_diff($languageKeys, $referenceKeys);
// Find empty translations
$emptyKeys = $this->findEmptyKeys($language);
$this->missingKeys[$language] = [
'missing' => $missingKeys,
'extra' => $extraKeys,
'empty' => $emptyKeys,
'total_missing' => count($missingKeys),
'total_extra' => count($extraKeys),
'total_empty' => count($emptyKeys)
];
return $this->getDetectionResults();
}
/**
* Analyze source code.
*/
protected function analyzeSourceCode(array $sourcePaths): void
{
foreach ($sourcePaths as $path) {
if (is_dir($path)) {
$this->analyzeSourceDirectory($path);
} elseif (file_exists($path)) {
$this->analyzeSourceFile($path);
}
}
}
/**
* Analyze source directory.
*/
protected function analyzeSourceDirectory(string $directory): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory)
);
foreach ($iterator as $file) {
if ($file->isFile() && $this->shouldAnalyzeFile($file->getPathname())) {
$this->analyzeSourceFile($file->getPathname());
}
}
}
/**
* Analyze source files.
*/
protected function analyzeSourceFiles(array $files): void
{
foreach ($files as $file) {
if (file_exists($file) && $this->shouldAnalyzeFile($file)) {
$this->analyzeSourceFile($file);
}
}
}
/**
* Analyze single source file.
*/
protected function analyzeSourceFile(string $filepath): void
{
$content = file_get_contents($filepath);
if ($content === false) {
return;
}
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
switch ($extension) {
case 'php':
$keys = $this->codeAnalyzer->analyzePhp($content);
break;
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
$keys = $this->codeAnalyzer->analyzeJs($content);
break;
case 'html':
case 'htm':
$keys = $this->templateAnalyzer->analyzeHtml($content);
break;
case 'twig':
$keys = $this->templateAnalyzer->analyzeTwig($content);
break;
case 'vue':
$keys = $this->codeAnalyzer->analyzeVue($content);
break;
default:
// Try to detect content type
if ($this->isPhpContent($content)) {
$keys = $this->codeAnalyzer->analyzePhp($content);
} elseif ($this->isJsContent($content)) {
$keys = $this->codeAnalyzer->analyzeJs($content);
} elseif ($this->isTemplateContent($content)) {
$keys = $this->templateAnalyzer->analyzeHtml($content);
} else {
$keys = [];
}
break;
}
$this->addUsedKeys($keys, $filepath);
}
/**
* Load translation keys.
*/
protected function loadTranslationKeys(array $translationPaths, array $languages): void
{
foreach ($languages as $language) {
$this->availableKeys[$language] = [];
foreach ($translationPaths as $path) {
$languagePath = $path . '/' . $language;
if (is_dir($languagePath)) {
$this->loadTranslationDirectory($languagePath, $language);
}
}
}
}
/**
* Load translation files.
*/
protected function loadTranslationFiles(array $translationFiles, array $languages): void
{
foreach ($languages as $language) {
$this->availableKeys[$language] = [];
}
foreach ($translationFiles as $filepath) {
$content = $this->loadTranslationFile($filepath);
if ($content) {
$language = $this->detectLanguageFromPath($filepath);
if ($language && in_array($language, $languages)) {
$this->availableKeys[$language] = array_merge(
$this->availableKeys[$language],
$this->flattenTranslationArray($content)
);
}
}
}
}
/**
* Load translation directory.
*/
protected function loadTranslationDirectory(string $directory, string $language): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory)
);
foreach ($iterator as $file) {
if ($file->isFile() && $this->isTranslationFile($file->getPathname())) {
$content = $this->loadTranslationFile($file->getPathname());
if ($content) {
$this->availableKeys[$language] = array_merge(
$this->availableKeys[$language],
$this->flattenTranslationArray($content)
);
}
}
}
}
/**
* Load translation file.
*/
protected function loadTranslationFile(string $filepath): ?array
{
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
switch ($extension) {
case 'php':
return include $filepath;
case 'json':
$content = file_get_contents($filepath);
return json_decode($content, true) ?: [];
case 'yaml':
case 'yml':
return $this->loadYamlFile($filepath);
case 'po':
return $this->loadPoFile($filepath);
default:
return null;
}
}
/**
* Load YAML file.
*/
protected function loadYamlFile(string $filepath): array
{
// Simple YAML parser (in production, use a proper YAML library)
$content = file_get_contents($filepath);
$lines = explode("\n", $content);
$data = [];
$currentPath = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (preg_match('/^(\w+):\s*(.*)$/', $line, $matches)) {
$key = $matches[1];
$value = $matches[2];
if (empty($value)) {
$currentPath[] = $key;
} else {
$path = array_merge($currentPath, [$key]);
$this->setNestedValue($data, $path, trim($value, '"\''));
}
}
}
return $data;
}
/**
* Load PO file.
*/
protected function loadPoFile(string $filepath): array
{
$content = file_get_contents($filepath);
$lines = explode("\n", $content);
$data = [];
$currentMsgid = null;
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'msgid ')) {
$currentMsgid = trim(substr($line, 6), '"');
} elseif (str_starts_with($line, 'msgstr ') && $currentMsgid) {
$msgstr = trim(substr($line, 7), '"');
if ($currentMsgid !== '""') {
$data[$currentMsgid] = $msgstr;
}
$currentMsgid = null;
}
}
return $data;
}
/**
* Flatten translation array.
*/
protected function flattenTranslationArray(array $array, string $prefix = ''): array
{
$flattened = [];
foreach ($array as $key => $value) {
$newKey = $prefix ? $prefix . '.' . $key : $key;
if (is_array($value)) {
$flattened = array_merge($flattened, $this->flattenTranslationArray($value, $newKey));
} else {
$flattened[$newKey] = $value;
}
}
return $flattened;
}
/**
* Set nested value.
*/
protected function setNestedValue(array &$array, array $path, $value): void
{
$current = &$array;
foreach ($path as $key) {
if (!isset($current[$key])) {
$current[$key] = [];
}
$current = &$current[$key];
}
$current = $value;
}
/**
* Detect language from file path.
*/
protected function detectLanguageFromPath(string $filepath): ?string
{
$parts = explode('/', str_replace('\\', '/', $filepath));
foreach ($parts as $part) {
if (preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $part)) {
return $part;
}
}
return null;
}
/**
* Compare used keys with available keys.
*/
protected function compareKeys(string $referenceLanguage): void
{
$usedKeys = array_keys($this->usedKeys);
$referenceKeys = $this->availableKeys[$referenceLanguage] ?? [];
// Find missing translations (used but not available)
$missingKeys = array_diff($usedKeys, $referenceKeys);
// Find unused translations (available but not used)
$unusedKeys = array_diff($referenceKeys, $usedKeys);
// Find keys used but missing in other languages
$languageMissingKeys = [];
foreach ($this->availableKeys as $language => $keys) {
if ($language === $referenceLanguage) {
continue;
}
$languageMissingKeys[$language] = array_diff($usedKeys, $keys);
}
$this->missingKeys = [
'missing_in_reference' => $missingKeys,
'unused_in_reference' => $unusedKeys,
'missing_by_language' => $languageMissingKeys,
'total_missing' => count($missingKeys),
'total_unused' => count($unusedKeys)
];
}
/**
* Find empty keys for language.
*/
protected function findEmptyKeys(string $language): array
{
$emptyKeys = [];
foreach ($this->availableKeys[$language] as $key => $value) {
if (empty($value) || trim((string) $value) === '') {
$emptyKeys[] = $key;
}
}
return $emptyKeys;
}
/**
* Add used keys.
*/
protected function addUsedKeys(array $keys, string $filepath): void
{
foreach ($keys as $key => $info) {
if (!isset($this->usedKeys[$key])) {
$this->usedKeys[$key] = [
'files' => [],
'contexts' => [],
'line_numbers' => []
];
}
$this->usedKeys[$key]['files'][] = $filepath;
if (isset($info['context'])) {
$this->usedKeys[$key]['contexts'][] = $info['context'];
}
if (isset($info['line'])) {
$this->usedKeys[$key]['line_numbers'][] = $filepath . ':' . $info['line'];
}
}
}
/**
* Check if file should be analyzed.
*/
protected function shouldAnalyzeFile(string $filepath): bool
{
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
if (!in_array($extension, $this->config['analyzed_extensions'])) {
return false;
}
foreach ($this->config['exclude_patterns'] as $pattern) {
if (fnmatch($pattern, $filepath)) {
return false;
}
}
return true;
}
/**
* Check if file is translation file.
*/
protected function isTranslationFile(string $filepath): bool
{
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
return in_array($extension, $this->config['translation_extensions']);
}
/**
* Check if content is PHP.
*/
protected function isPhpContent(string $content): bool
{
return str_contains($content, '<?php') ||
preg_match('/\b(?:function|class|interface|trait|namespace|use|require|include)\b/', $content);
}
/**
* Check if content is JavaScript.
*/
protected function isJsContent(string $content): bool
{
return preg_match('/\b(?:function|const|let|var|import|export|class|extends)\b/', $content) ||
str_contains($content, '=>') ||
str_contains($content, 'React');
}
/**
* Check if content is template.
*/
protected function isTemplateContent(string $content): bool
{
return preg_match('/\{\{.*?\}\}|\{%.*?%\}|data-i18n|data-translate/', $content);
}
/**
* Get detection results.
*/
public function getDetectionResults(): array
{
return [
'missing_keys' => $this->missingKeys,
'used_keys' => $this->usedKeys,
'available_keys' => $this->availableKeys,
'statistics' => $this->getStatistics(),
'analysis_results' => $this->analysisResults
];
}
/**
* Get statistics.
*/
public function getStatistics(): array
{
$totalUsed = count($this->usedKeys);
$totalAvailable = array_sum(array_map('count', $this->availableKeys));
$totalMissing = $this->missingKeys['total_missing'] ?? 0;
$totalUnused = $this->missingKeys['total_unused'] ?? 0;
$languageStats = [];
foreach ($this->availableKeys as $language => $keys) {
$missing = count($this->missingKeys['missing_by_language'][$language] ?? []);
$languageStats[$language] = [
'total_keys' => count($keys),
'missing_keys' => $missing,
'completion_rate' => $totalUsed > 0 ? (($totalUsed - $missing) / $totalUsed) * 100 : 0
];
}
return [
'total_used_keys' => $totalUsed,
'total_available_keys' => $totalAvailable,
'total_missing_keys' => $totalMissing,
'total_unused_keys' => $totalUnused,
'completion_rate' => $totalUsed > 0 ? (($totalUsed - $totalMissing) / $totalUsed) * 100 : 0,
'language_statistics' => $languageStats,
'most_used_keys' => $this->getMostUsedKeys(10),
'least_used_keys' => $this->getLeastUsedKeys(10)
];
}
/**
* Get most used keys.
*/
protected function getMostUsedKeys(int $limit = 10): array
{
$keys = [];
foreach ($this->usedKeys as $key => $info) {
$keys[$key] = count($info['files']);
}
arsort($keys);
return array_slice($keys, 0, $limit, true);
}
/**
* Get least used keys.
*/
protected function getLeastUsedKeys(int $limit = 10): array
{
$keys = [];
foreach ($this->usedKeys as $key => $info) {
$keys[$key] = count($info['files']);
}
asort($keys);
return array_slice($keys, 0, $limit, true);
}
/**
* Generate missing translation report.
*/
public function generateReport(string $format = 'text'): string
{
$results = $this->getDetectionResults();
switch ($format) {
case 'text':
return $this->generateTextReport($results);
case 'html':
return $this->generateHtmlReport($results);
case 'json':
return json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
case 'csv':
return $this->generateCsvReport($results);
default:
throw new \InvalidArgumentException("Unsupported report format: {$format}");
}
}
/**
* Generate text report.
*/
protected function generateTextReport(array $results): string
{
$report = "Missing Translation Detection Report\n";
$report .= "====================================\n\n";
$stats = $results['statistics'];
$missing = $results['missing_keys'];
$report .= "Statistics:\n";
$report .= "-----------\n";
$report .= "Total used keys: {$stats['total_used_keys']}\n";
$report .= "Total available keys: {$stats['total_available_keys']}\n";
$report .= "Total missing keys: {$stats['total_missing_keys']}\n";
$report .= "Total unused keys: {$stats['total_unused_keys']}\n";
$report .= "Overall completion rate: " . number_format($stats['completion_rate'], 2) . "%\n\n";
if (!empty($missing['missing_in_reference'])) {
$report .= "Missing in Reference Language:\n";
$report .= "------------------------------\n";
foreach ($missing['missing_in_reference'] as $key) {
$report .= "- {$key}\n";
}
$report .= "\n";
}
if (!empty($missing['unused_in_reference'])) {
$report .= "Unused in Reference Language:\n";
$report .= "------------------------------\n";
foreach ($missing['unused_in_reference'] as $key) {
$report .= "- {$key}\n";
}
$report .= "\n";
}
foreach ($missing['missing_by_language'] as $language => $keys) {
if (!empty($keys)) {
$report .= "Missing in {$language}:\n";
$report .= "-------------------\n";
foreach ($keys as $key) {
$report .= "- {$key}\n";
}
$report .= "\n";
}
}
return $report;
}
/**
* Generate HTML report.
*/
protected function generateHtmlReport(array $results): string
{
$stats = $results['statistics'];
$missing = $results['missing_keys'];
$html = "<!DOCTYPE html>\n<html>\n<head>\n";
$html .= "<title>Missing Translation Detection Report</title>\n";
$html .= "<style>\n";
$html .= "body { font-family: Arial, sans-serif; margin: 20px; }\n";
$html .= ".stats { background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; }\n";
$html .= ".section { margin-bottom: 30px; }\n";
$html .= ".section h3 { color: #333; border-bottom: 2px solid #007bff; padding-bottom: 5px; }\n";
$html .= ".key-list { list-style-type: none; padding: 0; }\n";
$html .= ".key-list li { background-color: #f8f9fa; margin: 5px 0; padding: 8px; border-radius: 3px; }\n";
$html .= ".missing { background-color: #f8d7da; }\n";
$html .= ".unused { background-color: #fff3cd; }\n";
$html .= "table { width: 100%; border-collapse: collapse; margin-top: 10px; }\n";
$html .= "th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n";
$html .= "th { background-color: #f2f2f2; }\n";
$html .= "</style>\n</head>\n<body>\n";
$html .= "<h1>Missing Translation Detection Report</h1>\n";
$html .= "<div class=\"stats\">\n";
$html .= "<h2>Statistics</h2>\n";
$html .= "<p><strong>Total used keys:</strong> {$stats['total_used_keys']}</p>\n";
$html .= "<p><strong>Total available keys:</strong> {$stats['total_available_keys']}</p>\n";
$html .= "<p><strong>Total missing keys:</strong> {$stats['total_missing_keys']}</p>\n";
$html .= "<p><strong>Total unused keys:</strong> {$stats['total_unused_keys']}</p>\n";
$html .= "<p><strong>Overall completion rate:</strong> " . number_format($stats['completion_rate'], 2) . "%</p>\n";
$html .= "</div>\n";
if (!empty($missing['missing_in_reference'])) {
$html .= "<div class=\"section\">\n";
$html .= "<h3>Missing in Reference Language</h3>\n";
$html .= "<ul class=\"key-list\">\n";
foreach ($missing['missing_in_reference'] as $key) {
$html .= "<li class=\"missing\">" . htmlspecialchars($key) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
}
if (!empty($missing['unused_in_reference'])) {
$html .= "<div class=\"section\">\n";
$html .= "<h3>Unused in Reference Language</h3>\n";
$html .= "<ul class=\"key-list\">\n";
foreach ($missing['unused_in_reference'] as $key) {
$html .= "<li class=\"unused\">" . htmlspecialchars($key) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
}
if (!empty($stats['language_statistics'])) {
$html .= "<div class=\"section\">\n";
$html .= "<h3>Language Statistics</h3>\n";
$html .= "<table>\n";
$html .= "<tr><th>Language</th><th>Total Keys</th><th>Missing Keys</th><th>Completion Rate</th></tr>\n";
foreach ($stats['language_statistics'] as $language => $langStats) {
$html .= "<tr>\n";
$html .= "<td>{$language}</td>\n";
$html .= "<td>{$langStats['total_keys']}</td>\n";
$html .= "<td>{$langStats['missing_keys']}</td>\n";
$html .= "<td>" . number_format($langStats['completion_rate'], 2) . "%</td>\n";
$html .= "</tr>\n";
}
$html .= "</table>\n</div>\n";
}
$html .= "</body>\n</html>";
return $html;
}
/**
* Generate CSV report.
*/
protected function generateCsvReport(array $results): string
{
$csv = "Language,Total Keys,Missing Keys,Completion Rate\n";
foreach ($results['statistics']['language_statistics'] as $language => $stats) {
$csv .= "{$language},{$stats['total_keys']},{$stats['missing_keys']}," . number_format($stats['completion_rate'], 2) . "%\n";
}
return $csv;
}
/**
* Generate missing translation template.
*/
public function generateTemplate(string $language, string $format = 'php'): string
{
$missingKeys = $this->missingKeys['missing_by_language'][$language] ?? [];
if (empty($missingKeys)) {
return '';
}
$template = [];
foreach ($missingKeys as $key) {
$template[$key] = $this->generatePlaceholder($key, $language);
}
switch ($format) {
case 'php':
return '<?php return ' . var_export($template, true) . ';';
case 'json':
return json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
case 'yaml':
return $this->arrayToYaml($template);
default:
throw new \InvalidArgumentException("Unsupported template format: {$format}");
}
}
/**
* Generate placeholder for missing key.
*/
protected function generatePlaceholder(string $key, string $language): string
{
$parts = explode('.', $key);
$lastPart = end($parts);
// Convert to human readable format
$placeholder = str_replace(['_', '-'], ' ', $lastPart);
$placeholder = ucwords($placeholder);
return "[MISSING: {$placeholder}]";
}
/**
* Convert array to YAML.
*/
protected function arrayToYaml(array $array, int $depth = 0): string
{
$yaml = '';
$indent = str_repeat(' ', $depth);
foreach ($array as $key => $value) {
if (is_array($value)) {
$yaml .= "{$indent}{$key}:\n";
$yaml .= $this->arrayToYaml($value, $depth + 1);
} else {
$escapedValue = str_replace(["\n", "\r"], ['\\n', '\\r'], (string) $value);
$yaml .= "{$indent}{$key}: \"{$escapedValue}\"\n";
}
}
return $yaml;
}
/**
* Reset detector state.
*/
protected function reset(): void
{
$this->usedKeys = [];
$this->availableKeys = [];
$this->missingKeys = [];
$this->unusedKeys = [];
$this->analysisResults = [];
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'source_paths' => ['src', 'app', 'resources/views'],
'translation_paths' => ['resources/lang', 'lang'],
'languages' => ['en', 'es', 'fr', 'de', 'zh-CN', 'ja'],
'reference_language' => 'en',
'analyzed_extensions' => ['php', 'js', 'jsx', 'ts', 'tsx', 'html', 'htm', 'twig', 'vue'],
'translation_extensions' => ['php', 'json', 'yaml', 'yml', 'po'],
'exclude_patterns' => [
'vendor/*',
'node_modules/*',
'.git/*',
'storage/*',
'bootstrap/*',
'config/*',
'routes/*',
'tests/*',
'*.min.js',
'*.min.css'
]
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create detector instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for Laravel project.
*/
public static function forLaravel(): self
{
return new self([
'source_paths' => ['app', 'resources/views', 'routes'],
'translation_paths' => ['resources/lang', 'lang'],
'languages' => ['en', 'es', 'fr', 'de', 'pt', 'it', 'ru', 'zh-CN', 'ja', 'ko', 'ar'],
'reference_language' => 'en'
]);
}
/**
* Create for Symfony project.
*/
public static function forSymfony(): self
{
return new self([
'source_paths' => ['src', 'templates'],
'translation_paths' => ['translations'],
'languages' => ['en', 'fr', 'de', 'es', 'it', 'pt', 'ru', 'zh-CN', 'ja'],
'reference_language' => 'en'
]);
}
/**
* Create for Vue.js project.
*/
public static function forVue(): self
{
return new self([
'source_paths' => ['src', 'components'],
'translation_paths' => ['src/locales', 'locales'],
'languages' => ['en', 'es', 'fr', 'de', 'pt', 'it', 'ru', 'zh-CN', 'ja', 'ko'],
'reference_language' => 'en'
]);
}
}

View File

@@ -0,0 +1,725 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Tools\Extractor;
use Fendx\I18n\Tools\Extractor\Parser\PhpParser;
use Fendx\I18n\Tools\Extractor\Parser\JsParser;
use Fendx\I18n\Tools\Extractor\Parser\HtmlParser;
use Fendx\I18n\Tools\Extractor\Parser\TwigParser;
use Fendx\I18n\Tools\Extractor\Parser\VueParser;
class TranslationKeyExtractor
{
protected PhpParser $phpParser;
protected JsParser $jsParser;
protected HtmlParser $htmlParser;
protected TwigParser $twigParser;
protected VueParser $vueParser;
protected array $config = [];
protected array $extractedKeys = [];
protected array $fileScanned = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->phpParser = new PhpParser($this->config);
$this->jsParser = new JsParser($this->config);
$this->htmlParser = new HtmlParser($this->config);
$this->twigParser = new TwigParser($this->config);
$this->vueParser = new VueParser($this->config);
}
/**
* Extract translation keys from directory.
*/
public function extractFromDirectory(string $directory, array $options = []): array
{
$this->extractedKeys = [];
$this->fileScanned = [];
$recursive = $options['recursive'] ?? true;
$patterns = $options['patterns'] ?? $this->config['file_patterns'];
$excludePatterns = $options['exclude_patterns'] ?? $this->config['exclude_patterns'];
$this->scanDirectory($directory, $recursive, $patterns, $excludePatterns);
return $this->getExtractedKeys();
}
/**
* Extract translation keys from file.
*/
public function extractFromFile(string $filepath): array
{
$this->extractedKeys = [];
$this->fileScanned = [];
if (!file_exists($filepath)) {
throw new \InvalidArgumentException("File not found: {$filepath}");
}
$this->scanFile($filepath);
return $this->getExtractedKeys();
}
/**
* Extract translation keys from string content.
*/
public function extractFromString(string $content, string $filename = 'unknown'): array
{
$this->extractedKeys = [];
$this->fileScanned = [];
$this->parseContent($content, $filename);
return $this->getExtractedKeys();
}
/**
* Scan directory for files.
*/
protected function scanDirectory(string $directory, bool $recursive, array $patterns, array $excludePatterns): void
{
$iterator = new \RecursiveIteratorIterator(
$recursive ? new \RecursiveDirectoryIterator($directory) : new \DirectoryIterator($directory),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) {
continue;
}
$filepath = $file->getPathname();
// Check exclude patterns
if ($this->matchesExcludePatterns($filepath, $excludePatterns)) {
continue;
}
// Check include patterns
if ($this->matchesPatterns($filepath, $patterns)) {
$this->scanFile($filepath);
}
}
}
/**
* Scan single file.
*/
protected function scanFile(string $filepath): void
{
if (in_array($filepath, $this->fileScanned)) {
return;
}
$content = file_get_contents($filepath);
if ($content === false) {
return;
}
$this->fileScanned[] = $filepath;
$this->parseContent($content, $filepath);
}
/**
* Parse content based on file type.
*/
protected function parseContent(string $content, string $filename): void
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
switch ($extension) {
case 'php':
$this->extractFromPhp($content, $filename);
break;
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
$this->extractFromJs($content, $filename);
break;
case 'html':
case 'htm':
$this->extractFromHtml($content, $filename);
break;
case 'twig':
$this->extractFromTwig($content, $filename);
break;
case 'vue':
$this->extractFromVue($content, $filename);
break;
default:
// Try to detect content type
if ($this->isPhpContent($content)) {
$this->extractFromPhp($content, $filename);
} elseif ($this->isJsContent($content)) {
$this->extractFromJs($content, $filename);
} elseif ($this->isHtmlContent($content)) {
$this->extractFromHtml($content, $filename);
} elseif ($this->isTwigContent($content)) {
$this->extractFromTwig($content, $filename);
}
break;
}
}
/**
* Extract from PHP content.
*/
protected function extractFromPhp(string $content, string $filename): void
{
$keys = $this->phpParser->extract($content);
$this->addExtractedKeys($keys, $filename, 'php');
}
/**
* Extract from JavaScript content.
*/
protected function extractFromJs(string $content, string $filename): void
{
$keys = $this->jsParser->extract($content);
$this->addExtractedKeys($keys, $filename, 'js');
}
/**
* Extract from HTML content.
*/
protected function extractFromHtml(string $content, string $filename): void
{
$keys = $this->htmlParser->extract($content);
$this->addExtractedKeys($keys, $filename, 'html');
}
/**
* Extract from Twig content.
*/
protected function extractFromTwig(string $content, string $filename): void
{
$keys = $this->twigParser->extract($content);
$this->addExtractedKeys($keys, $filename, 'twig');
}
/**
* Extract from Vue content.
*/
protected function extractFromVue(string $content, string $filename): void
{
$keys = $this->vueParser->extract($content);
$this->addExtractedKeys($keys, $filename, 'vue');
}
/**
* Add extracted keys.
*/
protected function addExtractedKeys(array $keys, string $filename, string $type): void
{
foreach ($keys as $key => $info) {
if (!isset($this->extractedKeys[$key])) {
$this->extractedKeys[$key] = [
'key' => $key,
'files' => [],
'contexts' => [],
'parameters' => [],
'types' => [],
'line_numbers' => []
];
}
$this->extractedKeys[$key]['files'][] = $filename;
$this->extractedKeys[$key]['types'][] = $type;
if (isset($info['context'])) {
$this->extractedKeys[$key]['contexts'][] = $info['context'];
}
if (isset($info['parameters'])) {
$this->extractedKeys[$key]['parameters'] = array_merge(
$this->extractedKeys[$key]['parameters'],
$info['parameters']
);
}
if (isset($info['line'])) {
$this->extractedKeys[$key]['line_numbers'][] = $filename . ':' . $info['line'];
}
}
}
/**
* Get extracted keys.
*/
public function getExtractedKeys(): array
{
// Remove duplicates and sort
$result = [];
foreach ($this->extractedKeys as $key => $info) {
$result[$key] = [
'key' => $key,
'files' => array_unique($info['files']),
'contexts' => array_unique($info['contexts']),
'parameters' => array_unique($info['parameters']),
'types' => array_unique($info['types']),
'line_numbers' => array_unique($info['line_numbers']),
'usage_count' => count($info['files'])
];
}
ksort($result);
return $result;
}
/**
* Get keys by file.
*/
public function getKeysByFile(): array
{
$keysByFile = [];
foreach ($this->extractedKeys as $key => $info) {
foreach ($info['files'] as $file) {
if (!isset($keysByFile[$file])) {
$keysByFile[$file] = [];
}
$keysByFile[$file][] = $key;
}
}
return $keysByFile;
}
/**
* Get keys by type.
*/
public function getKeysByType(): array
{
$keysByType = [];
foreach ($this->extractedKeys as $key => $info) {
foreach ($info['types'] as $type) {
if (!isset($keysByType[$type])) {
$keysByType[$type] = [];
}
$keysByType[$type][] = $key;
}
}
return $keysByType;
}
/**
* Get unused keys (keys not found in any file).
*/
public function getUnusedKeys(array $existingKeys): array
{
$extractedKeySet = array_keys($this->extractedKeys);
$unusedKeys = array_diff($existingKeys, $extractedKeySet);
return array_values($unusedKeys);
}
/**
* Get missing keys (keys found but not in existing translations).
*/
public function getMissingKeys(array $existingKeys): array
{
$extractedKeySet = array_keys($this->extractedKeys);
$missingKeys = array_diff($extractedKeySet, $existingKeys);
return array_values($missingKeys);
}
/**
* Get keys with parameters.
*/
public function getKeysWithParameters(): array
{
$keysWithParams = [];
foreach ($this->extractedKeys as $key => $info) {
if (!empty($info['parameters'])) {
$keysWithParams[$key] = array_unique($info['parameters']);
}
}
return $keysWithParams;
}
/**
* Get duplicate keys (keys found in multiple contexts).
*/
public function getDuplicateKeys(): array
{
$duplicates = [];
foreach ($this->extractedKeys as $key => $info) {
if (count($info['contexts']) > 1) {
$duplicates[$key] = [
'contexts' => array_unique($info['contexts']),
'files' => array_unique($info['files'])
];
}
}
return $duplicates;
}
/**
* Generate translation template.
*/
public function generateTemplate(array $existingKeys = null, string $language = 'en'): array
{
$template = [];
$extractedKeys = array_keys($this->extractedKeys);
if ($existingKeys) {
// Merge with existing keys
$allKeys = array_unique(array_merge($extractedKeys, $existingKeys));
} else {
$allKeys = $extractedKeys;
}
foreach ($allKeys as $key) {
$template[$key] = $this->generatePlaceholder($key, $language);
}
return $template;
}
/**
* Generate placeholder for translation key.
*/
protected function generatePlaceholder(string $key, string $language): string
{
$parts = explode('.', $key);
$lastPart = end($parts);
// Convert to human readable format
$placeholder = str_replace(['_', '-'], ' ', $lastPart);
$placeholder = ucwords($placeholder);
// Add language-specific prefix/suffix if needed
switch ($language) {
case 'zh-CN':
case 'zh-TW':
return $placeholder; // Chinese doesn't need articles
case 'ja':
return $placeholder; // Japanese doesn't need articles
case 'ko':
return $placeholder; // Korean doesn't need articles
case 'fr':
return "La {$placeholder}"; // French article
case 'de':
return "Der {$placeholder}"; // German article
case 'es':
return "El {$placeholder}"; // Spanish article
case 'it':
return "Il {$placeholder}"; // Italian article
case 'ru':
return "{$placeholder} (русский)"; // Russian suffix
case 'ar':
return "{$placeholder} (عربي)"; // Arabic suffix
default:
return "The {$placeholder}"; // English article
}
}
/**
* Export extracted keys.
*/
public function export(string $format = 'json'): string
{
$data = [
'extracted_keys' => $this->getExtractedKeys(),
'statistics' => $this->getStatistics(),
'generated_at' => date('Y-m-d H:i:s'),
'config' => $this->config
];
return match ($format) {
'json' => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
'csv' => $this->exportToCsv(),
'xlsx' => $this->exportToXlsx(),
'php' => '<?php return ' . var_export($this->getExtractedKeys(), true) . ';',
default => throw new \InvalidArgumentException("Unsupported export format: {$format}")
};
}
/**
* Export to CSV format.
*/
protected function exportToCsv(): string
{
$csv = "Key,Files,Types,Contexts,Parameters,Usage Count\n";
foreach ($this->getExtractedKeys() as $key => $info) {
$csv .= '"' . $key . '",';
$csv .= '"' . implode('; ', $info['files']) . '",';
$csv .= '"' . implode('; ', $info['types']) . '",';
$csv .= '"' . implode('; ', $info['contexts']) . '",';
$csv .= '"' . implode('; ', $info['parameters']) . '",';
$csv .= $info['usage_count'] . "\n";
}
return $csv;
}
/**
* Export to XLSX format (basic implementation).
*/
protected function exportToXlsx(): string
{
// This would require a proper XLSX library
// For now, return CSV as placeholder
return $this->exportToCsv();
}
/**
* Get extraction statistics.
*/
public function getStatistics(): array
{
$keysByType = $this->getKeysByType();
$keysByFile = $this->getKeysByFile();
return [
'total_keys' => count($this->extractedKeys),
'total_files_scanned' => count($this->fileScanned),
'keys_by_type' => array_map('count', $keysByType),
'keys_by_file' => array_map('count', $keysByFile),
'keys_with_parameters' => count($this->getKeysWithParameters()),
'duplicate_keys' => count($this->getDuplicateKeys()),
'most_used_keys' => $this->getMostUsedKeys(10),
'file_types' => array_unique(array_merge(...array_values($keysByType)))
];
}
/**
* Get most used keys.
*/
protected function getMostUsedKeys(int $limit = 10): array
{
$keys = $this->getExtractedKeys();
uasort($keys, fn($a, $b) => $b['usage_count'] - $a['usage_count']);
return array_slice($keys, 0, $limit, true);
}
/**
* Check if file matches patterns.
*/
protected function matchesPatterns(string $filepath, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (fnmatch($pattern, basename($filepath)) || fnmatch($pattern, $filepath)) {
return true;
}
}
return false;
}
/**
* Check if file matches exclude patterns.
*/
protected function matchesExcludePatterns(string $filepath, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (fnmatch($pattern, basename($filepath)) || fnmatch($pattern, $filepath)) {
return true;
}
}
return false;
}
/**
* Check if content is PHP.
*/
protected function isPhpContent(string $content): bool
{
return str_contains($content, '<?php') ||
preg_match('/\b(?:function|class|interface|trait|namespace|use|require|include)\b/', $content);
}
/**
* Check if content is JavaScript.
*/
protected function isJsContent(string $content): bool
{
return preg_match('/\b(?:function|const|let|var|import|export|class|extends)\b/', $content) ||
str_contains($content, '=>') ||
str_contains($content, 'React');
}
/**
* Check if content is HTML.
*/
protected function isHtmlContent(string $content): bool
{
return preg_match('/<[^>]+>/', $content) &&
(str_contains($content, '<html') || str_contains($content, '<div') || str_contains($content, '<span'));
}
/**
* Check if content is Twig.
*/
protected function isTwigContent(string $content): bool
{
return preg_match('/\{\{.*?\}\}|\{%.*?%\}/', $content);
}
/**
* Add custom parser.
*/
public function addParser(string $extension, callable $parser): void
{
$this->customParsers[$extension] = $parser;
}
/**
* Set custom patterns.
*/
public function setPatterns(array $patterns): void
{
$this->config['file_patterns'] = $patterns;
}
/**
* Set exclude patterns.
*/
public function setExcludePatterns(array $patterns): void
{
$this->config['exclude_patterns'] = $patterns;
}
/**
* Reset extractor state.
*/
public function reset(): void
{
$this->extractedKeys = [];
$this->fileScanned = [];
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'file_patterns' => [
'*.php',
'*.js',
'*.jsx',
'*.ts',
'*.tsx',
'*.html',
'*.htm',
'*.twig',
'*.vue'
],
'exclude_patterns' => [
'vendor/*',
'node_modules/*',
'.git/*',
'storage/*',
'bootstrap/*',
'config/*',
'routes/*',
'tests/*',
'*.min.js',
'*.min.css',
'*.cache'
],
'php_patterns' => [
'/trans\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/__\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/t\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/translate\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/Lang::get\s*\(\s*[\'"]([^\'"]+)[\'"]/'
],
'js_patterns' => [
'/trans\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/t\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/\$t\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/translate\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/i18n\.t\s*\(\s*[\'"]([^\'"]+)[\'"]/'
],
'html_patterns' => [
'/data-i18n\s*=\s*[\'"]([^\'"]+)[\'"]/',
'/data-translate\s*=\s*[\'"]([^\'"]+)[\'"]/',
'/data-trans\s*=\s*[\'"]([^\'"]+)[\'"]/'
],
'twig_patterns' => [
'/\{\{\s*trans\s*\([\'"]([^\'"]+)[\'"]/',
'/\{\{\s*t\s*\([\'"]([^\'"]+)[\'"]/',
'/\{\{\s*__\s*\([\'"]([^\'"]+)[\'"]/'
],
'vue_patterns' => [
'/\$t\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/t\s*\(\s*[\'"]([^\'"]+)[\'"]/',
'/v-t\s*=\s*[\'"]([^\'"]+)[\'"]/'
]
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create extractor instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for PHP project.
*/
public static function forPhp(): self
{
return new self([
'file_patterns' => ['*.php', '*.twig'],
'exclude_patterns' => ['vendor/*', 'node_modules/*', 'storage/*']
]);
}
/**
* Create for JavaScript project.
*/
public static function forJs(): self
{
return new self([
'file_patterns' => ['*.js', '*.jsx', '*.ts', '*.tsx', '*.vue', '*.html'],
'exclude_patterns' => ['node_modules/*', 'dist/*', 'build/*']
]);
}
/**
* Create for full-stack project.
*/
public static function forFullStack(): self
{
return new self([
'file_patterns' => ['*.php', '*.js', '*.jsx', '*.ts', '*.tsx', '*.vue', '*.html', '*.twig'],
'exclude_patterns' => ['vendor/*', 'node_modules/*', 'storage/*', 'dist/*', 'build/*']
]);
}
}

View File

@@ -0,0 +1,846 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Tools\Statistics;
use Fendx\I18n\Tools\Statistics\Analyzer\ProgressAnalyzer;
use Fendx\I18n\Tools\Statistics\Reporter\ProgressReporter;
use Fendx\I18n\Tools\Statistics\Calculator\CompletionCalculator;
class TranslationProgressTracker
{
protected ProgressAnalyzer $analyzer;
protected ProgressReporter $reporter;
protected CompletionCalculator $calculator;
protected array $config = [];
protected array $translationData = [];
protected array $progressData = [];
protected array $history = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->analyzer = new ProgressAnalyzer($this->config);
$this->reporter = new ProgressReporter($this->config);
$this->calculator = new CompletionCalculator($this->config);
}
/**
* Track translation progress for project.
*/
public function trackProject(string $projectPath, array $options = []): array
{
$this->reset();
$translationPaths = $options['translation_paths'] ?? $this->config['translation_paths'];
$languages = $options['languages'] ?? $this->config['languages'];
$referenceLanguage = $options['reference_language'] ?? $this->config['reference_language'];
$groups = $options['groups'] ?? $this->config['groups'];
// Load translation data
$this->loadTranslationData($projectPath, $translationPaths, $languages, $groups);
// Analyze progress
$this->analyzeProgress($referenceLanguage);
// Calculate completion rates
$this->calculateCompletion();
// Generate statistics
$this->generateStatistics();
return $this->getProgressResults();
}
/**
* Track translation progress from data.
*/
public function trackData(array $translationData, array $options = []): array
{
$this->reset();
$referenceLanguage = $options['reference_language'] ?? $this->config['reference_language'];
$this->translationData = $translationData;
// Analyze progress
$this->analyzeProgress($referenceLanguage);
// Calculate completion rates
$this->calculateCompletion();
// Generate statistics
$this->generateStatistics();
return $this->getProgressResults();
}
/**
* Track progress for specific language.
*/
public function trackLanguage(string $language, string $referenceLanguage = null): array
{
$referenceLanguage = $referenceLanguage ?? $this->config['reference_language'];
if (!isset($this->translationData[$referenceLanguage])) {
throw new \InvalidArgumentException("Reference language '{$referenceLanguage}' not found");
}
if (!isset($this->translationData[$language])) {
throw new \InvalidArgumentException("Language '{$language}' not found");
}
$referenceData = $this->translationData[$referenceLanguage];
$languageData = $this->translationData[$language];
$progress = $this->analyzer->analyzeLanguage($languageData, $referenceData);
$completion = $this->calculator->calculateLanguage($progress);
return [
'language' => $language,
'reference_language' => $referenceLanguage,
'progress' => $progress,
'completion' => $completion,
'statistics' => $this->generateLanguageStatistics($progress, $completion)
];
}
/**
* Track progress for specific group.
*/
public function trackGroup(string $group, array $options = []): array
{
$referenceLanguage = $options['reference_language'] ?? $this->config['reference_language'];
$languages = $options['languages'] ?? array_keys($this->translationData);
$groupProgress = [];
foreach ($languages as $language) {
if ($language === $referenceLanguage) {
continue;
}
if (isset($this->translationData[$language][$group])) {
$groupData = $this->translationData[$language][$group];
$referenceData = $this->translationData[$referenceLanguage][$group] ?? [];
$progress = $this->analyzer->analyzeGroup($groupData, $referenceData);
$completion = $this->calculator->calculateGroup($progress);
$groupProgress[$language] = [
'progress' => $progress,
'completion' => $completion
];
}
}
return [
'group' => $group,
'reference_language' => $referenceLanguage,
'languages' => $groupProgress,
'overall_completion' => $this->calculateGroupOverallCompletion($groupProgress)
];
}
/**
* Load translation data.
*/
protected function loadTranslationData(string $projectPath, array $translationPaths, array $languages, array $groups): void
{
foreach ($languages as $language) {
$this->translationData[$language] = [];
foreach ($translationPaths as $path) {
$languagePath = $projectPath . '/' . $path . '/' . $language;
if (is_dir($languagePath)) {
$this->loadLanguageDirectory($languagePath, $language, $groups);
}
}
}
}
/**
* Load language directory.
*/
protected function loadLanguageDirectory(string $directory, string $language, array $groups): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory)
);
foreach ($iterator as $file) {
if ($file->isFile() && $this->isTranslationFile($file->getPathname())) {
$group = $this->getGroupFromPath($file->getPathname(), $directory);
if (empty($groups) || in_array($group, $groups)) {
$content = $this->loadTranslationFile($file->getPathname());
if ($content) {
$this->translationData[$language][$group] = array_merge(
$this->translationData[$language][$group] ?? [],
$content
);
}
}
}
}
}
/**
* Load translation file.
*/
protected function loadTranslationFile(string $filepath): ?array
{
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
switch ($extension) {
case 'php':
return include $filepath;
case 'json':
$content = file_get_contents($filepath);
return json_decode($content, true) ?: [];
case 'yaml':
case 'yml':
return $this->loadYamlFile($filepath);
case 'po':
return $this->loadPoFile($filepath);
default:
return null;
}
}
/**
* Load YAML file.
*/
protected function loadYamlFile(string $filepath): array
{
$content = file_get_contents($filepath);
$lines = explode("\n", $content);
$data = [];
$currentPath = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (preg_match('/^(\w+):\s*(.*)$/', $line, $matches)) {
$key = $matches[1];
$value = $matches[2];
if (empty($value)) {
$currentPath[] = $key;
} else {
$path = array_merge($currentPath, [$key]);
$this->setNestedValue($data, $path, trim($value, '"\''));
}
}
}
return $data;
}
/**
* Load PO file.
*/
protected function loadPoFile(string $filepath): array
{
$content = file_get_contents($filepath);
$lines = explode("\n", $content);
$data = [];
$currentMsgid = null;
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'msgid ')) {
$currentMsgid = trim(substr($line, 6), '"');
} elseif (str_starts_with($line, 'msgstr ') && $currentMsgid) {
$msgstr = trim(substr($line, 7), '"');
if ($currentMsgid !== '""') {
$data[$currentMsgid] = $msgstr;
}
$currentMsgid = null;
}
}
return $data;
}
/**
* Set nested value.
*/
protected function setNestedValue(array &$array, array $path, $value): void
{
$current = &$array;
foreach ($path as $key) {
if (!isset($current[$key])) {
$current[$key] = [];
}
$current = &$current[$key];
}
$current = $value;
}
/**
* Get group from path.
*/
protected function getGroupFromPath(string $filepath, string $baseDirectory): string
{
$relativePath = str_replace($baseDirectory . '/', '', $filepath);
$parts = explode('/', $relativePath);
$filename = array_pop($parts);
$group = pathinfo($filename, PATHINFO_FILENAME);
return empty($parts) ? $group : implode('/', $parts) . '/' . $group;
}
/**
* Check if file is translation file.
*/
protected function isTranslationFile(string $filepath): bool
{
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
return in_array($extension, $this->config['translation_extensions']);
}
/**
* Analyze progress.
*/
protected function analyzeProgress(string $referenceLanguage): void
{
$this->progressData = $this->analyzer->analyze($this->translationData, $referenceLanguage);
}
/**
* Calculate completion.
*/
protected function calculateCompletion(): void
{
$this->progressData['completion'] = $this->calculator->calculate($this->progressData);
}
/**
* Generate statistics.
*/
protected function generateStatistics(): void
{
$this->progressData['statistics'] = $this->generateOverallStatistics();
}
/**
* Generate overall statistics.
*/
protected function generateOverallStatistics(): array
{
$stats = [];
// Language statistics
foreach ($this->progressData['languages'] as $language => $progress) {
$completion = $this->progressData['completion']['languages'][$language] ?? [];
$stats['languages'][$language] = $this->generateLanguageStatistics($progress, $completion);
}
// Group statistics
foreach ($this->progressData['groups'] as $group => $progress) {
$completion = $this->progressData['completion']['groups'][$group] ?? [];
$stats['groups'][$group] = $this->generateGroupStatistics($progress, $completion);
}
// Overall statistics
$stats['overall'] = [
'total_languages' => count($this->translationData),
'total_groups' => count($this->progressData['groups']),
'total_keys' => $this->progressData['total_keys'],
'average_completion' => $this->calculateAverageCompletion(),
'best_language' => $this->findBestLanguage(),
'worst_language' => $this->findWorstLanguage(),
'best_group' => $this->findBestGroup(),
'worst_group' => $this->findWorstGroup(),
'completion_distribution' => $this->getCompletionDistribution()
];
return $stats;
}
/**
* Generate language statistics.
*/
protected function generateLanguageStatistics(array $progress, array $completion): array
{
return [
'total_keys' => $progress['total_keys'] ?? 0,
'translated_keys' => $progress['translated_keys'] ?? 0,
'missing_keys' => $progress['missing_keys'] ?? 0,
'empty_keys' => $progress['empty_keys'] ?? 0,
'completion_rate' => $completion['rate'] ?? 0,
'quality_score' => $completion['quality_score'] ?? 0,
'status' => $this->getLanguageStatus($completion['rate'] ?? 0),
'needs_review' => $progress['needs_review'] ?? 0,
'placeholders_mismatch' => $progress['placeholders_mismatch'] ?? 0
];
}
/**
* Generate group statistics.
*/
protected function generateGroupStatistics(array $progress, array $completion): array
{
return [
'total_keys' => $progress['total_keys'] ?? 0,
'average_completion' => $completion['average_rate'] ?? 0,
'best_language' => $completion['best_language'] ?? null,
'worst_language' => $completion['worst_language'] ?? null,
'completion_range' => [
'min' => $completion['min_rate'] ?? 0,
'max' => $completion['max_rate'] ?? 0
],
'status' => $this->getGroupStatus($completion['average_rate'] ?? 0)
];
}
/**
* Calculate average completion.
*/
protected function calculateAverageCompletion(): float
{
if (empty($this->progressData['completion']['languages'])) {
return 0.0;
}
$total = array_sum(array_column($this->progressData['completion']['languages'], 'rate'));
$count = count($this->progressData['completion']['languages']);
return $total / $count;
}
/**
* Find best language.
*/
protected function findBestLanguage(): ?string
{
$bestRate = 0;
$bestLanguage = null;
foreach ($this->progressData['completion']['languages'] as $language => $completion) {
if ($completion['rate'] > $bestRate) {
$bestRate = $completion['rate'];
$bestLanguage = $language;
}
}
return $bestLanguage;
}
/**
* Find worst language.
*/
protected function findWorstLanguage(): ?string
{
$worstRate = 100;
$worstLanguage = null;
foreach ($this->progressData['completion']['languages'] as $language => $completion) {
if ($completion['rate'] < $worstRate) {
$worstRate = $completion['rate'];
$worstLanguage = $language;
}
}
return $worstLanguage;
}
/**
* Find best group.
*/
protected function findBestGroup(): ?string
{
$bestRate = 0;
$bestGroup = null;
foreach ($this->progressData['completion']['groups'] as $group => $completion) {
if ($completion['average_rate'] > $bestRate) {
$bestRate = $completion['average_rate'];
$bestGroup = $group;
}
}
return $bestGroup;
}
/**
* Find worst group.
*/
protected function findWorstGroup(): ?string
{
$worstRate = 100;
$worstGroup = null;
foreach ($this->progressData['completion']['groups'] as $group => $completion) {
if ($completion['average_rate'] < $worstRate) {
$worstRate = $completion['average_rate'];
$worstGroup = $group;
}
}
return $worstGroup;
}
/**
* Get completion distribution.
*/
protected function getCompletionDistribution(): array
{
$distribution = [
'0-25%' => 0,
'26-50%' => 0,
'51-75%' => 0,
'76-99%' => 0,
'100%' => 0
];
foreach ($this->progressData['completion']['languages'] as $completion) {
$rate = $completion['rate'];
if ($rate == 100) {
$distribution['100%']++;
} elseif ($rate >= 76) {
$distribution['76-99%']++;
} elseif ($rate >= 51) {
$distribution['51-75%']++;
} elseif ($rate >= 26) {
$distribution['26-50%']++;
} else {
$distribution['0-25%']++;
}
}
return $distribution;
}
/**
* Get language status.
*/
protected function getLanguageStatus(float $completionRate): string
{
if ($completionRate == 100) {
return 'complete';
} elseif ($completionRate >= 90) {
return 'nearly_complete';
} elseif ($completionRate >= 75) {
return 'good_progress';
} elseif ($completionRate >= 50) {
return 'in_progress';
} elseif ($completionRate >= 25) {
return 'started';
} else {
return 'not_started';
}
}
/**
* Get group status.
*/
protected function getGroupStatus(float $completionRate): string
{
if ($completionRate == 100) {
return 'complete';
} elseif ($completionRate >= 90) {
return 'nearly_complete';
} elseif ($completionRate >= 75) {
return 'good_progress';
} elseif ($completionRate >= 50) {
return 'in_progress';
} elseif ($completionRate >= 25) {
return 'started';
} else {
return 'not_started';
}
}
/**
* Calculate group overall completion.
*/
protected function calculateGroupOverallCompletion(array $groupProgress): float
{
if (empty($groupProgress)) {
return 0.0;
}
$total = array_sum(array_column($groupProgress, 'completion'));
$count = count($groupProgress);
return $total / $count;
}
/**
* Get progress results.
*/
public function getProgressResults(): array
{
return [
'progress' => $this->progressData,
'translation_data' => $this->translationData,
'history' => $this->history,
'generated_at' => date('Y-m-d H:i:s'),
'config' => $this->config
];
}
/**
* Generate progress report.
*/
public function generateReport(string $format = 'text'): string
{
$results = $this->getProgressResults();
return $this->reporter->generate($results, $format);
}
/**
* Save progress to history.
*/
public function saveToHistory(string $label = null): void
{
$snapshot = [
'timestamp' => time(),
'label' => $label ?? date('Y-m-d H:i:s'),
'progress' => $this->progressData,
'completion' => $this->progressData['completion'] ?? []
];
$this->history[] = $snapshot;
// Keep only last N snapshots
$maxHistory = $this->config['max_history'] ?? 100;
if (count($this->history) > $maxHistory) {
$this->history = array_slice($this->history, -$maxHistory);
}
}
/**
* Get progress history.
*/
public function getHistory(): array
{
return $this->history;
}
/**
* Compare with previous snapshot.
*/
public function compareWithPrevious(): array
{
if (count($this->history) < 2) {
return [];
}
$current = end($this->history);
$previous = $this->history[count($this->history) - 2];
return $this->calculateProgressChange($previous, $current);
}
/**
* Calculate progress change.
*/
protected function calculateProgressChange(array $previous, array $current): array
{
$changes = [];
foreach ($current['completion']['languages'] as $language => $completion) {
$previousCompletion = $previous['completion']['languages'][$language] ?? ['rate' => 0];
$changes['languages'][$language] = [
'rate_change' => $completion['rate'] - $previousCompletion['rate'],
'translated_change' => ($completion['translated'] ?? 0) - ($previousCompletion['translated'] ?? 0),
'missing_change' => ($completion['missing'] ?? 0) - ($previousCompletion['missing'] ?? 0)
];
}
return $changes;
}
/**
* Export progress data.
*/
public function export(string $format = 'json'): string
{
$data = $this->getProgressResults();
switch ($format) {
case 'json':
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
case 'csv':
return $this->exportToCsv();
case 'xlsx':
return $this->exportToXlsx();
default:
throw new \InvalidArgumentException("Unsupported export format: {$format}");
}
}
/**
* Export to CSV.
*/
protected function exportToCsv(): string
{
$csv = "Language,Total Keys,Translated,Missing,Empty,Completion Rate,Status\n";
if (isset($this->progressData['statistics']['languages'])) {
foreach ($this->progressData['statistics']['languages'] as $language => $stats) {
$csv .= "{$language},{$stats['total_keys']},{$stats['translated_keys']},";
$csv .= "{$stats['missing_keys']},{$stats['empty_keys']},";
$csv .= number_format($stats['completion_rate'], 2) . "%,{$stats['status']}\n";
}
}
return $csv;
}
/**
* Export to XLSX (basic implementation).
*/
protected function exportToXlsx(): string
{
// This would require a proper XLSX library
// For now, return CSV as placeholder
return $this->exportToCsv();
}
/**
* Get completion trends.
*/
public function getCompletionTrends(): array
{
if (count($this->history) < 2) {
return [];
}
$trends = [];
foreach ($this->history as $snapshot) {
$trends[] = [
'timestamp' => $snapshot['timestamp'],
'label' => $snapshot['label'],
'overall_completion' => $this->calculateSnapshotOverallCompletion($snapshot)
];
}
return $trends;
}
/**
* Calculate snapshot overall completion.
*/
protected function calculateSnapshotOverallCompletion(array $snapshot): float
{
if (empty($snapshot['completion']['languages'])) {
return 0.0;
}
$total = array_sum(array_column($snapshot['completion']['languages'], 'rate'));
$count = count($snapshot['completion']['languages']);
return $total / $count;
}
/**
* Reset tracker state.
*/
protected function reset(): void
{
$this->translationData = [];
$this->progressData = [];
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'translation_paths' => ['resources/lang', 'lang'],
'languages' => ['en', 'es', 'fr', 'de', 'zh-CN', 'ja', 'ko', 'pt', 'it', 'ru'],
'reference_language' => 'en',
'groups' => [], // Empty means all groups
'translation_extensions' => ['php', 'json', 'yaml', 'yml', 'po'],
'max_history' => 100,
'quality_weights' => [
'completion_rate' => 0.6,
'placeholder_consistency' => 0.2,
'review_status' => 0.2
],
'completion_thresholds' => [
'complete' => 100,
'nearly_complete' => 90,
'good_progress' => 75,
'in_progress' => 50,
'started' => 25
]
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create tracker instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for Laravel project.
*/
public static function forLaravel(): self
{
return new self([
'translation_paths' => ['resources/lang', 'lang'],
'languages' => ['en', 'es', 'fr', 'de', 'pt', 'it', 'ru', 'zh-CN', 'ja', 'ko', 'ar'],
'reference_language' => 'en'
]);
}
/**
* Create for Symfony project.
*/
public static function forSymfony(): self
{
return new self([
'translation_paths' => ['translations'],
'languages' => ['en', 'fr', 'de', 'es', 'it', 'pt', 'ru', 'zh-CN', 'ja'],
'reference_language' => 'en'
]);
}
/**
* Create for Vue.js project.
*/
public static function forVue(): self
{
return new self([
'translation_paths' => ['src/locales', 'locales'],
'languages' => ['en', 'es', 'fr', 'de', 'pt', 'it', 'ru', 'zh-CN', 'ja', 'ko'],
'reference_language' => 'en'
]);
}
}

View File

@@ -0,0 +1,858 @@
<?php
declare(strict_types=1);
namespace Fendx\I18n\Tools\Validator;
use Fendx\I18n\Tools\Validator\Checker\StructureChecker;
use Fendx\I18n\Tools\Validator\Checker\ContentChecker;
use Fendx\I18n\Tools\Validator\Checker\ConsistencyChecker;
use Fendx\I18n\Tools\Validator\Checker\FormatChecker;
class TranslationValidator
{
protected StructureChecker $structureChecker;
protected ContentChecker $contentChecker;
protected ConsistencyChecker $consistencyChecker;
protected FormatChecker $formatChecker;
protected array $config = [];
protected array $validationResults = [];
protected array $errors = [];
protected array $warnings = [];
protected array $suggestions = [];
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->structureChecker = new StructureChecker($this->config);
$this->contentChecker = new ContentChecker($this->config);
$this->consistencyChecker = new ConsistencyChecker($this->config);
$this->formatChecker = new FormatChecker($this->config);
}
/**
* Validate translation files.
*/
public function validateFiles(array $files, array $options = []): array
{
$this->reset();
$strict = $options['strict'] ?? $this->config['strict_mode'];
$checkStructure = $options['check_structure'] ?? true;
$checkContent = $options['check_content'] ?? true;
$checkConsistency = $options['check_consistency'] ?? true;
$checkFormat = $options['check_format'] ?? true;
// Load all translation data
$translationData = $this->loadTranslationFiles($files);
// Structure validation
if ($checkStructure) {
$this->validateStructure($translationData, $strict);
}
// Content validation
if ($checkContent) {
$this->validateContent($translationData, $strict);
}
// Consistency validation
if ($checkConsistency) {
$this->validateConsistency($translationData, $strict);
}
// Format validation
if ($checkFormat) {
$this->validateFormat($translationData, $strict);
}
return $this->getValidationResults();
}
/**
* Validate translation data.
*/
public function validateData(array $translationData, array $options = []): array
{
$this->reset();
$strict = $options['strict'] ?? $this->config['strict_mode'];
$checkStructure = $options['check_structure'] ?? true;
$checkContent = $options['check_content'] ?? true;
$checkConsistency = $options['check_consistency'] ?? true;
$checkFormat = $options['check_format'] ?? true;
// Structure validation
if ($checkStructure) {
$this->validateStructure($translationData, $strict);
}
// Content validation
if ($checkContent) {
$this->validateContent($translationData, $strict);
}
// Consistency validation
if ($checkConsistency) {
$this->validateConsistency($translationData, $strict);
}
// Format validation
if ($checkFormat) {
$this->validateFormat($translationData, $strict);
}
return $this->getValidationResults();
}
/**
* Validate single translation file.
*/
public function validateFile(string $filepath, array $options = []): array
{
if (!file_exists($filepath)) {
throw new \InvalidArgumentException("File not found: {$filepath}");
}
$data = $this->loadTranslationFile($filepath);
return $this->validateData([$filepath => $data], $options);
}
/**
* Validate against reference language.
*/
public function validateAgainstReference(array $translationData, string $referenceLanguage, array $options = []): array
{
$this->reset();
if (!isset($translationData[$referenceLanguage])) {
$this->addError("Reference language '{$referenceLanguage}' not found");
return $this->getValidationResults();
}
$referenceData = $translationData[$referenceLanguage];
foreach ($translationData as $language => $data) {
if ($language === $referenceLanguage) {
continue;
}
$this->validateLanguageAgainstReference($language, $data, $referenceData, $referenceLanguage);
}
return $this->getValidationResults();
}
/**
* Validate language against reference.
*/
protected function validateLanguageAgainstReference(string $language, array $data, array $referenceData, string $referenceLanguage): void
{
// Check for missing keys
$missingKeys = $this->findMissingKeys($referenceData, $data);
foreach ($missingKeys as $key) {
$this->addError("Missing translation key '{$key}' in language '{$language}' (exists in '{$referenceLanguage}')");
}
// Check for extra keys
$extraKeys = $this->findMissingKeys($data, $referenceData);
foreach ($extraKeys as $key) {
$this->addWarning("Extra translation key '{$key}' in language '{$language}' (not found in '{$referenceLanguage}')");
}
// Check for empty translations
$emptyKeys = $this->findEmptyTranslations($data);
foreach ($emptyKeys as $key) {
$this->addError("Empty translation for key '{$key}' in language '{$language}'");
}
// Check for placeholder consistency
$this->validatePlaceholderConsistency($language, $data, $referenceData, $referenceLanguage);
}
/**
* Validate structure.
*/
protected function validateStructure(array $translationData, bool $strict): void
{
$results = $this->structureChecker->check($translationData, $strict);
foreach ($results['errors'] as $error) {
$this->addError($error);
}
foreach ($results['warnings'] as $warning) {
$this->addWarning($warning);
}
foreach ($results['suggestions'] as $suggestion) {
$this->addSuggestion($suggestion);
}
}
/**
* Validate content.
*/
protected function validateContent(array $translationData, bool $strict): void
{
$results = $this->contentChecker->check($translationData, $strict);
foreach ($results['errors'] as $error) {
$this->addError($error);
}
foreach ($results['warnings'] as $warning) {
$this->addWarning($warning);
}
foreach ($results['suggestions'] as $suggestion) {
$this->addSuggestion($suggestion);
}
}
/**
* Validate consistency.
*/
protected function validateConsistency(array $translationData, bool $strict): void
{
$results = $this->consistencyChecker->check($translationData, $strict);
foreach ($results['errors'] as $error) {
$this->addError($error);
}
foreach ($results['warnings'] as $warning) {
$this->addWarning($warning);
}
foreach ($results['suggestions'] as $suggestion) {
$this->addSuggestion($suggestion);
}
}
/**
* Validate format.
*/
protected function validateFormat(array $translationData, bool $strict): void
{
$results = $this->formatChecker->check($translationData, $strict);
foreach ($results['errors'] as $error) {
$this->addError($error);
}
foreach ($results['warnings'] as $warning) {
$this->addWarning($warning);
}
foreach ($results['suggestions'] as $suggestion) {
$this->addSuggestion($suggestion);
}
}
/**
* Load translation files.
*/
protected function loadTranslationFiles(array $files): array
{
$data = [];
foreach ($files as $filepath) {
$data[$filepath] = $this->loadTranslationFile($filepath);
}
return $data;
}
/**
* Load single translation file.
*/
protected function loadTranslationFile(string $filepath): array
{
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
switch ($extension) {
case 'php':
return include $filepath;
case 'json':
$content = file_get_contents($filepath);
return json_decode($content, true) ?: [];
case 'yaml':
case 'yml':
return $this->loadYamlFile($filepath);
case 'po':
return $this->loadPoFile($filepath);
default:
throw new \InvalidArgumentException("Unsupported file format: {$extension}");
}
}
/**
* Load YAML file.
*/
protected function loadYamlFile(string $filepath): array
{
// Simple YAML parser (in production, use a proper YAML library)
$content = file_get_contents($filepath);
$lines = explode("\n", $content);
$data = [];
$currentPath = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
$indent = strlen($line) - strlen(ltrim($line));
$level = $indent / 2;
// Adjust current path based on indentation
$currentPath = array_slice($currentPath, 0, $level);
if (preg_match('/^(\w+):\s*(.*)$/', $line, $matches)) {
$key = $matches[1];
$value = $matches[2];
if (empty($value)) {
// This is a parent key
$currentPath[] = $key;
} else {
// This is a key-value pair
$path = array_merge($currentPath, [$key]);
$this->setNestedValue($data, $path, trim($value, '"\''));
}
}
}
return $data;
}
/**
* Load PO file.
*/
protected function loadPoFile(string $filepath): array
{
$content = file_get_contents($filepath);
$lines = explode("\n", $content);
$data = [];
$currentMsgid = null;
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'msgid ')) {
$currentMsgid = trim(substr($line, 6), '"');
} elseif (str_starts_with($line, 'msgstr ') && $currentMsgid) {
$msgstr = trim(substr($line, 7), '"');
if ($currentMsgid !== '""') {
$data[$currentMsgid] = $msgstr;
}
$currentMsgid = null;
}
}
return $data;
}
/**
* Set nested value in array.
*/
protected function setNestedValue(array &$array, array $path, $value): void
{
$current = &$array;
foreach ($path as $key) {
if (!isset($current[$key])) {
$current[$key] = [];
}
$current = &$current[$key];
}
$current = $value;
}
/**
* Find missing keys.
*/
protected function findMissingKeys(array $reference, array $data): array
{
$missing = [];
foreach ($reference as $key => $value) {
if (is_array($value)) {
if (!isset($data[$key]) || !is_array($data[$key])) {
$missing[] = $key;
} else {
$nestedMissing = $this->findMissingKeys($value, $data[$key]);
foreach ($nestedMissing as $nestedKey) {
$missing[] = $key . '.' . $nestedKey;
}
}
} else {
if (!isset($data[$key])) {
$missing[] = $key;
}
}
}
return $missing;
}
/**
* Find empty translations.
*/
protected function findEmptyTranslations(array $data): array
{
$empty = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$nestedEmpty = $this->findEmptyTranslations($value);
foreach ($nestedEmpty as $nestedKey) {
$empty[] = $key . '.' . $nestedKey;
}
} else {
if (empty($value) || trim($value) === '') {
$empty[] = $key;
}
}
}
return $empty;
}
/**
* Validate placeholder consistency.
*/
protected function validatePlaceholderConsistency(string $language, array $data, array $referenceData, string $referenceLanguage): void
{
foreach ($referenceData as $key => $referenceValue) {
if (!isset($data[$key])) {
continue;
}
$value = $data[$key];
if (!is_string($referenceValue) || !is_string($value)) {
continue;
}
$referencePlaceholders = $this->extractPlaceholders($referenceValue);
$placeholders = $this->extractPlaceholders($value);
// Check for missing placeholders
$missingPlaceholders = array_diff($referencePlaceholders, $placeholders);
foreach ($missingPlaceholders as $placeholder) {
$this->addError("Missing placeholder '{$placeholder}' in key '{$key}' for language '{$language}'");
}
// Check for extra placeholders
$extraPlaceholders = array_diff($placeholders, $referencePlaceholders);
foreach ($extraPlaceholders as $placeholder) {
$this->addWarning("Extra placeholder '{$placeholder}' in key '{$key}' for language '{$language}'");
}
}
}
/**
* Extract placeholders from string.
*/
protected function extractPlaceholders(string $string): array
{
$placeholders = [];
// Extract :placeholder format
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $string, $matches)) {
$placeholders = array_merge($placeholders, $matches[1]);
}
// Extract {placeholder} format
if (preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', $string, $matches)) {
$placeholders = array_merge($placeholders, $matches[1]);
}
// Extract %s, %d format
if (preg_match_all('/%[sd]/', $string, $matches)) {
$placeholders = array_merge($placeholders, $matches[0]);
}
return array_unique($placeholders);
}
/**
* Add error.
*/
protected function addError(string $message): void
{
$this->errors[] = $message;
}
/**
* Add warning.
*/
protected function addWarning(string $message): void
{
$this->warnings[] = $message;
}
/**
* Add suggestion.
*/
protected function addSuggestion(string $message): void
{
$this->suggestions[] = $message;
}
/**
* Reset validation results.
*/
protected function reset(): void
{
$this->validationResults = [];
$this->errors = [];
$this->warnings = [];
$this->suggestions = [];
}
/**
* Get validation results.
*/
public function getValidationResults(): array
{
return [
'valid' => empty($this->errors),
'errors' => $this->errors,
'warnings' => $this->warnings,
'suggestions' => $this->suggestions,
'statistics' => [
'error_count' => count($this->errors),
'warning_count' => count($this->warnings),
'suggestion_count' => count($this->suggestions)
]
];
}
/**
* Get errors.
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Get warnings.
*/
public function getWarnings(): array
{
return $this->warnings;
}
/**
* Get suggestions.
*/
public function getSuggestions(): array
{
return $this->suggestions;
}
/**
* Check if validation passed.
*/
public function isValid(): bool
{
return empty($this->errors);
}
/**
* Generate validation report.
*/
public function generateReport(string $format = 'text'): string
{
$results = $this->getValidationResults();
switch ($format) {
case 'text':
return $this->generateTextReport($results);
case 'html':
return $this->generateHtmlReport($results);
case 'json':
return json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
default:
throw new \InvalidArgumentException("Unsupported report format: {$format}");
}
}
/**
* Generate text report.
*/
protected function generateTextReport(array $results): string
{
$report = "Translation Validation Report\n";
$report .= "==============================\n\n";
$report .= "Status: " . ($results['valid'] ? 'PASSED' : 'FAILED') . "\n";
$report .= "Errors: {$results['statistics']['error_count']}\n";
$report .= "Warnings: {$results['statistics']['warning_count']}\n";
$report .= "Suggestions: {$results['statistics']['suggestion_count']}\n\n";
if (!empty($results['errors'])) {
$report .= "ERRORS:\n";
$report .= "-------\n";
foreach ($results['errors'] as $error) {
$report .= "- {$error}\n";
}
$report .= "\n";
}
if (!empty($results['warnings'])) {
$report .= "WARNINGS:\n";
$report .= "---------\n";
foreach ($results['warnings'] as $warning) {
$report .= "- {$warning}\n";
}
$report .= "\n";
}
if (!empty($results['suggestions'])) {
$report .= "SUGGESTIONS:\n";
$report .= "-----------\n";
foreach ($results['suggestions'] as $suggestion) {
$report .= "- {$suggestion}\n";
}
$report .= "\n";
}
return $report;
}
/**
* Generate HTML report.
*/
protected function generateHtmlReport(array $results): string
{
$status = $results['valid'] ? 'success' : 'danger';
$statusText = $results['valid'] ? 'PASSED' : 'FAILED';
$html = "<!DOCTYPE html>\n<html>\n<head>\n";
$html .= "<title>Translation Validation Report</title>\n";
$html .= "<style>\n";
$html .= "body { font-family: Arial, sans-serif; margin: 20px; }\n";
$html .= ".status { padding: 10px; border-radius: 5px; margin-bottom: 20px; }\n";
$html .= ".success { background-color: #d4edda; color: #155724; }\n";
$html .= ".danger { background-color: #f8d7da; color: #721c24; }\n";
$html .= ".section { margin-bottom: 20px; }\n";
$html .= ".section h3 { margin-bottom: 10px; }\n";
$html .= ".error { color: #721c24; }\n";
$html .= ".warning { color: #856404; }\n";
$html .= ".suggestion { color: #0c5460; }\n";
$html .= "ul { margin: 0; padding-left: 20px; }\n";
$html .= "</style>\n</head>\n<body>\n";
$html .= "<h1>Translation Validation Report</h1>\n";
$html .= "<div class=\"status {$status}\">Status: {$statusText}</div>\n";
$html .= "<div class=\"section\">\n";
$html .= "<h3>Statistics</h3>\n";
$html .= "<ul>\n";
$html .= "<li>Errors: {$results['statistics']['error_count']}</li>\n";
$html .= "<li>Warnings: {$results['statistics']['warning_count']}</li>\n";
$html .= "<li>Suggestions: {$results['statistics']['suggestion_count']}</li>\n";
$html .= "</ul>\n</div>\n";
if (!empty($results['errors'])) {
$html .= "<div class=\"section\">\n";
$html .= "<h3>Errors</h3>\n";
$html .= "<ul class=\"error\">\n";
foreach ($results['errors'] as $error) {
$html .= "<li>" . htmlspecialchars($error) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
}
if (!empty($results['warnings'])) {
$html .= "<div class=\"section\">\n";
$html .= "<h3>Warnings</h3>\n";
$html .= "<ul class=\"warning\">\n";
foreach ($results['warnings'] as $warning) {
$html .= "<li>" . htmlspecialchars($warning) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
}
if (!empty($results['suggestions'])) {
$html .= "<div class=\"section\">\n";
$html .= "<h3>Suggestions</h3>\n";
$html .= "<ul class=\"suggestion\">\n";
foreach ($results['suggestions'] as $suggestion) {
$html .= "<li>" . htmlspecialchars($suggestion) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
}
$html .= "</body>\n</html>";
return $html;
}
/**
* Fix common issues automatically.
*/
public function autoFix(array $translationData): array
{
$fixedData = $translationData;
// Remove empty translations
$fixedData = $this->removeEmptyTranslations($fixedData);
// Fix placeholder format consistency
$fixedData = $this->fixPlaceholderFormat($fixedData);
// Normalize whitespace
$fixedData = $this->normalizeWhitespace($fixedData);
return $fixedData;
}
/**
* Remove empty translations.
*/
protected function removeEmptyTranslations(array $data): array
{
$cleaned = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$nested = $this->removeEmptyTranslations($value);
if (!empty($nested)) {
$cleaned[$key] = $nested;
}
} elseif (is_string($value) && trim($value) !== '') {
$cleaned[$key] = $value;
}
}
return $cleaned;
}
/**
* Fix placeholder format consistency.
*/
protected function fixPlaceholderFormat(array $data): array
{
$fixed = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$fixed[$key] = $this->fixPlaceholderFormat($value);
} elseif (is_string($value)) {
// Convert %s, %d to :placeholder format
$fixed[$key] = preg_replace('/%([sd])/', ':param\1', $value);
} else {
$fixed[$key] = $value;
}
}
return $fixed;
}
/**
* Normalize whitespace.
*/
protected function normalizeWhitespace(array $data): array
{
$normalized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$normalized[$key] = $this->normalizeWhitespace($value);
} elseif (is_string($value)) {
// Normalize line endings and trim whitespace
$normalized[$key] = trim(str_replace(["\r\n", "\r"], "\n", $value));
} else {
$normalized[$key] = $value;
}
}
return $normalized;
}
/**
* Get default configuration.
*/
protected function getDefaultConfig(): array
{
return [
'strict_mode' => false,
'check_structure' => true,
'check_content' => true,
'check_consistency' => true,
'check_format' => true,
'max_key_length' => 100,
'max_value_length' => 1000,
'required_placeholders' => [],
'forbidden_patterns' => [
'/<script[^>]*>.*?<\/script>/is',
'/<iframe[^>]*>.*?<\/iframe>/is'
],
'allowed_html_tags' => ['p', 'br', 'strong', 'em', 'u', 'span', 'div'],
'placeholder_patterns' => [
'/:([a-zA-Z_][a-zA-Z0-9_]*)/',
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
'/%[sd]/'
]
];
}
/**
* Get configuration.
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Set configuration.
*/
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
/**
* Create validator instance.
*/
public static function create(array $config = []): self
{
return new self($config);
}
/**
* Create for strict validation.
*/
public static function strict(): self
{
return new self([
'strict_mode' => true,
'check_structure' => true,
'check_content' => true,
'check_consistency' => true,
'check_format' => true
]);
}
/**
* Create for basic validation.
*/
public static function basic(): self
{
return new self([
'strict_mode' => false,
'check_structure' => true,
'check_content' => false,
'check_consistency' => true,
'check_format' => false
]);
}
}