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