Merge "Function for "pretty timestamps" that are human readable and understandable."
authorReedy <reedy@wikimedia.org>
Thu, 25 Oct 2012 00:58:19 +0000 (00:58 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 25 Oct 2012 00:58:19 +0000 (00:58 +0000)
1  2 
languages/Language.php
languages/messages/MessagesEn.php
languages/messages/MessagesQqq.php
maintenance/language/messages.inc
tests/phpunit/languages/LanguageTest.php

diff --combined languages/Language.php
@@@ -54,7 -54,6 +54,7 @@@ class FakeConverter 
        function convert( $t ) { return $t; }
        function convertTo( $text, $variant ) { return $text; }
        function convertTitle( $t ) { return $t->getPrefixedText(); }
 +      function convertNamespace( $ns ) { return $this->mLang->getFormattedNsText( $ns ); }
        function getVariants() { return array( $this->mLang->getCode() ); }
        function getPreferredVariant() { return $this->mLang->getCode(); }
        function getDefaultVariant() { return $this->mLang->getCode(); }
@@@ -155,6 -154,10 +155,10 @@@ class Language 
                'hijri-calendar-m10', 'hijri-calendar-m11', 'hijri-calendar-m12'
        );
  
+       // For pretty timestamps
+       // Cutoff for specifying "weekday at XX:XX" format
+       protected $mWeekdayAtCutoff = 432000; // 5 days
        /**
         * @since 1.20
         * @var array
         * @deprecated in 1.19
         */
        function getFallbackLanguageCode() {
 -              wfDeprecated( __METHOD__ );
 +              wfDeprecated( __METHOD__, '1.19' );
                return self::getFallbackFor( $this->mCode );
        }
  
         */
        public function setNamespaces( array $namespaces ) {
                $this->namespaceNames = $namespaces;
 +              $this->mNamespaceIds = null;
 +      }
 +
 +      /**
 +       * Resets all of the namespace caches. Mainly used for testing
 +       */
 +      public function resetNamespaces( ) {
 +              $this->namespaceNames = null;
 +              $this->mNamespaceIds = null;
 +              $this->namespaceAliases = null;
        }
  
        /**
         * @param $usePrefs Mixed: if true, the user's preference is used
         *                         if false, the site/language default is used
         *                         if int/string, assumed to be a format.
+        *                         if User object, assumed to be a User to get preference from
         * @return string
         */
        function dateFormat( $usePrefs = true ) {
                        } else {
                                $datePreference = (string)User::getDefaultOption( 'date' );
                        }
+               } elseif ( $usePrefs instanceof User ) {
+                       $datePreference = $usePrefs->getDatePreference();
                } else {
                        $datePreference = (string)$usePrefs;
                }
                                $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
                        } else {
                                $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
+                               if ( $type === 'shortdate' && is_null( $df ) ) {
+                                       $df = $this->getDateFormatString( 'date', $pref );
+                               }
                                if ( is_null( $df ) ) {
                                        $pref = $this->getDefaultDateFormat();
                                        $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" );
                                }
                        }
                        $this->dateFormatStrings[$type][$pref] = $df;
                }
                return $this->dateFormatStrings[$type][$pref];
                return $this->internalUserTimeAndDate( 'both', $ts, $user, $options );
        }
  
+       /**
+        * Formats a timestamp in a pretty, human-readable format.
+        * Instead of "13:04, 16 July 2012", we have:
+        * - Just now
+        * - 35 minutes ago
+        * - At 13:04
+        * - Yesterday at 13:04
+        * - Wednesday at 13:04
+        * - July 16, 13:04
+        * - July 16 2012 at 13:04
+        *
+        * @todo Port to JavaScript
+        *
+        * @param $ts Mixed: the time format which needs to be turned into a
+        *            date('YmdHis') format with wfTimestamp(TS_MW,$ts)
+        * @param $relativeTo Mixed: The timestamp to use as "now"
+        * @param $user User: The user to format for (needed for timezone information)
+        * @since 1.20
+        * @return string Formatted timestamp
+        */
+       public function prettyTimestamp( $timestamp, $relativeTo = false, $user ) {
+               // Parameter defaults
+               if ( $relativeTo === false ) {
+                       $relativeTo = wfTimestampNow();
+               }
+               // Normalise input to UNIX time
+               $relativeTo = wfTimestamp( TS_UNIX, $relativeTo );
+               $timestamp = wfTimestamp( TS_UNIX, $timestamp );
+               $timeAgo = $relativeTo - $timestamp;
+               $adjustedRelativeTo = $this->userAdjust( wfTimestamp( TS_MW, $relativeTo ), $user->getOption('timecorrection') );
+               $adjustedRelativeTo = wfTimestamp( TS_UNIX, $adjustedRelativeTo );
+               $relativeToYear = gmdate( 'Y', $adjustedRelativeTo );
+               $adjustedTimestamp = $this->userAdjust( wfTimestamp( TS_MW, $timestamp ), $user->getOption('timecorrection') );
+               $adjustedTimestamp = wfTimestamp( TS_UNIX, $adjustedTimestamp );
+               $timestampYear = gmdate( 'Y', $adjustedTimestamp );
+               if ( $timeAgo < 0 ) {
+                       throw new MWException( "Future timestamps not currently supported" );
+               } elseif ( $timeAgo < 30 ) {
+                       return wfMessage( 'just-now' )
+                               ->inLanguage( $this )
+                               ->text();
+               } elseif ( $timeAgo < 5400 ) {
+                       // Less than 90 minutes ago. Return number of hours, minutes or seconds ago.
+                       return $this->formatRelativeTime( $timeAgo );
+               } elseif ( // Same day
+                       intval( $adjustedRelativeTo / (24*60*60) ) ===
+                       intval( $adjustedTimestamp / (24*60*60) )
+               ) {
+                       // Today at XX:XX
+                       $time = $this->time( $adjustedTimestamp );
+                       return wfMessage( 'today-at' )
+                               ->inLanguage( $this )
+                               ->params( $time )
+                               ->text();
+               } elseif ( // Previous day
+                       intval( $adjustedRelativeTo / (24*60*60) ) ===
+                       ( intval( $adjustedTimestamp / (24*60*60) ) + 1 )
+               ) {
+                       // Yesterday at XX:XX
+                       $time = $this->time( $adjustedTimestamp );
+                       return wfMessage( 'yesterday-at' )
+                               ->inLanguage( $this )
+                               ->params( $time )
+                               ->text();
+               } elseif ( $timeAgo < ( $this->mWeekdayAtCutoff ) ) { // Less than 5 days ago
+                       // Xday at XX:XX
+                       return $this->formatPastWeekTimestamp( $adjustedTimestamp, $adjustedRelativeTo );
+               } elseif ( $relativeToYear == $timestampYear ) {
+                       // XX XMonth
+                       $df = $this->getDateFormatString( 'shortdate', $this->dateFormat( $user ) );
+                       $mwTimestamp = wfTimestamp( TS_MW, $timestamp );
+                       return $this->sprintfDate( $df, $mwTimestamp );
+               } else {
+                       // Full timestamp
+                       $mwTimestamp = wfTimestamp( TS_MW, $timestamp );
+                       return $this->userDate( $mwTimestamp, $user );
+               }
+       }
+       /**
+        * For pretty timestamps: Formats the "X {hours,minutes,seconds} ago" message.
+        *
+        * @param $timeAgo The number of seconds ago the event occurred
+        * @return Formatted string
+        */
+       protected function formatRelativeTime( $timeAgo ) {
+               $count = false;
+               if ( $timeAgo < 60 ) {
+                       $unit = 'seconds';
+                       $count = $timeAgo;
+               } elseif ( $timeAgo < 3600 ) {
+                       $unit = 'minutes';
+                       $count = intval( $timeAgo / 60 );
+               } else {
+                       $unit = 'hours';
+                       $count = intval( $timeAgo / 3600 );
+               }
+               return wfMessage( "{$unit}-ago" )->inLanguage( $this )->params( $count )->text();
+       }
+       /**
+        * For pretty timestamps: Formats the timestamp for events that occurred
+        * "in the last few days".
+        * (cutoff is configurable by the member variable $mWeekdayAtCutoff)
+        *
+        * @param $eventTimestamp The timestamp of the event, adjusted to
+        *  the user's local timezone, in UNIX format (# of seconds since epoch)
+        * @param $relativeTo The timestamp to format relative to, adjusted to
+        *  the user's local timezone, in UNIX format (# of seconds since epoch)
+        * @return String: The date formatted in a human-friendly way.
+        */
+       protected function formatPastWeekTimestamp( $adjustedTimestamp, $relativeTo ) {
+               $day = date( 'w', $adjustedTimestamp );
+               $weekday = self::$mWeekdayMsgs[$day];
+               $time = $this->time( $adjustedTimestamp );
+               return wfMessage( "$weekday-at" )
+                       ->inLanguage( $this )
+                       ->params( $time )
+                       ->text();
+       }
        /**
         * @param $key string
         * @return array|null
                return $this->mConverter->convertTitle( $title );
        }
  
 +      /**
 +       * Convert a namespace index to a string in the preferred variant
 +       *
 +       * @param $ns int
 +       * @return string
 +       */
 +      public function convertNamespace( $ns ) {
 +              return $this->mConverter->convertNamespace( $ns );
 +      }
 +
        /**
         * Check if this is a language with variants
         *
@@@ -162,18 -162,22 +162,22 @@@ $dateFormats = array
        'mdy time' => 'H:i',
        'mdy date' => 'F j, Y',
        'mdy both' => 'H:i, F j, Y',
+       'mdy shortdate' => 'F j',
  
        'dmy time' => 'H:i',
        'dmy date' => 'j F Y',
        'dmy both' => 'H:i, j F Y',
+       'dmy shortdate' => 'j F',
  
        'ymd time' => 'H:i',
        'ymd date' => 'Y F j',
        'ymd both' => 'H:i, Y F j',
+       'ymd shortdate' => 'F j',
  
        'ISO 8601 time' => 'xnH:xni:xns',
        'ISO 8601 date' => 'xnY-xnm-xnd',
        'ISO 8601 both' => 'xnY-xnm-xnd"T"xnH:xni:xns',
+       // 'ISO 8601 shortdate' => 'xnm-xnd', // This is just confusing
  );
  
  /**
@@@ -783,6 -787,7 +787,6 @@@ XHTML id names
  'qbbrowse'       => 'Browse',
  'qbedit'         => 'Edit',
  'qbpageoptions'  => 'This page',
 -'qbpageinfo'     => 'Context',
  'qbmyoptions'    => 'My pages',
  'qbspecialpages' => 'Special pages',
  'faq'            => 'FAQ',
@@@ -1064,7 -1069,7 +1068,7 @@@ The administrator who locked it offere
  # Login and logout pages
  'logouttext'                 => "'''You are now logged out.'''
  
 -You can continue to use {{SITENAME}} anonymously, or you can [[Special:UserLogin|log in again]] as the same or as a different user.
 +You can continue to use {{SITENAME}} anonymously, or you can <span class='plainlinks'>[$1 log in again]</span> as the same or as a different user.
  Note that some pages may continue to be displayed as if you were still logged in, until you clear your browser cache.",
  'welcomecreation'            => '== Welcome, $1! ==
  Your account has been created.
@@@ -1483,15 -1488,6 +1487,15 @@@ It already exists.'
  'addsection-preload'               => '', # do not translate or duplicate this message to other languages
  'addsection-editintro'             => '', # do not translate or duplicate this message to other languages
  'defaultmessagetext'               => 'Default message text',
 +'content-failed-to-parse'          => 'Failed to parse $2 content for $1 model: $3',
 +'invalid-content-data'             => 'Invalid content data',
 +'content-not-allowed-here'         => '"$1" content is not allowed on page [[$2]]',
 +
 +# Content models
 +'content-model-wikitext'   => 'wikitext',
 +'content-model-text'       => 'plain text',
 +'content-model-javascript' => 'JavaScript',
 +'content-model-css'        => 'CSS',
  
  # Parser/template warnings
  'expensive-parserfunction-warning'        => "'''Warning:''' This page contains too many expensive parser function calls.
@@@ -2465,7 -2461,7 +2469,7 @@@ Maybe you want to edit the description 
  'shared-repo'                       => 'a shared repository',
  'shared-repo-name-wikimediacommons' => 'Wikimedia Commons', # only translate this message to other languages if you have to change it
  'filepage.css'                      => '/* CSS placed here is included on the file description page, also included on foreign client wikis */', # only translate this message to other languages if you have to change it
 -'upload-disallowed-here'            => 'Unfortunately you cannot overwrite this image.',
 +'upload-disallowed-here'            => 'You cannot overwrite this file.',
  
  # File reversion
  'filerevert'                => 'Revert $1',
@@@ -3073,8 -3069,8 +3077,8 @@@ You may have a bad link, or the revisio
  'undeletedrevisions'           => '{{PLURAL:$1|1 revision|$1 revisions}} restored',
  'undeletedrevisions-files'     => '{{PLURAL:$1|1 revision|$1 revisions}} and {{PLURAL:$2|1 file|$2 files}} restored',
  'undeletedfiles'               => '{{PLURAL:$1|1 file|$1 files}} restored',
 -'cannotundelete'               => 'Undelete failed;
 -someone else may have undeleted the page first.',
 +'cannotundelete'               => 'Undelete failed:
 +$1',
  'undeletedpage'                => "'''$1 has been restored'''
  
  Consult the [[Special:Log/delete|deletion log]] for a record of recent deletions and restorations.",
@@@ -3396,7 -3392,6 +3400,7 @@@ cannot move a page over itself.'
  'immobile-target-namespace-iw' => 'Interwiki link is not a valid target for page move.',
  'immobile-source-page'         => 'This page is not movable.',
  'immobile-target-page'         => 'Cannot move to that destination title.',
 +'bad-target-model'             => 'The desired destination uses a different content model. Can not convert from $1 to $2.',
  'imagenocrossnamespace'        => 'Cannot move file to non-file namespace',
  'nonfile-cannot-move-to-file'  => 'Cannot move non-file to file namespace',
  'imagetypemismatch'            => 'The new file extension does not match its type',
@@@ -3684,7 -3679,7 +3688,7 @@@ You can view its source'
  'standard.css'            => '/* CSS placed here will affect users of the Standard skin */', # only translate this message to other languages if you have to change it
  'nostalgia.css'           => '/* CSS placed here will affect users of the Nostalgia skin */', # only translate this message to other languages if you have to change it
  'cologneblue.css'         => '/* CSS placed here will affect users of the Cologne Blue skin */', # only translate this message to other languages if you have to change it
 -'monobook.css'            => '/* CSS placed here will affect users of the Monobook skin */', # only translate this message to other languages if you have to change it
 +'monobook.css'            => '/* CSS placed here will affect users of the MonoBook skin */', # only translate this message to other languages if you have to change it
  'myskin.css'              => '/* CSS placed here will affect users of the MySkin skin */', # only translate this message to other languages if you have to change it
  'chick.css'               => '/* CSS placed here will affect users of the Chick skin */', # only translate this message to other languages if you have to change it
  'simple.css'              => '/* CSS placed here will affect users of the Simple skin */', # only translate this message to other languages if you have to change it
@@@ -3742,7 -3737,6 +3746,7 @@@ This is probably caused by a link to a 
  # Info page
  'pageinfo-header'              => '-', # do not translate or duplicate this message to other languages
  'pageinfo-title'               => 'Information for "$1"',
 +'pageinfo-not-current'         => 'Information may only be displayed for the current revision.',
  'pageinfo-header-basic'        => 'Basic information',
  'pageinfo-header-edits'        => 'Edit history',
  'pageinfo-header-restrictions' => 'Page protection',
  'pageinfo-authors'             => 'Total number of distinct authors',
  'pageinfo-recent-edits'        => 'Recent number of edits (within past $1)',
  'pageinfo-recent-authors'      => 'Recent number of distinct authors',
 -'pageinfo-restriction'         => 'Page protection ({{lcfirst:$1}})',
  'pageinfo-magic-words'         => 'Magic {{PLURAL:$1|word|words}} ($1)',
  'pageinfo-hidden-categories'   => 'Hidden {{PLURAL:$1|category|categories}} ($1)',
  'pageinfo-templates'           => 'Transcluded {{PLURAL:$1|template|templates}} ($1)',
  'pageinfo-footer'              => '-', # do not translate or duplicate this message to other languages
  'pageinfo-toolboxlink'         => 'Page information',
 +'pageinfo-redirectsto'         => 'Redirects to',
 +'pageinfo-redirectsto-info'    => 'info',
 +'pageinfo-contentpage'         => 'Counted as a content page',
 +'pageinfo-contentpage-yes'     => 'Yes',
 +'pageinfo-protect-cascading'      => 'Protections are cascading from here',
 +'pageinfo-protect-cascading-yes'  => 'Yes',
 +'pageinfo-protect-cascading-from' => 'Protections are cascading from',
  
  # Skin names
  'skinname-standard'    => 'Classic', # only translate this message to other languages if you have to change it
@@@ -3863,16 -3851,32 +3867,32 @@@ By executing it, your system may be com
  
  # Video information, used by Language::formatTimePeriod() to format lengths in the above messages
  'video-dims'     => '$1, $2 × $3', # only translate this message to other languages if you have to change it
 -'seconds-abbrev' => '$1s', # only translate this message to other languages if you have to change it
 -'minutes-abbrev' => '$1m', # only translate this message to other languages if you have to change it
 -'hours-abbrev'   => '$1h', # only translate this message to other languages if you have to change it
 -'days-abbrev'    => '$1d', # only translate this message to other languages if you have to change it
 +'seconds-abbrev' => '$1 s', # only translate this message to other languages if you have to change it
 +'minutes-abbrev' => '$1 min', # only translate this message to other languages if you have to change it
 +'hours-abbrev'   => '$1 h', # only translate this message to other languages if you have to change it
 +'days-abbrev'    => '$1 d', # only translate this message to other languages if you have to change it
  'seconds'        => '{{PLURAL:$1|$1 second|$1 seconds}}',
  'minutes'        => '{{PLURAL:$1|$1 minute|$1 minutes}}',
  'hours'          => '{{PLURAL:$1|$1 hour|$1 hours}}',
  'days'           => '{{PLURAL:$1|$1 day|$1 days}}',
  'ago'            => '$1 ago',
  
+ 'hours-ago'      => '$1 {{PLURAL:$1|hour|hours}} ago',
+ 'minutes-ago'    => '$1 {{PLURAL:$1|minute|minutes}} ago',
+ 'seconds-ago'    => '$1 {{PLURAL:$1|seconds|seconds}} ago',
+ 'monday-at'   => 'Monday at $1',
+ 'tuesday-at'   => 'Tuesday at $1',
+ 'wednesday-at'   => 'Wednesday at $1',
+ 'thursday-at'   => 'Thursday at $1',
+ 'friday-at'   => 'Friday at $1',
+ 'saturday-at'   => 'Saturday at $1',
+ 'sunday-at'   => 'Sunday at $1',
+ 'today-at'       => '$1',
+ 'yesterday-at'   => 'Yesterday at $1',
+ 'just-now'       => 'Just now',
  # Bad image list
  'bad_image_list' => 'The format is as follows:
  
@@@ -45,7 -45,6 +45,7 @@@
   * @author Guglani
   * @author Gustronico
   * @author Hamilton Abreu
 + * @author Harsh4101991
   * @author Helix84
   * @author Holek
   * @author Huji
@@@ -727,8 -726,7 +727,8 @@@ See also {{msg-mw|protectedinterface}}.
  'exception-nologin-text' => 'Generic reason displayed on error page when a user is not logged in. Message used by the UserNotLoggedIn exception.',
  
  # Login and logout pages
 -'logouttext' => 'Log out message',
 +'logouttext' => 'Log out message
 +* $1 is an URL to [[Special:Userlogin]] containing returnto and returntoquery parameters',
  'welcomecreation' => 'The welcome message users see after registering a user account. $1 is the username of the new user.',
  'yourname' => "In user preferences
  
@@@ -1036,9 -1034,7 +1036,9 @@@ Example: [http://translatewiki.net/w/i.
  'explainconflict' => 'Appears at the top of a page when there is an edit conflict.',
  'storedversion' => 'This is used in an edit conflict as the label for the top revision that has been stored, as opposed to your version that has not been stored which is shown at the bottom of the page.',
  'yourdiff' => '',
 -'copyrightwarning' => 'Copyright warning displayed under the edit box in editor',
 +'copyrightwarning' => 'Copyright warning displayed under the edit box in editor
 +*$1 - ...
 +*$2 - ...',
  'longpageerror' => 'Warning displayed when trying to save a text larger than the maximum size allowed',
  'protectedpagewarning' => '{{Related|Semiprotectedpagewarning}}',
  'semiprotectedpagewarning' => '{{Related|Semiprotectedpagewarning}}',
@@@ -1063,36 -1059,6 +1063,36 @@@ Please report at [[Support]] if you ar
  'moveddeleted-notice' => 'Shown on top of a deleted page in normal view modus ([http://translatewiki.net/wiki/Test example]).',
  'edit-conflict' => "An 'Edit conflict' happens when more than one edit is being made to a page at the same time. This would usually be caused by separate individuals working on the same page. However, if the system is slow, several edits from one individual could back up and attempt to apply simultaneously - causing the conflict.",
  'defaultmessagetext' => 'Caption above the default message text shown on the left-hand side of a diff displayed after clicking “Show changes” when creating a new page in the MediaWiki: namespace',
 +'content-failed-to-parse' => "Error message indicating that the page's content can not be saved because it is syntactically invalid. This may occurr for content types using serialization or a strict markup syntax.
 +*$1 – content model ({{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}})
 +*$2 – content format as MIME type (e.g. <tt>text/css</tt>)
 +*$3 – specific error message",
 +'invalid-content-data' => "Error message indicating that the page's content can not be saved because it is invalid. This may occurr for content types with internal consistency constraints.",
 +'content-not-allowed-here' => 'Error message indicating that the desired content model is not supported in given localtion.
 +* $1 is the human readable name of the content model: {{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}
 +* $2 is the title of the page in question.',
 +
 +# Content models
 +'content-model-wikitext' => 'Name for the wikitext content model, used when decribing what type of content a page contains.
 +
 +This message is substituted in:
 +*{{msg-mw|Bad-target-model}}
 +*{{msg-mw|Content-not-allowed-here}}',
 +'content-model-text' => 'Name for the plain text content model, used when decribing what type of content a page contains.
 +
 +This message is substituted in:
 +*{{msg-mw|Bad-target-model}}
 +*{{msg-mw|Content-not-allowed-here}}',
 +'content-model-javascript' => 'Name for the JavaScript content model, used when decribing what type of content a page contains.
 +
 +This message is substituted in:
 +*{{msg-mw|Bad-target-model}}
 +*{{msg-mw|Content-not-allowed-here}}',
 +'content-model-css' => 'Name for the CSS content model, used when decribing what type of content a page contains.
 +
 +This message is substituted in:
 +*{{msg-mw|Bad-target-model}}
 +*{{msg-mw|Content-not-allowed-here}}',
  
  # Parser/template warnings
  'expensive-parserfunction-warning' => 'On some (expensive) [[MetaWikipedia:Help:ParserFunctions|parser functions]] (e.g. <code><nowiki>{{#ifexist:}}</nowiki></code>) there is a limit of how many times it may be used. This is an error message shown when the limit is exceeded.
@@@ -1639,17 -1605,15 +1639,17 @@@ Used in [[Special:Preferences]], tab "W
  'userrights-editusergroup' => '{{Identical|Edit user groups}}. Parameter:
  * $1 is a username - optional, can be used for GENDER',
  'saveusergroups' => 'Button text when editing user groups',
 -'userrights-groupsmember' => 'Used when editing user groups in [[Special:Userrights]]. The messsage is followed by a list of group names.
 +'userrights-groupsmember' => 'Used when editing user groups in [[Special:Userrights]]. The message is followed by a list of group names.
  
  Parameters:
 -* $1 - optional, for PLURAL use, the number of items in the list following the message. Avoid PLURAL, if your language can do without.',
 +* $1 - the number of items in the list following the message, for PLURAL.
 +* $2 - the user name, for GENDER.',
  'userrights-groupsmember-auto' => 'Used when editing user groups in [[Special:Userrights]]. The messsage is followed by a list of group names.
  "Implicit" is for groups that the user was automatically added to (such as "autoconfirmed"); cf. {{msg-mw|userrights-groupsmember}}
  
 -Parameters:
 -* $1 - optional, for PLURAL use, the number of items in the list following the message. Please avoid PLURAL, if your language can do without.',
 +Parameters
 +* $1 - the number of items in the list following the message, for PLURAL.
 +* $2 - the user name, for GENDER.',
  'userrights-groups-help' => 'Instructions displayed on [[Special:UserRights]]. Parameters:
  * $1 is a username - optional, can be used for GENDER',
  'userrights-reason' => 'Text beside log field when editing user groups
@@@ -1896,7 -1860,7 +1896,7 @@@ This action allows editing of all of th
  'recentchanges-legend' => 'Legend of the fieldset of [[Special:RecentChanges]]',
  'recentchanges-summary' => 'Summary of [[Special:RecentChanges]].',
  'recentchanges-label-newpage' => 'Tooltip for {{msg-mw|newpageletter}}',
 -'recentchanges-label-minor' => 'Tooltip for {{msg-mw|newpageletter}}',
 +'recentchanges-label-minor' => 'Tooltip for {{msg-mw|minoreditletter}}',
  'recentchanges-label-bot' => 'Tooltip for {{msg-mw|boteditletter}}',
  'recentchanges-label-unpatrolled' => 'Tooltip for {{msg-mw|unpatrolledletter}}',
  'rcnote' => 'Used on [[Special:RecentChanges]].
@@@ -2843,12 -2807,7 +2843,12 @@@ This message was something like "unloc
  'protect-text' => 'Intro of the protection interface. See [[meta:Protect]] for more information.',
  'protect-default' => '{{Identical|Default}}',
  'protect-fallback' => 'This message is used as an option in the protection form on wikis were extra protection levels have been configured.',
 -'protect-summary-cascade' => 'Used in edit summary when cascade protecting a page.',
 +'protect-summary-cascade' => 'Used in edit summary when cascade protecting a page. Appears in protection log. See [[Special:Log]] and [[m:Special:Log]].
 +
 +Also used in [[Special:ProtectedPages]] when a page is cascade protected. See example: [[m:Special:ProtectedPages]].<br />
 +See also:
 +*{{msg-mw|Restriction-level-sysop}}
 +*{{msg-mw|Restriction-level-autoconfirmed}}',
  'protect-expiring' => 'Used in page history, and in [[Special:Protectedtitles]], [[Special:Protectedpages]], and extension FlaggedRevs.
  * $1 is a date and time
  * $2 is a date (optional)
@@@ -2896,21 -2855,9 +2896,21 @@@ Options for the duration of the page pr
  {{Identical|Create}}',
  
  # Restriction levels
 -'restriction-level-sysop' => "Used on [[Special:ProtectedPages]] and [[Special:ProtectedTitles]]. An option in the drop-down box 'Restriction level' and in brackets after each page name entry. See the [//www.mediawiki.org/wiki/Project:Protected_titles help page on Mediawiki] and on [http://meta.wikimedia.org/wiki/Protect Meta] for more information.",
 -'restriction-level-autoconfirmed' => "Used on [[Special:ProtectedPages]] and [[Special:ProtectedTitles]]. An option in the drop-down box 'Restriction level', and in brackets after each page name entry. See the [//www.mediawiki.org/wiki/Project:Protected_titles help page on Mediawiki] and on [http://meta.wikimedia.org/wiki/Protect Meta] for more information.",
 -'restriction-level-all' => "Used on [[Special:ProtectedPages]] and [[Special:ProtectedTitles]]. An option in the drop-down box 'Restriction level'. See the [//www.mediawiki.org/wiki/Project:Protected_titles help page on Mediawiki] and on [http://meta.wikimedia.org/wiki/Protect Meta] for more information.",
 +'restriction-level-sysop' => "Used on [[Special:ProtectedPages]] and [[Special:ProtectedTitles]]. An option in the drop-down box 'Restriction level' and in brackets after each page name entry. See the [//www.mediawiki.org/wiki/Project:Protected_titles help page on Mediawiki] and on [http://meta.wikimedia.org/wiki/Protect Meta] for more information.
 +
 +*{{msg-mw|Restriction-level-sysop}}
 +*{{msg-mw|Restriction-level-autoconfirmed}}
 +*{{msg-mw|Restriction-level-all}}",
 +'restriction-level-autoconfirmed' => "Used on [[Special:ProtectedPages]] and [[Special:ProtectedTitles]]. An option in the drop-down box 'Restriction level', and in brackets after each page name entry. See the [//www.mediawiki.org/wiki/Project:Protected_titles help page on Mediawiki] and on [http://meta.wikimedia.org/wiki/Protect Meta] for more information.
 +
 +*{{msg-mw|Restriction-level-sysop}}
 +*{{msg-mw|Restriction-level-autoconfirmed}}
 +*{{msg-mw|Restriction-level-all}}",
 +'restriction-level-all' => "Used on [[Special:ProtectedPages]] and [[Special:ProtectedTitles]]. An option in the drop-down box 'Restriction level'. See the [//www.mediawiki.org/wiki/Project:Protected_titles help page on Mediawiki] and on [http://meta.wikimedia.org/wiki/Protect Meta] for more information.
 +
 +*{{msg-mw|Restriction-level-sysop}}
 +*{{msg-mw|Restriction-level-autoconfirmed}}
 +*{{msg-mw|Restriction-level-all}}",
  
  # Undelete
  'undelete' => 'Name of special page for admins as displayed in [[Special:SpecialPages]].
  {{Identical|Reset}}',
  'undeleteinvert' => '{{Identical|Invert selection}}',
  'undeletecomment' => '{{Identical|Reason}}',
 +'cannotundelete' => 'Message shown when undeletion failed for some reason.
 +* <code>$1</code> is the combined wikitext of messages for all errors that caused the failure.',
  'undelete-search-title' => 'Page title when showing the search form in Special:Undelete',
  'undelete-search-submit' => '{{Identical|Search}}',
  'undelete-error' => 'Page title when a page could not be undeleted',
  
  # Namespace form on various pages
  'namespace' => 'This message is located at [[Special:Contributions]].',
 -'invert' => 'Displayed in [[Special:RecentChanges|RecentChanges]], [[Special:RecentChangesLinked|RecentChangesLinked]] and [[Special:Watchlist|Watchlist]]
 +'invert' => 'Displayed in [[Special:RecentChanges|RecentChanges]], [[Special:RecentChangesLinked|RecentChangesLinked]] and [[Special:Watchlist|Watchlist]].
  
 -{{Identical|Invert selection}}
 +This message means "Invert selection of namespace".
  
 -This message has a tooltip {{msg-mw|tooltip-invert}}',
 +This message has a tooltip {{msg-mw|tooltip-invert}}
 +{{Identical|Invert selection}}',
  'tooltip-invert' => 'Used in [[Special:Recentchanges]] as a tooltip for the invert checkbox. See also the message {{msg-mw|invert}}',
  'namespace_association' => 'Used in [[Special:Recentchanges]] with a checkbox which selects the associated namespace to be added to the selected namespace, so that both are searched (or excluded depending on another checkbox selection). The association is between a namespace and its talk namespace.
  
@@@ -3247,12 -3191,6 +3247,12 @@@ Parameters
  'immobile-target-namespace-iw' => "This message appears when attempting to move a page, if a person has typed an interwiki link as a namespace prefix in the input box labelled 'To new title'.  The special page 'Movepage' cannot be used to move a page to another wiki.
  
  'Destination' can be used instead of 'target' in this message.",
 +'bad-target-model' => 'This message is shown when attempting to move a page, but the move would change the page\'s content model.
 +This may be the case when [[mw:Manual:$wgContentHandlerUseDB|$wgContentHandlerUseDB]] is set to false, because then a page\'s content model is derived from the page\'s title.
 +* $1: The localized name of the original page\'s content model:
 +**{{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}
 +* $2: The localized name of the content model used by the destination title:
 +**{{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}',
  'fix-double-redirects' => 'This is a checkbox in [[Special:MovePage]] which allows to move all redirects from the old title to the new title.',
  'protectedpagemovewarning' => 'Related message: [[MediaWiki:protectedpagewarning/{{#titleparts:{{PAGENAME}}|1|2}}]]
  {{Related|Semiprotectedpagewarning}}',
@@@ -3531,14 -3469,13 +3531,14 @@@ See also {{msg-mw|Anonuser}} and {{msg-
  # Info page
  'pageinfo-title' => 'Page title for action=info. Parameters:
  * $1 is the page name',
 +'pageinfo-not-current' => 'Error message displayed when information for an old revision is requested. Example: [{{fullurl:Project:News|oldid=4266597&action=info}}]',
  'pageinfo-header-basic' => 'Table section header in action=info.',
  'pageinfo-header-edits' => 'Table section header in action=info.',
  'pageinfo-header-restrictions' => 'Table section header in action=info.',
  'pageinfo-header-properties' => 'Table section header in action=info.',
  'pageinfo-display-title' => 'The title that is displayed when the page is viewed.',
  'pageinfo-default-sort' => 'The key by which the page is sorted in categories by default.',
 -'pageinfo-length' => 'The length of the page, in bytes.',
 +'pageinfo-length' => 'પૃષ્ઠની લંબાઇ બાઇટમાં',
  'pageinfo-article-id' => 'The numeric identifier of the page.',
  'pageinfo-robot-policy' => 'The search engine status of the page, e.g. "marked as index".',
  'pageinfo-robot-index' => 'An indication that the page is indexable.',
  'pageinfo-authors' => 'The total number of users who have edited the page.',
  'pageinfo-recent-edits' => 'The number of times the page has been edited recently. $1 is a localised duration (e.g. 9 days).',
  'pageinfo-recent-authors' => 'The number of users who have edited the page recently.',
 -'pageinfo-restriction' => 'Parameters:
 -* $1 is the type of page protection (message restriction-$type, preferably in lowercase). If your language doesn\'t have small and capital letters, you can simply write <nowiki>$1</nowiki>.',
  'pageinfo-magic-words' => 'The list of magic words on the page. Parameters:
  * $1 is the number of magic words on the page.',
  'pageinfo-hidden-categories' => 'The list of hidden categories on the page. Parameters:
  * $1 is the number of hidden categories on the page.',
  'pageinfo-templates' => 'The list of templates transcluded within the page. Parameters:
  * $1 is the number of templates transcluded within the page.',
 -'pageinfo-toolboxlink' => 'Information link for the page (like \'What links here\', but to action=info for the current page instead)',
 +'pageinfo-toolboxlink' => "Information link for the page (like 'What links here', but to action=info for the current page instead)",
 +'pageinfo-redirectsto' => 'Key for the row shown if this page is a redirect.',
 +'pageinfo-redirectsto-info' => 'Text to put in parentheses for the link to the action=info of the redirect target.',
 +'pageinfo-contentpage' => 'Key for the row shown on [{{fullurl:News|action=info}} action=info] if this page is [[mw:Manual:Article count|counted as a content page]]',
 +'pageinfo-contentpage-yes' => 'Yes, this page is a content page',
 +'pageinfo-protect-cascading' => 'Key for the row which shows whether this page has cascading protection enabled
 +*{{msg-mw|Pageinfo-protect-cascading}}
 +*{{msg-mw|Pageinfo-protect-cascading-yes}}',
 +'pageinfo-protect-cascading-yes' => 'Yes, protections are cascading from here
 +*{{msg-mw|Pageinfo-protect-cascading}}
 +*{{msg-mw|Pageinfo-protect-cascading-yes}}',
 +'pageinfo-protect-cascading-from' => 'Key for a list of pages where protections are cascading from',
  
  # Skin names
  'skinname-standard' => '{{optional}}
@@@ -3720,6 -3648,21 +3720,21 @@@ Part of variable $1 in {{msg-mw|Ago}
  *{{msg-mw|Hours}}
  *{{msg-mw|Days}}',
  
+ 'hours-ago'      => 'Phrase for indicating that something occurred a certain number of hours ago',
+ 'minutes-ago'    => 'Phrase for indicating that something occurred a certain number of minutes ago',
+ 'seconds-ago'    => 'Phrase for indicating that something occurred a certain number of seconds ago',
+ 'monday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Monday. $1 is the time.',
+ 'tuesday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Tuesday. $1 is the time.',
+ 'wednesday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Wednesday. $1 is the time.',
+ 'thursday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Thursday. $1 is the time.',
+ 'friday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Friday. $1 is the time.',
+ 'saturday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Saturday. $1 is the time.',
+ 'sunday-at'   => 'Phrase for indicating that something occurred at a particular time on the most recent Sunday. $1 is the time.',
+ 'today-at'       => 'Phrase for indicating that something occurred at a particular time today. $1 is the time.',
+ 'yesterday-at'   => 'Phrase for indicating that something occurred at a particular time yesterday. $1 is the time.',
+ 'just-now'       => 'Phrase for indicating that something occurred very recently (for example, less than 30 seconds ago)',
  # Bad image list
  'bad_image_list' => 'This message only appears to guide administrators to add links with the right format. This will not appear anywhere else in MediaWiki.',
  
@@@ -4424,7 -4367,7 +4439,7 @@@ See also [[MediaWiki:Confirmemail_body_
  # Scary transclusion
  'scarytranscludedisabled' => 'Shown when scary transclusion is disabled.',
  'scarytranscludefailed' => 'Shown when the HTTP request for the template failed.',
 -'scarytranscludefailed-httpstatus' => 'Identical to scarytranscludefailed, but shows the HTTP error which was received.',
 +'scarytranscludefailed-httpstatus' => 'Identical to {{msg-mw|scarytranscludefailed}}, but shows the HTTP error which was received.',
  'scarytranscludetoolong' => 'The URL was too long.',
  
  'unit-pixel' => '{{optional}}',
@@@ -137,6 -137,18 +137,18 @@@ $wgMessageStructure = array
                'nov',
                'dec',
        ),
+       'pretty-timestamps' => array(
+               'monday-at',
+               'tuesday-at',
+               'wednesday-at',
+               'thursday-at',
+               'friday-at',
+               'saturday-at',
+               'sunday-at',
+               'today-at',
+               'yesterday-at',
+               'just-now',
+       ),
        'categorypages' => array(
                'pagecategories',
                'pagecategorieslink',
                'qbbrowse',
                'qbedit',
                'qbpageoptions',
 -              'qbpageinfo',
                'qbmyoptions',
                'qbspecialpages',
                'faq',
                'addsection-preload',
                'addsection-editintro',
                'defaultmessagetext',
 +              'content-failed-to-parse',
 +              'invalid-content-data',
 +              'content-not-allowed-here',
 +      ),
 +      'contentmodels' => array(
 +              'content-model-wikitext',
 +              'content-model-text',
 +              'content-model-javascript',
 +              'content-model-css',
        ),
        'parserwarnings' => array(
                'expensive-parserfunction-warning',
                'immobile-target-namespace-iw',
                'immobile-source-page',
                'immobile-target-page',
 +              'bad-target-model',
                'immobile_namespace',
                'imagenocrossnamespace',
                'nonfile-cannot-move-to-file',
        'info' => array(
                'pageinfo-header',
                'pageinfo-title',
 +              'pageinfo-not-current',
                'pageinfo-header-basic',
                'pageinfo-header-edits',
                'pageinfo-header-restrictions',
                'pageinfo-authors',
                'pageinfo-recent-edits',
                'pageinfo-recent-authors',
 -              'pageinfo-restriction',
                'pageinfo-magic-words',
                'pageinfo-hidden-categories',
                'pageinfo-templates',
                'pageinfo-footer',
                'pageinfo-toolboxlink',
 +              'pageinfo-redirectsto',
 +              'pageinfo-redirectsto-info',
 +              'pageinfo-contentpage',
 +              'pageinfo-contentpage-yes',
 +              'pageinfo-protect-cascading',
 +              'pageinfo-protect-cascading-yes',
 +              'pageinfo-protect-cascading-from',
        ),
        'skin' => array(
                'skinname-standard',
@@@ -3846,7 -3842,6 +3858,7 @@@ XHTML id names."
        'toolbar'             => 'Edit page toolbar',
        'edit'                => 'Edit pages',
        'parserwarnings'      => 'Parser/template warnings',
 +      'contentmodels'       => 'Content models',
        'undo'                => '"Undo" feature',
        'cantcreateaccount'   => 'Account creation failure',
        'history'             => 'History pages',
@@@ -7,10 -7,10 +7,10 @@@ class LanguageTest extends MediaWikiTes
         */
        private $lang;
  
 -      function setUp() {
 +      protected function setUp() {
                $this->lang = Language::factory( 'en' );
        }
 -      function tearDown() {
 +      protected function tearDown() {
                unset( $this->lang );
        }
  
@@@ -36,7 -36,7 +36,7 @@@
                        array(
                                9.45,
                                array(),
 -                              '9.5s',
 +                              '9.5 s',
                                'formatTimePeriod() rounding (<10s)'
                        ),
                        array(
@@@ -48,7 -48,7 +48,7 @@@
                        array(
                                9.95,
                                array(),
 -                              '10s',
 +                              '10 s',
                                'formatTimePeriod() rounding (<10s)'
                        ),
                        array(
@@@ -60,7 -60,7 +60,7 @@@
                        array(
                                59.55,
                                array(),
 -                              '1m 0s',
 +                              '1 min 0 s',
                                'formatTimePeriod() rounding (<60s)'
                        ),
                        array(
@@@ -72,7 -72,7 +72,7 @@@
                        array(
                                119.55,
                                array(),
 -                              '2m 0s',
 +                              '2 min 0 s',
                                'formatTimePeriod() rounding (<1h)'
                        ),
                        array(
@@@ -84,7 -84,7 +84,7 @@@
                        array(
                                3599.55,
                                array(),
 -                              '1h 0m 0s',
 +                              '1 h 0 min 0 s',
                                'formatTimePeriod() rounding (<1h)'
                        ),
                        array(
@@@ -96,7 -96,7 +96,7 @@@
                        array(
                                7199.55,
                                array(),
 -                              '2h 0m 0s',
 +                              '2 h 0 min 0 s',
                                'formatTimePeriod() rounding (>=1h)'
                        ),
                        array(
                        array(
                                7199.55,
                                'avoidseconds',
 -                              '2h 0m',
 +                              '2 h 0 min',
                                'formatTimePeriod() rounding (>=1h), avoidseconds'
                        ),
                        array(
                        array(
                                7199.55,
                                'avoidminutes',
 -                              '2h 0m',
 +                              '2 h 0 min',
                                'formatTimePeriod() rounding (>=1h), avoidminutes'
                        ),
                        array(
                        array(
                                172799.55,
                                'avoidseconds',
 -                              '48h 0m',
 +                              '48 h 0 min',
                                'formatTimePeriod() rounding (=48h), avoidseconds'
                        ),
                        array(
                        array(
                                259199.55,
                                'avoidminutes',
 -                              '3d 0h',
 +                              '3 d 0 h',
                                'formatTimePeriod() rounding (>48h), avoidminutes'
                        ),
                        array(
                        array(
                                176399.55,
                                'avoidseconds',
 -                              '2d 1h 0m',
 +                              '2 d 1 h 0 min',
                                'formatTimePeriod() rounding (>48h), avoidseconds'
                        ),
                        array(
                        array(
                                176399.55,
                                'avoidminutes',
 -                              '2d 1h',
 +                              '2 d 1 h',
                                'formatTimePeriod() rounding (>48h), avoidminutes'
                        ),
                        array(
                        array(
                                259199.55,
                                'avoidseconds',
 -                              '3d 0h 0m',
 +                              '3 d 0 h 0 min',
                                'formatTimePeriod() rounding (>48h), avoidseconds'
                        ),
                        array(
                        array(
                                172801.55,
                                'avoidseconds',
 -                              '2d 0h 0m',
 +                              '2 d 0 h 0 min',
                                'formatTimePeriod() rounding, (>48h), avoidseconds'
                        ),
                        array(
                        array(
                                176460.55,
                                array(),
 -                              '2d 1h 1m 1s',
 +                              '2 d 1 h 1 min 1 s',
                                'formatTimePeriod() rounding, recursion, (>48h)'
                        ),
                        array(
                        ) ),
                );
        }
- }
  
+       /**
+        * @test
+        * @dataProvider providePrettyTimestampTests
+        */
+       public function testPrettyTimestamp(
+               $tsTime, // The timestamp to format
+               $currentTime, // The time to consider "now"
+               $timeCorrection, // The time offset to use
+               $dateFormat, // The date preference to use
+               $expectedOutput, // The expected output
+               $desc // Description
+       ) {
+               $user = new User;
+               $user->setOption( 'timecorrection', $timeCorrection );
+               $user->setOption( 'date', $dateFormat );
+               $this->assertEquals(
+                       $expectedOutput,
+                       $this->lang->prettyTimestamp( $tsTime, $currentTime, $user ),
+                       $desc
+               );
+       }
+       public function providePrettyTimestampTests() {
+               return array(
+                       array(
+                               '20111231170000',
+                               '20120101000000',
+                               'Offset|0',
+                               'mdy',
+                               'Yesterday at 17:00',
+                               '"Yesterday" across years',
+                       ),
+                       array(
+                               '20120717190900',
+                               '20120717190929',
+                               'Offset|0',
+                               'mdy',
+                               'Just now',
+                               '"Just now"',
+                       ),
+                       array(
+                               '20120717190900',
+                               '20120717191530',
+                               'Offset|0',
+                               'mdy',
+                               '6 minutes ago',
+                               'X minutes ago',
+                       ),
+                       array(
+                               '20121006173100',
+                               '20121006173200',
+                               'Offset|0',
+                               'mdy',
+                               '1 minute ago',
+                               '"1 minute ago"',
+                       ),
+                       array(
+                               '20120617190900',
+                               '20120717190900',
+                               'Offset|0',
+                               'mdy',
+                               'June 17',
+                               'Another month'
+                       ),
+                       array(
+                               '19910130151500',
+                               '20120716193700',
+                               'Offset|0',
+                               'mdy',
+                               'January 30, 1991',
+                               'Different year',
+                       ),
+                       array(
+                               '20120101050000',
+                               '20120101080000',
+                               'Offset|-360',
+                               'mdy',
+                               'Yesterday at 23:00',
+                               '"Yesterday" across years with time correction',
+                       ),
+                       array(
+                               '20120714184300',
+                               '20120716184300',
+                               'Offset|-420',
+                               'mdy',
+                               'Saturday at 11:43',
+                               'Recent weekday with time correction',
+                       ),
+                       array(
+                               '20120714184300',
+                               '20120715040000',
+                               'Offset|-420',
+                               'mdy',
+                               '11:43',
+                               'Today at another time with time correction',
+                       ),
+                       array(
+                               '20120617190900',
+                               '20120717190900',
+                               'Offset|0',
+                               'dmy',
+                               '17 June',
+                               'Another month with dmy'
+                       ),
+                       array(
+                               '20120617190900',
+                               '20120717190900',
+                               'Offset|0',
+                               'ISO 8601',
+                               '2012-06-17',
+                               'Another month with ISO-8601'
+                       ),
+                       array(
+                               '19910130151500',
+                               '20120716193700',
+                               'Offset|0',
+                               'ISO 8601',
+                               '1991-01-30',
+                               'Different year with ISO-8601',
+                       ),
+               );
+       }
+ }