1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2016 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: A button label, first page */ self::translate('first') . '",' . 222 ' "last": "' . /* I18N: A button label, last page */ self::translate('last') . '",' . 223 ' "next": "' . /* I18N: A button label, next page */ self::translate('next') . '",' . 224 ' "previous": "' . /* I18N: A 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 && is_writeable($cache_file)) { 425 // During setup, we may not have been able to create it. 426 file_put_contents($cache_file, '<' . '?php return ' . var_export($translations, true) . ';'); 427 } 428 } else { 429 $translations = include $cache_file; 430 } 431 432 // Create a translator 433 self::$translator = new Translator($translations, self::$locale->pluralRule()); 434 435 // Alphabetic sorting sequence (upper-case letters), used by webtrees to sort strings 436 list(, self::$alphabet_upper) = explode('=', self::$translator->translate('ALPHABET_upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ')); 437 // Alphabetic sorting sequence (lower-case letters), used by webtrees to sort strings 438 list(, self::$alphabet_lower) = explode('=', self::$translator->translate('ALPHABET_lower=abcdefghijklmnopqrstuvwxyz')); 439 440 self::$list_separator = /* I18N: This punctuation is used to separate lists of items */ self::translate(', '); 441 442 return self::$locale->languageTag(); 443 } 444 445 /** 446 * All locales for which a translation file exists. 447 * 448 * @return LocaleInterface[] 449 */ 450 public static function installedLocales() { 451 $locales = array(); 452 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 453 try { 454 $locales[] = Locale::create(basename($file, '.mo')); 455 } catch (\Exception $ex) { 456 // Not a recognised locale 457 } 458 } 459 usort($locales, '\Fisharebest\Localization\Locale::compare'); 460 461 return $locales; 462 } 463 464 /** 465 * Return the endonym for a given language - as per http://cldr.unicode.org/ 466 * 467 * @param string $locale 468 * 469 * @return string 470 */ 471 public static function languageName($locale) { 472 return Locale::create($locale)->endonym(); 473 } 474 475 /** 476 * Return the script used by a given language 477 * 478 * @param string $locale 479 * 480 * @return string 481 */ 482 public static function languageScript($locale) { 483 return Locale::create($locale)->script()->code(); 484 } 485 486 /** 487 * Translate a number into the local representation. 488 * 489 * e.g. 12345.67 becomes 490 * en: 12,345.67 491 * fr: 12 345,67 492 * de: 12.345,67 493 * 494 * @param float $n 495 * @param int $precision 496 * 497 * @return string 498 */ 499 public static function number($n, $precision = 0) { 500 return self::$locale->number(round($n, $precision)); 501 } 502 503 /** 504 * Translate a fraction into a percentage. 505 * 506 * e.g. 0.123 becomes 507 * en: 12.3% 508 * fr: 12,3 % 509 * de: 12,3% 510 * 511 * @param float $n 512 * @param int $precision 513 * 514 * @return string 515 */ 516 public static function percentage($n, $precision = 0) { 517 return self::$locale->percent(round($n, $precision + 2)); 518 } 519 520 /** 521 * Translate a plural string 522 * 523 * echo self::plural('There is an error', 'There are errors', $num_errors); 524 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 525 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 526 * 527 * @return string 528 */ 529 public static function plural(/* var_args */) { 530 $args = func_get_args(); 531 $args[0] = self::$translator->translatePlural($args[0], $args[1], (int) $args[2]); 532 unset($args[1], $args[2]); 533 534 return self::substitutePlaceholders($args); 535 } 536 537 /** 538 * UTF8 version of PHP::strrev() 539 * 540 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 541 * 542 * These do not support UTF8 text direction, so we must mimic it for them. 543 * 544 * Numbers are always rendered LTR, even in RTL text. 545 * The visual direction of characters such as parentheses should be reversed. 546 * 547 * @param string $text Text to be reversed 548 * 549 * @return string 550 */ 551 public static function reverseText($text) { 552 // Remove HTML markup - we can't display it and it is LTR. 553 $text = Filter::unescapeHtml($text); 554 555 // LTR text doesn't need reversing 556 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 557 return $text; 558 } 559 560 // Mirrored characters 561 $text = strtr($text, self::$mirror_characters); 562 563 $reversed = ''; 564 $digits = ''; 565 while ($text != '') { 566 $letter = mb_substr($text, 0, 1); 567 $text = mb_substr($text, 1); 568 if (strpos(self::DIGITS, $letter) !== false) { 569 $digits .= $letter; 570 } else { 571 $reversed = $letter . $digits . $reversed; 572 $digits = ''; 573 } 574 } 575 576 return $digits . $reversed; 577 } 578 579 /** 580 * Return the direction (ltr or rtl) for a given script 581 * 582 * The PHP/intl library does not provde this information, so we need 583 * our own lookup table. 584 * 585 * @param string $script 586 * 587 * @return string 588 */ 589 public static function scriptDirection($script) { 590 switch ($script) { 591 case 'Arab': 592 case 'Hebr': 593 case 'Mong': 594 case 'Thaa': 595 return 'rtl'; 596 default: 597 return 'ltr'; 598 } 599 } 600 601 /** 602 * UTF8 version of PHP::strcasecmp() 603 * 604 * Perform a case-insensitive comparison of two strings, using rules from the current locale 605 * 606 * @param string $string1 607 * @param string $string2 608 * 609 * @return int 610 */ 611 public static function strcasecmp($string1, $string2) { 612 $strpos1 = 0; 613 $strpos2 = 0; 614 $strlen1 = strlen($string1); 615 $strlen2 = strlen($string2); 616 while ($strpos1 < $strlen1 && $strpos2 < $strlen2) { 617 $byte1 = ord($string1[$strpos1]); 618 $byte2 = ord($string2[$strpos2]); 619 if (($byte1 & 0xE0) === 0xC0) { 620 $chr1 = $string1[$strpos1++] . $string1[$strpos1++]; 621 } elseif (($byte1 & 0xF0) === 0xE0) { 622 $chr1 = $string1[$strpos1++] . $string1[$strpos1++] . $string1[$strpos1++]; 623 } else { 624 $chr1 = $string1[$strpos1++]; 625 } 626 if (($byte2 & 0xE0) === 0xC0) { 627 $chr2 = $string2[$strpos2++] . $string2[$strpos2++]; 628 } elseif (($byte2 & 0xF0) === 0xE0) { 629 $chr2 = $string2[$strpos2++] . $string2[$strpos2++] . $string2[$strpos2++]; 630 } else { 631 $chr2 = $string2[$strpos2++]; 632 } 633 if ($chr1 === $chr2) { 634 continue; 635 } 636 // Try the local alphabet first 637 $offset1 = strpos(self::$alphabet_lower, $chr1); 638 if ($offset1 === false) { 639 $offset1 = strpos(self::$alphabet_upper, $chr1); 640 } 641 $offset2 = strpos(self::$alphabet_lower, $chr2); 642 if ($offset2 === false) { 643 $offset2 = strpos(self::$alphabet_upper, $chr2); 644 } 645 if ($offset1 !== false && $offset2 !== false) { 646 if ($offset1 === $offset2) { 647 continue; 648 } else { 649 return $offset1 - $offset2; 650 } 651 } 652 // Try the global alphabet next 653 $offset1 = strpos(self::ALPHABET_LOWER, $chr1); 654 if ($offset1 === false) { 655 $offset1 = strpos(self::ALPHABET_UPPER, $chr1); 656 } 657 $offset2 = strpos(self::ALPHABET_LOWER, $chr2); 658 if ($offset2 === false) { 659 $offset2 = strpos(self::ALPHABET_UPPER, $chr2); 660 } 661 if ($offset1 !== false && $offset2 !== false) { 662 if ($offset1 === $offset2) { 663 continue; 664 } else { 665 return $offset1 - $offset2; 666 } 667 } 668 // Just compare by unicode order 669 return strcmp($chr1, $chr2); 670 } 671 // Shortest string comes first. 672 return ($strlen1 - $strpos1) - ($strlen2 - $strpos2); 673 } 674 675 /** 676 * UTF8 version of PHP::strtolower() 677 * 678 * Convert a string to lower case, using the rules from the current locale 679 * 680 * @param string $string 681 * 682 * @return string 683 */ 684 public static function strtolower($string) { 685 if (self::$locale->language()->code() === 'tr' || self::$locale->language()->code() === 'az') { 686 $string = strtr($string, array('I' => 'ı', 'İ' => 'i')); 687 } 688 689 return mb_strtolower($string); 690 } 691 692 /** 693 * UTF8 version of PHP::strtoupper() 694 * 695 * Convert a string to upper case, using the rules from the current locale 696 * 697 * @param string $string 698 * 699 * @return string 700 */ 701 public static function strtoupper($string) { 702 if (self::$locale->language()->code() === 'tr' || self::$locale->language()->code() === 'az') { 703 $string = strtr($string, array('ı' => 'I', 'i' => 'İ')); 704 } 705 706 return mb_strtoupper($string); 707 } 708 709 /** 710 * Substitute any "%s" placeholders in a translated string. 711 * This also allows us to have translated strings that contain 712 * "%" characters, which can't be passed to sprintf. 713 * 714 * @param string[] $args translated string plus optional parameters 715 * 716 * @return string 717 */ 718 private static function substitutePlaceholders(array $args) { 719 if (count($args) > 1) { 720 return call_user_func_array('sprintf', $args); 721 } else { 722 return $args[0]; 723 } 724 } 725 726 /** 727 * Identify the script used for a piece of text 728 * 729 * @param $string 730 * 731 * @return string 732 */ 733 public static function textScript($string) { 734 $string = strip_tags($string); // otherwise HTML tags show up as latin 735 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 736 $string = str_replace(array('@N.N.', '@P.N.'), '', $string); // otherwise unknown names show up as latin 737 $pos = 0; 738 $strlen = strlen($string); 739 while ($pos < $strlen) { 740 // get the Unicode Code Point for the character at position $pos 741 $byte1 = ord($string[$pos]); 742 if ($byte1 < 0x80) { 743 $code_point = $byte1; 744 $chrlen = 1; 745 } elseif ($byte1 < 0xC0) { 746 // Invalid continuation character 747 return 'Latn'; 748 } elseif ($byte1 < 0xE0) { 749 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 750 $chrlen = 2; 751 } elseif ($byte1 < 0xF0) { 752 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 753 $chrlen = 3; 754 } elseif ($byte1 < 0xF8) { 755 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 756 $chrlen = 3; 757 } else { 758 // Invalid UTF 759 return 'Latn'; 760 } 761 762 foreach (self::$scripts as $range) { 763 if ($code_point >= $range[1] && $code_point <= $range[2]) { 764 return $range[0]; 765 } 766 } 767 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 768 $pos += $chrlen; 769 } 770 771 return 'Latn'; 772 } 773 774 /** 775 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 776 * 777 * @param int $seconds 778 * 779 * @return string 780 */ 781 public static function timeAgo($seconds) { 782 $minute = 60; 783 $hour = 60 * $minute; 784 $day = 24 * $hour; 785 $month = 30 * $day; 786 $year = 365 * $day; 787 788 if ($seconds > $year) { 789 $years = (int) ($seconds / $year); 790 791 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 792 } elseif ($seconds > $month) { 793 $months = (int) ($seconds / $month); 794 795 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 796 } elseif ($seconds > $day) { 797 $days = (int) ($seconds / $day); 798 799 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 800 } elseif ($seconds > $hour) { 801 $hours = (int) ($seconds / $hour); 802 803 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 804 } elseif ($seconds > $minute) { 805 $minutes = (int) ($seconds / $minute); 806 807 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 808 } else { 809 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 810 } 811 } 812 813 /** 814 * What format is used to display dates in the current locale? 815 * 816 * @return string 817 */ 818 public static function timeFormat() { 819 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'); 820 } 821 822 /** 823 * Translate a string, and then substitute placeholders 824 * 825 * echo I18N::translate('Hello World!'); 826 * echo I18N::translate('The %s sat on the mat', 'cat'); 827 * 828 * @return string 829 */ 830 public static function translate(/* var_args */) { 831 $args = func_get_args(); 832 $args[0] = self::$translator->translate($args[0]); 833 834 return self::substitutePlaceholders($args); 835 } 836 837 /** 838 * Context sensitive version of translate. 839 * 840 * echo I18N::translateContext('NOMINATIVE', 'January'); 841 * echo I18N::translateContext('GENITIVE', 'January'); 842 * 843 * @return string 844 */ 845 public static function translateContext(/* var_args */) { 846 $args = func_get_args(); 847 $args[0] = self::$translator->translateContext($args[0], $args[1]); 848 unset($args[1]); 849 850 return self::substitutePlaceholders($args); 851 } 852 853 /** 854 * What is the last day of the weekend. 855 * 856 * @return int Sunday=0, Monday=1, etc. 857 */ 858 public static function weekendEnd() { 859 return self::$locale->territory()->weekendEnd(); 860 } 861 862 /** 863 * What is the first day of the weekend. 864 * 865 * @return int Sunday=0, Monday=1, etc. 866 */ 867 public static function weekendStart() { 868 return self::$locale->territory()->weekendStart(); 869 } 870 871 /** 872 * Which calendar prefered in this locale? 873 * 874 * @return CalendarInterface 875 */ 876 public static function defaultCalendar() { 877 switch (self::$locale->languageTag()) { 878 case 'ar': 879 return new ArabicCalendar; 880 case 'fa': 881 return new PersianCalendar; 882 case 'he': 883 case 'yi': 884 return new JewishCalendar; 885 default: 886 return new GregorianCalendar; 887 } 888 } 889} 890