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 * What is the first day of the week. 262 * 263 * @return integer Sunday=0, Monday=1, etc. 264 */ 265 public static function firstDay() { 266 return self::$locale->territory()->firstDay(); 267 } 268 269 /** 270 * Convert a GEDCOM age string into translated_text 271 * 272 * NB: The import function will have normalised this, so we don't need 273 * to worry about badly formatted strings 274 * NOTE: this function is not yet complete - eventually it will replace get_age_at_event() 275 * 276 * @param $string 277 * 278 * @return string 279 */ 280 public static function gedcomAge($string) { 281 switch ($string) { 282 case 'STILLBORN': 283 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 284 return self::translate('(stillborn)'); 285 case 'INFANT': 286 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 287 return self::translate('(in infancy)'); 288 case 'CHILD': 289 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 290 return self::translate('(in childhood)'); 291 } 292 $age = array(); 293 if (preg_match('/(\d+)y/', $string, $match)) { 294 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 295 $years = $match[1]; 296 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 297 } else { 298 $years = -1; 299 } 300 if (preg_match('/(\d+)m/', $string, $match)) { 301 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 302 $age[] = self::plural('%s month', '%s months', $match[1], self::number($match[1])); 303 } 304 if (preg_match('/(\d+)w/', $string, $match)) { 305 // I18N: Part of an age string. e.g. 7 weeks and 3 days 306 $age[] = self::plural('%s week', '%s weeks', $match[1], self::number($match[1])); 307 } 308 if (preg_match('/(\d+)d/', $string, $match)) { 309 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 310 $age[] = self::plural('%s day', '%s days', $match[1], self::number($match[1])); 311 } 312 // If an age is just a number of years, only show the number 313 if (count($age) === 1 && $years >= 0) { 314 $age = $years; 315 } 316 if ($age) { 317 if (!substr_compare($string, '<', 0, 1)) { 318 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years) 319 return self::translate('(aged less than %s)', $age); 320 } elseif (!substr_compare($string, '>', 0, 1)) { 321 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years) 322 return self::translate('(aged more than %s)', $age); 323 } else { 324 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 325 return self::translate('(aged %s)', $age); 326 } 327 } else { 328 // Not a valid string? 329 return self::translate('(aged %s)', $string); 330 } 331 } 332 333 /** 334 * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 335 * 336 * @return string 337 */ 338 public static function htmlAttributes() { 339 return self::$locale->htmlAttributes(); 340 } 341 342 /** 343 * Initialise the translation adapter with a locale setting. 344 * 345 * @param string|null $code Use this locale/language code, or choose one automatically 346 * 347 * @return string $string 348 */ 349 public static function init($code = null) { 350 global $WT_SESSION, $WT_TREE; 351 352 if ($code !== null) { 353 // Create the specified locale 354 self::$locale = Locale::create($code); 355 } else { 356 // Negotiate a locale, but if we can't then use a failsafe 357 self::$locale = new LocaleEnUs; 358 if (Filter::get('lang')) { 359 // A request in the URL 360 try { 361 $locale = Locale::create(Filter::get('lang')); 362 if (file_exists(WT_ROOT . 'language/' . $locale->languageTag() . '.mo')) { 363 self::$locale = $locale; 364 } 365 } catch (\Exception $ex) { 366 } 367 } elseif ($WT_SESSION->locale) { 368 // Previously used 369 self::$locale = Locale::create($WT_SESSION->locale); 370 } else { 371 // Browser negotiation 372 $default_locale = new LocaleEnUs; 373 try { 374 if ($WT_TREE) { 375 $default_locale = Locale::create($WT_TREE->getPreference('LANGUAGE')); 376 } 377 } catch (\Exception $ex) { 378 } 379 self::$locale = Locale::httpAcceptLanguage($_SESSION, self::installedLocales(), $default_locale); 380 } 381 } 382 383 File::mkdir(WT_DATA_DIR . 'cache'); 384 $cache_file = WT_DATA_DIR . 'cache/language-' . self::$locale->languageTag() . '-cache.php'; 385 if (file_exists($cache_file)) { 386 $filemtime = filemtime($cache_file); 387 } else { 388 $filemtime = 0; 389 } 390 391 // Load the translation file(s) 392 // Note that glob() returns false instead of an empty array when open_basedir_restriction 393 // is in force and no files are found. See PHP bug #47358. 394 $translation_files = array_merge( 395 array(WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'), 396 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: array(), 397 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: array() 398 ); 399 400 // Rebuild files after 2 hours 401 $rebuild_cache = time() > $filemtime + 7200; 402 // Rebuild files if any translation file has been updated 403 foreach ($translation_files as $translation_file) { 404 if (filemtime($translation_file) > $filemtime) { 405 $rebuild_cache = true; 406 break; 407 } 408 } 409 410 if ($rebuild_cache) { 411 $translations = array(); 412 foreach ($translation_files as $translation_file) { 413 $translation = new Translation($translation_file); 414 $translations = array_merge($translations, $translation->asArray()); 415 } 416 file_put_contents($cache_file, '<' . '?php return ' . var_export($translations, true) . ';'); 417 } else { 418 $translations = include $cache_file; 419 } 420 421 // Create a translator 422 self::$translator = new Translator($translations, self::$locale->pluralRule()); 423 424 // Alphabetic sorting sequence (upper-case letters), used by webtrees to sort strings 425 list(, self::$alphabet_upper) = explode('=', self::$translator->translate('ALPHABET_upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ')); 426 // Alphabetic sorting sequence (lower-case letters), used by webtrees to sort strings 427 list(, self::$alphabet_lower) = explode('=', self::$translator->translate('ALPHABET_lower=abcdefghijklmnopqrstuvwxyz')); 428 429 global $WEEK_START; 430 $WEEK_START = self::$locale->territory()->firstDay(); 431 432 self::$list_separator = /* I18N: This punctuation is used to separate lists of items */ self::translate(', '); 433 434 return self::$locale->languageTag(); 435 } 436 437 /** 438 * All locales for which a translation file exists. 439 * 440 * @return LocaleInterface[] 441 */ 442 public static function installedLocales() { 443 $locales = array(); 444 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 445 try { 446 $locales[] = Locale::create(basename($file, '.mo')); 447 } catch (\Exception $ex) { 448 // Not a recognised locale 449 } 450 } 451 usort($locales, '\Fisharebest\Localization\Locale::compare'); 452 453 return $locales; 454 } 455 456 /** 457 * Return the endonym for a given language - as per http://cldr.unicode.org/ 458 * 459 * @param string $locale 460 * 461 * @return string 462 */ 463 public static function languageName($locale) { 464 return Locale::create($locale)->endonym(); 465 } 466 467 /** 468 * Return the script used by a given language 469 * 470 * @param string $locale 471 * 472 * @return string 473 */ 474 public static function languageScript($locale) { 475 return Locale::create($locale)->script()->code(); 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 /** 841 * What is the last day of the weekend. 842 * 843 * @return integer Sunday=0, Monday=1, etc. 844 */ 845 public static function weekendEnd() { 846 return self::$locale->territory()->weekendEnd(); 847 } 848 849 /** 850 * What is the first day of the weekend. 851 * 852 * @return integer Sunday=0, Monday=1, etc. 853 */ 854 public static function weekendStart() { 855 return self::$locale->territory()->weekendStart(); 856 } 857} 858