mirror of
https://devops.lemonos.cn/lawson/FendxPHP.git
synced 2026-06-15 23:12:49 +08:00
feat(database): 添加用户角色权限系统及相关监控功能
- 创建用户表(users)包含基本信息和认证字段 - 创建角色表(roles)用于权限控制 - 创建权限表(permissions)定义系统权限 - 创建用户角色关联表(user_roles)建立用户与角色关系 - 创建角色权限关联表(role_permissions)建立角色与权限关系 - 创建迁移记录表(migrations)追踪数据库变更 - 添加AdminController提供管理员面板功能 - 实现系统监控、配置管理、缓存清理等功能 - 添加AOP切面编程支持的各种通知类型 - 实现告警管理AlertManager支持多渠道告警 - 添加文档注解接口规范
This commit is contained in:
824
fendx-framework/fendx-i18n/src/Config/I18nConfigManager.php
Normal file
824
fendx-framework/fendx-i18n/src/Config/I18nConfigManager.php
Normal 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]
|
||||
]);
|
||||
}
|
||||
}
|
||||
1480
fendx-framework/fendx-i18n/src/Formatter/CurrencyFormatter.php
Normal file
1480
fendx-framework/fendx-i18n/src/Formatter/CurrencyFormatter.php
Normal file
File diff suppressed because it is too large
Load Diff
1016
fendx-framework/fendx-i18n/src/Formatter/DateTimeFormatter.php
Normal file
1016
fendx-framework/fendx-i18n/src/Formatter/DateTimeFormatter.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
779
fendx-framework/fendx-i18n/src/Locale/LanguageManager.php
Normal file
779
fendx-framework/fendx-i18n/src/Locale/LanguageManager.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
813
fendx-framework/fendx-i18n/src/Timezone/TimezoneManager.php
Normal file
813
fendx-framework/fendx-i18n/src/Timezone/TimezoneManager.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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/*']
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user