1<?php
2/*********************************************************************
3    class.gettext.php
4
5    This implements a `Translation` class that is loosely based on the PHP
6    gettext pure-php module. It includes some code from the project and some
7    code which is based in part at least on the PHP gettext project.
8
9    This extension to the PHP gettext extension using a specially crafted MO
10    file which is a PHP hash array. The file can be built using a utility
11    method in this class.
12
13    Jared Hancock <jared@osticket.com>
14    Copyright (c)  2006-2014 osTicket
15    http://www.osticket.com
16
17    PHP gettext extension is copyrighted separately:
18    ---------------
19    Copyright (c) 2003, 2009 Danilo Segan <danilo@kvota.net>.
20    Copyright (c) 2005 Nico Kaiser <nico@siriux.net>
21
22    This file is part of PHP-gettext.
23
24    PHP-gettext is free software; you can redistribute it and/or modify
25    it under the terms of the GNU General Public License as published by
26    the Free Software Foundation; either version 2 of the License, or
27    (at your option) any later version.
28
29    PHP-gettext is distributed in the hope that it will be useful,
30    but WITHOUT ANY WARRANTY; without even the implied warranty of
31    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
32    GNU General Public License for more details.
33
34    You should have received a copy of the GNU General Public License
35    along with PHP-gettext; if not, write to the Free Software
36    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
37    ---------------
38
39    Released under the GNU General Public License WITHOUT ANY WARRANTY.
40    See LICENSE.TXT for details.
41
42    vim: expandtab sw=4 ts=4 sts=4:
43**********************************************************************/
44
45/**
46 * Provides a simple gettext replacement that works independently from
47 * the system's gettext abilities.
48 * It can read MO files and use them for translating strings.
49 * The files are passed to gettext_reader as a Stream (see streams.php)
50 *
51 * This version has the ability to cache all strings and translations to
52 * speed up the string lookup.
53 * While the cache is enabled by default, it can be switched off with the
54 * second parameter in the constructor (e.g. whenusing very large MO files
55 * that you don't want to keep in memory)
56 */
57class gettext_reader {
58  //public:
59   var $error = 0; // public variable that holds error code (0 if no error)
60
61   //private:
62  var $BYTEORDER = 0;        // 0: low endian, 1: big endian
63  var $STREAM = NULL;
64  var $short_circuit = false;
65  var $enable_cache = false;
66  var $originals = NULL;      // offset of original table
67  var $translations = NULL;    // offset of translation table
68  var $pluralheader = NULL;    // cache header field for plural forms
69  var $total = 0;          // total string count
70  var $table_originals = NULL;  // table for original strings (offsets)
71  var $table_translations = NULL;  // table for translated strings (offsets)
72  var $cache_translations = NULL;  // original -> translation mapping
73
74
75  /* Methods */
76
77
78  /**
79   * Reads a 32bit Integer from the Stream
80   *
81   * @access private
82   * @return Integer from the Stream
83   */
84  function readint() {
85      if ($this->BYTEORDER == 0) {
86        // low endian
87        $input=unpack('V', $this->STREAM->read(4));
88        return array_shift($input);
89      } else {
90        // big endian
91        $input=unpack('N', $this->STREAM->read(4));
92        return array_shift($input);
93      }
94    }
95
96  function read($bytes) {
97    return $this->STREAM->read($bytes);
98  }
99
100  /**
101   * Reads an array of Integers from the Stream
102   *
103   * @param int count How many elements should be read
104   * @return Array of Integers
105   */
106  function readintarray($count) {
107    if ($this->BYTEORDER == 0) {
108        // low endian
109        return unpack('V'.$count, $this->STREAM->read(4 * $count));
110      } else {
111        // big endian
112        return unpack('N'.$count, $this->STREAM->read(4 * $count));
113      }
114  }
115
116  /**
117   * Constructor
118   *
119   * @param object Reader the StreamReader object
120   * @param boolean enable_cache Enable or disable caching of strings (default on)
121   */
122  function __construct($Reader, $enable_cache = true) {
123    // If there isn't a StreamReader, turn on short circuit mode.
124    if (! $Reader || isset($Reader->error) ) {
125      $this->short_circuit = true;
126      return;
127    }
128
129    // Caching can be turned off
130    $this->enable_cache = $enable_cache;
131
132    $MAGIC1 = "\x95\x04\x12\xde";
133    $MAGIC2 = "\xde\x12\x04\x95";
134
135    $this->STREAM = $Reader;
136    $magic = $this->read(4);
137    if ($magic == $MAGIC1) {
138      $this->BYTEORDER = 1;
139    } elseif ($magic == $MAGIC2) {
140      $this->BYTEORDER = 0;
141    } else {
142      $this->error = 1; // not MO file
143      return false;
144    }
145
146    // FIXME: Do we care about revision? We should.
147    $this->revision = $this->readint();
148
149    $this->total = $this->readint();
150    $this->originals = $this->readint();
151    $this->translations = $this->readint();
152  }
153
154  /**
155   * Loads the translation tables from the MO file into the cache
156   * If caching is enabled, also loads all strings into a cache
157   * to speed up translation lookups
158   *
159   * @access private
160   */
161  function load_tables() {
162    if (is_array($this->cache_translations) &&
163      is_array($this->table_originals) &&
164      is_array($this->table_translations))
165      return;
166
167    /* get original and translations tables */
168    if (!is_array($this->table_originals)) {
169      $this->STREAM->seekto($this->originals);
170      $this->table_originals = $this->readintarray($this->total * 2);
171    }
172    if (!is_array($this->table_translations)) {
173      $this->STREAM->seekto($this->translations);
174      $this->table_translations = $this->readintarray($this->total * 2);
175    }
176
177    if ($this->enable_cache) {
178      $this->cache_translations = array ();
179      /* read all strings in the cache */
180      for ($i = 0; $i < $this->total; $i++) {
181        $this->STREAM->seekto($this->table_originals[$i * 2 + 2]);
182        $original = $this->STREAM->read($this->table_originals[$i * 2 + 1]);
183        $this->STREAM->seekto($this->table_translations[$i * 2 + 2]);
184        $translation = $this->STREAM->read($this->table_translations[$i * 2 + 1]);
185        $this->cache_translations[$original] = $translation;
186      }
187    }
188  }
189
190  /**
191   * Returns a string from the "originals" table
192   *
193   * @access private
194   * @param int num Offset number of original string
195   * @return string Requested string if found, otherwise ''
196   */
197  function get_original_string($num) {
198    $length = $this->table_originals[$num * 2 + 1];
199    $offset = $this->table_originals[$num * 2 + 2];
200    if (! $length)
201      return '';
202    $this->STREAM->seekto($offset);
203    $data = $this->STREAM->read($length);
204    return (string)$data;
205  }
206
207  /**
208   * Returns a string from the "translations" table
209   *
210   * @access private
211   * @param int num Offset number of original string
212   * @return string Requested string if found, otherwise ''
213   */
214  function get_translation_string($num) {
215    $length = $this->table_translations[$num * 2 + 1];
216    $offset = $this->table_translations[$num * 2 + 2];
217    if (! $length)
218      return '';
219    $this->STREAM->seekto($offset);
220    $data = $this->STREAM->read($length);
221    return (string)$data;
222  }
223
224  /**
225   * Binary search for string
226   *
227   * @access private
228   * @param string string
229   * @param int start (internally used in recursive function)
230   * @param int end (internally used in recursive function)
231   * @return int string number (offset in originals table)
232   */
233  function find_string($string, $start = -1, $end = -1) {
234    if (($start == -1) or ($end == -1)) {
235      // find_string is called with only one parameter, set start end end
236      $start = 0;
237      $end = $this->total;
238    }
239    if (abs($start - $end) <= 1) {
240      // We're done, now we either found the string, or it doesn't exist
241      $txt = $this->get_original_string($start);
242      if ($string == $txt)
243        return $start;
244      else
245        return -1;
246    } else if ($start > $end) {
247      // start > end -> turn around and start over
248      return $this->find_string($string, $end, $start);
249    } else {
250      // Divide table in two parts
251      $half = (int)(($start + $end) / 2);
252      $cmp = strcmp($string, $this->get_original_string($half));
253      if ($cmp == 0)
254        // string is exactly in the middle => return it
255        return $half;
256      else if ($cmp < 0)
257        // The string is in the upper half
258        return $this->find_string($string, $start, $half);
259      else
260        // The string is in the lower half
261        return $this->find_string($string, $half, $end);
262    }
263  }
264
265  /**
266   * Translates a string
267   *
268   * @access public
269   * @param string string to be translated
270   * @return string translated string (or original, if not found)
271   */
272  function translate($string) {
273    if ($this->short_circuit)
274      return $string;
275    $this->load_tables();
276
277    if ($this->enable_cache) {
278      // Caching enabled, get translated string from cache
279      if (array_key_exists($string, $this->cache_translations))
280        return $this->cache_translations[$string];
281      else
282        return $string;
283    } else {
284      // Caching not enabled, try to find string
285      $num = $this->find_string($string);
286      if ($num == -1)
287        return $string;
288      else
289        return $this->get_translation_string($num);
290    }
291  }
292
293  /**
294   * Sanitize plural form expression for use in PHP eval call.
295   *
296   * @access private
297   * @return string sanitized plural form expression
298   */
299  function sanitize_plural_expression($expr) {
300    // Get rid of disallowed characters.
301    $expr = preg_replace('@[^a-zA-Z0-9_:;\(\)\?\|\&=!<>+*/\%-]@', '', $expr);
302
303    // Add parenthesis for tertiary '?' operator.
304    $expr .= ';';
305    $res = '';
306    $p = 0;
307    for ($i = 0, $k = strlen($expr); $i < $k; $i++) {
308      $ch = $expr[$i];
309      switch ($ch) {
310      case '?':
311        $res .= ' ? (';
312        $p++;
313        break;
314      case ':':
315        $res .= ') : (';
316        break;
317      case ';':
318        $res .= str_repeat( ')', $p) . ';';
319        $p = 0;
320        break;
321      default:
322        $res .= $ch;
323      }
324    }
325    return $res;
326  }
327
328  /**
329   * Parse full PO header and extract only plural forms line.
330   *
331   * @access private
332   * @return string verbatim plural form header field
333   */
334  function extract_plural_forms_header_from_po_header($header) {
335    $regs = array();
336    if (preg_match("/(^|\n)plural-forms: ([^\n]*)\n/i", $header, $regs))
337      $expr = $regs[2];
338    else
339      $expr = "nplurals=2; plural=n == 1 ? 0 : 1;";
340    return $expr;
341  }
342
343  /**
344   * Get possible plural forms from MO header
345   *
346   * @access private
347   * @return string plural form header
348   */
349  function get_plural_forms() {
350    // lets assume message number 0 is header
351    // this is true, right?
352    $this->load_tables();
353
354    // cache header field for plural forms
355    if (! is_string($this->pluralheader)) {
356      if ($this->enable_cache) {
357        $header = $this->cache_translations[""];
358      } else {
359        $header = $this->get_translation_string(0);
360      }
361      $expr = $this->extract_plural_forms_header_from_po_header($header);
362      $this->pluralheader = $this->sanitize_plural_expression($expr);
363    }
364    return $this->pluralheader;
365  }
366
367  /**
368   * Detects which plural form to take
369   *
370   * @access private
371   * @param n count
372   * @return int array index of the right plural form
373   */
374  function select_string($n) {
375      // Expression reads
376      // nplurals=X; plural= n != 1
377      if (!isset($this->plural_expression)) {
378          $matches = array();
379          if (!preg_match('`nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*(.+$)`',
380                  $this->get_plural_forms(), $matches))
381              return 1;
382
383          $this->plural_expression = create_function('$n',
384              sprintf('return %s;', str_replace('n', '($n)', $matches[2])));
385          $this->plural_total = (int) $matches[1];
386      }
387      $func = $this->plural_expression;
388      $plural = $func($n);
389      return ($plural > $this->plural_total)
390          ? $this->plural_total - 1
391          : $plural;
392  }
393
394  /**
395   * Plural version of gettext
396   *
397   * @access public
398   * @param string single
399   * @param string plural
400   * @param string number
401   * @return translated plural form
402   */
403  function ngettext($single, $plural, $number) {
404    if ($this->short_circuit) {
405      if ($number != 1)
406        return $plural;
407      else
408        return $single;
409    }
410
411    // find out the appropriate form
412    $select = $this->select_string($number);
413
414    // this should contains all strings separated by NULLs
415    $key = $single . chr(0) . $plural;
416
417
418    if ($this->enable_cache) {
419      if (! array_key_exists($key, $this->cache_translations)) {
420        return ($number != 1) ? $plural : $single;
421      } else {
422        $result = $this->cache_translations[$key];
423        $list = explode(chr(0), $result);
424        return $list[$select];
425      }
426    } else {
427      $num = $this->find_string($key);
428      if ($num == -1) {
429        return ($number != 1) ? $plural : $single;
430      } else {
431        $result = $this->get_translation_string($num);
432        $list = explode(chr(0), $result);
433        return $list[$select];
434      }
435    }
436  }
437
438  function pgettext($context, $msgid) {
439    $key = $context . chr(4) . $msgid;
440    $ret = $this->translate($key);
441    if (strpos($ret, "\004") !== FALSE) {
442      return $msgid;
443    } else {
444      return $ret;
445    }
446  }
447
448  function npgettext($context, $singular, $plural, $number) {
449    $key = $context . chr(4) . $singular;
450    $ret = $this->ngettext($key, $plural, $number);
451    if (strpos($ret, "\004") !== FALSE) {
452      return $singular;
453    } else {
454      return $ret;
455    }
456
457  }
458}
459
460class FileReader {
461  var $_pos;
462  var $_fd;
463  var $_length;
464
465  function __construct($filename) {
466    if (is_resource($filename)) {
467        $this->_length = strlen(stream_get_contents($filename));
468        rewind($filename);
469        $this->_fd = $filename;
470    }
471    elseif (file_exists($filename)) {
472
473      $this->_length=filesize($filename);
474      $this->_fd = fopen($filename,'rb');
475      if (!$this->_fd) {
476        $this->error = 3; // Cannot read file, probably permissions
477        return false;
478      }
479    } else {
480      $this->error = 2; // File doesn't exist
481      return false;
482    }
483    $this->_pos = 0;
484  }
485
486  function read($bytes) {
487    if ($bytes) {
488      fseek($this->_fd, $this->_pos);
489
490      // PHP 5.1.1 does not read more than 8192 bytes in one fread()
491      // the discussions at PHP Bugs suggest it's the intended behaviour
492      $data = '';
493      while ($bytes > 0) {
494        $chunk  = fread($this->_fd, $bytes);
495        $data  .= $chunk;
496        $bytes -= strlen($chunk);
497      }
498      $this->_pos = ftell($this->_fd);
499
500      return $data;
501    } else return '';
502  }
503
504  function seekto($pos) {
505    fseek($this->_fd, $pos);
506    $this->_pos = ftell($this->_fd);
507    return $this->_pos;
508  }
509
510  function currentpos() {
511    return $this->_pos;
512  }
513
514  function length() {
515    return $this->_length;
516  }
517
518  function close() {
519    fclose($this->_fd);
520  }
521
522}
523
524/**
525 * Class: Translation
526 *
527 * This class is strongly based on the gettext_reader class. It makes use of
528 * a few simple optimizations for the context of osTicket
529 *
530 *    * The language packs are pre-compiled and distributed (which means
531 *      they can be customized).
532 *    * The MO file will always be processed by PHP code
533 *    * osTicket uses utf-8 output exclusively (for web traffic anyway)
534 *
535 * These allow us to optimize the MO file for the osTicket project
536 * specifically and make enough of an optimization to allow using a pure-PHP
537 * source gettext library implementation which should be roughly the same
538 * performance as the libc gettext library.
539 */
540class Translation extends gettext_reader implements Serializable {
541
542    var $charset;
543
544    const META_HEADER = 0;
545
546    function __construct($reader, $charset=false) {
547        if (!$reader)
548            return $this->short_circuit = true;
549
550        // Just load the cache
551        if (!is_string($reader))
552            throw new RuntimeException('Programming Error: Expected filename for translation source');
553        $this->STREAM = $reader;
554
555        $this->enable_cache = true;
556        $this->charset = $charset;
557        $this->encode = $charset && strcasecmp($charset, 'utf-8') !== 0;
558        $this->load_tables();
559    }
560
561    function load_tables() {
562        if (isset($this->cache_translations))
563            return;
564
565        $this->cache_translations = (include $this->STREAM);
566    }
567
568    function translate($string) {
569        if ($this->short_circuit)
570            return $string;
571
572        // Caching enabled, get translated string from cache
573        if (isset($this->cache_translations[$string]))
574            $string = $this->cache_translations[$string];
575
576        if (!$this->encode)
577            return $string;
578
579        return Charset::transcode($string, 'utf-8', $this->charset);
580    }
581
582    static function buildHashFile($mofile, $outfile=false, $return=false) {
583        if (!$outfile) {
584            $stream = fopen('php://stdout', 'w');
585        }
586        elseif (is_string($outfile)) {
587            $stream = fopen($outfile, 'w');
588        }
589        elseif (is_resource($outfile)) {
590            $stream = $outfile;
591        }
592
593        if (!$stream)
594            throw new InvalidArgumentException(
595                'Expected a filename or valid resource');
596
597        if (!$mofile instanceof FileReader)
598            $mofile = new FileReader($mofile);
599
600        $reader = new parent($mofile, true);
601
602        if ($reader->short_circuit || $reader->error)
603            throw new Exception('Unable to initialize MO input file');
604
605        $reader->load_tables();
606
607        // Get basic table
608        if (!($table = $reader->cache_translations))
609            throw new Exception('Unable to read translations from file');
610
611        // Transcode the table to UTF-8
612        $header = $table[""];
613        $info = array();
614        preg_match('/^content-type: (.*)$/im', $header, $info);
615        $charset = false;
616        if ($content_type = $info[1]) {
617            // Find the charset property
618            $settings = explode(';', $content_type);
619            foreach ($settings as $v) {
620                @list($prop, $value) = explode('=', trim($v), 2);
621                if (strtolower($prop) == 'charset') {
622                    $charset = trim($value);
623                    break;
624                }
625            }
626        }
627        if ($charset && strcasecmp($charset, 'utf-8') !== 0) {
628            foreach ($table as $orig=>$trans) {
629                $table[Charset::utf8($orig, $charset)] =
630                    Charset::utf8($trans, $charset);
631                unset($table[$orig]);
632            }
633        }
634
635        // Add in some meta-data
636        $table[self::META_HEADER] = array(
637            'Revision' => $reader->revision,      // From the MO
638            'Total-Strings' => $reader->total,    // From the MO
639            'Table-Size' => count($table),      // Sanity check for later
640            'Build-Timestamp' => gmdate(DATE_RFC822),
641            'Format-Version' => 'A',            // Support future formats
642            'Encoding' => 'UTF-8',
643        );
644
645        // Serialize the PHP array and write to output
646        $contents = sprintf('<?php return %s;', var_export($table, true));
647        if ($return)
648            return $contents;
649        else
650            fwrite($stream, $contents);
651    }
652
653    static function resurrect($key) {
654        if (!function_exists('apcu_fetch'))
655            return false;
656
657        $success = true;
658        if (($translation = apcu_fetch($key, $success)) && $success)
659            return $translation;
660    }
661    function cache($key) {
662        if (function_exists('apcu_add'))
663            apcu_add($key, $this);
664    }
665
666
667    function serialize() {
668        return serialize(array($this->charset, $this->encode, $this->cache_translations));
669    }
670    function unserialize($what) {
671        list($this->charset, $this->encode, $this->cache_translations)
672            = unserialize($what);
673        $this->short_circuit = ! $this->enable_cache
674            = 0 < $this->cache_translations ? count($this->cache_translations) : 1;
675    }
676}
677
678if (!defined('LC_MESSAGES')) {
679    define('LC_ALL', 0);
680    define('LC_CTYPE', 1);
681    define('LC_NUMERIC', 2);
682    define('LC_TIME', 3);
683    define('LC_COLLATE', 4);
684    define('LC_MONETARY', 5);
685    define('LC_MESSAGES', 6);
686}
687
688class TextDomain {
689    var $l10n = array();
690    var $path;
691    var $codeset;
692    var $domain;
693
694    static $registry;
695    static $default_domain = 'messages';
696    static $current_locale = '';
697    static $LC_CATEGORIES = array(
698        LC_ALL => 'LC_ALL',
699        LC_CTYPE => 'LC_CTYPE',
700        LC_NUMERIC => 'LC_NUMERIC',
701        LC_TIME => 'LC_TIME',
702        LC_COLLATE => 'LC_COLLATE',
703        LC_MONETARY => 'LC_MONETARY',
704        LC_MESSAGES => 'LC_MESSAGES'
705    );
706
707    function __construct($domain) {
708        $this->domain = $domain;
709    }
710
711    function getTranslation($category=LC_MESSAGES, $locale=false) {
712        $locale = $locale ?: self::$current_locale
713            ?: self::setLocale(LC_MESSAGES, 0);
714
715        if (isset($this->l10n[$locale]))
716            return $this->l10n[$locale];
717
718        if ($locale == 'en_US') {
719            $this->l10n[$locale] = new Translation(null);
720        }
721        else {
722            // get the current locale
723            $bound_path = @$this->path ?: './';
724            $subpath = self::$LC_CATEGORIES[$category] .
725                '/'.$this->domain.'.mo.php';
726
727            // APC short-circuit (if supported)
728            $key = sha1($locale .':lang:'. $subpath);
729            if ($T = Translation::resurrect($key)) {
730                return $this->l10n[$locale] = $T;
731            }
732
733            $locale_names = self::get_list_of_locales($locale);
734            $input = null;
735            foreach ($locale_names as $T) {
736                if (substr($bound_path, 7) != 'phar://') {
737                    $phar_path = 'phar://' . $bound_path . $T . ".phar/" . $subpath;
738                    if (file_exists($phar_path)) {
739                        $input = $phar_path;
740                        break;
741                    }
742                }
743                $full_path = $bound_path . $T . "/" . $subpath;
744                if (file_exists($full_path)) {
745                    $input = $full_path;
746                    break;
747                }
748            }
749            // TODO: Handle charset hint from the environment
750            $this->l10n[$locale] = $T = new Translation($input);
751            $T->cache($key);
752        }
753        return $this->l10n[$locale];
754    }
755
756    function setPath($path) {
757        $this->path = $path;
758    }
759
760    static function configureForUser($user=false) {
761        $lang = Internationalization::getCurrentLanguage($user);
762        $info = Internationalization::getLanguageInfo($lang);
763        if (!$info)
764            // Not a supported language
765            return;
766
767        // Define locale for C-libraries
768        putenv('LC_ALL=' . $info['code']);
769        self::setLocale(LC_ALL, $info['code']);
770    }
771
772    static function setDefaultDomain($domain) {
773        static::$default_domain = $domain;
774    }
775
776    /**
777     * Returns passed in $locale, or environment variable $LANG if $locale == ''.
778     */
779    static function get_default_locale($locale='') {
780        if ($locale == '') // emulate variable support
781            return getenv('LANG');
782        else
783            return $locale;
784    }
785
786    static function get_list_of_locales($locale) {
787        /* Figure out all possible locale names and start with the most
788         * specific ones.  I.e. for sr_CS.UTF-8@latin, look through all of
789         * sr_CS.UTF-8@latin, sr_CS@latin, sr@latin, sr_CS.UTF-8, sr_CS, sr.
790        */
791        $locale_names = $m = array();
792        $lang = null;
793        if ($locale) {
794            if (preg_match("/^(?P<lang>[a-z]{2,3})"              // language code
795                ."(?:_(?P<country>[A-Z]{2}))?"           // country code
796                ."(?:\.(?P<charset>[-A-Za-z0-9_]+))?"    // charset
797                ."(?:@(?P<modifier>[-A-Za-z0-9_]+))?$/",  // @ modifier
798                $locale, $m)
799            ) {
800
801            if ($m['modifier']) {
802                // TODO: Confirm if Crowdin uses the modifer flags
803                if ($m['country']) {
804                    $locale_names[] = "{$m['lang']}_{$m['country']}@{$m['modifier']}";
805                }
806                $locale_names[] = "{$m['lang']}@{$m['modifier']}";
807            }
808            if ($m['country']) {
809                $locale_names[] = "{$m['lang']}_{$m['country']}";
810            }
811            $locale_names[] = $m['lang'];
812        }
813
814        // If the locale name doesn't match POSIX style, just include it as-is.
815        if (!in_array($locale, $locale_names))
816            $locale_names[] = $locale;
817      }
818      return array_filter($locale_names);
819    }
820
821    static function setLocale($category, $locale) {
822        if ($locale === 0) { // use === to differentiate between string "0"
823            if (self::$current_locale != '')
824                return self::$current_locale;
825            else
826                // obey LANG variable, maybe extend to support all of LC_* vars
827                // even if we tried to read locale without setting it first
828                return self::setLocale($category, self::$current_locale);
829        } else {
830            if (function_exists('setlocale')) {
831              $ret = setlocale($category, $locale);
832              if (($locale == '' and !$ret) or // failed setting it by env
833                  ($locale != '' and $ret != $locale)) { // failed setting it
834                // Failed setting it according to environment.
835                self::$current_locale = self::get_default_locale($locale);
836              } else {
837                self::$current_locale = $ret;
838              }
839            } else {
840              // No function setlocale(), emulate it all.
841              self::$current_locale = self::get_default_locale($locale);
842            }
843            return self::$current_locale;
844        }
845    }
846
847    static function lookup($domain=null) {
848        if (!isset($domain))
849            $domain = self::$default_domain;
850        if (!isset(static::$registry[$domain])) {
851            static::$registry[$domain] = new TextDomain($domain);
852        }
853        return static::$registry[$domain];
854    }
855}
856
857require_once INCLUDE_DIR . 'class.orm.php';
858class CustomDataTranslation extends VerySimpleModel {
859
860    static $meta = array(
861        'table' => TRANSLATION_TABLE,
862        'pk' => array('id')
863    );
864
865    const FLAG_FUZZY        = 0x01;     // Source string has been changed
866    const FLAG_UNAPPROVED   = 0x02;     // String has been reviewed by an authority
867    const FLAG_CURRENT      = 0x04;     // If more than one version exist, this is current
868    const FLAG_COMPLEX      = 0x08;     // Multiple strings in one translation. For instance article title and body
869
870    var $_complex;
871
872    static function lookup($msgid, $flags=0) {
873        if (!is_string($msgid))
874            return parent::lookup($msgid);
875
876        // Hash is 16 char of md5
877        $hash = substr(md5($msgid), -16);
878
879        $criteria = array('object_hash'=>$hash);
880
881        if ($flags)
882            $criteria += array('flags__hasbit'=>$flags);
883
884        return parent::lookup($criteria);
885    }
886
887    static function getTranslation($locale, $cache=true) {
888        static $_cache = array();
889
890        if ($cache && isset($_cache[$locale]))
891            return $_cache[$locale];
892
893        $criteria = array(
894            'lang' => $locale,
895            'type' => 'phrase',
896        );
897
898        $mo = array();
899        foreach (static::objects()->filter($criteria) as $t) {
900            $mo[$t->object_hash] = $t;
901        }
902
903        return $_cache[$locale] = $mo;
904    }
905
906    static function translate($msgid, $locale=false, $cache=true, $type='phrase') {
907        global $thisstaff, $thisclient;
908
909        // Support sending a User as the locale
910        if (is_object($locale) && method_exists($locale, 'getLanguage'))
911            $locale = $locale->getLanguage();
912        elseif (!$locale)
913            $locale = Internationalization::getCurrentLanguage();
914
915        // Perhaps a slight optimization would be to check if the selected
916        // locale is also the system primary. If so, short-circuit
917
918        if ($locale) {
919            if ($cache) {
920                $mo = static::getTranslation($locale);
921                if (isset($mo[$msgid]))
922                    $msgid = $mo[$msgid]->text;
923            }
924            elseif ($p = static::lookup(array(
925                    'type' => $type,
926                    'lang' => $locale,
927                    'object_hash' => $msgid
928            ))) {
929                $msgid = $p->text;
930            }
931        }
932        return $msgid;
933    }
934
935    /**
936     * Decode complex translation message. Format is given in the $text
937     * parameter description. Complex data should be stored with the
938     * FLAG_COMPLEX flag set, and allows for complex key:value paired data
939     * to be translated. This is useful for strings which are translated
940     * together, such as the title and the body of an article. Storing the
941     * data in a single, complex record allows for a single database query
942     * to fetch or update all data for a particular object, such as a
943     * knowledgebase article. It also simplifies search indexing as only one
944     * translation record could be added for all the translatable elements
945     * for a single translatable object.
946     *
947     * Caveats:
948     * ::$text will return the stored, complex text. Use ::getComplex() to
949     * decode the complex storage format and retrieve the array.
950     *
951     * Parameters:
952     * $text - (string) - encoded text with the following format
953     *      version \x03 key \x03 item1 \x03 key \x03 item2 ...
954     *
955     * Returns:
956     * (array) key:value pairs of translated content
957     */
958    function decodeComplex($text) {
959        $blocks = explode("\x03", $text);
960        $version = array_shift($blocks);
961
962        $data = array();
963        switch ($version) {
964        case 'A':
965            while (count($blocks) > 1) {
966                $key = array_shift($blocks);
967                $data[$key] = array_shift($blocks);
968            }
969            break;
970        default:
971            throw new Exception($version . ': Unknown complex format');
972        }
973
974        return $data;
975    }
976
977    /**
978     * Encode complex content using the format outlined in ::decodeComplex.
979     *
980     * Caveats:
981     * This method does not set the FLAG_COMPLEX flag for this record, which
982     * should be set when storing complex data.
983     */
984    static function encodeComplex(array $data) {
985        $encoded = 'A';
986        foreach ($data as $key=>$text) {
987            $encoded .= "\x03{$key}\x03{$text}";
988        }
989        return $encoded;
990    }
991
992    function getComplex() {
993        if (!$this->flags && self::FLAG_COMPLEX)
994            throw new Exception('Data consistency error. Translation is not complex');
995        if (!isset($this->_complex))
996            $this->_complex = $this->decodeComplex($this->text);
997        return $this->_complex;
998    }
999
1000    static function translateArticle($msgid, $locale=false) {
1001        return static::translate($msgid, $locale, false, 'article');
1002    }
1003
1004    function save($refetch=false) {
1005        if (isset($this->text) && is_array($this->text)) {
1006            $this->text = static::encodeComplex($this->text);
1007            $this->flags |= self::FLAG_COMPLEX;
1008        }
1009        return parent::save($refetch);
1010    }
1011
1012    static function create($ht=false) {
1013        if (!is_array($ht))
1014            return null;
1015
1016        if (is_array($ht['text'])) {
1017            // The parent constructor does not honor arrays
1018            $ht['text'] = static::encodeComplex($ht['text']);
1019            $ht['flags'] = ($ht['flags'] ?: 0) | self::FLAG_COMPLEX;
1020        }
1021        return new static($ht);
1022    }
1023
1024    static function allTranslations($msgid, $type='phrase', $lang=false) {
1025        $criteria = array('type' => $type);
1026
1027        if (is_array($msgid))
1028            $criteria['object_hash__in'] = $msgid;
1029        else
1030            $criteria['object_hash'] = $msgid;
1031
1032        if ($lang)
1033            $criteria['lang'] = $lang;
1034
1035        try {
1036            return static::objects()->filter($criteria)->all();
1037        }
1038        catch (OrmException $e) {
1039            // Translation table might not exist yet — happens on the upgrader
1040            return array();
1041        }
1042    }
1043}
1044
1045class CustomTextDomain {
1046
1047}
1048
1049// Functions for gettext library. Since the gettext extension for PHP is not
1050// used as a fallback, there is no detection and compat funciton
1051// installation for the gettext library function calls.
1052
1053function _gettext($msgid) {
1054    return TextDomain::lookup()->getTranslation()->translate($msgid);
1055}
1056function __($msgid) {
1057    return _gettext($msgid);
1058}
1059function _ngettext($singular, $plural, $number) {
1060    return TextDomain::lookup()->getTranslation()
1061        ->ngettext($singular, $plural, $number);
1062}
1063function _dgettext($domain, $msgid) {
1064    return TextDomain::lookup($domain)->getTranslation()
1065        ->translate($msgid);
1066}
1067function _dngettext($domain, $singular, $plural, $number) {
1068    return TextDomain::lookup($domain)->getTranslation()
1069        ->ngettext($singular, $plural, $number);
1070}
1071function _dcgettext($domain, $msgid, $category) {
1072    return TextDomain::lookup($domain)->getTranslation($category)
1073        ->translate($msgid);
1074}
1075function _dcngettext($domain, $singular, $plural, $number, $category) {
1076    return TextDomain::lookup($domain)->getTranslation($category)
1077        ->ngettext($singular, $plural, $number);
1078}
1079function _pgettext($context, $msgid) {
1080    return TextDomain::lookup()->getTranslation()
1081        ->pgettext($context, $msgid);
1082}
1083function _dpgettext($domain, $context, $msgid) {
1084    return TextDomain::lookup($domain)->getTranslation()
1085        ->pgettext($context, $msgid);
1086}
1087function _dcpgettext($domain, $context, $msgid, $category) {
1088    return TextDomain::lookup($domain)->getTranslation($category)
1089        ->pgettext($context, $msgid);
1090}
1091function _npgettext($context, $singular, $plural, $n) {
1092    return TextDomain::lookup()->getTranslation()
1093        ->npgettext($context, $singular, $plural, $n);
1094}
1095function _dnpgettext($domain, $context, $singular, $plural, $n) {
1096    return TextDomain::lookup($domain)->getTranslation()
1097        ->npgettext($context, $singular, $plural, $n);
1098}
1099function _dcnpgettext($domain, $context, $singular, $plural, $category, $n) {
1100    return TextDomain::lookup($domain)->getTranslation($category)
1101        ->npgettext($context, $singular, $plural, $n);
1102}
1103
1104// Custom data translations
1105function _H($tag) {
1106    return substr(md5($tag), -16);
1107}
1108
1109interface Translatable {
1110    function getTranslationTag();
1111    function getLocalName($user=false);
1112}
1113
1114do {
1115  if (PHP_SAPI != 'cli') break;
1116  if (empty ($_SERVER['argc']) || $_SERVER['argc'] < 2) break;
1117  if (empty ($_SERVER['PHP_SELF']) || FALSE === strpos ($_SERVER['PHP_SELF'], basename(__FILE__)) ) break;
1118  $file = $argv[1];
1119  Translation::buildHashFile($file);
1120} while (0);
1121