1<?php 2namespace Fisharebest\Webtrees; 3 4/** 5 * webtrees: online genealogy 6 * Copyright (C) 2015 webtrees development team 7 * This program is free software: you can redistribute it and/or modify 8 * it under the terms of the GNU General Public License as published by 9 * the Free Software Foundation, either version 3 of the License, or 10 * (at your option) any later version. 11 * This program is distributed in the hope that it will be useful, 12 * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 * GNU General Public License for more details. 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 */ 18 19use Fisharebest\Localization\Locale; 20use Fisharebest\Localization\Locale\LocaleEnUs; 21use Fisharebest\Localization\Locale\LocaleInterface; 22use Fisharebest\Localization\Translation; 23use Fisharebest\Localization\Translator; 24use Patchwork\TurkishUtf8; 25 26/** 27 * Class I18N - Functions to support internationalization (i18n) functionality. 28 */ 29class I18N { 30 /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 31 private static $locale; 32 33 /** @var Translator */ 34 private static $translator; 35 36 // Digits are always rendered LTR, even in RTL text. 37 const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 38 39 // Reversable character conversions from the UNICODE 5.1 database. 40 // It excludes ambiguous (turkish dotless i) and mixed-case (Dz) characters. 41 // The characters should be arranged in default unicode-collation order. 42 const ALPHABET_LOWER = 'aàáâãäåāăąǎǟǡǻȁȃȧḁạảấầẩẫậắằẳẵặⓐaæǣǽbḃḅḇⓑbƀɓƃcçćĉċčḉⅽⓒcƈdďḋḍḏḑḓⅾⓓddždzđɖɗƌðeèéêëēĕėęěȅȇȩḕḗḙḛḝẹẻẽếềểễệⓔeǝəɛfḟⓕfƒgĝğġģǧǵḡⓖgǥɠɣƣhĥȟḣḥḧḩḫⓗhƕħiìíîïĩīĭįǐȉȋḭḯỉịⅰⓘiⅱⅲijⅳⅸɨɩjĵⓙjkķǩḱḳḵⓚkƙlĺļľḷḹḻḽⅼⓛlŀljłƚmḿṁṃⅿⓜmnñńņňǹṅṇṉṋⓝnnjɲƞŋoòóôõöōŏőơǒǫǭȍȏȫȭȯȱṍṏṑṓọỏốồổỗộớờởỡợⓞoœøǿɔɵȣpṕṗⓟpƥqⓠqrŕŗřȑȓṙṛṝṟⓡrʀsśŝşšșṡṣṥṧṩⓢsʃtţťțṫṭṯṱⓣtŧƭʈuùúûüũūŭůűųưǔǖǘǚǜȕȗṳṵṷṹṻụủứừửữựⓤuʉɯʊvṽṿⅴⓥvⅵⅶⅷʋʌwŵẁẃẅẇẉⓦwxẋẍⅹⓧxⅺⅻyýÿŷȳẏỳỵỷỹⓨyƴzźżžẑẓẕⓩzƶȥǯʒƹȝþƿƨƽƅάαἀἁἂἃἄἅἆἇὰάᾀᾁᾂᾃᾄᾅᾆᾇᾰᾱᾳβγδέεἐἑἒἓἔἕὲέϝϛζήηἠἡἢἣἤἥἦἧὴήᾐᾑᾒᾓᾔᾕᾖᾗῃθϊἰἱἲἳἴἵἶἷὶίῐῑκϗλμνξοόὀὁὂὃὄὅὸόπϟϙρῥσϲτυϋύὑὓὕὗὺύῠῡφχψωώὠὡὢὣὤὥὦὧὼώᾠᾡᾢᾣᾤᾥᾦᾧῳϡϸϻϣϥϧϩϫϭϯаӑӓәӛӕбвгґғҕдԁђԃѓҙеѐёӗєжӂӝҗзԅӟѕӡԇиѝӣҋӥіїйјкқӄҡҟҝлӆљԉмӎнӊңӈҥњԋоӧөӫпҧҁрҏсԍҫтԏҭћќуӯўӱӳүұѹфхҳһѡѿѽѻцҵчӵҷӌҹҽҿџшщъыӹьҍѣэӭюяѥѧѫѩѭѯѱѳѵѷҩաբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆȼɂɇɉɋɍɏͱͳͷͻͼͽӏӷӻӽӿԑԓԕԗԙԛԝԟԡԣԥᵹᵽỻỽỿⅎↄⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞⱡⱨⱪⱬⱳⱶⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣⳬⳮⴀⴁⴂⴃⴄⴅⴆⴇⴈⴉⴊⴋⴌⴍⴎⴏⴐⴑⴒⴓⴔⴕⴖⴗⴘⴙⴚⴛⴜⴝⴞⴟⴠⴡⴢⴣⴤⴥꙁꙃꙅꙇꙉꙋꙍꙏꙑꙓꙕꙗꙙꙛꙝꙟꙣꙥꙧꙩꙫꙭꚁꚃꚅꚇꚉꚋꚍꚏꚑꚓꚕꚗꜣꜥꜧꜩꜫꜭꜯꜳꜵꜷꜹꜻꜽꜿꝁꝃꝅꝇꝉꝋꝍꝏꝑꝓꝕꝗꝙꝛꝝꝟꝡꝣꝥꝧꝩꝫꝭꝯꝺꝼꝿꞁꞃꞅꞇꞌ'; 43 const ALPHABET_UPPER = 'AÀÁÂÃÄÅĀĂĄǍǞǠǺȀȂȦḀẠẢẤẦẨẪẬẮẰẲẴẶⒶAÆǢǼBḂḄḆⒷBɃƁƂCÇĆĈĊČḈⅭⒸCƇDĎḊḌḎḐḒⅮⒹDDŽDZĐƉƊƋÐEÈÉÊËĒĔĖĘĚȄȆȨḔḖḘḚḜẸẺẼẾỀỂỄỆⒺEƎƏƐFḞⒻFƑGĜĞĠĢǦǴḠⒼGǤƓƔƢHĤȞḢḤḦḨḪⒽHǶĦIÌÍÎÏĨĪĬĮǏȈȊḬḮỈỊⅠⒾIⅡⅢIJⅣⅨƗƖJĴⒿJKĶǨḰḲḴⓀKƘLĹĻĽḶḸḺḼⅬⓁLĿLJŁȽMḾṀṂⅯⓂMNÑŃŅŇǸṄṆṈṊⓃNNJƝȠŊOÒÓÔÕÖŌŎŐƠǑǪǬȌȎȪȬȮȰṌṎṐṒỌỎỐỒỔỖỘỚỜỞỠỢⓄOŒØǾƆƟȢPṔṖⓅPƤQⓆQRŔŖŘȐȒṘṚṜṞⓇRƦSŚŜŞŠȘṠṢṤṦṨⓈSƩTŢŤȚṪṬṮṰⓉTŦƬƮUÙÚÛÜŨŪŬŮŰŲƯǓǕǗǙǛȔȖṲṴṶṸṺỤỦỨỪỬỮỰⓊUɄƜƱVṼṾⅤⓋVⅥⅦⅧƲɅWŴẀẂẄẆẈⓌWXẊẌⅩⓍXⅪⅫYÝŸŶȲẎỲỴỶỸⓎYƳZŹŻŽẐẒẔⓏZƵȤǮƷƸȜÞǷƧƼƄΆΑἈἉἊἋἌἍἎἏᾺΆᾈᾉᾊᾋᾌᾍᾎᾏᾸᾹᾼΒΓΔΈΕἘἙἚἛἜἝῈΈϜϚΖΉΗἨἩἪἫἬἭἮἯῊΉᾘᾙᾚᾛᾜᾝᾞᾟῌΘΪἸἹἺἻἼἽἾἿῚΊῘῙΚϏΛΜΝΞΟΌὈὉὊὋὌὍῸΌΠϞϘΡῬΣϹΤΥΫΎὙὛὝὟῪΎῨῩΦΧΨΩΏὨὩὪὫὬὭὮὯῺΏᾨᾩᾪᾫᾬᾭᾮᾯῼϠϷϺϢϤϦϨϪϬϮАӐӒӘӚӔБВГҐҒҔДԀЂԂЃҘЕЀЁӖЄЖӁӜҖЗԄӞЅӠԆИЍӢҊӤІЇЙЈКҚӃҠҞҜЛӅЉԈМӍНӉҢӇҤЊԊОӦӨӪПҦҀРҎСԌҪТԎҬЋЌУӮЎӰӲҮҰѸФХҲҺѠѾѼѺЦҴЧӴҶӋҸҼҾЏШЩЪЫӸЬҌѢЭӬЮЯѤѦѪѨѬѮѰѲѴѶҨԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖȻɁɆɈɊɌɎͰͲͶϽϾϿӀӶӺӼӾԐԒԔԖԘԚԜԞԠԢԤꝽⱣỺỼỾℲↃⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮⱠⱧⱩⱫⱲⱵⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢⳫⳭႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅꙀꙂꙄꙆꙈꙊꙌꙎꙐꙒꙔꙖꙘꙚꙜꙞꙢꙤꙦꙨꙪꙬꚀꚂꚄꚆꚈꚊꚌꚎꚐꚒꚔꚖꜢꜤꜦꜨꜪꜬꜮꜲꜴꜶꜸꜺꜼꜾꝀꝂꝄꝆꝈꝊꝌꝎꝐꝒꝔꝖꝘꝚꝜꝞꝠꝢꝤꝦꝨꝪꝬꝮꝹꝻꝾꞀꞂꞄꞆꞋ'; 44 45 // Alphabet for the currently selected locale 46 private static $alphabet_lower = 'abcdefghijklmnopqrstuvwxyz'; 47 private static $alphabet_upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 48 49 // Lookup table to convert unicode code-points into scripts. 50 // See https://en.wikipedia.org/wiki/Unicode_block 51 // Note: we only need details for scripts of languages into which webtrees is translated. 52 private static $scripts = array( 53 array('Latn', 0x0041, 0x005A), // a-z 54 array('Latn', 0x0061, 0x007A), // A-Z 55 array('Latn', 0x0100, 0x02AF), 56 array('Grek', 0x0370, 0x03FF), 57 array('Cyrl', 0x0400, 0x052F), 58 array('Hebr', 0x0590, 0x05FF), 59 array('Arab', 0x0600, 0x06FF), 60 array('Arab', 0x0750, 0x077F), 61 array('Arab', 0x08A0, 0x08FF), 62 array('Deva', 0x0900, 0x097F), 63 array('Taml', 0x0B80, 0x0BFF), 64 array('Sinh', 0x0D80, 0x0DFF), 65 array('Thai', 0x0E00, 0x0E7F), 66 array('Geor', 0x10A0, 0x10FF), 67 array('Grek', 0x1F00, 0x1FFF), 68 array('Deva', 0xA8E0, 0xA8FF), 69 array('Hans', 0x3000, 0x303F), // Mixed CJK, not just Hans 70 array('Hans', 0x3400, 0xFAFF), // Mixed CJK, not just Hans 71 array('Hans', 0x20000, 0x2FA1F), // Mixed CJK, not just Hans 72 ); 73 74 // Characters that are displayed in mirror form in RTL text. 75 private static $mirror_characters = array( 76 '(' => ')', 77 ')' => '(', 78 '[' => ']', 79 ']' => '[', 80 '{' => '}', 81 '}' => '{', 82 '<' => '>', 83 '>' => '<', 84 '‹' => '›', 85 '›' => '‹', 86 '«' => '»', 87 '»' => '«', 88 '﴾' => '﴿', 89 '﴿' => '﴾', 90 '“' => '”', 91 '”' => '“', 92 '‘' => '’', 93 '’' => '‘', 94 ); 95 96 /** @var string Punctuation used to separate list items, typically a comma */ 97 public static $list_separator; 98 99 /** 100 * The prefered locales for this site, or a default list if no preference. 101 * 102 * @return LocaleInterface[] 103 */ 104 public static function activeLocales() { 105 $code_list = Site::getPreference('LANGUAGES'); 106 107 if ($code_list) { 108 $codes = explode(',', $code_list); 109 } else { 110 $codes = array( 111 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'el', 'en-GB', 'en-US', 'es', 112 'et', 'fi', 'fr', 'he', 'hr', 'hu', 'is', 'it', 'ka', 'lt', 'mr', 'nb', 113 'nl', 'nn', 'pl', 'pt', 'ru', 'sk', 'sv', 'tr', 'uk', 'vi', 'zh-Hans', 114 ); 115 } 116 117 $locales = array(); 118 foreach ($codes as $code) { 119 if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) { 120 try { 121 $locales[] = Locale::create($code); 122 } catch (\Exception $ex) { 123 // No such locale exists? 124 } 125 } 126 } 127 usort($locales, '\Fisharebest\Localization\Locale::compare'); 128 129 return $locales; 130 } 131 132 /** 133 * Which MySQL collation should be used for this locale? 134 * 135 * @return string 136 */ 137 public static function collation() { 138 $collation = self::$locale->collation(); 139 switch ($collation) { 140 case 'croatian_ci': 141 case 'german2_ci': 142 case 'vietnamese_ci': 143 // Only available in MySQL 5.6 144 return 'utf8_unicode_ci'; 145 default: 146 return 'utf8_' . $collation; 147 } 148 } 149 150 /** 151 * What format is used to display dates in the current locale? 152 * 153 * @return string 154 */ 155 public static function dateFormat() { 156 return /* I18N: This is the format string for full dates. See http://php.net/date for codes */ self::$translator->translate('%j %F %Y'); 157 } 158 159 /** 160 * Generate consistent I18N for datatables.js 161 * 162 * @param array|null $lengths An optional array of page lengths 163 * 164 * @return string 165 */ 166 public static function datatablesI18N(array $lengths = null) { 167 if ($lengths === null) { 168 $lengths = array(10, 20, 30, 50, 100, -1); 169 } 170 171 $length_menu = ''; 172 foreach ($lengths as $length) { 173 $length_menu .= 174 '<option value="' . $length . '">' . 175 ($length === -1 ? /* I18N: listbox option, e.g. “10,25,50,100,all” */ self::translate('All') : self::number($length)) . 176 '</option>'; 177 } 178 $length_menu = '<select>' . $length_menu . '</select>'; 179 $length_menu = /* I18N: Display %s [records per page], %s is a placeholder for listbox containing numeric options */ self::translate('Display %s', $length_menu); 180 181 $digits = self::$locale->digits('0123456789'); 182 if ($digits === '0123456789') { 183 $callback = ''; 184 } else { 185 $callback = ', 186 "infoCallback": function(oSettings, iStart, iEnd, iMax, iTotal, sPre) { 187 return sPre 188 .replace(/0/g, "' . mb_substr($digits, 0, 1) . '") 189 .replace(/1/g, "' . mb_substr($digits, 1, 1) . '") 190 .replace(/2/g, "' . mb_substr($digits, 2, 1) . '") 191 .replace(/3/g, "' . mb_substr($digits, 3, 1) . '") 192 .replace(/4/g, "' . mb_substr($digits, 4, 1) . '") 193 .replace(/5/g, "' . mb_substr($digits, 5, 1) . '") 194 .replace(/6/g, "' . mb_substr($digits, 6, 1) . '") 195 .replace(/7/g, "' . mb_substr($digits, 7, 1) . '") 196 .replace(/8/g, "' . mb_substr($digits, 8, 1) . '") 197 .replace(/9/g, "' . mb_substr($digits, 9, 1) . '"); 198 }, 199 "formatNumber": function(iIn) { 200 return String(iIn) 201 .replace(/0/g, "' . mb_substr($digits, 0, 1) . '") 202 .replace(/1/g, "' . mb_substr($digits, 1, 1) . '") 203 .replace(/2/g, "' . mb_substr($digits, 2, 1) . '") 204 .replace(/3/g, "' . mb_substr($digits, 3, 1) . '") 205 .replace(/4/g, "' . mb_substr($digits, 4, 1) . '") 206 .replace(/5/g, "' . mb_substr($digits, 5, 1) . '") 207 .replace(/6/g, "' . mb_substr($digits, 6, 1) . '") 208 .replace(/7/g, "' . mb_substr($digits, 7, 1) . '") 209 .replace(/8/g, "' . mb_substr($digits, 8, 1) . '") 210 .replace(/9/g, "' . mb_substr($digits, 9, 1) . '"); 211 } 212 '; 213 } 214 215 return 216 '"language": {' . 217 ' "paginate": {' . 218 ' "first": "' . /* I18N: button label, first page */ self::translate('first') . '",' . 219 ' "last": "' . /* I18N: button label, last page */ self::translate('last') . '",' . 220 ' "next": "' . /* I18N: button label, next page */ self::translate('next') . '",' . 221 ' "previous": "' . /* I18N: button label, previous page */ self::translate('previous') . '"' . 222 ' },' . 223 ' "emptyTable": "' . self::translate('No records to display') . '",' . 224 ' "info": "' . /* I18N: %s are placeholders for numbers */ self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' . 225 ' "infoEmpty": "' . self::translate('Showing %1$s to %2$s of %3$s', 0, 0, 0) . '",' . 226 ' "infoFiltered": "' . /* I18N: %s is a placeholder for a number */ self::translate('(filtered from %s total entries)', '_MAX_') . '",' . 227 ' "infoPostfix": "",' . 228 ' "lengthMenu": "' . Filter::escapeJs($length_menu) . '",' . 229 ' "loadingRecords": "' . self::translate('Loading…') . '",' . 230 ' "processing": "' . self::translate('Loading…') . '",' . 231 ' "search": "' . self::translate('Filter') . '",' . 232 ' "url": "",' . 233 ' "zeroRecords": "' . self::translate('No records to display') . '"' . 234 '}' . 235 $callback; 236 } 237 238 /** 239 * Convert the digits 0-9 into the local script 240 * 241 * Used for years, etc., where we do not want thousands-separators, decimals, etc. 242 * 243 * @param integer $n 244 * 245 * @return string 246 */ 247 public static function digits($n) { 248 return self::$locale->digits($n); 249 } 250 251 /** 252 * What is the direction of the current locale 253 * 254 * @return string "ltr" or "rtl" 255 */ 256 public static function direction() { 257 return self::$locale->direction(); 258 } 259 260 /** 261 * Convert a GEDCOM age string into translated_text 262 * 263 * NB: The import function will have normalised this, so we don't need 264 * to worry about badly formatted strings 265 * NOTE: this function is not yet complete - eventually it will replace get_age_at_event() 266 * 267 * @param $string 268 * 269 * @return string 270 */ 271 public static function gedcomAge($string) { 272 switch ($string) { 273 case 'STILLBORN': 274 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 275 return self::translate('(stillborn)'); 276 case 'INFANT': 277 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 278 return self::translate('(in infancy)'); 279 case 'CHILD': 280 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 281 return self::translate('(in childhood)'); 282 } 283 $age = array(); 284 if (preg_match('/(\d+)y/', $string, $match)) { 285 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 286 $years = $match[1]; 287 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 288 } else { 289 $years = -1; 290 } 291 if (preg_match('/(\d+)m/', $string, $match)) { 292 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 293 $age[] = self::plural('%s month', '%s months', $match[1], self::number($match[1])); 294 } 295 if (preg_match('/(\d+)w/', $string, $match)) { 296 // I18N: Part of an age string. e.g. 7 weeks and 3 days 297 $age[] = self::plural('%s week', '%s weeks', $match[1], self::number($match[1])); 298 } 299 if (preg_match('/(\d+)d/', $string, $match)) { 300 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 301 $age[] = self::plural('%s day', '%s days', $match[1], self::number($match[1])); 302 } 303 // If an age is just a number of years, only show the number 304 if (count($age) === 1 && $years >= 0) { 305 $age = $years; 306 } 307 if ($age) { 308 if (!substr_compare($string, '<', 0, 1)) { 309 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years) 310 return self::translate('(aged less than %s)', $age); 311 } elseif (!substr_compare($string, '>', 0, 1)) { 312 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years) 313 return self::translate('(aged more than %s)', $age); 314 } else { 315 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 316 return self::translate('(aged %s)', $age); 317 } 318 } else { 319 // Not a valid string? 320 return self::translate('(aged %s)', $string); 321 } 322 } 323 324 /** 325 * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 326 * 327 * @return string 328 */ 329 public static function htmlAttributes() { 330 return self::$locale->htmlAttributes(); 331 } 332 333 /** 334 * Initialise the translation adapter with a locale setting. 335 * 336 * @param string|null $code Use this locale/language code, or choose one automatically 337 * 338 * @return string $string 339 */ 340 public static function init($code = null) { 341 global $WT_SESSION, $WT_TREE; 342 343 if ($code !== null) { 344 // Create the specified locale 345 self::$locale = Locale::create($code); 346 } else { 347 // Negotiate a locale, but if we can't then use a failsafe 348 self::$locale = new LocaleEnUs; 349 if (Filter::get('lang')) { 350 // A request in the URL 351 try { 352 $locale = Locale::create(Filter::get('lang')); 353 if (file_exists(WT_ROOT . 'language/' . $locale->languageTag() . '.mo')) { 354 self::$locale = $locale; 355 } 356 } catch (\Exception $ex) { 357 } 358 } elseif ($WT_SESSION->locale) { 359 // Previously used 360 self::$locale = Locale::create($WT_SESSION->locale); 361 } else { 362 // Browser negotiation 363 $default_locale = new LocaleEnUs; 364 try { 365 if ($WT_TREE) { 366 $default_locale = Locale::create($WT_TREE->getPreference('LANGUAGE')); 367 } 368 } catch (\Exception $ex) { 369 } 370 self::$locale = Locale::httpAcceptLanguage($_SESSION, self::installedLocales(), $default_locale); 371 } 372 } 373 374 File::mkdir(WT_DATA_DIR . 'cache'); 375 $cache_file = WT_DATA_DIR . 'cache/language-' . self::$locale->languageTag() . '-cache.php'; 376 if (file_exists($cache_file)) { 377 $filemtime = filemtime($cache_file); 378 } else { 379 $filemtime = 0; 380 } 381 382 // Load the translation file(s) 383 // Note that glob() returns false instead of an empty array when open_basedir_restriction 384 // is in force and no files are found. See PHP bug #47358. 385 $translation_files = array_merge( 386 array(WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'), 387 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: array(), 388 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: array() 389 ); 390 391 // Rebuild files after 2 hours 392 $rebuild_cache = time() > $filemtime + 7200; 393 // Rebuild files if any translation file has been updated 394 foreach ($translation_files as $translation_file) { 395 if (filemtime($translation_file) > $filemtime) { 396 $rebuild_cache = true; 397 break; 398 } 399 } 400 401 if ($rebuild_cache) { 402 $translations = array(); 403 foreach ($translation_files as $translation_file) { 404 $translation = new Translation($translation_file); 405 $translations = array_merge($translations, $translation->asArray()); 406 } 407 file_put_contents($cache_file, '<' . '?php return ' . var_export($translations, true) . ';'); 408 } else { 409 $translations = include $cache_file; 410 } 411 412 // Create a translator 413 self::$translator = new Translator($translations, self::$locale->pluralRule()); 414 415 // Alphabetic sorting sequence (upper-case letters), used by webtrees to sort strings 416 list(, self::$alphabet_upper) = explode('=', self::$translator->translate('ALPHABET_upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ')); 417 // Alphabetic sorting sequence (lower-case letters), used by webtrees to sort strings 418 list(, self::$alphabet_lower) = explode('=', self::$translator->translate('ALPHABET_lower=abcdefghijklmnopqrstuvwxyz')); 419 420 global $WEEK_START; 421 $WEEK_START = self::$locale->territory()->firstDay(); 422 423 self::$list_separator = /* I18N: This punctuation is used to separate lists of items */ self::translate(', '); 424 425 return self::$locale->languageTag(); 426 } 427 428 /** 429 * All locales for which a translation file exists. 430 * 431 * @return LocaleInterface[] 432 */ 433 public static function installedLocales() { 434 $locales = array(); 435 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 436 try { 437 $locales[] = Locale::create(basename($file, '.mo')); 438 } catch (\Exception $ex) { 439 // Not a recognised locale 440 } 441 } 442 usort($locales, '\Fisharebest\Localization\Locale::compare'); 443 444 return $locales; 445 } 446 447 /** 448 * Return the endonym for a given language - as per http://cldr.unicode.org/ 449 * 450 * @param string $locale 451 * 452 * @return string 453 */ 454 public static function languageName($locale) { 455 return Locale::create($locale)->endonym(); 456 } 457 458 /** 459 * Return the script used by a given language 460 * 461 * @param string $locale 462 * 463 * @return string 464 */ 465 public static function languageScript($locale) { 466 return Locale::create($locale)->script()->code(); 467 } 468 469 /** 470 * Return the current locale object 471 * 472 * @return LocaleInterface 473 */ 474 public static function locale() { 475 return self::$locale; 476 } 477 478 /** 479 * Translate a number into the local representation. 480 * 481 * e.g. 12345.67 becomes 482 * en: 12,345.67 483 * fr: 12 345,67 484 * de: 12.345,67 485 * 486 * @param float $n 487 * @param integer $precision 488 * 489 * @return string 490 */ 491 public static function number($n, $precision = 0) { 492 return self::$locale->number(round($n, $precision)); 493 } 494 495 /** 496 * Translate a fraction into a percentage. 497 * 498 * e.g. 0.123 becomes 499 * en: 12.3% 500 * fr: 12,3 % 501 * de: 12,3% 502 * 503 * @param float $n 504 * @param integer $precision 505 * 506 * @return string 507 */ 508 public static function percentage($n, $precision = 0) { 509 return self::$locale->percent(round($n, $precision + 2)); 510 } 511 512 /** 513 * Translate a plural string 514 * 515 * echo self::plural('There is an error', 'There are errors', $num_errors); 516 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 517 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 518 * 519 * @return string 520 */ 521 public static function plural(/* var_args */) { 522 $args = func_get_args(); 523 $args[0] = self::$translator->translatePlural($args[0], $args[1], $args[2]); 524 unset($args[1], $args[2]); 525 526 return self::substitutePlaceholders($args); 527 } 528 529 /** 530 * UTF8 version of PHP::strrev() 531 * 532 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 533 * 534 * These do not support UTF8 text direction, so we must mimic it for them. 535 * 536 * Numbers are always rendered LTR, even in RTL text. 537 * The visual direction of characters such as parentheses should be reversed. 538 * 539 * @param string $text Text to be reversed 540 * 541 * @return string 542 */ 543 public static function reverseText($text) { 544 // Remove HTML markup - we can't display it and it is LTR. 545 $text = Filter::unescapeHtml($text); 546 547 // LTR text doesn't need reversing 548 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 549 return $text; 550 } 551 552 // Mirrored characters 553 $text = strtr($text, self::$mirror_characters); 554 555 $reversed = ''; 556 $digits = ''; 557 while ($text != '') { 558 $letter = mb_substr($text, 0, 1); 559 $text = mb_substr($text, 1); 560 if (strpos(self::DIGITS, $letter) !== false) { 561 $digits .= $letter; 562 } else { 563 $reversed = $letter . $digits . $reversed; 564 $digits = ''; 565 } 566 } 567 568 return $digits . $reversed; 569 } 570 571 /** 572 * Return the direction (ltr or rtl) for a given script 573 * 574 * The PHP/intl library does not provde this information, so we need 575 * our own lookup table. 576 * 577 * @param string $script 578 * 579 * @return string 580 */ 581 public static function scriptDirection($script) { 582 switch ($script) { 583 case 'Arab': 584 case 'Hebr': 585 case 'Mong': 586 case 'Thaa': 587 return 'rtl'; 588 default: 589 return 'ltr'; 590 } 591 } 592 593 /** 594 * UTF8 version of PHP::strcasecmp() 595 * 596 * Perform a case-insensitive comparison of two strings, using rules from the current locale 597 * 598 * @param string $string1 599 * @param string $string2 600 * 601 * @return integer 602 */ 603 public static function strcasecmp($string1, $string2) { 604 $strpos1 = 0; 605 $strpos2 = 0; 606 $strlen1 = strlen($string1); 607 $strlen2 = strlen($string2); 608 while ($strpos1 < $strlen1 && $strpos2 < $strlen2) { 609 $byte1 = ord($string1[$strpos1]); 610 $byte2 = ord($string2[$strpos2]); 611 if (($byte1 & 0xE0) === 0xC0) { 612 $chr1 = $string1[$strpos1++] . $string1[$strpos1++]; 613 } elseif (($byte1 & 0xF0) === 0xE0) { 614 $chr1 = $string1[$strpos1++] . $string1[$strpos1++] . $string1[$strpos1++]; 615 } else { 616 $chr1 = $string1[$strpos1++]; 617 } 618 if (($byte2 & 0xE0) === 0xC0) { 619 $chr2 = $string2[$strpos2++] . $string2[$strpos2++]; 620 } elseif (($byte2 & 0xF0) === 0xE0) { 621 $chr2 = $string2[$strpos2++] . $string2[$strpos2++] . $string2[$strpos2++]; 622 } else { 623 $chr2 = $string2[$strpos2++]; 624 } 625 if ($chr1 === $chr2) { 626 continue; 627 } 628 // Try the local alphabet first 629 $offset1 = strpos(self::$alphabet_lower, $chr1); 630 if ($offset1 === false) { 631 $offset1 = strpos(self::$alphabet_upper, $chr1); 632 } 633 $offset2 = strpos(self::$alphabet_lower, $chr2); 634 if ($offset2 === false) { 635 $offset2 = strpos(self::$alphabet_upper, $chr2); 636 } 637 if ($offset1 !== false && $offset2 !== false) { 638 if ($offset1 === $offset2) { 639 continue; 640 } else { 641 return $offset1 - $offset2; 642 } 643 } 644 // Try the global alphabet next 645 $offset1 = strpos(self::ALPHABET_LOWER, $chr1); 646 if ($offset1 === false) { 647 $offset1 = strpos(self::ALPHABET_UPPER, $chr1); 648 } 649 $offset2 = strpos(self::ALPHABET_LOWER, $chr2); 650 if ($offset2 === false) { 651 $offset2 = strpos(self::ALPHABET_UPPER, $chr2); 652 } 653 if ($offset1 !== false && $offset2 !== false) { 654 if ($offset1 === $offset2) { 655 continue; 656 } else { 657 return $offset1 - $offset2; 658 } 659 } 660 // Just compare by unicode order 661 return strcmp($chr1, $chr2); 662 } 663 // Shortest string comes first. 664 return ($strlen1 - $strpos1) - ($strlen2 - $strpos2); 665 } 666 667 /** 668 * UTF8 version of PHP::strtolower() 669 * 670 * Convert a string to lower case, using the rules from the current locale 671 * 672 * @param string $string 673 * 674 * @return string 675 */ 676 public static function strtolower($string) { 677 if (self::$locale->language()->code() === 'tr' || self::$locale->language()->code() === 'az') { 678 return TurkishUtf8::strtolower($string); 679 } else { 680 return mb_strtolower($string); 681 } 682 } 683 684 /** 685 * UTF8 version of PHP::strtoupper() 686 * 687 * Convert a string to upper case, using the rules from the current locale 688 * 689 * @param string $string 690 * 691 * @return string 692 */ 693 public static function strtoupper($string) { 694 if (self::$locale->language()->code() === 'tr' || self::$locale->language()->code() === 'az') { 695 return TurkishUtf8::strtoupper($string); 696 } else { 697 return mb_strtoupper($string); 698 } 699 } 700 701 /** 702 * Substitute any "%s" placeholders in a translated string. 703 * This also allows us to have translated strings that contain 704 * "%" characters, which can't be passed to sprintf. 705 * 706 * @param string[] $args translated string plus optional parameters 707 * 708 * @return string 709 */ 710 private static function substitutePlaceholders(array $args) { 711 if (count($args) > 1) { 712 return call_user_func_array('sprintf', $args); 713 } else { 714 return $args[0]; 715 } 716 } 717 718 /** 719 * Identify the script used for a piece of text 720 * 721 * @param $string 722 * 723 * @return string 724 */ 725 public static function textScript($string) { 726 $string = strip_tags($string); // otherwise HTML tags show up as latin 727 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 728 $string = str_replace(array('@N.N.', '@P.N.'), '', $string); // otherwise unknown names show up as latin 729 $pos = 0; 730 $strlen = strlen($string); 731 while ($pos < $strlen) { 732 // get the Unicode Code Point for the character at position $pos 733 $byte1 = ord($string[$pos]); 734 if ($byte1 < 0x80) { 735 $code_point = $byte1; 736 $chrlen = 1; 737 } elseif ($byte1 < 0xC0) { 738 // Invalid continuation character 739 return 'Latn'; 740 } elseif ($byte1 < 0xE0) { 741 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 742 $chrlen = 2; 743 } elseif ($byte1 < 0xF0) { 744 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 745 $chrlen = 3; 746 } elseif ($byte1 < 0xF8) { 747 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 748 $chrlen = 3; 749 } else { 750 // Invalid UTF 751 return 'Latn'; 752 } 753 754 foreach (self::$scripts as $range) { 755 if ($code_point >= $range[1] && $code_point <= $range[2]) { 756 return $range[0]; 757 } 758 } 759 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 760 $pos += $chrlen; 761 } 762 763 return 'Latn'; 764 } 765 766 /** 767 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 768 * 769 * @param integer $seconds 770 * 771 * @return string 772 */ 773 public static function timeAgo($seconds) { 774 $minute = 60; 775 $hour = 60 * $minute; 776 $day = 24 * $hour; 777 $month = 30 * $day; 778 $year = 365 * $day; 779 780 if ($seconds > $year) { 781 $years = (int) ($seconds / $year); 782 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 783 } elseif ($seconds > $month) { 784 $months = (int) ($seconds / $month); 785 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 786 } elseif ($seconds > $day) { 787 $days = (int) ($seconds / $day); 788 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 789 } elseif ($seconds > $hour) { 790 $hours = (int) ($seconds / $hour); 791 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 792 } elseif ($seconds > $minute) { 793 $minutes = (int) ($seconds / $minute); 794 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 795 } else { 796 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 797 } 798 } 799 800 /** 801 * What format is used to display dates in the current locale? 802 * 803 * @return string 804 */ 805 public static function timeFormat() { 806 return /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ self::$translator->translate('%H:%i:%s'); 807 } 808 809 /** 810 * Translate a string, and then substitute placeholders 811 * 812 * echo I18N::translate('Hello World!'); 813 * echo I18N::translate('The %s sat on the mat', 'cat'); 814 * 815 * @return string 816 */ 817 public static function translate(/* var_args */) { 818 $args = func_get_args(); 819 $args[0] = self::$translator->translate($args[0]); 820 821 return self::substitutePlaceholders($args); 822 } 823 824 /** 825 * Context sensitive version of translate. 826 * 827 * echo I18N::translate_c('NOMINATIVE', 'January'); 828 * echo I18N::translate_c('GENITIVE', 'January'); 829 * 830 * @return string 831 */ 832 public static function translateContext(/* var_args */) { 833 $args = func_get_args(); 834 $args[0] = self::$translator->translateContext($args[0], $args[1]); 835 unset($args[1]); 836 837 return self::substitutePlaceholders($args); 838 } 839} 840