From 7e3386d41774fcfefea696a30b1a434aabfb0db2 Mon Sep 17 00:00:00 2001 From: Tyler Anthony Romeo Date: Thu, 24 Jan 2013 16:14:21 -0500 Subject: [PATCH] Refactor MWTimestamp::getHumanTimestamp and add hook. Changed logic in MWTimestamp::getHumanTimestamp so that all the message and formatting was offloaded into the Language class, keeping only actual timestamp logic in the MWTimestamp class. Also added a hook so extensions can override the human timestamp format. Change-Id: Ie667088010e24eb6cb569f9e8e8e2553005223eb --- docs/hooks.txt | 9 ++ includes/Timestamp.php | 152 ++++++++++++++++------- languages/Language.php | 82 +++++++++++- languages/messages/MessagesEn.php | 20 +++ languages/messages/MessagesQqq.php | 17 +++ maintenance/language/messages.inc | 12 ++ tests/phpunit/includes/TimestampTest.php | 145 +++++++++++++++++++-- 7 files changed, 378 insertions(+), 59 deletions(-) diff --git a/docs/hooks.txt b/docs/hooks.txt index 2cde591942..2d64bcebef 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -1138,6 +1138,15 @@ $title: Title object of page $url: string value as output (out parameter, can modify) $query: query options passed to Title::getFullURL() +'GetHumanTimestamp': Pre-emptively override the human-readable timestamp generated +by MWTimestamp::getHumanTimestamp(). Return false in this hook to use the custom +output. +&$output: string for the output timestamp +$timestamp: MWTimestamp object of the current (user-adjusted) timestamp +$relativeTo: MWTimestamp object of the relative (user-adjusted) timestamp +$user: User whose preferences are being used to make timestamp +$lang: Language that will be used to render the timestamp + 'GetInternalURL': Modify fully-qualified URLs used for squid cache purging. $title: Title object of page $url: string value as output (out parameter, can modify) diff --git a/includes/Timestamp.php b/includes/Timestamp.php index caf78b0789..ba9b7b283c 100644 --- a/includes/Timestamp.php +++ b/includes/Timestamp.php @@ -45,26 +45,10 @@ class MWTimestamp { ); /** - * Different units for human readable timestamps. - * @see MWTimestamp::getHumanTimestamp + * The actual timestamp being wrapped (DateTime object). + * @var DateTime */ - private static $units = array( - "milliseconds" => 1, - "seconds" => 1000, // 1000 milliseconds per second - "minutes" => 60, // 60 seconds per minute - "hours" => 60, // 60 minutes per hour - "days" => 24, // 24 hours per day - "months" => 30, // approximately 30 days per month - "years" => 12, // 12 months per year - ); - - /** - * The actual timestamp being wrapped. Either a DateTime - * object or a string with a Unix timestamp depending on - * PHP. - * @var string|DateTime - */ - private $timestamp; + public $timestamp; /** * Make a new timestamp and set it to the specified time, @@ -168,16 +152,7 @@ class MWTimestamp { throw new TimestampException( __METHOD__ . ' : Illegal timestamp output type.' ); } - if ( is_object( $this->timestamp ) ) { - // DateTime object was used, call DateTime::format. - $output = $this->timestamp->format( self::$formats[$style] ); - } elseif ( TS_UNIX == $style ) { - // Unix timestamp was used and is wanted, just return it. - $output = $this->timestamp; - } else { - // Unix timestamp was used, use gmdate(). - $output = gmdate( self::$formats[$style], $this->timestamp ); - } + $output = $this->timestamp->format( self::$formats[$style] ); if ( ( $style == TS_RFC2822 ) || ( $style == TS_POSTGRES ) ) { $output .= ' GMT'; @@ -194,31 +169,105 @@ class MWTimestamp { * largest possible unit is used. * * @since 1.20 + * @since 1.22 Uses Language::getHumanTimestamp to produce the timestamp * - * @return Message Formatted timestamp + * @param MWTimestamp|null $relativeTo The base timestamp to compare to (defaults to now) + * @param User|null $user User the timestamp is being generated for (or null to use main context's user) + * @param Language|null $lang Language to use to make the human timestamp (or null to use main context's language) + * @return string Formatted timestamp */ - public function getHumanTimestamp() { - $then = $this->getTimestamp( TS_UNIX ); - $now = time(); - $timeago = ($now - $then) * 1000; - $message = false; - - foreach ( self::$units as $unit => $factor ) { - $next = $timeago / $factor; - if ( $next < 1 ) { - break; + public function getHumanTimestamp( MWTimestamp $relativeTo = null, User $user = null, Language $lang = null ) { + if ( $relativeTo === null ) { + $relativeTo = new self(); + } + if ( $user === null ) { + $user = RequestContext::getMain()->getUser(); + } + if ( $lang === null ) { + $lang = RequestContext::getMain()->getLanguage(); + } + + // Adjust for the user's timezone. + $offsetThis = $this->offsetForUser( $user ); + $offsetRel = $relativeTo->offsetForUser( $user ); + + $ts = ''; + if ( wfRunHooks( 'GetHumanTimestamp', array( &$ts, $this, $relativeTo, $user, $lang ) ) ) { + $ts = $lang->getHumanTimestamp( $this, $relativeTo, $user ); + } + + // Reset the timezone on the objects. + $this->timestamp->sub( $offsetThis ); + $relativeTo->timestamp->sub( $offsetRel ); + + return $ts; + } + + /** + * Adjust the timestamp depending on the given user's preferences. + * + * @since 1.22 + * + * @param User $user User to take preferences from + * @param[out] MWTimestamp $ts Timestamp to adjust + * @return DateInterval Offset that was applied to the timestamp + */ + public function offsetForUser( User $user ) { + global $wgLocalTZOffset; + + $option = $user->getOption( 'timecorrection' ); + $data = explode( '|', $option, 3 ); + + // First handle the case of an actual timezone being specified. + if ( $data[0] == 'ZoneInfo' ) { + try { + $tz = new DateTimeZone( $data[2] ); + } catch ( Exception $e ) { + $tz = false; + } + + if ( $tz ) { + $this->timestamp->setTimezone( $tz ); + return new DateInterval( 'P0Y' ); } else { - $timeago = $next; - $message = array( $unit, floor( $timeago ) ); + $data[0] = 'Offset'; } } - if ( $message ) { - $initial = call_user_func_array( 'wfMessage', $message ); - return wfMessage( 'ago', $initial->parse() ); + $diff = 0; + // If $option is in fact a pipe-separated value, check the + // first value. + if ( $data[0] == 'System' ) { + // First value is System, so use the system offset. + if ( isset( $wgLocalTZOffset ) ) { + $diff = $wgLocalTZOffset; + } + } elseif ( $data[0] == 'Offset' ) { + // First value is Offset, so use the specified offset + $diff = (int)$data[1]; } else { - return wfMessage( 'just-now' ); + // $option actually isn't a pipe separated value, but instead + // a comma separated value. Isn't MediaWiki fun? + $data = explode( ':', $option ); + if ( count( $data ) >= 2 ) { + // Combination hours and minutes. + $diff = abs( (int)$data[0] ) * 60 + (int)$data[1]; + if ( (int) $data[0] < 0 ) { + $diff *= -1; + } + } else { + // Just hours. + $diff = (int)$data[0] * 60; + } } + + $interval = new DateInterval('PT' . abs( $diff ) . 'M'); + if ( $diff < 1 ) { + $interval->invert = 1; + } + + $this->timestamp->add( $interval ); + return $interval; } /** @@ -229,6 +278,17 @@ class MWTimestamp { public function __toString() { return $this->getTimestamp(); } + + /** + * Calculate the difference between two MWTimestamp objects. + * + * @since 1.22 + * @param MWTimestamp $relativeTo Base time to calculate difference from + * @return DateInterval|bool The DateInterval object representing the difference between the two dates or false on failure + */ + public function diff( MWTimestamp $relativeTo ) { + return $this->timestamp->diff( $relativeTo->timestamp ); + } } /** diff --git a/languages/Language.php b/languages/Language.php index df1b156a83..24d083d0f8 100644 --- a/languages/Language.php +++ b/languages/Language.php @@ -1961,6 +1961,8 @@ class Language { * @param $type string May be date, time or both * @param $pref string The format name as it appears in Messages*.php * + * @since 1.22 New type 'pretty' that provides a more readable timestamp format + * * @return string */ function getDateFormatString( $type, $pref ) { @@ -1970,7 +1972,12 @@ class Language { $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" ); } else { $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" ); - if ( is_null( $df ) ) { + + if ( $type === 'pretty' && $df === null ) { + $df = $this->getDateFormatString( 'date', $pref ); + } + + if ( $df === null ) { $pref = $this->getDefaultDateFormat(); $df = self::$dataCache->getSubitem( $this->mCode, 'dateFormats', "$pref $type" ); } @@ -2203,6 +2210,79 @@ class Language { return $this->internalUserTimeAndDate( 'both', $ts, $user, $options ); } + /** + * Convert an MWTimestamp into a pretty human-readable timestamp using + * the given user preferences and relative base time. + * + * DO NOT USE THIS FUNCTION DIRECTLY. Instead, call MWTimestamp::getHumanTimestamp + * on your timestamp object, which will then call this function. Calling + * this function directly will cause hooks to be skipped over. + * + * @see MWTimestamp::getHumanTimestamp + * @param MWTimestamp $ts Timestamp to prettify + * @param MWTimestamp $relativeTo Base timestamp + * @param User $user User preferences to use + * @return string Human timestamp + * @since 1.21 + */ + public function getHumanTimestamp( MWTimestamp $ts, MWTimestamp $relativeTo, User $user ) { + $diff = $ts->diff( $relativeTo ); + $diffDay = (bool)( (int)$ts->timestamp->format( 'w' ) - (int)$relativeTo->timestamp->format( 'w' ) ); + $days = $diff->days ?: (int)$diffDay; + if ( $diff->invert || $days > 5 && $ts->timestamp->format( 'Y' ) !== $relativeTo->timestamp->format( 'Y' ) ) { + // Timestamps are in different years: use full timestamp + // Also do full timestamp for future dates + /** + * @FIXME Add better handling of future timestamps. + */ + $format = $this->getDateFormatString( 'both', $user->getDatePreference() ?: 'default' ); + $ts = $this->sprintfDate( $format, $ts->getTimestamp( TS_MW ) ); + } elseif ( $days > 5 ) { + // Timestamps are in same year, but more than 5 days ago: show day and month only. + $format = $this->getDateFormatString( 'pretty', $user->getDatePreference() ?: 'default' ); + $ts = $this->sprintfDate( $format, $ts->getTimestamp( TS_MW ) ); + } elseif ( $days > 1 ) { + // Timestamp within the past week: show the day of the week and time + $format = $this->getDateFormatString( 'time', $user->getDatePreference() ?: 'default' ); + $weekday = self::$mWeekdayMsgs[$ts->timestamp->format( 'w' )]; + $ts = wfMessage( "$weekday-at" ) + ->inLanguage( $this ) + ->params( $this->sprintfDate( $format, $ts->getTimestamp( TS_MW ) ) ) + ->text(); + } elseif ( $days == 1 ) { + // Timestamp was yesterday: say 'yesterday' and the time. + $format = $this->getDateFormatString( 'time', $user->getDatePreference() ?: 'default' ); + $ts = wfMessage( 'yesterday-at' ) + ->inLanguage( $this ) + ->params( $this->sprintfDate( $format, $ts->getTimestamp( TS_MW ) ) ) + ->text(); + } elseif ( $diff->h > 1 || $diff->h == 1 && $diff->i > 30 ) { + // Timestamp was today, but more than 90 minutes ago: say 'today' and the time. + $format = $this->getDateFormatString( 'time', $user->getDatePreference() ?: 'default' ); + $ts = wfMessage( 'today-at' ) + ->inLanguage( $this ) + ->params( $this->sprintfDate( $format, $ts->getTimestamp( TS_MW ) ) ) + ->text(); + + // From here on in, the timestamp was soon enough ago so that we can simply say + // XX units ago, e.g., "2 hours ago" or "5 minutes ago" + } elseif ( $diff->h == 1 ) { + // Less than 90 minutes, but more than an hour ago. + $ts = wfMessage( 'hours-ago' )->inLanguage( $this )->numParams( 1 )->text(); + } elseif ( $diff->i >= 1 ) { + // A few minutes ago. + $ts = wfMessage( 'minutes-ago' )->inLanguage( $this )->numParams( $diff->i )->text(); + } elseif ( $diff->s >= 30 ) { + // Less than a minute, but more than 30 sec ago. + $ts = wfMessage( 'seconds-ago' )->inLanguage( $this )->numParams( $diff->s )->text(); + } else { + // Less than 30 seconds ago. + $ts = wfMessage( 'just-now' )->text(); + } + + return $ts; + } + /** * @param $key string * @return array|null diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index f3a0072225..80dd417c83 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -162,18 +162,22 @@ $dateFormats = array( 'mdy time' => 'H:i', 'mdy date' => 'F j, Y', 'mdy both' => 'H:i, F j, Y', + 'mdy pretty' => 'F j', 'dmy time' => 'H:i', 'dmy date' => 'j F Y', 'dmy both' => 'H:i, j F Y', + 'dmy pretty' => 'j F', 'ymd time' => 'H:i', 'ymd date' => 'Y F j', 'ymd both' => 'H:i, Y F j', + 'ymd pretty' => '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 pretty' => 'xnm-xnd' ); /** @@ -3880,11 +3884,27 @@ By executing it, your system may be compromised.", 'minutes' => '{{PLURAL:$1|$1 minute|$1 minutes}}', 'hours' => '{{PLURAL:$1|$1 hour|$1 hours}}', 'days' => '{{PLURAL:$1|$1 day|$1 days}}', +'weeks' => '{{PLURAL:$1|$1 week|$1 weeks}}', 'months' => '{{PLURAL:$1|$1 month|$1 months}}', 'years' => '{{PLURAL:$1|$1 year|$1 years}}', 'ago' => '$1 ago', 'just-now' => 'just now', +'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', + # Bad image list 'bad_image_list' => 'The format is as follows: diff --git a/languages/messages/MessagesQqq.php b/languages/messages/MessagesQqq.php index 022b1392b6..4c2a04879d 100644 --- a/languages/messages/MessagesQqq.php +++ b/languages/messages/MessagesQqq.php @@ -7019,6 +7019,9 @@ See also {{msg-mw|Days-abbrev}} Part of variable $1 in {{msg-mw|Ago}} {{Identical|Day}}', +'weeks' => 'Full word for "weeks". $1 is the number of weeks. + +Part of variable $1 in {{msg-mw|Ago}}', 'months' => 'Full word for "months". $1 is the number of months. Part of variable $1 in {{msg-mw|Ago}}', @@ -7034,6 +7037,20 @@ Part of variable $1 in {{msg-mw|Ago}}', *{{msg-mw|Years}}', 'just-now' => 'Phrase for indicating something happened just now.', +'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.', + # 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.', diff --git a/maintenance/language/messages.inc b/maintenance/language/messages.inc index 3b9fadfb22..cc5b9d36bd 100644 --- a/maintenance/language/messages.inc +++ b/maintenance/language/messages.inc @@ -136,6 +136,17 @@ $wgMessageStructure = array( 'nov', 'dec', ), + 'human-timestamps' => array( + 'monday-at', + 'tuesday-at', + 'wednesday-at', + 'thursday-at', + 'friday-at', + 'saturday-at', + 'sunday-at', + 'today-at', + 'yesterday-at', + ), 'categorypages' => array( 'pagecategories', 'pagecategorieslink', @@ -2808,6 +2819,7 @@ $wgMessageStructure = array( 'minutes', 'hours', 'days', + 'weeks', 'months', 'years', 'ago', diff --git a/tests/phpunit/includes/TimestampTest.php b/tests/phpunit/includes/TimestampTest.php index 0690683aab..2d550bc6b5 100644 --- a/tests/phpunit/includes/TimestampTest.php +++ b/tests/phpunit/includes/TimestampTest.php @@ -50,18 +50,6 @@ class TimestampTest extends MediaWikiTestCase { $timestamp->getTimestamp( 98 ); } - /** - * Test human readable timestamp format. - */ - function testHumanOutput() { - $timestamp = new MWTimestamp( time() - 3600 ); - $this->assertEquals( "1 hour ago", $timestamp->getHumanTimestamp()->inLanguage( 'en' )->text() ); - $timestamp = new MWTimestamp( time() - 5184000 ); - $this->assertEquals( "2 months ago", $timestamp->getHumanTimestamp()->inLanguage( 'en' )->text() ); - $timestamp = new MWTimestamp( time() - 31536000 ); - $this->assertEquals( "1 year ago", $timestamp->getHumanTimestamp()->inLanguage( 'en' )->text() ); - } - /** * Returns a list of valid timestamps in the format: * array( type, timestamp_of_type, timestamp_in_MW ) @@ -83,4 +71,137 @@ class TimestampTest extends MediaWikiTestCase { array( TS_UNIX, '-62135596801', '00001231235959' ) ); } + + /** + * @test + * @dataProvider provideHumanTimestampTests + */ + public function testHumanTimestamp( + $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 = $this->getMock( 'User' ); + $user->expects( $this->any() ) + ->method( 'getOption' ) + ->with( 'timecorrection' ) + ->will( $this->returnValue( $timeCorrection ) ); + + $user->expects( $this->any() ) + ->method( 'getDatePreference' ) + ->will( $this->returnValue( $dateFormat ) ); + + $tsTime = new MWTimestamp( $tsTime ); + $currentTime = new MWTimestamp( $currentTime ); + + $this->assertEquals( + $expectedOutput, + $tsTime->getHumanTimestamp( $currentTime, $user ), + $desc + ); + } + + public static function provideHumanTimestampTests() { + 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', + '15:15, 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', + '06-17', + 'Another month with ISO-8601' + ), + array( + '19910130151500', + '20120716193700', + 'Offset|0', + 'ISO 8601', + '1991-01-30T15:15:00', + 'Different year with ISO-8601', + ), + ); + } } -- 2.20.1