WordPress.org

Make WordPress Core

Changeset 5266


Ignore:
Timestamp:
04/13/2007 11:29:47 PM (11 years ago)
Author:
rob1n
Message:

Fix gettext's plural forms for more than 2 plural forms. Props moeffju and nbachiyski. fixes #4005

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/wp-includes/gettext.php

    r4953 r5266  
    11<?php
    22/*
    3   Copyright (c) 2003 Danilo Segan <danilo@kvota.net>.
    4   Copyright (c) 2005 Nico Kaiser <nico@siriux.net>
    5    
    6   This file is part of PHP-gettext.
    7 
    8   PHP-gettext is free software; you can redistribute it and/or modify
    9   it under the terms of the GNU General Public License as published by
    10   the Free Software Foundation; either version 2 of the License, or
    11   (at your option) any later version.
    12 
    13   PHP-gettext is distributed in the hope that it will be useful,
    14   but WITHOUT ANY WARRANTY; without even the implied warranty of
    15   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    16   GNU General Public License for more details.
    17 
    18   You should have received a copy of the GNU General Public License
    19   along with PHP-gettext; if not, write to the Free Software
    20   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
     3    Copyright (c) 2003 Danilo Segan <danilo@kvota.net>.
     4    Copyright (c) 2005 Nico Kaiser <nico@siriux.net>
     5
     6    This file is part of PHP-gettext.
     7
     8    PHP-gettext is free software; you can redistribute it and/or modify
     9    it under the terms of the GNU General Public License as published by
     10    the Free Software Foundation; either version 2 of the License, or
     11    (at your option) any later version.
     12
     13    PHP-gettext is distributed in the hope that it will be useful,
     14    but WITHOUT ANY WARRANTY; without even the implied warranty of
     15    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     16    GNU General Public License for more details.
     17
     18    You should have received a copy of the GNU General Public License
     19    along with PHP-gettext; if not, write to the Free Software
     20    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
    2121
    2222*/
    23  
     23
    2424/**
    2525 * Provides a simple gettext replacement that works independently from
     
    2727 * It can read MO files and use them for translating strings.
    2828 * The files are passed to gettext_reader as a Stream (see streams.php)
    29  * 
     29 *
    3030 * This version has the ability to cache all strings and translations to
    3131 * speed up the string lookup.
     
    3535 */
    3636class gettext_reader {
    37   //public:
    38    var $error = 0; // public variable that holds error code (0 if no error)
    39    
    40    //private:
    41   var $BYTEORDER = 0;        // 0: low endian, 1: big endian
    42   var $STREAM = NULL;
    43   var $short_circuit = false;
    44   var $enable_cache = false;
    45   var $originals = NULL;      // offset of original table
    46   var $translations = NULL;    // offset of translation table
    47   var $pluralheader = NULL;    // cache header field for plural forms
    48   var $total = 0;          // total string count
    49   var $table_originals = NULL;  // table for original strings (offsets)
    50   var $table_translations = NULL;  // table for translated strings (offsets)
    51   var $cache_translations = NULL;  // original -> translation mapping
    52 
    53 
    54   /* Methods */
    55  
    56    
    57   /**
    58    * Reads a 32bit Integer from the Stream
    59    *
    60    * @access private
    61    * @return Integer from the Stream
    62    */
    63   function readint() {
    64       if ($this->BYTEORDER == 0) {
    65         // low endian
    66         $low_end = unpack('V', $this->STREAM->read(4));
    67         return array_shift($low_end);
    68       } else {
    69         // big endian
    70         $big_end = unpack('N', $this->STREAM->read(4));
    71         return array_shift($big_end);
    72       }
    73     }
    74 
    75   /**
    76    * Reads an array of Integers from the Stream
    77    *
    78    * @param int count How many elements should be read
    79    * @return Array of Integers
    80    */
    81   function readintarray($count) {
    82     if ($this->BYTEORDER == 0) {
    83         // low endian
    84         return unpack('V'.$count, $this->STREAM->read(4 * $count));
    85       } else {
    86         // big endian
    87         return unpack('N'.$count, $this->STREAM->read(4 * $count));
    88       }
    89   }
    90  
    91   /**
    92    * Constructor
    93    *
    94    * @param object Reader the StreamReader object
    95    * @param boolean enable_cache Enable or disable caching of strings (default on)
    96    */
    97   function gettext_reader($Reader, $enable_cache = true) {
    98     // If there isn't a StreamReader, turn on short circuit mode.
    99     if (! $Reader || isset($Reader->error) ) {
    100       $this->short_circuit = true;
    101       return;
    102     }
    103    
    104     // Caching can be turned off
    105     $this->enable_cache = $enable_cache;
    106 
    107     // $MAGIC1 = (int)0x950412de; //bug in PHP 5.0.2, see https://savannah.nongnu.org/bugs/?func=detailitem&item_id=10565
    108     $MAGIC1 = (int) - 1794895138;
    109     // $MAGIC2 = (int)0xde120495; //bug
    110     $MAGIC2 = (int) - 569244523;
    111     // 64-bit fix
    112     $MAGIC3 = (int) 2500072158;
    113 
    114     $this->STREAM = $Reader;
    115     $magic = $this->readint();
    116     if ($magic == ($MAGIC1 & 0xFFFFFFFF) || $magic == ($MAGIC3 & 0xFFFFFFFF)) { // to make sure it works for 64-bit platforms
    117       $this->BYTEORDER = 0;
    118     } elseif ($magic == ($MAGIC2 & 0xFFFFFFFF)) {
    119       $this->BYTEORDER = 1;
    120     } else {
    121       $this->error = 1; // not MO file
    122       return false;
    123     }
    124    
    125     // FIXME: Do we care about revision? We should.
    126     $revision = $this->readint();
    127    
    128     $this->total = $this->readint();
    129     $this->originals = $this->readint();
    130     $this->translations = $this->readint();
    131   }
    132  
    133   /**
    134    * Loads the translation tables from the MO file into the cache
    135    * If caching is enabled, also loads all strings into a cache
    136    * to speed up translation lookups
    137    *
    138    * @access private
    139    */
    140   function load_tables() {
    141     if (is_array($this->cache_translations) &&
    142       is_array($this->table_originals) &&
    143       is_array($this->table_translations))
    144       return;
    145    
    146     /* get original and translations tables */
    147     $this->STREAM->seekto($this->originals);
    148     $this->table_originals = $this->readintarray($this->total * 2);
    149     $this->STREAM->seekto($this->translations);
    150     $this->table_translations = $this->readintarray($this->total * 2);
    151    
    152     if ($this->enable_cache) {
    153       $this->cache_translations = array ();
    154       /* read all strings in the cache */
    155       for ($i = 0; $i < $this->total; $i++) {
    156         $this->STREAM->seekto($this->table_originals[$i * 2 + 2]);
    157         $original = $this->STREAM->read($this->table_originals[$i * 2 + 1]);
    158         $this->STREAM->seekto($this->table_translations[$i * 2 + 2]);
    159         $translation = $this->STREAM->read($this->table_translations[$i * 2 + 1]);
    160         $this->cache_translations[$original] = $translation;
    161       }
    162     }
    163   }
    164  
    165   /**
    166    * Returns a string from the "originals" table
    167    *
    168    * @access private
    169    * @param int num Offset number of original string
    170    * @return string Requested string if found, otherwise ''
    171    */
    172   function get_original_string($num) {
    173     $length = $this->table_originals[$num * 2 + 1];
    174     $offset = $this->table_originals[$num * 2 + 2];
    175     if (! $length)
    176       return '';
    177     $this->STREAM->seekto($offset);
    178     $data = $this->STREAM->read($length);
    179     return (string)$data;
    180   }
    181  
    182   /**
    183    * Returns a string from the "translations" table
    184    *
    185    * @access private
    186    * @param int num Offset number of original string
    187    * @return string Requested string if found, otherwise ''
    188    */
    189   function get_translation_string($num) {
    190     $length = $this->table_translations[$num * 2 + 1];
    191     $offset = $this->table_translations[$num * 2 + 2];
    192     if (! $length)
    193       return '';
    194     $this->STREAM->seekto($offset);
    195     $data = $this->STREAM->read($length);
    196     return (string)$data;
    197   }
    198  
    199   /**
    200    * Binary search for string
    201    *
    202    * @access private
    203    * @param string string
    204    * @param int start (internally used in recursive function)
    205    * @param int end (internally used in recursive function)
    206    * @return int string number (offset in originals table)
    207    */
    208   function find_string($string, $start = -1, $end = -1) {
    209     if (($start == -1) or ($end == -1)) {
    210       // find_string is called with only one parameter, set start end end
    211       $start = 0;
    212       $end = $this->total;
    213     }
    214     if (abs($start - $end) <= 1) {
    215       // We're done, now we either found the string, or it doesn't exist
    216       $txt = $this->get_original_string($start);
    217       if ($string == $txt)
    218         return $start;
    219       else
    220         return -1;
    221     } else if ($start > $end) {
    222       // start > end -> turn around and start over
    223       return $this->find_string($string, $end, $start);
    224     } else {
    225       // Divide table in two parts
    226       $half = (int)(($start + $end) / 2);
    227       $cmp = strcmp($string, $this->get_original_string($half));
    228       if ($cmp == 0)
    229         // string is exactly in the middle => return it
    230         return $half;
    231       else if ($cmp < 0)
    232         // The string is in the upper half
    233         return $this->find_string($string, $start, $half);
    234       else
    235         // The string is in the lower half
    236         return $this->find_string($string, $half, $end);
    237     }
    238   }
    239  
    240   /**
    241    * Translates a string
    242    *
    243    * @access public
    244    * @param string string to be translated
    245    * @return string translated string (or original, if not found)
    246    */
    247   function translate($string) {
    248     if ($this->short_circuit)
    249       return $string;
    250     $this->load_tables();     
    251    
    252     if ($this->enable_cache) {
    253       // Caching enabled, get translated string from cache
    254       if (array_key_exists($string, $this->cache_translations))
    255         return $this->cache_translations[$string];
    256       else
    257         return $string;
    258     } else {
    259       // Caching not enabled, try to find string
    260       $num = $this->find_string($string);
    261       if ($num == -1)
    262         return $string;
    263       else
    264         return $this->get_translation_string($num);
    265     }
    266   }
    267 
    268   /**
    269    * Get possible plural forms from MO header
    270    *
    271    * @access private
    272    * @return string plural form header
    273    */
    274   function get_plural_forms() {
    275     // lets assume message number 0 is header 
    276     // this is true, right?
    277     $this->load_tables();
    278    
    279     // cache header field for plural forms
    280     if (! is_string($this->pluralheader)) {
    281       if ($this->enable_cache) {
    282         $header = $this->cache_translations[""];
    283       } else {
    284         $header = $this->get_translation_string(0);
    285       }
    286       if (eregi("plural-forms: ([^\n]*)\n", $header, $regs))
    287         $expr = $regs[1];
    288       else
    289         $expr = "nplurals=2; plural=n == 1 ? 0 : 1;";
    290       $this->pluralheader = $expr;
    291     }
    292     return $this->pluralheader;
    293   }
    294 
    295   /**
    296    * Detects which plural form to take
    297    *
    298    * @access private
    299    * @param n count
    300    * @return int array index of the right plural form
    301    */
    302   function select_string($n) {
    303     $string = $this->get_plural_forms();
    304     $string = str_replace('nplurals',"\$total",$string);
    305     $string = str_replace("n",$n,$string);
    306     $string = str_replace('plural',"\$plural",$string);
    307 
    308     # poEdit doesn't put any semicolons, which
    309     # results in parse error in eval
    310     $string .= ';';
    311 
    312     $total = 0;
    313     $plural = 0;
    314 
    315     eval("$string");
    316     if ($plural >= $total) $plural = $total - 1;
    317     return $plural;
    318   }
    319 
    320   /**
    321    * Plural version of gettext
    322    *
    323    * @access public
    324    * @param string single
    325    * @param string plural
    326    * @param string number
    327    * @return translated plural form
    328    */
    329   function ngettext($single, $plural, $number) {
    330     if ($this->short_circuit) {
    331       if ($number != 1)
    332         return $plural;
    333       else
    334         return $single;
    335     }
    336 
    337     // find out the appropriate form
    338     $select = $this->select_string($number);
    339    
    340     // this should contains all strings separated by NULLs
    341     $key = $single.chr(0).$plural;
    342    
    343    
    344     if ($this->enable_cache) {
    345       if (! array_key_exists($key, $this->cache_translations)) {
    346         return ($number != 1) ? $plural : $single;
    347       } else {
    348         $result = $this->cache_translations[$key];
    349         $list = explode(chr(0), $result);
    350         return $list[$select];
    351       }
    352     } else {
    353       $num = $this->find_string($key);
    354       if ($num == -1) {
    355         return ($number != 1) ? $plural : $single;
    356       } else {
    357         $result = $this->get_translation_string($num);
    358         $list = explode(chr(0), $result);
    359         return $list[$select];
    360       }
    361     }
    362   }
     37    //public:
     38     var $error = 0; // public variable that holds error code (0 if no error)
     39
     40     //private:
     41    var $BYTEORDER = 0;        // 0: low endian, 1: big endian
     42    var $STREAM = NULL;
     43    var $short_circuit = false;
     44    var $enable_cache = false;
     45    var $originals = NULL;      // offset of original table
     46    var $translations = NULL;    // offset of translation table
     47    var $pluralheader = NULL;    // cache header field for plural forms
     48    var $select_string_function = NULL; // cache function, which chooses plural forms
     49    var $total = 0;          // total string count
     50    var $table_originals = NULL;  // table for original strings (offsets)
     51    var $table_translations = NULL;  // table for translated strings (offsets)
     52    var $cache_translations = NULL;  // original -> translation mapping
     53
     54
     55    /* Methods */
     56
     57
     58    /**
     59     * Reads a 32bit Integer from the Stream
     60     *
     61     * @access private
     62     * @return Integer from the Stream
     63     */
     64    function readint() {
     65        if ($this->BYTEORDER == 0) {
     66            // low endian
     67            $low_end = unpack('V', $this->STREAM->read(4));
     68            return array_shift($low_end);
     69        } else {
     70            // big endian
     71            $big_end = unpack('N', $this->STREAM->read(4));
     72            return array_shift($big_end);
     73        }
     74    }
     75
     76    /**
     77     * Reads an array of Integers from the Stream
     78     *
     79     * @param int count How many elements should be read
     80     * @return Array of Integers
     81     */
     82    function readintarray($count) {
     83    if ($this->BYTEORDER == 0) {
     84            // low endian
     85            return unpack('V'.$count, $this->STREAM->read(4 * $count));
     86        } else {
     87            // big endian
     88            return unpack('N'.$count, $this->STREAM->read(4 * $count));
     89        }
     90    }
     91
     92    /**
     93     * Constructor
     94     *
     95     * @param object Reader the StreamReader object
     96     * @param boolean enable_cache Enable or disable caching of strings (default on)
     97     */
     98    function gettext_reader($Reader, $enable_cache = true) {
     99        // If there isn't a StreamReader, turn on short circuit mode.
     100        if (! $Reader || isset($Reader->error) ) {
     101            $this->short_circuit = true;
     102            return;
     103        }
     104
     105        // Caching can be turned off
     106        $this->enable_cache = $enable_cache;
     107
     108        // $MAGIC1 = (int)0x950412de; //bug in PHP 5.0.2, see https://savannah.nongnu.org/bugs/?func=detailitem&item_id=10565
     109        $MAGIC1 = (int) - 1794895138;
     110        // $MAGIC2 = (int)0xde120495; //bug
     111        $MAGIC2 = (int) - 569244523;
     112        // 64-bit fix
     113        $MAGIC3 = (int) 2500072158;
     114
     115        $this->STREAM = $Reader;
     116        $magic = $this->readint();
     117        if ($magic == ($MAGIC1 & 0xFFFFFFFF) || $magic == ($MAGIC3 & 0xFFFFFFFF)) { // to make sure it works for 64-bit platforms
     118            $this->BYTEORDER = 0;
     119        } elseif ($magic == ($MAGIC2 & 0xFFFFFFFF)) {
     120            $this->BYTEORDER = 1;
     121        } else {
     122            $this->error = 1; // not MO file
     123            return false;
     124        }
     125
     126        // FIXME: Do we care about revision? We should.
     127        $revision = $this->readint();
     128
     129        $this->total = $this->readint();
     130        $this->originals = $this->readint();
     131        $this->translations = $this->readint();
     132    }
     133
     134    /**
     135     * Loads the translation tables from the MO file into the cache
     136     * If caching is enabled, also loads all strings into a cache
     137     * to speed up translation lookups
     138     *
     139     * @access private
     140     */
     141    function load_tables() {
     142        if (is_array($this->cache_translations) &&
     143            is_array($this->table_originals) &&
     144            is_array($this->table_translations))
     145            return;
     146
     147        /* get original and translations tables */
     148        $this->STREAM->seekto($this->originals);
     149        $this->table_originals = $this->readintarray($this->total * 2);
     150        $this->STREAM->seekto($this->translations);
     151        $this->table_translations = $this->readintarray($this->total * 2);
     152
     153        if ($this->enable_cache) {
     154            $this->cache_translations = array ();
     155            /* read all strings in the cache */
     156            for ($i = 0; $i < $this->total; $i++) {
     157                $this->STREAM->seekto($this->table_originals[$i * 2 + 2]);
     158                $original = $this->STREAM->read($this->table_originals[$i * 2 + 1]);
     159                $this->STREAM->seekto($this->table_translations[$i * 2 + 2]);
     160                $translation = $this->STREAM->read($this->table_translations[$i * 2 + 1]);
     161                $this->cache_translations[$original] = $translation;
     162            }
     163        }
     164    }
     165
     166    /**
     167     * Returns a string from the "originals" table
     168     *
     169     * @access private
     170     * @param int num Offset number of original string
     171     * @return string Requested string if found, otherwise ''
     172     */
     173    function get_original_string($num) {
     174        $length = $this->table_originals[$num * 2 + 1];
     175        $offset = $this->table_originals[$num * 2 + 2];
     176        if (! $length)
     177            return '';
     178        $this->STREAM->seekto($offset);
     179        $data = $this->STREAM->read($length);
     180        return (string)$data;
     181    }
     182
     183    /**
     184     * Returns a string from the "translations" table
     185     *
     186     * @access private
     187     * @param int num Offset number of original string
     188     * @return string Requested string if found, otherwise ''
     189     */
     190    function get_translation_string($num) {
     191        $length = $this->table_translations[$num * 2 + 1];
     192        $offset = $this->table_translations[$num * 2 + 2];
     193        if (! $length)
     194            return '';
     195        $this->STREAM->seekto($offset);
     196        $data = $this->STREAM->read($length);
     197        return (string)$data;
     198    }
     199
     200    /**
     201     * Binary search for string
     202     *
     203     * @access private
     204     * @param string string
     205     * @param int start (internally used in recursive function)
     206     * @param int end (internally used in recursive function)
     207     * @return int string number (offset in originals table)
     208     */
     209    function find_string($string, $start = -1, $end = -1) {
     210        if (($start == -1) or ($end == -1)) {
     211            // find_string is called with only one parameter, set start end end
     212            $start = 0;
     213            $end = $this->total;
     214        }
     215        if (abs($start - $end) <= 1) {
     216            // We're done, now we either found the string, or it doesn't exist
     217            $txt = $this->get_original_string($start);
     218            if ($string == $txt)
     219                return $start;
     220            else
     221                return -1;
     222        } else if ($start > $end) {
     223            // start > end -> turn around and start over
     224            return $this->find_string($string, $end, $start);
     225        } else {
     226            // Divide table in two parts
     227            $half = (int)(($start + $end) / 2);
     228            $cmp = strcmp($string, $this->get_original_string($half));
     229            if ($cmp == 0)
     230                // string is exactly in the middle => return it
     231                return $half;
     232            else if ($cmp < 0)
     233                // The string is in the upper half
     234                return $this->find_string($string, $start, $half);
     235            else
     236                // The string is in the lower half
     237                return $this->find_string($string, $half, $end);
     238        }
     239    }
     240
     241    /**
     242     * Translates a string
     243     *
     244     * @access public
     245     * @param string string to be translated
     246     * @return string translated string (or original, if not found)
     247     */
     248    function translate($string) {
     249        if ($this->short_circuit)
     250            return $string;
     251        $this->load_tables();
     252
     253        if ($this->enable_cache) {
     254            // Caching enabled, get translated string from cache
     255            if (array_key_exists($string, $this->cache_translations))
     256                return $this->cache_translations[$string];
     257            else
     258                return $string;
     259        } else {
     260            // Caching not enabled, try to find string
     261            $num = $this->find_string($string);
     262            if ($num == -1)
     263                return $string;
     264            else
     265                return $this->get_translation_string($num);
     266        }
     267    }
     268
     269    /**
     270     * Get possible plural forms from MO header
     271     *
     272     * @access private
     273     * @return string plural form header
     274     */
     275    function get_plural_forms() {
     276        // lets assume message number 0 is header
     277        // this is true, right?
     278        $this->load_tables();
     279
     280        // cache header field for plural forms
     281        if (! is_string($this->pluralheader)) {
     282            if ($this->enable_cache) {
     283                $header = $this->cache_translations[""];
     284            } else {
     285                $header = $this->get_translation_string(0);
     286            }
     287            $header .= "\n"; //make sure our regex matches
     288            if (eregi("plural-forms: ([^\n]*)\n", $header, $regs))
     289                $expr = $regs[1];
     290            else
     291                $expr = "nplurals=2; plural=n == 1 ? 0 : 1;";
     292
     293            // add parentheses
     294            // important since PHP's ternary evaluates from left to right
     295            $expr.= ';';
     296            $res= '';
     297            $p= 0;
     298            for ($i= 0; $i < strlen($expr); $i++) {
     299                $ch= $expr[$i];
     300                switch ($ch) {
     301                    case '?':
     302                        $res.= ' ? (';
     303                        $p++;
     304                        break;
     305                    case ':':
     306                        $res.= ') : (';
     307                        break;
     308                    case ';':
     309                        $res.= str_repeat( ')', $p) . ';';
     310                        $p= 0;
     311                        break;
     312                    default:
     313                        $res.= $ch;
     314                }
     315            }
     316            $this->pluralheader = $res;
     317        }
     318
     319        return $this->pluralheader;
     320    }
     321
     322    /**
     323     * Detects which plural form to take
     324     *
     325     * @access private
     326     * @param n count
     327     * @return int array index of the right plural form
     328     */
     329    function select_string($n) {
     330        if (is_null($this->select_string_function)) {
     331            $string = $this->get_plural_forms();
     332            if (preg_match("/nplurals\s*=\s*(\d+)\s*\;\s*plural\s*=\s*(.*?)\;+/", $string, $matches)) {
     333                $nplurals = $matches[1];
     334                $expression = $matches[2];
     335                $expression = str_replace("n", '$n', $expression);
     336            } else {
     337                $nplurals = 2;
     338                $expression = ' $n == 1 ? 0 : 1 ';
     339            }
     340            $func_body = "
     341                \$plural = ($expression);
     342                return (\$plural <= $nplurals)? \$plural : \$plural - 1;";
     343            $this->select_string_function = create_function('$n', $func_body);
     344        }
     345        return call_user_func($this->select_string_function, $n);
     346    }
     347
     348    /**
     349     * Plural version of gettext
     350     *
     351     * @access public
     352     * @param string single
     353     * @param string plural
     354     * @param string number
     355     * @return translated plural form
     356     */
     357    function ngettext($single, $plural, $number) {
     358        if ($this->short_circuit) {
     359            if ($number != 1)
     360                return $plural;
     361            else
     362                return $single;
     363        }
     364
     365        // find out the appropriate form
     366        $select = $this->select_string($number);
     367
     368        // this should contains all strings separated by NULLs
     369        $key = $single.chr(0).$plural;
     370
     371
     372        if ($this->enable_cache) {
     373            if (! array_key_exists($key, $this->cache_translations)) {
     374                return ($number != 1) ? $plural : $single;
     375            } else {
     376                $result = $this->cache_translations[$key];
     377                $list = explode(chr(0), $result);
     378                return $list[$select];
     379            }
     380        } else {
     381            $num = $this->find_string($key);
     382            if ($num == -1) {
     383                return ($number != 1) ? $plural : $single;
     384            } else {
     385                $result = $this->get_translation_string($num);
     386                $list = explode(chr(0), $result);
     387                return $list[$select];
     388            }
     389        }
     390    }
    363391
    364392}
Note: See TracChangeset for help on using the changeset viewer.