1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2015 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16namespace Fisharebest\Webtrees; 17 18use Fisharebest\ExtCalendar\ArabicCalendar; 19use Fisharebest\ExtCalendar\CalendarInterface; 20use Fisharebest\ExtCalendar\GregorianCalendar; 21use Fisharebest\ExtCalendar\JewishCalendar; 22use Fisharebest\ExtCalendar\PersianCalendar; 23use Fisharebest\Localization\Locale; 24use Fisharebest\Localization\Locale\LocaleEnUs; 25use Fisharebest\Localization\Locale\LocaleInterface; 26use Fisharebest\Localization\Translation; 27use Fisharebest\Localization\Translator; 28 29/** 30 * Internationalization (i18n) and localization (l10n). 31 */ 32class I18N { 33 /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 34 private static $locale; 35 36 /** @var Translator An object that performs translation*/ 37 private static $translator; 38 39 // Digits are always rendered LTR, even in RTL text. 40 const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 41 42 // Reversable character conversions from the UNICODE 5.1 database. 43 // It excludes ambiguous (turkish dotless i) and mixed-case (Dz) characters. 44 // The characters should be arranged in default unicode-collation order. 45 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ƶȥǯʒƹȝþƿƨƽƅάαἀἁἂἃἄἅἆἇὰάᾀᾁᾂᾃᾄᾅᾆᾇᾰᾱᾳβγδέεἐἑἒἓἔἕὲέϝϛζήηἠἡἢἣἤἥἦἧὴήᾐᾑᾒᾓᾔᾕᾖᾗῃθϊἰἱἲἳἴἵἶἷὶίῐῑκϗλμνξοόὀὁὂὃὄὅὸόπϟϙρῥσϲτυϋύὑὓὕὗὺύῠῡφχψωώὠὡὢὣὤὥὦὧὼώᾠᾡᾢᾣᾤᾥᾦᾧῳϡϸϻϣϥϧϩϫϭϯаӑӓәӛӕбвгґғҕдԁђԃѓҙеѐёӗєжӂӝҗзԅӟѕӡԇиѝӣҋӥіїйјкқӄҡҟҝлӆљԉмӎнӊңӈҥњԋоӧөӫпҧҁрҏсԍҫтԏҭћќуӯўӱӳүұѹфхҳһѡѿѽѻцҵчӵҷӌҹҽҿџшщъыӹьҍѣэӭюяѥѧѫѩѭѯѱѳѵѷҩաբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆȼɂɇɉɋɍɏͱͳͷͻͼͽӏӷӻӽӿԑԓԕԗԙԛԝԟԡԣԥᵹᵽỻỽỿⅎↄⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞⱡⱨⱪⱬⱳⱶⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣⳬⳮⴀⴁⴂⴃⴄⴅⴆⴇⴈⴉⴊⴋⴌⴍⴎⴏⴐⴑⴒⴓⴔⴕⴖⴗⴘⴙⴚⴛⴜⴝⴞⴟⴠⴡⴢⴣⴤⴥꙁꙃꙅꙇꙉꙋꙍꙏꙑꙓꙕꙗꙙꙛꙝꙟꙣꙥꙧꙩꙫꙭꚁꚃꚅꚇꚉꚋꚍꚏꚑꚓꚕꚗꜣꜥꜧꜩꜫꜭꜯꜳꜵꜷꜹꜻꜽꜿꝁꝃꝅꝇꝉꝋꝍꝏꝑꝓꝕꝗꝙꝛꝝꝟꝡꝣꝥꝧꝩꝫꝭꝯꝺꝼꝿꞁꞃꞅꞇꞌ'; 46 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ƵȤǮƷƸȜÞǷƧƼƄΆΑἈἉἊἋἌἍἎἏᾺΆᾈᾉᾊᾋᾌᾍᾎᾏᾸᾹᾼΒΓΔΈΕἘἙἚἛἜἝῈΈϜϚΖΉΗἨἩἪἫἬἭἮἯῊΉᾘᾙᾚᾛᾜᾝᾞᾟῌΘΪἸἹἺἻἼἽἾἿῚΊῘῙΚϏΛΜΝΞΟΌὈὉὊὋὌὍῸΌΠϞϘΡῬΣϹΤΥΫΎὙὛὝὟῪΎῨῩΦΧΨΩΏὨὩὪὫὬὭὮὯῺΏᾨᾩᾪᾫᾬᾭᾮᾯῼϠϷϺϢϤϦϨϪϬϮАӐӒӘӚӔБВГҐҒҔДԀЂԂЃҘЕЀЁӖЄЖӁӜҖЗԄӞЅӠԆИЍӢҊӤІЇЙЈКҚӃҠҞҜЛӅЉԈМӍНӉҢӇҤЊԊОӦӨӪПҦҀРҎСԌҪТԎҬЋЌУӮЎӰӲҮҰѸФХҲҺѠѾѼѺЦҴЧӴҶӋҸҼҾЏШЩЪЫӸЬҌѢЭӬЮЯѤѦѪѨѬѮѰѲѴѶҨԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖȻɁɆɈɊɌɎͰͲͶϽϾϿӀӶӺӼӾԐԒԔԖԘԚԜԞԠԢԤꝽⱣỺỼỾℲↃⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮⱠⱧⱩⱫⱲⱵⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢⳫⳭႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅꙀꙂꙄꙆꙈꙊꙌꙎꙐꙒꙔꙖꙘꙚꙜꙞꙢꙤꙦꙨꙪꙬꚀꚂꚄꚆꚈꚊꚌꚎꚐꚒꚔꚖꜢꜤꜦꜨꜪꜬꜮꜲꜴꜶꜸꜺꜼꜾꝀꝂꝄꝆꝈꝊꝌꝎꝐꝒꝔꝖꝘꝚꝜꝞꝠꝢꝤꝦꝨꝪꝬꝮꝹꝻꝾꞀꞂꞄꞆꞋ'; 47 48 /** @var string Alphabet, in lower case, for the current locale. */ 49 private static $alphabet_lower = 'abcdefghijklmnopqrstuvwxyz'; 50 51 /** @var string Alphabet, in upper case, for the current locale. */ 52 private static $alphabet_upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 53 54 /** @var int[][] Character ranges used by each script. */ 55 private static $scripts = array( 56 array('Latn', 0x0041, 0x005A), // a-z 57 array('Latn', 0x0061, 0x007A), // A-Z 58 array('Latn', 0x0100, 0x02AF), 59 array('Grek', 0x0370, 0x03FF), 60 array('Cyrl', 0x0400, 0x052F), 61 array('Hebr', 0x0590, 0x05FF), 62 array('Arab', 0x0600, 0x06FF), 63 array('Arab', 0x0750, 0x077F), 64 array('Arab', 0x08A0, 0x08FF), 65 array('Deva', 0x0900, 0x097F), 66 array('Taml', 0x0B80, 0x0BFF), 67 array('Sinh', 0x0D80, 0x0DFF), 68 array('Thai', 0x0E00, 0x0E7F), 69 array('Geor', 0x10A0, 0x10FF), 70 array('Grek', 0x1F00, 0x1FFF), 71 array('Deva', 0xA8E0, 0xA8FF), 72 array('Hans', 0x3000, 0x303F), // Mixed CJK, not just Hans 73 array('Hans', 0x3400, 0xFAFF), // Mixed CJK, not just Hans 74 array('Hans', 0x20000, 0x2FA1F), // Mixed CJK, not just Hans 75 ); 76 77 /** @var string[] Characters that are displayed in mirror form in RTL text. */ 78 private static $mirror_characters = array( 79 '(' => ')', 80 ')' => '(', 81 '[' => ']', 82 ']' => '[', 83 '{' => '}', 84 '}' => '{', 85 '<' => '>', 86 '>' => '<', 87 '‹' => '›', 88 '›' => '‹', 89 '«' => '»', 90 '»' => '«', 91 '﴾' => '﴿', 92 '﴿' => '﴾', 93 '“' => '”', 94 '”' => '“', 95 '‘' => '’', 96 '’' => '‘', 97 ); 98 99 /** @var string Punctuation used to separate list items, typically a comma */ 100 public static $list_separator; 101 102 /** 103 * The prefered locales for this site, or a default list if no preference. 104 * 105 * @return LocaleInterface[] 106 */ 107 public static function activeLocales() { 108 $code_list = Site::getPreference('LANGUAGES'); 109 110 if ($code_list) { 111 $codes = explode(',', $code_list); 112 } else { 113 $codes = array( 114 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'el', 'en-GB', 'en-US', 'es', 115 'et', 'fi', 'fr', 'he', 'hr', 'hu', 'is', 'it', 'ka', 'lt', 'mr', 'nb', 116 'nl', 'nn', 'pl', 'pt', 'ru', 'sk', 'sv', 'tr', 'uk', 'vi', 'zh-Hans', 117 ); 118 } 119 120 $locales = array(); 121 foreach ($codes as $code) { 122 if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) { 123 try { 124 $locales[] = Locale::create($code); 125 } catch (\Exception $ex) { 126 // No such locale exists? 127 } 128 } 129 } 130 usort($locales, '\Fisharebest\Localization\Locale::compare'); 131 132 return $locales; 133 } 134 135 /** 136 * Which MySQL collation should be used for this locale? 137 * 138 * @return string 139 */ 140 public static function collation() { 141 $collation = self::$locale->collation(); 142 switch ($collation) { 143 case 'croatian_ci': 144 case 'german2_ci': 145 case 'vietnamese_ci': 146 // Only available in MySQL 5.6 147 return 'utf8_unicode_ci'; 148 default: 149 return 'utf8_' . $collation; 150 } 151 } 152 153 /** 154 * What format is used to display dates in the current locale? 155 * 156 * @return string 157 */ 158 public static function dateFormat() { 159 return /* I18N: This is the format string for full dates. See http://php.net/date for codes */ self::$translator->translate('%j %F %Y'); 160 } 161 162 /** 163 * Generate consistent I18N for datatables.js 164 * 165 * @param array|null $lengths An optional array of page lengths 166 * 167 * @return string 168 */ 169 public static function datatablesI18N(array $lengths = null) { 170 if ($lengths === null) { 171 $lengths = array(10, 20, 30, 50, 100, -1); 172 } 173 174 $length_menu = ''; 175 foreach ($lengths as $length) { 176 $length_menu .= 177 '<option value="' . $length . '">' . 178 ($length === -1 ? /* I18N: listbox option, e.g. “10,25,50,100,all” */ self::translate('All') : self::number($length)) . 179 '</option>'; 180 } 181 $length_menu = '<select>' . $length_menu . '</select>'; 182 $length_menu = /* I18N: Display %s [records per page], %s is a placeholder for listbox containing numeric options */ self::translate('Display %s', $length_menu); 183 184 $digits = self::$locale->digits('0123456789'); 185 if ($digits === '0123456789') { 186 $callback = ''; 187 } else { 188 $callback = ', 189 "infoCallback": function(oSettings, iStart, iEnd, iMax, iTotal, sPre) { 190 return sPre 191 .replace(/0/g, "' . mb_substr($digits, 0, 1) . '") 192 .replace(/1/g, "' . mb_substr($digits, 1, 1) . '") 193 .replace(/2/g, "' . mb_substr($digits, 2, 1) . '") 194 .replace(/3/g, "' . mb_substr($digits, 3, 1) . '") 195 .replace(/4/g, "' . mb_substr($digits, 4, 1) . '") 196 .replace(/5/g, "' . mb_substr($digits, 5, 1) . '") 197 .replace(/6/g, "' . mb_substr($digits, 6, 1) . '") 198 .replace(/7/g, "' . mb_substr($digits, 7, 1) . '") 199 .replace(/8/g, "' . mb_substr($digits, 8, 1) . '") 200 .replace(/9/g, "' . mb_substr($digits, 9, 1) . '"); 201 }, 202 "formatNumber": function(iIn) { 203 return String(iIn) 204 .replace(/0/g, "' . mb_substr($digits, 0, 1) . '") 205 .replace(/1/g, "' . mb_substr($digits, 1, 1) . '") 206 .replace(/2/g, "' . mb_substr($digits, 2, 1) . '") 207 .replace(/3/g, "' . mb_substr($digits, 3, 1) . '") 208 .replace(/4/g, "' . mb_substr($digits, 4, 1) . '") 209 .replace(/5/g, "' . mb_substr($digits, 5, 1) . '") 210 .replace(/6/g, "' . mb_substr($digits, 6, 1) . '") 211 .replace(/7/g, "' . mb_substr($digits, 7, 1) . '") 212 .replace(/8/g, "' . mb_substr($digits, 8, 1) . '") 213 .replace(/9/g, "' . mb_substr($digits, 9, 1) . '"); 214 } 215 '; 216 } 217 218 return 219 '"language": {' . 220 ' "paginate": {' . 221 ' "first": "' . /* I18N: button label, first page */ self::translate('first') . '",' . 222 ' "last": "' . /* I18N: button label, last page */ self::translate('last') . '",' . 223 ' "next": "' . /* I18N: button label, next page */ self::translate('next') . '",' . 224 ' "previous": "' . /* I18N: button label, previous page */ self::translate('previous') . '"' . 225 ' },' . 226 ' "emptyTable": "' . self::translate('No records to display') . '",' . 227 ' "info": "' . /* I18N: %s are placeholders for numbers */ self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' . 228 ' "infoEmpty": "' . self::translate('Showing %1$s to %2$s of %3$s', 0, 0, 0) . '",' . 229 ' "infoFiltered": "' . /* I18N: %s is a placeholder for a number */ self::translate('(filtered from %s total entries)', '_MAX_') . '",' . 230 ' "infoPostfix": "",' . 231 ' "lengthMenu": "' . Filter::escapeJs($length_menu) . '",' . 232 ' "loadingRecords": "' . self::translate('Loading…') . '",' . 233 ' "processing": "' . self::translate('Loading…') . '",' . 234 ' "search": "' . self::translate('Filter') . '",' . 235 ' "url": "",' . 236 ' "zeroRecords": "' . self::translate('No records to display') . '"' . 237 '}' . 238 $callback; 239 } 240 241 /** 242 * Convert the digits 0-9 into the local script 243 * 244 * Used for years, etc., where we do not want thousands-separators, decimals, etc. 245 * 246 * @param int $n 247 * 248 * @return string 249 */ 250 public static function digits($n) { 251 return self::$locale->digits($n); 252 } 253 254 /** 255 * What is the direction of the current locale 256 * 257 * @return string "ltr" or "rtl" 258 */ 259 public static function direction() { 260 return self::$locale->direction(); 261 } 262 263 /** 264 * What is the first day of the week. 265 * 266 * @return int Sunday=0, Monday=1, etc. 267 */ 268 public static function firstDay() { 269 return self::$locale->territory()->firstDay(); 270 } 271 272 /** 273 * Convert a GEDCOM age string into translated_text 274 * 275 * NB: The import function will have normalised this, so we don't need 276 * to worry about badly formatted strings 277 * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event() 278 * 279 * @param $string 280 * 281 * @return string 282 */ 283 public static function gedcomAge($string) { 284 switch ($string) { 285 case 'STILLBORN': 286 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 287 return self::translate('(stillborn)'); 288 case 'INFANT': 289 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 290 return self::translate('(in infancy)'); 291 case 'CHILD': 292 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 293 return self::translate('(in childhood)'); 294 } 295 $age = array(); 296 if (preg_match('/(\d+)y/', $string, $match)) { 297 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 298 $years = $match[1]; 299 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 300 } else { 301 $years = -1; 302 } 303 if (preg_match('/(\d+)m/', $string, $match)) { 304 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 305 $age[] = self::plural('%s month', '%s months', $match[1], self::number($match[1])); 306 } 307 if (preg_match('/(\d+)w/', $string, $match)) { 308 // I18N: Part of an age string. e.g. 7 weeks and 3 days 309 $age[] = self::plural('%s week', '%s weeks', $match[1], self::number($match[1])); 310 } 311 if (preg_match('/(\d+)d/', $string, $match)) { 312 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 313 $age[] = self::plural('%s day', '%s days', $match[1], self::number($match[1])); 314 } 315 // If an age is just a number of years, only show the number 316 if (count($age) === 1 && $years >= 0) { 317 $age = $years; 318 } 319 if ($age) { 320 if (!substr_compare($string, '<', 0, 1)) { 321 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years) 322 return self::translate('(aged less than %s)', $age); 323 } elseif (!substr_compare($string, '>', 0, 1)) { 324 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years) 325 return self::translate('(aged more than %s)', $age); 326 } else { 327 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 328 return self::translate('(aged %s)', $age); 329 } 330 } else { 331 // Not a valid string? 332 return self::translate('(aged %s)', $string); 333 } 334 } 335 336 /** 337 * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 338 * 339 * @return string 340 */ 341 public static function htmlAttributes() { 342 return self::$locale->htmlAttributes(); 343 } 344 345 /** 346 * Initialise the translation adapter with a locale setting. 347 * 348 * @param string|null $code Use this locale/language code, or choose one automatically 349 * 350 * @return string $string 351 */ 352 public static function init($code = null) { 353 global $WT_TREE; 354 355 mb_internal_encoding('UTF-8'); 356 357 if ($code !== null) { 358 // Create the specified locale 359 self::$locale = Locale::create($code); 360 } else { 361 // Negotiate a locale, but if we can't then use a failsafe 362 self::$locale = new LocaleEnUs; 363 if (Session::has('locale')) { 364 // Previously used 365 self::$locale = Locale::create(Session::get('locale')); 366 } else { 367 // Browser negotiation 368 $default_locale = new LocaleEnUs; 369 try { 370 if ($WT_TREE) { 371 $default_locale = Locale::create($WT_TREE->getPreference('LANGUAGE')); 372 } 373 } catch (\Exception $ex) { 374 } 375 self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale); 376 } 377 } 378 379 $cache_dir_exists = File::mkdir(WT_DATA_DIR . 'cache'); 380 $cache_file = WT_DATA_DIR . 'cache/language-' . self::$locale->languageTag() . '-cache.php'; 381 if (file_exists($cache_file)) { 382 $filemtime = filemtime($cache_file); 383 } else { 384 $filemtime = 0; 385 } 386 387 // Load the translation file(s) 388 // Note that glob() returns false instead of an empty array when open_basedir_restriction 389 // is in force and no files are found. See PHP bug #47358. 390 if (defined('GLOB_BRACE')) { 391 $translation_files = array_merge( 392 array(WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'), 393 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: array(), 394 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: array() 395 ); 396 } else { 397 // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php 398 $translation_files = array_merge( 399 array(WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'), 400 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.csv') ?: array(), 401 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.php') ?: array(), 402 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.mo') ?: array(), 403 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: array(), 404 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: array(), 405 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: array() 406 ); 407 } 408 // Rebuild files after one hour 409 $rebuild_cache = time() > $filemtime + 3600; 410 // Rebuild files if any translation file has been updated 411 foreach ($translation_files as $translation_file) { 412 if (filemtime($translation_file) > $filemtime) { 413 $rebuild_cache = true; 414 break; 415 } 416 } 417 418 if ($rebuild_cache) { 419 $translations = array(); 420 foreach ($translation_files as $translation_file) { 421 $translation = new Translation($translation_file); 422 $translations = array_merge($translations, $translation->asArray()); 423 } 424 if ($cache_dir_exists) { // During setup, we may not have been able to create it. 425 file_put_contents($cache_file, '<' . '?php return ' . var_export($translations, true) . ';'); 426 } 427 } else { 428 $translations = include $cache_file; 429 } 430 431 // Create a translator 432 self::$translator = new Translator($translations, self::$locale->pluralRule()); 433 434 // Alphabetic sorting sequence (upper-case letters), used by webtrees to sort strings 435 list(, self::$alphabet_upper) = explode('=', self::$translator->translate('ALPHABET_upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ')); 436 // Alphabetic sorting sequence (lower-case letters), used by webtrees to sort strings 437 list(, self::$alphabet_lower) = explode('=', self::$translator->translate('ALPHABET_lower=abcdefghijklmnopqrstuvwxyz')); 438 439 self::$list_separator = /* I18N: This punctuation is used to separate lists of items */ self::translate(', '); 440 441 return self::$locale->languageTag(); 442 } 443 444 /** 445 * All locales for which a translation file exists. 446 * 447 * @return LocaleInterface[] 448 */ 449 public static function installedLocales() { 450 $locales = array(); 451 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 452 try { 453 $locales[] = Locale::create(basename($file, '.mo')); 454 } catch (\Exception $ex) { 455 // Not a recognised locale 456 } 457 } 458 usort($locales, '\Fisharebest\Localization\Locale::compare'); 459 460 return $locales; 461 } 462 463 /** 464 * Return the endonym for a given language - as per http://cldr.unicode.org/ 465 * 466 * @param string $locale 467 * 468 * @return string 469 */ 470 public static function languageName($locale) { 471 return Locale::create($locale)->endonym(); 472 } 473 474 /** 475 * Return the script used by a given language 476 * 477 * @param string $locale 478 * 479 * @return string 480 */ 481 public static function languageScript($locale) { 482 return Locale::create($locale)->script()->code(); 483 } 484 485 /** 486 * Translate a number into the local representation. 487 * 488 * e.g. 12345.67 becomes 489 * en: 12,345.67 490 * fr: 12 345,67 491 * de: 12.345,67 492 * 493 * @param float $n 494 * @param int $precision 495 * 496 * @return string 497 */ 498 public static function number($n, $precision = 0) { 499 return self::$locale->number(round($n, $precision)); 500 } 501 502 /** 503 * Translate a fraction into a percentage. 504 * 505 * e.g. 0.123 becomes 506 * en: 12.3% 507 * fr: 12,3 % 508 * de: 12,3% 509 * 510 * @param float $n 511 * @param int $precision 512 * 513 * @return string 514 */ 515 public static function percentage($n, $precision = 0) { 516 return self::$locale->percent(round($n, $precision + 2)); 517 } 518 519 /** 520 * Translate a plural string 521 * 522 * echo self::plural('There is an error', 'There are errors', $num_errors); 523 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 524 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 525 * 526 * @return string 527 */ 528 public static function plural(/* var_args */) { 529 $args = func_get_args(); 530 $args[0] = self::$translator->translatePlural($args[0], $args[1], (int) $args[2]); 531 unset($args[1], $args[2]); 532 533 return self::substitutePlaceholders($args); 534 } 535 536 /** 537 * UTF8 version of PHP::strrev() 538 * 539 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 540 * 541 * These do not support UTF8 text direction, so we must mimic it for them. 542 * 543 * Numbers are always rendered LTR, even in RTL text. 544 * The visual direction of characters such as parentheses should be reversed. 545 * 546 * @param string $text Text to be reversed 547 * 548 * @return string 549 */ 550 public static function reverseText($text) { 551 // Remove HTML markup - we can't display it and it is LTR. 552 $text = Filter::unescapeHtml($text); 553 554 // LTR text doesn't need reversing 555 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 556 return $text; 557 } 558 559 // Mirrored characters 560 $text = strtr($text, self::$mirror_characters); 561 562 $reversed = ''; 563 $digits = ''; 564 while ($text != '') { 565 $letter = mb_substr($text, 0, 1); 566 $text = mb_substr($text, 1); 567 if (strpos(self::DIGITS, $letter) !== false) { 568 $digits .= $letter; 569 } else { 570 $reversed = $letter . $digits . $reversed; 571 $digits = ''; 572 } 573 } 574 575 return $digits . $reversed; 576 } 577 578 /** 579 * Return the direction (ltr or rtl) for a given script 580 * 581 * The PHP/intl library does not provde this information, so we need 582 * our own lookup table. 583 * 584 * @param string $script 585 * 586 * @return string 587 */ 588 public static function scriptDirection($script) { 589 switch ($script) { 590 case 'Arab': 591 case 'Hebr': 592 case 'Mong': 593 case 'Thaa': 594 return 'rtl'; 595 default: 596 return 'ltr'; 597 } 598 } 599 600 /** 601 * UTF8 version of PHP::strcasecmp() 602 * 603 * Perform a case-insensitive comparison of two strings, using rules from the current locale 604 * 605 * @param string $string1 606 * @param string $string2 607 * 608 * @return int 609 */ 610 public static function strcasecmp($string1, $string2) { 611 $strpos1 = 0; 612 $strpos2 = 0; 613 $strlen1 = strlen($string1); 614 $strlen2 = strlen($string2); 615 while ($strpos1 < $strlen1 && $strpos2 < $strlen2) { 616 $byte1 = ord($string1[$strpos1]); 617 $byte2 = ord($string2[$strpos2]); 618 if (($byte1 & 0xE0) === 0xC0) { 619 $chr1 = $string1[$strpos1++] . $string1[$strpos1++]; 620 } elseif (($byte1 & 0xF0) === 0xE0) { 621 $chr1 = $string1[$strpos1++] . $string1[$strpos1++] . $string1[$strpos1++]; 622 } else { 623 $chr1 = $string1[$strpos1++]; 624 } 625 if (($byte2 & 0xE0) === 0xC0) { 626 $chr2 = $string2[$strpos2++] . $string2[$strpos2++]; 627 } elseif (($byte2 & 0xF0) === 0xE0) { 628 $chr2 = $string2[$strpos2++] . $string2[$strpos2++] . $string2[$strpos2++]; 629 } else { 630 $chr2 = $string2[$strpos2++]; 631 } 632 if ($chr1 === $chr2) { 633 continue; 634 } 635 // Try the local alphabet first 636 $offset1 = strpos(self::$alphabet_lower, $chr1); 637 if ($offset1 === false) { 638 $offset1 = strpos(self::$alphabet_upper, $chr1); 639 } 640 $offset2 = strpos(self::$alphabet_lower, $chr2); 641 if ($offset2 === false) { 642 $offset2 = strpos(self::$alphabet_upper, $chr2); 643 } 644 if ($offset1 !== false && $offset2 !== false) { 645 if ($offset1 === $offset2) { 646 continue; 647 } else { 648 return $offset1 - $offset2; 649 } 650 } 651 // Try the global alphabet next 652 $offset1 = strpos(self::ALPHABET_LOWER, $chr1); 653 if ($offset1 === false) { 654 $offset1 = strpos(self::ALPHABET_UPPER, $chr1); 655 } 656 $offset2 = strpos(self::ALPHABET_LOWER, $chr2); 657 if ($offset2 === false) { 658 $offset2 = strpos(self::ALPHABET_UPPER, $chr2); 659 } 660 if ($offset1 !== false && $offset2 !== false) { 661 if ($offset1 === $offset2) { 662 continue; 663 } else { 664 return $offset1 - $offset2; 665 } 666 } 667 // Just compare by unicode order 668 return strcmp($chr1, $chr2); 669 } 670 // Shortest string comes first. 671 return ($strlen1 - $strpos1) - ($strlen2 - $strpos2); 672 } 673 674 /** 675 * UTF8 version of PHP::strtolower() 676 * 677 * Convert a string to lower case, using the rules from the current locale 678 * 679 * @param string $string 680 * 681 * @return string 682 */ 683 public static function strtolower($string) { 684 if (self::$locale->language()->code() === 'tr' || self::$locale->language()->code() === 'az') { 685 $string = strtr($string, array('I' => 'ı', 'İ' => 'i')); 686 } 687 688 return mb_strtolower($string); 689 } 690 691 /** 692 * UTF8 version of PHP::strtoupper() 693 * 694 * Convert a string to upper case, using the rules from the current locale 695 * 696 * @param string $string 697 * 698 * @return string 699 */ 700 public static function strtoupper($string) { 701 if (self::$locale->language()->code() === 'tr' || self::$locale->language()->code() === 'az') { 702 $string = strtr($string, array('ı' => 'I', 'i' => 'İ')); 703 } 704 705 return mb_strtoupper($string); 706 } 707 708 /** 709 * Substitute any "%s" placeholders in a translated string. 710 * This also allows us to have translated strings that contain 711 * "%" characters, which can't be passed to sprintf. 712 * 713 * @param string[] $args translated string plus optional parameters 714 * 715 * @return string 716 */ 717 private static function substitutePlaceholders(array $args) { 718 if (count($args) > 1) { 719 return call_user_func_array('sprintf', $args); 720 } else { 721 return $args[0]; 722 } 723 } 724 725 /** 726 * Identify the script used for a piece of text 727 * 728 * @param $string 729 * 730 * @return string 731 */ 732 public static function textScript($string) { 733 $string = strip_tags($string); // otherwise HTML tags show up as latin 734 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 735 $string = str_replace(array('@N.N.', '@P.N.'), '', $string); // otherwise unknown names show up as latin 736 $pos = 0; 737 $strlen = strlen($string); 738 while ($pos < $strlen) { 739 // get the Unicode Code Point for the character at position $pos 740 $byte1 = ord($string[$pos]); 741 if ($byte1 < 0x80) { 742 $code_point = $byte1; 743 $chrlen = 1; 744 } elseif ($byte1 < 0xC0) { 745 // Invalid continuation character 746 return 'Latn'; 747 } elseif ($byte1 < 0xE0) { 748 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 749 $chrlen = 2; 750 } elseif ($byte1 < 0xF0) { 751 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 752 $chrlen = 3; 753 } elseif ($byte1 < 0xF8) { 754 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 755 $chrlen = 3; 756 } else { 757 // Invalid UTF 758 return 'Latn'; 759 } 760 761 foreach (self::$scripts as $range) { 762 if ($code_point >= $range[1] && $code_point <= $range[2]) { 763 return $range[0]; 764 } 765 } 766 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 767 $pos += $chrlen; 768 } 769 770 return 'Latn'; 771 } 772 773 /** 774 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 775 * 776 * @param int $seconds 777 * 778 * @return string 779 */ 780 public static function timeAgo($seconds) { 781 $minute = 60; 782 $hour = 60 * $minute; 783 $day = 24 * $hour; 784 $month = 30 * $day; 785 $year = 365 * $day; 786 787 if ($seconds > $year) { 788 $years = (int) ($seconds / $year); 789 790 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 791 } elseif ($seconds > $month) { 792 $months = (int) ($seconds / $month); 793 794 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 795 } elseif ($seconds > $day) { 796 $days = (int) ($seconds / $day); 797 798 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 799 } elseif ($seconds > $hour) { 800 $hours = (int) ($seconds / $hour); 801 802 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 803 } elseif ($seconds > $minute) { 804 $minutes = (int) ($seconds / $minute); 805 806 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 807 } else { 808 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 809 } 810 } 811 812 /** 813 * What format is used to display dates in the current locale? 814 * 815 * @return string 816 */ 817 public static function timeFormat() { 818 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'); 819 } 820 821 /** 822 * Translate a string, and then substitute placeholders 823 * 824 * echo I18N::translate('Hello World!'); 825 * echo I18N::translate('The %s sat on the mat', 'cat'); 826 * 827 * @return string 828 */ 829 public static function translate(/* var_args */) { 830 $args = func_get_args(); 831 $args[0] = self::$translator->translate($args[0]); 832 833 return self::substitutePlaceholders($args); 834 } 835 836 /** 837 * Context sensitive version of translate. 838 * 839 * echo I18N::translateContext('NOMINATIVE', 'January'); 840 * echo I18N::translateContext('GENITIVE', 'January'); 841 * 842 * @return string 843 */ 844 public static function translateContext(/* var_args */) { 845 $args = func_get_args(); 846 $args[0] = self::$translator->translateContext($args[0], $args[1]); 847 unset($args[1]); 848 849 return self::substitutePlaceholders($args); 850 } 851 852 /** 853 * What is the last day of the weekend. 854 * 855 * @return int Sunday=0, Monday=1, etc. 856 */ 857 public static function weekendEnd() { 858 return self::$locale->territory()->weekendEnd(); 859 } 860 861 /** 862 * What is the first day of the weekend. 863 * 864 * @return int Sunday=0, Monday=1, etc. 865 */ 866 public static function weekendStart() { 867 return self::$locale->territory()->weekendStart(); 868 } 869 870 /** 871 * Which calendar prefered in this locale? 872 * 873 * @return CalendarInterface 874 */ 875 public static function defaultCalendar() { 876 switch (self::$locale->languageTag()) { 877 case 'ar': 878 return new ArabicCalendar; 879 case 'fa': 880 return new PersianCalendar; 881 case 'he': 882 case 'yi': 883 return new JewishCalendar; 884 default: 885 return new GregorianCalendar; 886 } 887 } 888} 889