Textpattern | PHP Cross Reference | Content Management Systems |
Description: Language manipulation.
1 <?php 2 3 /* 4 * Textpattern Content Management System 5 * https://textpattern.com/ 6 * 7 * Copyright (C) 2020 The Textpattern Development Team 8 * 9 * This file is part of Textpattern. 10 * 11 * Textpattern is free software; you can redistribute it and/or 12 * modify it under the terms of the GNU General Public License 13 * as published by the Free Software Foundation, version 2. 14 * 15 * Textpattern is distributed in the hope that it will be useful, 16 * but WITHOUT ANY WARRANTY; without even the implied warranty of 17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 * GNU General Public License for more details. 19 * 20 * You should have received a copy of the GNU General Public License 21 * along with Textpattern. If not, see <https://www.gnu.org/licenses/>. 22 */ 23 24 /** 25 * Language manipulation. 26 * 27 * @since 4.7.0 28 * @package L10n 29 */ 30 31 namespace Textpattern\L10n; 32 33 class Lang implements \Textpattern\Container\ReusableInterface 34 { 35 /** 36 * Language base directory that houses all the language files/textpacks. 37 * 38 * @var string 39 */ 40 41 protected $langDirectory = null; 42 43 /** 44 * List of files in the $langDirectory. 45 * 46 * @var array 47 */ 48 49 protected $files = array(); 50 51 /** 52 * The currently active language designator. 53 * 54 * @var string 55 */ 56 57 protected $activeLang = null; 58 59 /** 60 * Metadata for languages installed in the database. 61 * 62 * @var array 63 */ 64 65 protected $dbLangs = array(); 66 67 /** 68 * Metadata for all available languages in the filesystem. 69 * 70 * @var array 71 */ 72 73 protected $allLangs = array(); 74 75 /** 76 * List of strings that have been loaded. 77 * 78 * @var array 79 */ 80 81 protected $strings = null; 82 83 /** 84 * List of cached strings that have been temporarily overridden. 85 * 86 * @var array 87 */ 88 89 protected $cachedStrings = null; 90 91 /** 92 * Array of events that have been loaded. 93 * 94 * @var array 95 */ 96 97 protected $loaded = array(); 98 99 /** 100 * Date format to use for the lastmod column. 101 * 102 * @var string 103 */ 104 105 protected $lastmodFormat = 'YmdHis'; 106 107 /** 108 * Constructor. 109 * 110 * @param string $langDirectory Language directory to use 111 */ 112 113 public function __construct($langDirectory = null) 114 { 115 if ($langDirectory === null) { 116 $langDirectory = txpath.DS.'lang'.DS; 117 } 118 119 $this->langDirectory = $langDirectory; 120 121 if (!$this->files) { 122 $this->files = $this->files(); 123 } 124 } 125 126 /** 127 * Return all installed languages in the database. 128 * 129 * @return array Available language codes 130 */ 131 132 public function installed() 133 { 134 if (!$this->dbLangs) { 135 $this->available(); 136 } 137 138 $installed_langs = array(); 139 140 foreach ($this->dbLangs as $row) { 141 $installed_langs[] = $row['lang']; 142 } 143 144 return $installed_langs; 145 } 146 147 /** 148 * Return all language files in the lang directory. 149 * 150 * @param array $extensions Language files extensions 151 * @return array Available language filenames 152 */ 153 154 public function files($extensions = array('ini', 'textpack', 'txt')) 155 { 156 if (!is_dir($this->langDirectory) || !is_readable($this->langDirectory)) { 157 trigger_error('Lang directory is not accessible: '.$this->langDirectory, E_USER_WARNING); 158 159 return array(); 160 } 161 162 if (defined('GLOB_BRACE')) { 163 return glob($this->langDirectory.'*.{'.implode(',', $extensions).'}', GLOB_BRACE); 164 } 165 166 $files = array(); 167 168 foreach ((array)$extensions as $ext) { 169 $files = array_merge($files, (array) glob($this->langDirectory.'*.'.$ext)); 170 } 171 172 return $files; 173 } 174 175 /** 176 * Locate a file in the lang directory based on a language code. 177 * 178 * @param string $lang_code The language code to look up 179 * @return string|null The matching filename 180 */ 181 182 public function findFilename($lang_code) 183 { 184 $out = null; 185 186 if (!empty($this->files)) { 187 foreach ($this->files as $file) { 188 $pathinfo = pathinfo($file); 189 190 if ($pathinfo['filename'] === $lang_code) { 191 $out = $file; 192 break; 193 } 194 } 195 } 196 197 return $out; 198 } 199 200 /** 201 * Read the meta info from the top of the given language file. 202 * 203 * @param string $file The filename to read 204 * @return array Meta info such as language name, language code, language direction and last modified time 205 */ 206 207 public function fetchMeta($file) 208 { 209 $meta = array(); 210 211 if (is_file($file) && is_readable($file)) { 212 $numMetaRows = 4; 213 $separator = '=>'; 214 extract(pathinfo($file)); 215 $filename = preg_replace('/\.(txt|textpack|ini)$/i', '', $basename); 216 $ini = strtolower($extension) == 'ini'; 217 218 $meta['filename'] = $filename; 219 220 if ($fp = @fopen($file, 'r')) { 221 for ($idx = 0; $idx < $numMetaRows; $idx++) { 222 $rows[] = fgets($fp, 1024); 223 } 224 225 fclose($fp); 226 $meta['time'] = filemtime($file); 227 228 if ($ini) { 229 $langInfo = parse_ini_string(join($rows)); 230 $meta['name'] = (!empty($langInfo['lang_name'])) ? $langInfo['lang_name'] : $filename; 231 $meta['code'] = (!empty($langInfo['lang_code'])) ? strtolower($langInfo['lang_code']) : $filename; 232 $meta['direction'] = (!empty($langInfo['lang_dir'])) ? strtolower($langInfo['lang_dir']) : 'ltr'; 233 } else { 234 $langName = do_list($rows[1], $separator); 235 $langCode = do_list($rows[2], $separator); 236 $langDirection = do_list($rows[3], $separator); 237 238 $meta['name'] = (isset($langName[1])) ? $langName[1] : $filename; 239 $meta['code'] = (isset($langCode[1])) ? strtolower($langCode[1]) : $filename; 240 $meta['direction'] = (isset($langDirection[1])) ? strtolower($langDirection[1]) : 'ltr'; 241 } 242 } 243 } 244 245 return $meta; 246 } 247 248 /** 249 * Fetch available languages. 250 * 251 * Depending on the flags, the returned array can contain active, 252 * installed or available language metadata. 253 * 254 * @param int $flags Determine which type of information to return 255 * @param int $force Force update the given information, even if it's already populated 256 * @return array 257 */ 258 259 public function available($flags = TEXTPATTERN_LANG_AVAILABLE, $force = 0) 260 { 261 if ($force & TEXTPATTERN_LANG_ACTIVE || $this->activeLang === null) { 262 $this->activeLang = get_pref('language', TEXTPATTERN_DEFAULT_LANG, true); 263 $this->activeLang = \Txp::get('\Textpattern\L10n\Locale')->validLocale($this->activeLang); 264 } 265 266 if ($force & TEXTPATTERN_LANG_INSTALLED || !$this->dbLangs) { 267 // Need a value here for the language itself, not for each one of the rows. 268 $ownClause = ($this->hasOwnerSupport() ? "owner = ''" : "1")." GROUP BY lang ORDER BY lastmod DESC"; 269 $this->dbLangs = safe_rows( 270 "lang, UNIX_TIMESTAMP(MAX(lastmod)) AS lastmod", 271 'txp_lang', 272 $ownClause 273 ); 274 } 275 276 if ($force & TEXTPATTERN_LANG_AVAILABLE || !$this->allLangs) { 277 $currently_lang = array(); 278 $installed_lang = array(); 279 $available_lang = array(); 280 281 // Set up the current and installed array. Define their names as 282 // 'unknown' for now in case the file is missing or mangled. The 283 // name will be overwritten when reading from the filesystem if 284 // it's intact. 285 foreach ($this->dbLangs as $language) { 286 if ($language['lang'] === $this->activeLang) { 287 $currently_lang[$language['lang']] = array( 288 'db_lastmod' => $language['lastmod'], 289 'type' => 'active', 290 'name' => gTxt('unknown'), 291 ); 292 } else { 293 $installed_lang[$language['lang']] = array( 294 'db_lastmod' => $language['lastmod'], 295 'type' => 'installed', 296 'name' => gTxt('unknown'), 297 ); 298 } 299 } 300 301 // Get items from filesystem. 302 if (!empty($this->files)) { 303 foreach ($this->files as $file) { 304 $meta = $this->fetchMeta($file); 305 306 if ($meta && !isset($available_lang[$meta['filename']])) { 307 $name = $meta['filename']; 308 309 if (array_key_exists($name, $currently_lang)) { 310 $currently_lang[$name]['name'] = $meta['name']; 311 $currently_lang[$name]['direction'] = $meta['direction']; 312 $currently_lang[$name]['file_lastmod'] = $meta['time']; 313 } elseif (array_key_exists($name, $installed_lang)) { 314 $installed_lang[$name]['name'] = $meta['name']; 315 $installed_lang[$name]['direction'] = $meta['direction']; 316 $installed_lang[$name]['file_lastmod'] = $meta['time']; 317 } 318 319 $available_lang[$name]['file_lastmod'] = $meta['time']; 320 $available_lang[$name]['name'] = $meta['name']; 321 $available_lang[$name]['direction'] = $meta['direction']; 322 $available_lang[$name]['type'] = 'available'; 323 } 324 } 325 } 326 327 $this->allLangs = array( 328 'active' => $currently_lang, 329 'installed' => $installed_lang, 330 'available' => $available_lang, 331 ); 332 } 333 334 $out = array(); 335 336 if ($flags & TEXTPATTERN_LANG_ACTIVE) { 337 $out = array_merge($out, $this->allLangs['active']); 338 } 339 340 if ($flags & TEXTPATTERN_LANG_INSTALLED) { 341 $out = array_merge($out, $this->allLangs['installed']); 342 } 343 344 if ($flags & TEXTPATTERN_LANG_AVAILABLE) { 345 $out = array_merge($out, $this->allLangs['available']); 346 } 347 348 return $out; 349 } 350 351 /** 352 * Set/overwrite the language strings. Chainable. 353 * 354 * @param array $strings Set of strings to use 355 * @param bool $merge Whether to merge the strings (true) or replace them entirely (false) 356 */ 357 358 public function setPack(array $strings, $merge = false) 359 { 360 if ((bool)$merge && is_array($this->strings)) { 361 foreach ((array)$strings as $k => $v) { 362 $this->strings[$k] = $v; 363 } 364 } else { 365 $this->strings = (array)$strings; 366 } 367 368 return $this; 369 } 370 371 /** 372 * Fetch Textpack strings from the file matching the given $lang_code. 373 * 374 * A subset of the strings may be fetched by supplying a list of 375 * $group names to grab. 376 * 377 * @param string|array $lang_code The language code to fetch, or array(lang_code, override_lang_code) 378 * @param string|array $group Comma-separated list or array of headings from which to extract strings 379 * @param string|array $filter Comma-separated list or array of strings that should be returned 380 * @return array 381 */ 382 383 public function getPack($lang_code, $group = null, $filter = null) 384 { 385 if (is_array($lang_code)) { 386 $lang_over = $lang_code[1]; 387 $lang_code = $lang_code[0]; 388 } else { 389 $lang_over = $lang_code; 390 } 391 392 $lang_file = $this->findFilename($lang_code); 393 $entries = array(); 394 $textpack = ''; 395 396 if ($lang_file && ($textpack = txp_get_contents($lang_file))) { 397 $parser = new \Textpattern\Textpack\Parser(); 398 $parser->setOwner(''); 399 $parser->setLanguage($lang_over); 400 $parser->parse($textpack, $group); 401 $entries = $parser->getStrings($lang_over); 402 } 403 404 // Reindex the pack so it can be merged. 405 $langpack = array(); 406 $filter = is_array($filter) ? $filter : do_list_unique($filter); 407 408 foreach ($entries as $translation) { 409 if (!$filter || in_array($translation['name'], $filter)) { 410 $langpack[$translation['name']] = $translation; 411 } 412 } 413 414 return $langpack; 415 } 416 417 /** 418 * Temporarily override a bunch of strings with a set from a different language. 419 * 420 * @param string|null $lang The language from which to extract strings. 421 * If it matches the current language, nothing happens. 422 * If null is passed in, the overwritten strings are restored. 423 * @param string|array $group List of groups (comma-separated or an array) to fetch in the new language. 424 * @return null|true Returns true if language swap took place, null otherwise. 425 */ 426 function swapStrings($lang, $group = 'admin') 427 { 428 if ($lang && in_array($lang, $this->installed())) { 429 $this->cachedStrings = $this->getStrings(); 430 431 // Override the language strings in the given groups with those of the passed language. 432 $userPack = $this->getPack($lang, $group); 433 $userStrings = array(); 434 435 foreach ($userPack as $key => $packBlock) { 436 $userStrings[$key] = $packBlock['data']; 437 } 438 439 $this->setPack($userStrings, true); 440 441 return true; 442 } elseif ($lang === null && $this->cachedStrings) { 443 $this->setPack($this->cachedStrings, true); 444 $this->cachedStrings = null; 445 446 return true; 447 } 448 449 return null; 450 } 451 452 /** 453 * Install a language pack from a file. 454 * 455 * @param string $lang_code The lang identifier to load 456 */ 457 458 public function installFile($lang_code, $owner = '') 459 { 460 $langpack = $this->getPack($lang_code); 461 462 if (empty($langpack)) { 463 return false; 464 } 465 466 if ($lang_code !== TEXTPATTERN_DEFAULT_LANG) { 467 // Load the fallback strings so we're not left with untranslated strings. 468 // Note that the language is overridden to match the to-be-installed lang. 469 $fallpack = $this->getPack(array(TEXTPATTERN_DEFAULT_LANG, $lang_code)); 470 $langpack += $fallpack; 471 } 472 473 return ($this->upsertPack($langpack, $owner) === false) ? false : true; 474 } 475 476 477 /** 478 * Load localisation strings from a Textpack using the given language. 479 * 480 * @param array $textpack The Textpack to install 481 * @param string $useLang Import strings for this language 482 * @package L10n 483 */ 484 485 public function loadTextpack($textpack, $useLang = null) 486 { 487 global $textarray; 488 489 $strings = array(); 490 $pack = new \Textpattern\Textpack\Parser(); 491 $pack->parse($textpack); 492 493 if (!isset($useLang)) { 494 $useLang = txpinterface === 'admin' ? get_pref('language_ui', TEXTPATTERN_DEFAULT_LANG) : get_pref('language', TEXTPATTERN_DEFAULT_LANG); 495 } 496 497 $wholePack = $pack->getStrings($useLang); 498 499 if (!$wholePack) { 500 $wholePack = $pack->getStrings(TEXTPATTERN_DEFAULT_LANG); 501 } 502 503 foreach ($wholePack as $entry) { 504 $strings[$entry['name']] = $entry['data']; 505 } 506 507 // Append lang strings on-the-fly. 508 $this->setPack($strings, true); 509 $textarray += $strings; 510 } 511 512 513 /** 514 * Install localisation strings from a Textpack. 515 * 516 * @param string $textpack The Textpack to install 517 * @param bool $addNewLangs If TRUE, installs strings for any included language 518 * @return int Number of installed strings 519 * @package L10n 520 */ 521 522 public function installTextpack($textpack, $addNewLangs = false) 523 { 524 $parser = new \Textpattern\Textpack\Parser(); 525 $parser->setLanguage(get_pref('language', TEXTPATTERN_DEFAULT_LANG)); 526 $parser->parse($textpack); 527 $packLanguages = $parser->getLanguages(); 528 529 if (empty($packLanguages)) { 530 return 0; 531 } 532 533 $allpacks = array(); 534 535 foreach ($packLanguages as $lang_code) { 536 $allpacks = array_merge($allpacks, $parser->getStrings($lang_code)); 537 } 538 539 $installed_langs = $this->installed(); 540 $values = array(); 541 542 foreach ($allpacks as $translation) { 543 extract(doSlash($translation)); 544 545 if (!$addNewLangs && !in_array($lang, $installed_langs)) { 546 continue; 547 } 548 549 $values[] = "('$name', '$lang', '$data', '$event', '$owner', NOW())"; 550 } 551 552 $value = implode(',', $values); 553 554 !$value || safe_query("INSERT INTO ".PFX."txp_lang 555 (name, lang, data, event, owner, lastmod) 556 VALUES $value 557 ON DUPLICATE KEY UPDATE 558 data=VALUES(data), event=VALUES(event), owner=VALUES(owner), lastmod=VALUES(lastmod)"); 559 560 return count($values); 561 } 562 563 /** 564 * Insert or update a language pack. 565 * 566 * @param array $langpack The language pack to store 567 * @param string $langpack The owner to use if not in the pack 568 * @return result set 569 */ 570 571 public function upsertPack($langpack, $owner_ref = '') 572 { 573 $result = false; 574 575 if ($langpack) { 576 $values = array(); 577 578 foreach ($langpack as $key => $translation) { 579 extract(doSlash($translation)); 580 581 $owner = empty($owner) ? doSlash($owner_ref) : $owner; 582 $lastmod = empty($lastmod) ? 'NOW()' : "'$lastmod'"; 583 $values[] = "('$name', '$lang', '$data', '$event', '$owner', $lastmod)"; 584 } 585 586 if ($values) { 587 $value = implode(',', $values); 588 $result = safe_query("INSERT INTO ".PFX."txp_lang 589 (name, lang, data, event, owner, lastmod) 590 VALUES $value 591 ON DUPLICATE KEY UPDATE 592 data=VALUES(data), event=VALUES(event), owner=VALUES(owner), lastmod=VALUES(lastmod)"); 593 } 594 } 595 596 return $result; 597 } 598 599 /** 600 * Fetch the given language's strings from the database as an array. 601 * 602 * If no $events are specified, only appropriate strings for the current context 603 * are returned. If the 'txpinterface' constant is 'public' only strings from 604 * events 'common' and 'public' are returned. 605 * 606 * Note the returned array includes the language if the fallback has been used. 607 * This ensures (as far as possible) a full complement of strings, regardless of 608 * the degree of translation that's taken place in the desired $lang code. 609 * Any holes can be mopped up by the default language. 610 * 611 * @param string $lang_code The language code 612 * @param array|string $events A list of loaded events to extract 613 * @param string|array $filter Comma-separated list or array of strings that should be returned 614 * @return array 615 */ 616 617 public function extract($lang_code, $events = null, $filter = null) 618 { 619 $where = array( 620 "lang = '".doSlash($lang_code)."'", 621 "name != ''", 622 ); 623 624 if (txpinterface === 'admin') { 625 $admin_events = array('admin-side', 'common'); 626 627 if ($events) { 628 $list = (is_array($events) ? $events : do_list_unique($events)); 629 $admin_events = array_merge($admin_events, $list); 630 } 631 632 $events = $admin_events; 633 } elseif ($events === null) { 634 $events = array('public', 'common'); 635 } else { 636 $events = is_array($events) ? $events : do_list_unique($events); 637 } 638 639 if ($events) { 640 // For the time being, load any non-core (plugin) strings on every 641 // page too. Core strings have no owner. Plugins installed since 4.6+ 642 // will have either the 'site' owner or their own plugin name. 643 // Longer term, when all plugins have caught up with the event 644 // naming convention, the owner clause can be removed. 645 $where[] = "(event IN (".join(',', quote_list((array) $events)).")".($this->hasOwnerSupport() ? " OR owner != '')" : ')'); 646 } 647 648 $out = array(); 649 650 $filter = is_array($filter) ? $filter : do_list_unique($filter); 651 652 $rs = safe_rows_start("name, data", 'txp_lang', join(' AND ', $where)); 653 654 if (!empty($rs)) { 655 while ($a = nextRow($rs)) { 656 if (!$filter || in_array($a['name'], $filter)) { 657 $out[$a['name']] = $a['data']; 658 } 659 } 660 } 661 662 return $out; 663 } 664 665 /** 666 * Load the given language's strings from the database into the class. 667 * 668 * Note the returned array includes the language if the fallback has been used. 669 * This ensures (as far as possible) a full complement of strings, regardless of 670 * the degree of translation that's taken place in the desired $lang code. 671 * Any holes can be mopped up by the default language. 672 * 673 * @param string $lang_code The language code 674 * @param array|string $events A list of loaded events to load 675 * @see extract() 676 * @return array 677 */ 678 679 public function load($lang_code, $events = null) 680 { 681 $loaded = isset($this->loaded[$lang_code]) ? $this->loaded[$lang_code] : null; 682 683 if ($events === true) { 684 return $loaded; 685 } 686 687 global $DB; 688 689 if (!empty($DB)) { 690 $this->strings = $this->extract($lang_code, $events); 691 $this->loaded = array($lang_code => isset($events) ? do_list_unique($events) : array(null)); 692 } 693 694 return $this->strings; 695 } 696 697 /** 698 * Fetch the language strings from the loaded language. 699 * 700 * @return array 701 */ 702 703 public function getStrings() 704 { 705 return $this->strings; 706 } 707 708 /** 709 * Determine if a string key exists in the current pack 710 * 711 * @param string $var The string name to check 712 * @return boolean 713 */ 714 715 public function hasString($var) 716 { 717 $v = strtolower($var); 718 719 return isset($this->strings[$v]); 720 } 721 722 /** 723 * Return a localisation string. 724 * 725 * @param string $var String name 726 * @param array $atts Replacement pairs 727 * @param string $escape Convert special characters to HTML entities. Either "html" or "" 728 * @return string A localisation string 729 * @package L10n 730 */ 731 732 public function txt($var, $atts = array(), $escape = 'html') 733 { 734 global $textarray; // deprecated since 4.7 735 736 $v = strtolower($var); 737 738 if (isset($this->strings[$v])) { 739 $out = $this->strings[$v]; 740 } else { 741 $out = isset($textarray[$v]) ? $textarray[$v] : ''; 742 } 743 744 if ($atts && $escape == 'html') { 745 $atts = array_map('txpspecialchars', $atts); 746 } 747 748 if ($out !== '') { 749 return $atts ? strtr($out, $atts) : $out; 750 } 751 752 if ($atts) { 753 return $var.': '.join(', ', $atts); 754 } 755 756 return $var; 757 } 758 759 /** 760 * Generate an array of languages and their localised names. 761 * 762 * @param int $flags Logical OR list of flags indiacting the type of list to return: 763 * TEXTPATTERN_LANG_ACTIVE: the active language 764 * TEXTPATTERN_LANG_INSTALLED: all installed languages 765 * TEXTPATTERN_LANG_AVAILABLE: all available languages in the file system 766 * @return array 767 */ 768 769 public function languageList($flags = null) 770 { 771 if ($flags === null) { 772 $flags = TEXTPATTERN_LANG_ACTIVE | TEXTPATTERN_LANG_INSTALLED; 773 } 774 775 $installed_langs = $this->available((int)$flags); 776 $vals = array(); 777 778 foreach ($installed_langs as $lang => $langdata) { 779 $vals[$lang] = $langdata['name']; 780 781 if (trim($vals[$lang]) == '') { 782 $vals[$lang] = $lang; 783 } 784 } 785 786 ksort($vals); 787 reset($vals); 788 789 return $vals; 790 } 791 792 /** 793 * Generate a <select> element of languages. 794 * 795 * @param string $name The HTML name and ID to assign to the select control 796 * @param string $val The currently active language identifier (en-gb, fr, de, ...) 797 * @param int $flags Logical OR list of flags indicating the type of list to return: 798 * TEXTPATTERN_LANG_ACTIVE: the active language 799 * TEXTPATTERN_LANG_INSTALLED: all installed languages 800 * TEXTPATTERN_LANG_AVAILABLE: all available languages in the file system 801 * @return string HTML 802 */ 803 804 public function languageSelect($name, $val, $flags = null) 805 { 806 $vals = $this->languageList($flags); 807 808 return selectInput($name, $vals, $val, false, true, $name); 809 } 810 811 /** 812 * Determine if the class supports the 'owner' column or not. 813 * 814 * Only of use during upgrades from older versions to guard against errors. 815 * 816 * @return boolean 817 */ 818 819 protected function hasOwnerSupport() 820 { 821 return (bool) version_compare(get_pref('version'), '4.6.0', '>='); 822 } 823 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
title