diff --git a/UPGRADE_GUIDE.md b/UPGRADE_GUIDE.md index 5f59b3613..40d7baf62 100644 --- a/UPGRADE_GUIDE.md +++ b/UPGRADE_GUIDE.md @@ -7,6 +7,7 @@ This guide helps you upgrade your TorrentPier installation to the latest version - [Database Layer Migration](#database-layer-migration) - [Unified Cache System Migration](#unified-cache-system-migration) - [Configuration System Migration](#configuration-system-migration) +- [Language System Migration](#language-system-migration) - [Censor System Migration](#censor-system-migration) - [Select System Migration](#select-system-migration) - [Development System Migration](#development-system-migration) @@ -297,6 +298,219 @@ if (isset(config()->bt_announce_url)) { } ``` +## 🌐 Language System Migration + +TorrentPier has modernized its language system with a singleton pattern while maintaining 100% backward compatibility with existing global `$lang` variable. + +### No Code Changes Required + +**Important**: All existing `global $lang` calls continue to work exactly as before. This is an internal modernization that requires **zero code changes** in your application. + +```php +// ✅ All existing code continues to work unchanged +global $lang; +echo $lang['FORUM']; +echo $lang['DATETIME']['TODAY']; +``` + +### Key Improvements + +#### Modern Foundation +- **Singleton Pattern**: Efficient memory usage and consistent TorrentPier architecture +- **Centralized Management**: Single point of control for language loading and switching +- **Type Safety**: Better error detection and IDE support +- **Dot Notation Support**: Access nested language arrays with simple syntax + +#### Enhanced Functionality +- **Automatic Fallback**: Source language fallback for missing translations +- **Dynamic Loading**: Load additional language files for modules/extensions +- **Runtime Modification**: Add or modify language strings at runtime +- **Locale Management**: Automatic locale setting based on language selection + +### Enhanced Capabilities + +New code can leverage the modern Language singleton features with convenient shorthand functions: + +```php +// ✅ Convenient shorthand functions (recommended for frequent use) +echo __('FORUM'); // Same as lang()->get('FORUM') +echo __('DATETIME.TODAY'); // Dot notation for nested arrays +_e('WELCOME_MESSAGE'); // Echo shorthand +$message = __('CUSTOM_MESSAGE', 'Default'); // With default value + +// ✅ Full singleton access (for advanced features) +echo lang()->get('FORUM'); +echo lang()->get('DATETIME.TODAY'); // Dot notation for nested arrays + +// ✅ Check if language key exists +if (lang()->has('ADVANCED_FEATURE')) { + echo __('ADVANCED_FEATURE'); +} + +// ✅ Get current language information +$currentLang = lang()->getCurrentLanguage(); +$langName = lang()->getLanguageName(); +$langLocale = lang()->getLanguageLocale(); + +// ✅ Load additional language files for modules +lang()->loadAdditionalFile('custom_module', 'en'); + +// ✅ Runtime language modifications +lang()->set('CUSTOM_KEY', 'Custom Value'); +lang()->set('NESTED.KEY', 'Nested Value'); +``` + +### Language Management + +#### Available Languages +```php +// Get all available languages from configuration +$availableLanguages = lang()->getAvailableLanguages(); + +// Get language display name +$englishName = lang()->getLanguageName('en'); // Returns: "English" +$currentName = lang()->getLanguageName(); // Current language name + +// Get language locale for formatting +$locale = lang()->getLanguageLocale('ru'); // Returns: "ru_RU.UTF-8" +``` + +#### Dynamic Language Loading +```php +// Load additional language files (useful for modules/plugins) +$success = lang()->loadAdditionalFile('torrent_management'); +if ($success) { + echo lang()->get('TORRENT_UPLOADED'); +} + +// Load from specific language +lang()->loadAdditionalFile('admin_panel', 'de'); +``` + +#### Runtime Modifications +```php +// Set custom language strings +lang()->set('SITE_WELCOME', 'Welcome to Our Tracker!'); +lang()->set('ERRORS.INVALID_TORRENT', 'Invalid torrent file'); + +// Modify existing strings +lang()->set('LOGIN', 'Sign In'); +``` + +### Backward Compatibility Features + +The singleton automatically maintains all global variables: + +```php +// Global variable is automatically updated by the singleton +global $lang; + +// When you call lang()->set(), global is updated +lang()->set('CUSTOM', 'Value'); +echo $lang['CUSTOM']; // Outputs: "Value" + +// When language is initialized, $lang is populated +// $lang contains user language + source language fallbacks +``` + +### Integration with User System + +The Language singleton integrates seamlessly with the User system: + +```php +// User language is automatically detected and initialized +// Based on user preferences, browser detection, or defaults + +// In User->init_userprefs(), language is now initialized with: +lang()->initializeLanguage($userLanguage); + +// This replaces the old manual language file loading +// while maintaining exact same functionality +``` + +### Convenient Shorthand Functions + +For frequent language access, TorrentPier provides convenient shorthand functions: + +```php +// ✅ __() - Get language string (most common) +echo __('FORUM'); // Returns: "Forum" +echo __('DATETIME.TODAY'); // Nested access: "Today" +$msg = __('MISSING_KEY', 'Default'); // With default value + +// ✅ _e() - Echo language string directly +_e('WELCOME_MESSAGE'); // Same as: echo __('WELCOME_MESSAGE') +_e('USER_ONLINE', 'Online'); // With default value + +// ✅ Common usage patterns +$title = __('PAGE_TITLE', config()->get('sitename')); +$error = __('ERROR.INVALID_INPUT', 'Invalid input'); +``` + +These functions make language access much more convenient compared to the full `lang()->get()` syntax: + +```php +// Before (verbose) +echo lang()->get('FORUM'); +echo lang()->get('DATETIME.TODAY'); +$msg = lang()->get('WELCOME', 'Welcome'); + +// After (concise) +echo __('FORUM'); +echo __('DATETIME.TODAY'); +$msg = __('WELCOME', 'Welcome'); +``` + +### Magic Methods Support +```php +// Magic getter (same as lang()->get()) +$welcome = lang()->WELCOME; +$today = lang()->{'DATETIME.TODAY'}; + +// Magic setter (same as lang()->set()) +lang()->CUSTOM_MESSAGE = 'Hello World'; +lang()->{'NESTED.KEY'} = 'Nested Value'; + +// Magic isset +if (isset(lang()->ADVANCED_FEATURE)) { + // Language key exists +} +``` + +### Performance Benefits + +While maintaining compatibility, you get: +- **Single Language Loading**: Languages loaded once and cached in singleton +- **Memory Efficiency**: No duplicate language arrays across application +- **Automatic Locale Setting**: Proper locale configuration for date/time formatting +- **Fallback Chain**: Source language → Default language → Requested language + +### Verification + +To verify the migration is working correctly: + +```php +// ✅ Test convenient shorthand functions +echo "Forum text: " . __('FORUM'); +echo "Today text: " . __('DATETIME.TODAY'); +_e('INFORMATION'); // Echo directly + +// ✅ Test with default values +echo "Custom: " . __('CUSTOM_KEY', 'Default Value'); + +// ✅ Test full singleton access +echo "Current language: " . lang()->getCurrentLanguage(); +echo "Language name: " . lang()->getLanguageName(); + +// ✅ Test backward compatibility +global $lang; +echo "Global access: " . $lang['FORUM']; + +// ✅ Verify globals are synchronized +lang()->set('TEST_KEY', 'Test Value'); +echo "Sync test: " . $lang['TEST_KEY']; // Should output: "Test Value" +``` + ## 🛡️ Censor System Migration The word censoring system has been refactored to use a singleton pattern, similar to the Configuration system, providing better performance and consistency. diff --git a/common.php b/common.php index 02f7ea2a5..7874fd589 100644 --- a/common.php +++ b/common.php @@ -120,6 +120,40 @@ function dev(): \TorrentPier\Dev return \TorrentPier\Dev::getInstance(); } +/** + * Get the Language instance + * + * @return \TorrentPier\Language + */ +function lang(): \TorrentPier\Language +{ + return \TorrentPier\Language::getInstance(); +} + +/** + * Get a language string (shorthand for lang()->get()) + * + * @param string $key Language key, supports dot notation (e.g., 'DATETIME.TODAY') + * @param mixed $default Default value if key doesn't exist + * @return mixed Language string or default value + */ +function __(string $key, mixed $default = null): mixed +{ + return \TorrentPier\Language::getInstance()->get($key, $default); +} + +/** + * Echo a language string (shorthand for echo __()) + * + * @param string $key Language key, supports dot notation + * @param mixed $default Default value if key doesn't exist + * @return void + */ +function _e(string $key, mixed $default = null): void +{ + echo \TorrentPier\Language::getInstance()->get($key, $default); +} + /** * Initialize debug */ diff --git a/library/includes/functions.php b/library/includes/functions.php index c569b55b3..27c81ebe3 100644 --- a/library/includes/functions.php +++ b/library/includes/functions.php @@ -1110,7 +1110,7 @@ function bb_date($gmepoch, $format = false, $friendly_date = true) $format = config()->get('default_dateformat'); } if (empty($lang)) { - require_once(config()->get('default_lang_dir') . 'main.php'); + lang()->initializeLanguage(); } if (!defined('IS_GUEST') || IS_GUEST) { @@ -1347,9 +1347,9 @@ function bb_die($msg_text, $status_code = null) define('HAS_DIED', 1); define('DISABLE_CACHING_OUTPUT', true); - // If empty lang + // If empty lang, initialize language singleton if (empty($lang)) { - require(config()->get('default_lang_dir') . 'main.php'); + lang()->initializeLanguage(); } // If empty session diff --git a/poll.php b/poll.php index 156b2aca0..b770e49c5 100644 --- a/poll.php +++ b/poll.php @@ -28,19 +28,19 @@ $poll = new TorrentPier\Legacy\Poll(); // Checking $topic_id if (!$topic_id) { - bb_die($lang['INVALID_TOPIC_ID']); + bb_die(__('INVALID_TOPIC_ID')); } // Getting topic data if present if (!$t_data = DB()->table(BB_TOPICS)->where('topic_id', $topic_id)->fetch()?->toArray()) { - bb_die($lang['INVALID_TOPIC_ID_DB']); + bb_die(__('INVALID_TOPIC_ID_DB')); } // Checking the rights if ($mode != 'poll_vote') { if ($t_data['topic_poster'] != $userdata['user_id']) { if (!IS_AM) { - bb_die($lang['NOT_AUTHORISED']); + bb_die(__('NOT_AUTHORISED')); } } } @@ -48,10 +48,10 @@ if ($mode != 'poll_vote') { // Checking the ability to make changes if ($mode == 'poll_delete') { if ($t_data['topic_time'] < TIMENOW - config()->get('poll_max_days') * 86400) { - bb_die(sprintf($lang['NEW_POLL_DAYS'], config()->get('poll_max_days'))); + bb_die(sprintf(__('NEW_POLL_DAYS'), config()->get('poll_max_days'))); } if (!IS_ADMIN && ($t_data['topic_vote'] != POLL_FINISHED)) { - bb_die($lang['CANNOT_DELETE_POLL']); + bb_die(__('CANNOT_DELETE_POLL')); } } @@ -59,25 +59,25 @@ switch ($mode) { case 'poll_vote': // Checking for poll existence if (!$t_data['topic_vote']) { - bb_die($lang['POST_HAS_NO_POLL']); + bb_die(__('POST_HAS_NO_POLL')); } // Checking that the topic has not been locked if ($t_data['topic_status'] == TOPIC_LOCKED) { - bb_die($lang['TOPIC_LOCKED_SHORT']); + bb_die(__('TOPIC_LOCKED_SHORT')); } // Checking that poll has not been finished if (!\TorrentPier\Legacy\Poll::pollIsActive($t_data)) { - bb_die($lang['NEW_POLL_ENDED']); + bb_die(__('NEW_POLL_ENDED')); } if (!$vote_id) { - bb_die($lang['NO_VOTE_OPTION']); + bb_die(__('NO_VOTE_OPTION')); } if (\TorrentPier\Legacy\Poll::userIsAlreadyVoted($topic_id, (int)$userdata['user_id'])) { - bb_die($lang['ALREADY_VOTED']); + bb_die(__('ALREADY_VOTED')); } $affected_rows = DB()->table(BB_POLL_VOTES) @@ -86,7 +86,7 @@ switch ($mode) { ->update(['vote_result' => new \Nette\Database\SqlLiteral('vote_result + 1')]); if ($affected_rows != 1) { - bb_die($lang['NO_VOTE_OPTION']); + bb_die(__('NO_VOTE_OPTION')); } // Voting process @@ -101,46 +101,46 @@ switch ($mode) { // Ignore duplicate entry (equivalent to INSERT IGNORE) } CACHE('bb_poll_data')->rm("poll_$topic_id"); - bb_die($lang['VOTE_CAST']); + bb_die(__('VOTE_CAST')); break; case 'poll_start': // Checking for poll existence if (!$t_data['topic_vote']) { - bb_die($lang['POST_HAS_NO_POLL']); + bb_die(__('POST_HAS_NO_POLL')); } // Starting the poll DB()->table(BB_TOPICS) ->where('topic_id', $topic_id) ->update(['topic_vote' => 1]); - bb_die($lang['NEW_POLL_START']); + bb_die(__('NEW_POLL_START')); break; case 'poll_finish': // Checking for poll existence if (!$t_data['topic_vote']) { - bb_die($lang['POST_HAS_NO_POLL']); + bb_die(__('POST_HAS_NO_POLL')); } // Finishing the poll DB()->table(BB_TOPICS) ->where('topic_id', $topic_id) ->update(['topic_vote' => POLL_FINISHED]); - bb_die($lang['NEW_POLL_END']); + bb_die(__('NEW_POLL_END')); break; case 'poll_delete': // Checking for poll existence if (!$t_data['topic_vote']) { - bb_die($lang['POST_HAS_NO_POLL']); + bb_die(__('POST_HAS_NO_POLL')); } // Removing poll from database $poll->delete_poll($topic_id); - bb_die($lang['NEW_POLL_DELETE']); + bb_die(__('NEW_POLL_DELETE')); break; case 'poll_add': // Checking that no other poll exists if ($t_data['topic_vote']) { - bb_die($lang['NEW_POLL_ALREADY']); + bb_die(__('NEW_POLL_ALREADY')); } // Make a poll from $_POST data @@ -153,12 +153,12 @@ switch ($mode) { // Adding poll info to the database $poll->insert_votes_into_db($topic_id); - bb_die($lang['NEW_POLL_ADDED']); + bb_die(__('NEW_POLL_ADDED')); break; case 'poll_edit': // Checking for poll existence if (!$t_data['topic_vote']) { - bb_die($lang['POST_HAS_NO_POLL']); + bb_die(__('POST_HAS_NO_POLL')); } // Make a poll from $_POST data @@ -172,7 +172,7 @@ switch ($mode) { // Updating poll info to the database $poll->insert_votes_into_db($topic_id); CACHE('bb_poll_data')->rm("poll_$topic_id"); - bb_die($lang['NEW_POLL_RESULTS']); + bb_die(__('NEW_POLL_RESULTS')); break; default: bb_die('Invalid mode: ' . htmlCHR($mode)); diff --git a/src/Language.php b/src/Language.php new file mode 100644 index 000000000..5d38b327c --- /dev/null +++ b/src/Language.php @@ -0,0 +1,343 @@ +initialized && !$forceReload) { + return; // Prevent multiple calling, same as existing logic + } + + // Determine language to use + if (empty($userLang)) { + $userLang = config()->get('default_lang', 'en'); + } + + $this->currentLanguage = $userLang; + + // Load source language first + $this->loadSourceLanguage(); + + // Load user language + $this->loadUserLanguage($userLang); + + // Set locale + $locale = config()->get("lang.{$userLang}.locale", 'en_US.UTF-8'); + setlocale(LC_ALL, $locale); + + $this->initialized = true; + + // Update global variables for backward compatibility + $this->updateGlobalVariables(); + } + + /** + * Load source language (fallback) + */ + private function loadSourceLanguage(): void + { + $sourceFile = LANG_ROOT_DIR . '/source/main.php'; + if (is_file($sourceFile)) { + $lang = []; + require $sourceFile; + $this->sourceLanguage = $lang; + } + } + + /** + * Load user language + */ + private function loadUserLanguage(string $userLang): void + { + $userFile = LANG_ROOT_DIR . '/' . $userLang . '/main.php'; + if (is_file($userFile)) { + $lang = []; + require $userFile; + $this->userLanguage = $lang; + } else { + // Fall back to default language if user language doesn't exist + $defaultFile = LANG_ROOT_DIR . '/' . config()->get('default_lang', 'source') . '/main.php'; + if (is_file($defaultFile)) { + $lang = []; + require $defaultFile; + $this->userLanguage = $lang; + } + } + + // Merge with source language as fallback + $this->userLanguage = array_merge($this->sourceLanguage, $this->userLanguage); + } + + /** + * Update global variables for backward compatibility + */ + private function updateGlobalVariables(): void + { + global $lang; + $lang = $this->userLanguage; + } + + /** + * Get a language string by key + * Supports dot notation for nested arrays (e.g., 'DATETIME.TODAY') + */ + public function get(string $key, mixed $default = null): mixed + { + if (str_contains($key, '.')) { + return $this->getNestedValue($this->userLanguage, $key, $default); + } + + return $this->userLanguage[$key] ?? $default; + } + + /** + * Get a language string from source language + */ + public function getSource(string $key, mixed $default = null): mixed + { + if (str_contains($key, '.')) { + return $this->getNestedValue($this->sourceLanguage, $key, $default); + } + + return $this->sourceLanguage[$key] ?? $default; + } + + /** + * Check if a language key exists + */ + public function has(string $key): bool + { + if (str_contains($key, '.')) { + return $this->getNestedValue($this->userLanguage, $key) !== null; + } + + return array_key_exists($key, $this->userLanguage); + } + + /** + * Get all language variables + */ + public function all(): array + { + return $this->userLanguage; + } + + /** + * Get all source language variables + */ + public function allSource(): array + { + return $this->sourceLanguage; + } + + /** + * Get current language code + */ + public function getCurrentLanguage(): string + { + return $this->currentLanguage; + } + + /** + * Get available languages from config + */ + public function getAvailableLanguages(): array + { + return config()->get('lang', []); + } + + /** + * Load additional language file (for modules/extensions) + */ + public function loadAdditionalFile(string $filename, string $language = ''): bool + { + if (empty($language)) { + $language = $this->currentLanguage; + } + + $filepath = LANG_ROOT_DIR . '/' . $language . '/' . $filename . '.php'; + if (!is_file($filepath)) { + // Try source language as fallback + $filepath = LANG_ROOT_DIR . '/source/' . $filename . '.php'; + if (!is_file($filepath)) { + return false; + } + } + + $lang = []; + require $filepath; + + // Merge with existing language data + $this->userLanguage = array_merge($this->userLanguage, $lang); + + // Update global variable for backward compatibility + global $lang; + $lang = $this->userLanguage; + + return true; + } + + /** + * Set a language variable (runtime modification) + */ + public function set(string $key, mixed $value): void + { + if (str_contains($key, '.')) { + $this->setNestedValue($this->userLanguage, $key, $value); + } else { + $this->userLanguage[$key] = $value; + } + + // Update global variable for backward compatibility + global $lang; + $lang = $this->userLanguage; + } + + /** + * Get nested value using dot notation + */ + private function getNestedValue(array $array, string $key, mixed $default = null): mixed + { + $keys = explode('.', $key); + $value = $array; + + foreach ($keys as $k) { + if (!is_array($value) || !array_key_exists($k, $value)) { + return $default; + } + $value = $value[$k]; + } + + return $value; + } + + /** + * Set nested value using dot notation + */ + private function setNestedValue(array &$array, string $key, mixed $value): void + { + $keys = explode('.', $key); + $target = &$array; + + foreach ($keys as $k) { + if (!isset($target[$k]) || !is_array($target[$k])) { + $target[$k] = []; + } + $target = &$target[$k]; + } + + $target = $value; + } + + /** + * Get language name for display + */ + public function getLanguageName(string $code = ''): string + { + if (empty($code)) { + $code = $this->currentLanguage; + } + + return config()->get("lang.{$code}.name", $code); + } + + /** + * Get language locale + */ + public function getLanguageLocale(string $code = ''): string + { + if (empty($code)) { + $code = $this->currentLanguage; + } + + return config()->get("lang.{$code}.locale", 'en_US.UTF-8'); + } + + /** + * Magic method to allow property access for backward compatibility + */ + public function __get(string $key): mixed + { + return $this->get($key); + } + + /** + * Magic method to allow property setting for backward compatibility + */ + public function __set(string $key, mixed $value): void + { + $this->set($key, $value); + } + + /** + * Magic method to check if property exists + */ + public function __isset(string $key): bool + { + return $this->has($key); + } + + /** + * Prevent cloning of the singleton instance + */ + private function __clone() + { + } + + /** + * Prevent unserialization of the singleton instance + */ + public function __wakeup() + { + throw new \Exception("Cannot unserialize a singleton."); + } +} diff --git a/src/Legacy/Common/User.php b/src/Legacy/Common/User.php index e76ca3a28..52bfafc01 100644 --- a/src/Legacy/Common/User.php +++ b/src/Legacy/Common/User.php @@ -583,7 +583,7 @@ class User */ public function init_userprefs() { - global $theme, $source_lang, $DeltaTime; + global $theme, $DeltaTime; if (defined('LANG_DIR')) { return; @@ -621,17 +621,8 @@ class User define('LANG_DIR', DEFAULT_LANG_DIR); } - /** Temporary place source language to the global */ - $lang = []; - require(SOURCE_LANG_DIR . 'main.php'); - $source_lang = $lang; - unset($lang); - - /** Place user language to the global */ - global $lang; - require(LANG_DIR . 'main.php'); - setlocale(LC_ALL, config()->get('lang')[$this->data['user_lang']]['locale'] ?? 'en_US.UTF-8'); - $lang += $source_lang; + // Initialize Language singleton with user preferences + lang()->initializeLanguage($this->data['user_lang']); $theme = setup_style(); $DeltaTime = new DateDelta(); diff --git a/src/Legacy/Template.php b/src/Legacy/Template.php index fa1139415..a65e3b81a 100644 --- a/src/Legacy/Template.php +++ b/src/Legacy/Template.php @@ -107,6 +107,7 @@ class Template $this->tpldir = TEMPLATES_DIR; $this->root = $root; $this->tpl = basename($root); + // Use Language singleton but maintain backward compatibility with global $lang $this->lang =& $lang; $this->use_cache = config()->get('xs_use_cache'); @@ -230,11 +231,10 @@ class Template /** @noinspection PhpUnusedLocalVariableInspection */ // bb_cfg deprecated, but kept for compatibility with non-adapted themes - global $lang, $source_lang, $bb_cfg, $user; + global $lang, $bb_cfg, $user; $L =& $lang; $V =& $this->vars; - $SL =& $source_lang; if ($filename) { include $filename; @@ -766,7 +766,7 @@ class Template $code = str_replace($search, $replace, $code); } // This will handle the remaining root-level varrefs - $code = preg_replace('#\{(L_([a-z0-9\-_]+?))\}#i', '', $code); + $code = preg_replace('#\{(L_([a-z0-9\-_]+?))\}#i', '', $code); $code = preg_replace('#\{(\$[a-z_][a-z0-9_$\->\'\"\.\[\]]*?)\}#i', '', $code); $code = preg_replace('#\{(\#([a-z_][a-z0-9_]*?)\#)\}#i', '', $code); $code = preg_replace('#\{([a-z0-9\-_]+?)\}#i', '', $code);