PageRenderTime 50ms CodeModel.GetById 19ms RepoModel.GetById 0ms app.codeStats 0ms

/public_html/wire/modules/LanguageSupport/LanguageTranslator.php

https://bitbucket.org/thomas1151/mats
PHP | 651 lines | 326 code | 80 blank | 245 comment | 56 complexity | a4d5185eb14ed8100951f81450099fc0 MD5 | raw file
Possible License(s): Apache-2.0, BSD-3-Clause, LGPL-2.1, MPL-2.0-no-copyleft-exception
  1. <?php namespace ProcessWire;
  2. /**
  3. * ProcessWire Language Translator
  4. *
  5. * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
  6. * https://processwire.com
  7. *
  8. *
  9. */
  10. class LanguageTranslator extends Wire {
  11. /**
  12. * Language (Page) instance of the current language
  13. *
  14. * @var Language
  15. *
  16. */
  17. protected $currentLanguage;
  18. /**
  19. * Path where language files are stored
  20. *
  21. * i.e. language_files path for current $language
  22. *
  23. * @var string
  24. *
  25. */
  26. protected $path;
  27. /**
  28. * Root path of installation, same as wire('config')->paths->root
  29. *
  30. * @var string
  31. *
  32. */
  33. protected $rootPath;
  34. /**
  35. * Alternate root path for systems where there might be symlinks
  36. *
  37. * @var string
  38. *
  39. */
  40. protected $rootPath2;
  41. /**
  42. * Translations for current language, in this format (consistent with the JSON):
  43. *
  44. * array(
  45. * 'textdomain' => array(
  46. * 'file' => 'filename',
  47. * 'translations' => array(
  48. * '[hash]' => array(
  49. * 'text' => string, // translated version of the text
  50. * )
  51. * )
  52. * )
  53. * );
  54. *
  55. * @var array
  56. *
  57. */
  58. protected $textdomains = array();
  59. /**
  60. * Cache of class names and the resulting textdomains
  61. *
  62. * @var array
  63. *
  64. */
  65. protected $classNamesToTextdomains = array();
  66. /**
  67. * Textdomains of parent classes that can be checked where applicable
  68. *
  69. * @var array
  70. *
  71. */
  72. protected $parentTextdomains = array(
  73. // 'className' => array('parent textdomain 1', 'parent textdomain 2', 'etc.')
  74. );
  75. /**
  76. * Is current language the default language?
  77. *
  78. * @var bool
  79. *
  80. */
  81. protected $isDefaultLanguage = false;
  82. /**
  83. * Construct the translator and set the current language
  84. *
  85. * @param Language $currentLanguage
  86. *
  87. */
  88. public function __construct(Language $currentLanguage) {
  89. $currentLanguage->wire($this);
  90. $this->setCurrentLanguage($currentLanguage);
  91. $this->rootPath = $this->wire('config')->paths->root;
  92. $file = __FILE__;
  93. $pos = strpos($file, '/wire/modules/LanguageSupport/');
  94. $this->rootPath2 = $pos ? substr($file, 0, $pos+1) : '';
  95. }
  96. /**
  97. * Set the current language and reset current stored textdomains
  98. *
  99. * @param Language $language
  100. * @return $this
  101. *
  102. */
  103. public function setCurrentLanguage(Language $language) {
  104. if($this->currentLanguage && $language->id == $this->currentLanguage->id) return $this;
  105. $this->path = $language->filesManager->path();
  106. // we only keep translations for one language in memory at once,
  107. // so if the language is changing, we clear out what's already in memory.
  108. if($this->currentLanguage && $language->id != $this->currentLanguage->id) $this->textdomains = array();
  109. $this->currentLanguage = $language;
  110. $this->isDefaultLanguage = $language->isDefault();
  111. return $this;
  112. }
  113. /**
  114. * Return the array template for a textdomain, optionally populating it with data
  115. *
  116. * @param string $file
  117. * @param string $textdomain
  118. * @param array $translations
  119. * @return array
  120. *
  121. */
  122. protected function textdomainTemplate($file = '', $textdomain = '', array $translations = array()) {
  123. foreach($translations as $hash => $translation) {
  124. if(!strlen($translation['text'])) unset($translations[$hash]);
  125. }
  126. return array(
  127. 'file' => $file,
  128. 'textdomain' => $textdomain,
  129. 'translations' => $translations
  130. );
  131. }
  132. /**
  133. * Given an object instance, return the resulting textdomain string
  134. *
  135. * This is accomplished with PHP's ReflectionClass to determine the file where the class lives
  136. * and then convert that to a textdomain string. Once determined, we cache it so that we
  137. * don't have to do this again.
  138. *
  139. * @param Wire|object $o
  140. * @return string
  141. *
  142. */
  143. protected function objectToTextdomain($o) {
  144. $class = wireClassName($o, false);
  145. if(isset($this->classNamesToTextdomains[$class])) {
  146. $textdomain = $this->classNamesToTextdomains[$class];
  147. } else {
  148. $reflection = new \ReflectionClass($o);
  149. $filename = $reflection->getFileName();
  150. $textdomain = $this->filenameToTextdomain($filename);
  151. $this->classNamesToTextdomains[$class] = $textdomain;
  152. $parentTextdomains = array();
  153. // core classes at which translations are no longer applicable
  154. // $stopClasses = array('Wire', 'WireData', 'WireArray', 'Fieldtype', 'FieldtypeMulti', 'Inputfield', 'Process');
  155. $stopClasses = array(
  156. 'Wire',
  157. 'WireData',
  158. 'WireArray',
  159. 'Process'
  160. );
  161. if(__NAMESPACE__) {
  162. foreach($stopClasses as $class) {
  163. $stopClass[] = __NAMESPACE__ . "\\$class";
  164. }
  165. }
  166. /** @var \ReflectionClass $parentClass */
  167. while($parentClass = $reflection->getParentClass()) {
  168. if(in_array($parentClass->getShortName(), $stopClasses)) break;
  169. $parentTextdomains[] = $this->filenameToTextdomain($parentClass->getFileName());
  170. $reflection = $parentClass;
  171. }
  172. $this->parentTextdomains[$textdomain] = $parentTextdomains;
  173. }
  174. return $textdomain;
  175. }
  176. /**
  177. * Given a filename, convert it to a textdomain string
  178. *
  179. * @param string $filename
  180. * @return string
  181. *
  182. */
  183. public function filenameToTextdomain($filename) {
  184. if(DIRECTORY_SEPARATOR != '/') $filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename);
  185. if(strpos($filename, $this->rootPath) === 0) {
  186. $filename = str_replace($this->rootPath, '', $filename);
  187. } else if($this->rootPath2 && strpos($filename, $this->rootPath2) === 0) {
  188. // in case /wire/ or /site/ is a symlink
  189. $filename = str_replace($this->rootPath2, '', $filename);
  190. } else {
  191. // last resort, may not ever occur, but here anyway
  192. $pos = strrpos($filename, '/wire/');
  193. if($pos === false) $pos = strrpos($filename, '/site/');
  194. if($pos !== false) $filename = substr($filename, $pos+1);
  195. }
  196. // convert FileCompiler paths
  197. $pos = stripos($filename, '/cache/FileCompiler/');
  198. if($pos) $filename = substr($filename, $pos+20);
  199. $textdomain = str_replace(array('/', '\\'), '--', ltrim($filename, '/'));
  200. $textdomain = str_replace('.', '-', $textdomain);
  201. return strtolower($textdomain);
  202. }
  203. /**
  204. * Given a textdomain string, convert it to a filename (relative to site root)
  205. *
  206. * This is determined by loading the textdomain and then grabbing the filename stored in the JSON properties
  207. *
  208. * @param string $textdomain
  209. * @return string
  210. *
  211. */
  212. public function textdomainToFilename($textdomain) {
  213. if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain);
  214. return $this->textdomains[$textdomain]['file'];
  215. }
  216. /**
  217. * Normalize a string, filename or object to be a textdomain string
  218. *
  219. * @param string|object $textdomain
  220. * @return string
  221. *
  222. */
  223. protected function textdomainString($textdomain) {
  224. if(is_string($textdomain) && (strpos($textdomain, DIRECTORY_SEPARATOR) !== false || strpos($textdomain, '/') !== false)) $textdomain = $this->filenameToTextdomain($textdomain); // @werker #424
  225. else if(is_object($textdomain)) $textdomain = $this->objectToTextdomain($textdomain);
  226. else $textdomain = strtolower($textdomain);
  227. // just in case there is an extension on it, remove it
  228. if(strpos($textdomain, '.')) $textdomain = basename($textdomain, '.json');
  229. return $textdomain;
  230. }
  231. /**
  232. * Perform a translation in the given $textdomain for $text to the current language
  233. *
  234. * @param string|object $textdomain Textdomain string, filename, or object.
  235. * @param string $text Text in default language (EN) that needs to be converted to current language.
  236. * @param string $context Optional context label for the text, to differentiate from others that may be the same in English, but not other languages.
  237. * @return string Translation if available, or original EN version if translation not available.
  238. *
  239. */
  240. public function getTranslation($textdomain, $text, $context = '') {
  241. if($this->wire('hooks')->isHooked('LanguageTranslator::getTranslation()')) {
  242. // if method has hooks, we let them run
  243. return $this->__call('getTranslation', array($textdomain, $text, $context));
  244. } else {
  245. // if method has no hooks, we avoid any overhead
  246. return $this->___getTranslation($textdomain, $text, $context);
  247. }
  248. }
  249. /**
  250. * Implementation for the getTranslation() function - you should call getTranslation() without underscores instead.
  251. *
  252. * @param string|object $textdomain Textdomain string, filename, or object.
  253. * @param string $text Text in default language (EN) that needs to be converted to current language.
  254. * @param string $context Optional context label for the text, to differentiate from others that may be the same in English, but not other languages.
  255. * @return string Translation if available, or original EN version if translation not available.
  256. *
  257. */
  258. public function ___getTranslation($textdomain, $text, $context = '') {
  259. // normalize textdomain to be a string, converting from filename or object if necessary
  260. $textdomain = $this->textdomainString($textdomain);
  261. $_text = $text;
  262. // if the text is already provided in the proper language then no reason to go further
  263. // if($this->currentLanguage->id == $this->defaultLanguagePageID) return $text;
  264. // hash of original text
  265. $hash = $this->getTextHash($text . $context);
  266. // translation textdomain hasn't yet been loaded, so load it
  267. if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain);
  268. // see if this translation exists
  269. if(!empty($this->textdomains[$textdomain]['translations'][$hash]['text'])) {
  270. // translation found
  271. $text = $this->textdomains[$textdomain]['translations'][$hash]['text'];
  272. } else if(!empty($this->parentTextdomains[$textdomain])) {
  273. // check parent class textdomains
  274. foreach($this->parentTextdomains[$textdomain] as $td) {
  275. if(!isset($this->textdomains[$td])) $this->loadTextdomain($td);
  276. if(!empty($this->textdomains[$td]['translations'][$hash]['text'])) {
  277. $text = $this->textdomains[$td]['translations'][$hash]['text'];
  278. break;
  279. }
  280. }
  281. }
  282. // see if text is available as a common translation
  283. if($text === $_text && !$this->isDefaultLanguage) {
  284. $_text = $this->commonTranslation($text);
  285. if(!empty($_text)) $text = $_text;
  286. }
  287. // if text hasn't changed at this point, we'll be returning it in the provided language since we have no translation
  288. return $text;
  289. }
  290. /**
  291. * Return ALL translations for the given textdomain
  292. *
  293. * @param string $textdomain
  294. * @return array
  295. *
  296. */
  297. public function getTranslations($textdomain) {
  298. // normalize to string
  299. $textdomain = $this->textdomainString($textdomain);
  300. // translation textdomain hasn't yet been loaded, so load it
  301. if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain);
  302. // return the translations array
  303. return $this->textdomains[$textdomain]['translations'];
  304. }
  305. /**
  306. * Set a translation
  307. *
  308. * @param string $textdomain
  309. * @param string $text
  310. * @param string $translation
  311. * @param string $context
  312. * @return string
  313. *
  314. */
  315. public function setTranslation($textdomain, $text, $translation, $context = '') {
  316. // get the unique hash identifier for the $text
  317. $hash = $this->getTextHash($text . $context);
  318. return $this->setTranslationFromHash($textdomain, $hash, $translation);
  319. }
  320. /**
  321. * Set a translation using an already known hash
  322. *
  323. * @param string $textdomain
  324. * @param string $hash
  325. * @param string $translation
  326. * @return string
  327. *
  328. */
  329. public function setTranslationFromHash($textdomain, $hash, $translation) {
  330. // if the textdomain isn't yet setup, then set it up
  331. if(!isset($this->textdomains[$textdomain]) || !is_array($this->textdomains[$textdomain])) {
  332. $this->textdomains[$textdomain] = $this->textdomainTemplate();
  333. }
  334. // populate the new translation
  335. if(strlen($translation)) $this->textdomains[$textdomain]['translations'][$hash] = array('text' => $translation);
  336. else unset($this->textdomains[$textdomain]['translations'][$hash]);
  337. // return the unique hash used to identify the translation
  338. return $hash;
  339. }
  340. /**
  341. * Remove a translation
  342. *
  343. * @param string $textdomain
  344. * @param string $hash May be the translation hash or the translated text.
  345. * @return $this
  346. *
  347. */
  348. public function removeTranslation($textdomain, $hash) {
  349. if(empty($hash)) return $this;
  350. if(isset($this->textdomains[$textdomain]['translations'][$hash])) {
  351. // remove by $hash
  352. unset($this->textdomains[$textdomain]['translations'][$hash]);
  353. } else {
  354. // remove by given translation (in $hash)
  355. $text = $hash;
  356. foreach($this->textdomains[$textdomain]['translations'] as $hash => $translation) {
  357. if($translation['text'] === $text) {
  358. unset($this->textdomains[$textdomain]['translations'][$hash]);
  359. break;
  360. }
  361. }
  362. }
  363. return $this;
  364. }
  365. /**
  366. * Given original $text, issue a unique MD5 key used to reference it
  367. *
  368. * @param string $text
  369. * @return string
  370. *
  371. */
  372. protected function getTextHash($text) {
  373. return md5($text);
  374. }
  375. /**
  376. * Get the JSON filename where the current languages class translations are
  377. *
  378. * @param string $textdomain
  379. * @return string
  380. *
  381. */
  382. protected function getTextdomainTranslationFile($textdomain) {
  383. $textdomain = $this->textdomainString($textdomain);
  384. return $this->path . $textdomain . ".json";
  385. }
  386. /**
  387. * Does a json translation file exist for the given textdomain?
  388. *
  389. * @param string $textdomain
  390. * @return bool
  391. *
  392. */
  393. public function textdomainFileExists($textdomain) {
  394. $file = $this->getTextdomainTranslationFile($textdomain);
  395. return is_file($file);
  396. }
  397. /**
  398. * Load translation group $textdomain into the current language translations
  399. *
  400. * @param string $textdomain
  401. * @return $this
  402. *
  403. */
  404. public function loadTextdomain($textdomain) {
  405. $textdomain = $this->textdomainString($textdomain);
  406. if(isset($this->textdomains[$textdomain]) && is_array($this->textdomains[$textdomain])) return $this;
  407. $file = $this->getTextdomainTranslationFile($textdomain);
  408. if(is_file($file)) {
  409. $data = json_decode(file_get_contents($file), true);
  410. $this->textdomains[$textdomain] = $this->textdomainTemplate($data['file'], $data['textdomain'], $data['translations']);
  411. } else {
  412. $this->textdomains[$textdomain] = $this->textdomainTemplate('', $textdomain);
  413. }
  414. return $this;
  415. }
  416. /**
  417. * Given a source file to translate, create a new textdomain
  418. *
  419. * @param string $filename Filename or textdomain that we will be translating, relative to site root.
  420. * @param bool $filenameIsTextdomain Specify true if $filename is a textdomain instead.
  421. * @param bool $save Whether to save the language
  422. * @return string|bool Returns textdomain string if successful, or false if not.
  423. *
  424. */
  425. public function addFileToTranslate($filename, $filenameIsTextdomain = false, $save = true) {
  426. if($filenameIsTextdomain) {
  427. $textdomain = $filename;
  428. $filename = $this->textdomainToFilename($textdomain);
  429. // $this->message($textdomain . ": " . $filename);
  430. } else {
  431. $textdomain = $this->filenameToTextdomain($filename);
  432. }
  433. $this->textdomains[$textdomain] = $this->textdomainTemplate(ltrim($filename, '/'), $textdomain);
  434. $file = $this->getTextdomainTranslationFile($textdomain);
  435. $result = file_put_contents($file, $this->encodeJSON($this->textdomains[$textdomain]), LOCK_EX);
  436. if($result && $this->config->chmodFile) chmod($file, octdec($this->config->chmodFile));
  437. if($result) {
  438. $fieldName = 'language_files';
  439. if(strpos($textdomain, 'wire--') !== 0) {
  440. if($this->wire('fields')->get('language_files_site')) {
  441. $fieldName = 'language_files_site';
  442. }
  443. }
  444. $this->currentLanguage->$fieldName->add($file);
  445. if($save) $this->currentLanguage->save();
  446. }
  447. return $result ? $textdomain : false;
  448. }
  449. /**
  450. * Save the translation group given by $textdomain to disk in its translation file
  451. *
  452. * @param string $textdomain
  453. * @return int|bool Number of bytes written or false on failure
  454. *
  455. */
  456. public function saveTextdomain($textdomain) {
  457. if(empty($this->textdomains[$textdomain])) return false;
  458. $data = $this->textdomains[$textdomain];
  459. //if(empty($data['file'])) $data['file'] = $this->textdomainToFilename($textdomain);
  460. $json = $this->encodeJSON($data);
  461. $file = $this->getTextdomainTranslationFile($textdomain);
  462. $result = file_put_contents($file, $json, LOCK_EX);
  463. return $result;
  464. }
  465. /**
  466. * Unload the given textdomain string from memory
  467. *
  468. * @param string $textdomain
  469. *
  470. */
  471. public function unloadTextdomain($textdomain) {
  472. unset($this->textdomains[$textdomain]);
  473. }
  474. /**
  475. * Return the data available for the given $textdomain string
  476. *
  477. * @param string $textdomain
  478. * @return array
  479. *
  480. */
  481. public function getTextdomain($textdomain) {
  482. $this->loadTextdomain($textdomain);
  483. return isset($this->textdomains[$textdomain]) ? $this->textdomains[$textdomain] : array();
  484. }
  485. /**
  486. * JSON encode language translation data
  487. *
  488. * @param string $str
  489. * @return string
  490. *
  491. */
  492. public function encodeJSON($str) {
  493. if(defined("JSON_PRETTY_PRINT")) {
  494. return json_encode($str, JSON_PRETTY_PRINT);
  495. } else {
  496. return json_encode($str);
  497. }
  498. }
  499. /**
  500. * Get a common translation
  501. *
  502. * These are commonly used translations that can be used as fallbacks.
  503. *
  504. * Returns blank string if given string is not a common phrase.
  505. * Returns given $str if given string is common, but not translated here.
  506. * Returns translated $str if common and translated.
  507. *
  508. * @param string $str
  509. * @return string
  510. *
  511. */
  512. public function commonTranslation($str) {
  513. static $level = 0;
  514. if(strlen($str) >= 15 || $level) return ''; // 15=max length of our common phrases
  515. $level++;
  516. $v = '';
  517. switch(strtolower($str)) {
  518. case 'edit': $v = $this->_('Edit'); break;
  519. case 'delete': $v = $this->_('Delete'); break;
  520. case 'save': $v = $this->_('Save'); break;
  521. case 'save & exit':
  522. case 'save and exit':
  523. case 'save + exit': $v = $this->_('Save + Exit'); break;
  524. case 'cancel': $v = $this->_('Cancel'); break;
  525. case 'ok': $v = $this->_('Ok'); break;
  526. case 'new': $v = $this->_('New'); break;
  527. case 'add': $v = $this->_('Add'); break;
  528. case 'add new': $v = $this->_('Add New'); break;
  529. case 'are you sure?': $v = $this->_('Are you sure?'); break;
  530. case 'confirm': $v = $this->_('Confirm'); break;
  531. case 'import': $v = $this->_('Import'); break;
  532. case 'export': $v = $this->_('Export'); break;
  533. case 'yes': $v = $this->_('Yes'); break;
  534. case 'no': $v = $this->_('No'); break;
  535. case 'on': $v = $this->_('On'); break;
  536. case 'off': $v = $this->_('Off'); break;
  537. case 'enabled': $v = $this->_('Enabled'); break;
  538. case 'disabled': $v = $this->_('Disabled'); break;
  539. case 'example': $v = $this->_('Example'); break;
  540. case 'please note': $v = $this->_('Please note:'); break;
  541. case 'note': $v = $this->_('Note'); break;
  542. case 'notes': $v = $this->_('Notes'); break;
  543. case 'settings': $v = $this->_('Settings'); break;
  544. case 'type': $v = $this->_('Type'); break;
  545. case 'label': $v = $this->_('Label'); break;
  546. case 'name': $v = $this->_('Name'); break;
  547. case 'description': $v = $this->_('Description'); break;
  548. case 'details': $v = $this->_('Details'); break;
  549. case 'access': $v = $this->_('Access'); break;
  550. case 'advanced': $v = $this->_('Advanced'); break;
  551. case 'icon': $v = $this->_('Icon'); break;
  552. case 'system': $v = $this->_('System'); break;
  553. case 'modified': $v = $this->_('Modified'); break;
  554. case 'error': $v = $this->_('Error'); break;
  555. }
  556. $level--;
  557. return $v;
  558. }
  559. }