diff --git a/src/ChronosInterface.php b/src/ChronosInterface.php index 2d4ec727..d3b47d56 100644 --- a/src/ChronosInterface.php +++ b/src/ChronosInterface.php @@ -842,6 +842,31 @@ public function subSecond($value = 1); */ public function subSeconds($value); + /** + * Get the difference in a human readable format in the current locale. + * + * When comparing a value in the past to default now: + * 1 hour ago + * 5 months ago + * + * When comparing a value in the future to default now: + * 1 hour from now + * 5 months from now + * + * When comparing a value in the past to another value: + * 1 hour before + * 5 months before + * + * When comparing a value in the future to another value: + * 1 hour after + * 5 months after + * + * @param \Cake\Chronos\ChronosInterface|null $other The datetime to compare with. + * @param bool $absolute Removes time difference modifiers ago, after, etc + * @return string + */ + public function diffForHumans(ChronosInterface $other = null, $absolute = false); + /** * Get the difference in years * diff --git a/src/DifferenceFormatter.php b/src/DifferenceFormatter.php new file mode 100644 index 00000000..21c46810 --- /dev/null +++ b/src/DifferenceFormatter.php @@ -0,0 +1,100 @@ + + * @link http://cakephp.org CakePHP(tm) Project + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Chronos; + +use Cake\Chronos\ChronosInterface; +use Cake\Chronos\Translator; + +/** + * Handles formatting differences in text. + * + * Provides a swappable component for other libraries to leverage. + * when localizing or customizing the difference output. + */ +class DifferenceFormatter +{ + /** + * Constructor. + * + * @param \Cake\Chronos\Translator|null $translate The text translator object. + */ + public function __construct($translate = null) + { + $this->translate = $translate ?: new Translator(); + } + + /** + * Get the difference in a human readable format. + * + * @param \Cake\Chronos\ChronosInterface $date The datetime to start with. + * @param \Cake\Chronos\ChronosInterface|null $other The datetime to compare against. + * @param bool $absolute removes time difference modifiers ago, after, etc + * @return string The difference between the two days in a human readable format + * @see Cake\Chronos\ChronosInterface::diffForHumans + */ + public function diffForHumans(ChronosInterface $date, ChronosInterface $other = null, $absolute = false) + { + $isNow = $other === null; + if ($isNow) { + $other = $date->now($date->tz); + } + $diffInterval = $date->diff($other); + + switch (true) { + case ($diffInterval->y > 0): + $unit = 'year'; + $count = $diffInterval->y; + break; + case ($diffInterval->m > 0): + $unit = 'month'; + $count = $diffInterval->m; + break; + case ($diffInterval->d > 0): + $unit = 'day'; + $count = $diffInterval->d; + if ($count >= ChronosInterface::DAYS_PER_WEEK) { + $unit = 'week'; + $count = (int)($count / ChronosInterface::DAYS_PER_WEEK); + } + break; + case ($diffInterval->h > 0): + $unit = 'hour'; + $count = $diffInterval->h; + break; + case ($diffInterval->i > 0): + $unit = 'minute'; + $count = $diffInterval->i; + break; + default: + $count = $diffInterval->s; + $unit = 'second'; + break; + } + if ($count === 0) { + $count = 1; + } + $time = $this->translate->plural($unit, $count, ['count' => $count]); + if ($absolute) { + return $time; + } + $isFuture = $diffInterval->invert === 1; + $transId = $isNow ? ($isFuture ? 'from_now' : 'ago') : ($isFuture ? 'after' : 'before'); + + // Some langs have special pluralization for past and future tense. + $tryKeyExists = $unit . '_' . $transId; + if ($this->translate->exists($tryKeyExists)) { + $time = $this->translate->plural($tryKeyExists, $count, ['count' => $count]); + } + return $this->translate->singular($transId, ['time' => $time]); + } +} diff --git a/src/Traits/DifferenceTrait.php b/src/Traits/DifferenceTrait.php index 52f9f82f..b191e5a3 100644 --- a/src/Traits/DifferenceTrait.php +++ b/src/Traits/DifferenceTrait.php @@ -14,6 +14,7 @@ use Cake\Chronos\ChronosInterface; use Cake\Chronos\ChronosInterval; +use Cake\Chronos\DifferenceFormatter; use DatePeriod; use DateTimeInterface; @@ -28,6 +29,8 @@ */ trait DifferenceTrait { + protected static $diffFormatter; + /** * Get the difference in years * @@ -234,4 +237,49 @@ public static function fromNow($datetime) $timeNow = new static(); return $timeNow->diff($datetime); } + + /** + * Get the difference in a human readable format. + * + * When comparing a value in the past to default now: + * 1 hour ago + * 5 months ago + * + * When comparing a value in the future to default now: + * 1 hour from now + * 5 months from now + * + * When comparing a value in the past to another value: + * 1 hour before + * 5 months before + * + * When comparing a value in the future to another value: + * 1 hour after + * 5 months after + * + * @param \Cake\Chronos\ChronosInterface|null $other The datetime to compare with. + * @param bool $absolute removes time difference modifiers ago, after, etc + * @return string + */ + public function diffForhumans(ChronosInterface $other = null, $absolute = false) + { + return $this->diffFormatter()->diffForHumans($this, $other, $absolute); + } + + /** + * Get the difference formatter instance or overwrite the current one. + * + * @param Cake\Chronos\DifferenceFormatter|null $formatter The formatter instance when setting. + * @return Cake\Chronos\DifferenceFormatter The formatter instance. + */ + public function diffFormatter($formatter = null) + { + if ($formatter === null) { + if (static::$diffFormatter === null) { + static::$diffFormatter = new DifferenceFormatter(); + } + return static::$diffFormatter; + } + return static::$diffFormatter = $translator; + } } diff --git a/src/Translator.php b/src/Translator.php new file mode 100644 index 00000000..44807654 --- /dev/null +++ b/src/Translator.php @@ -0,0 +1,90 @@ + '1 year', + 'year_plural' => '{count} years', + 'month' => '1 month', + 'month_plural' => '{count} months', + 'week' => '1 week', + 'week_plural' => '{count} weeks', + 'day' => '1 day', + 'day_plural' => '{count} days', + 'hour' => '1 hour', + 'hour_plural' => '{count} hours', + 'minute' => '1 minute', + 'minute_plural' => '{count} minutes', + 'second' => '1 second', + 'second_plural' => '{count} seconds', + 'ago' => '{time} ago', + 'from_now' => '{time} from now', + 'after' => '{time} after', + 'before' => '{time} before', + ]; + + /** + * Check if a translation key exists. + * + * @param string $key The key to check. + * @return bool Whether or not the key exists. + */ + public function exists($key) + { + return isset(static::$strings[$key]); + } + + /** + * Get a plural message. + * + * @param string $key The key to use. + * @param string $count The number of items in the translation. + * @param array $vars Additional context variables. + * @return string The translated message or ''. + */ + public function plural($key, $count, array $vars = []) + { + if ($count == 1) { + return $this->singular($key, $vars); + } + return $this->singular($key . '_plural', ['count' => $count] + $vars); + } + + /** + * Get a singular message. + * + * @param string $key The key to use. + * @param array $vars Additional context variables. + * @return string The translated message or ''. + */ + public function singular($key, array $vars = []) + { + if (isset(static::$strings[$key])) { + $varKeys = array_keys($vars); + foreach ($varKeys as $i => $k) { + $varKeys[$i] = '{' . $k . '}'; + } + return str_replace($varKeys, $vars, static::$strings[$key]); + } + return ''; + } +} diff --git a/tests/DateTime/DiffTest.php b/tests/DateTime/DiffTest.php index 201c5e52..83006167 100644 --- a/tests/DateTime/DiffTest.php +++ b/tests/DateTime/DiffTest.php @@ -10,7 +10,6 @@ * @link http://cakephp.org CakePHP(tm) Project * @license http://www.opensource.org/licenses/mit-license.php MIT License */ - namespace Cake\Chronos\Test\DateTime; use Cake\Chronos\Chronos; @@ -756,4 +755,71 @@ public function testFromNow($class) $result = $interval->format("%y %m %d %H %i %s"); $this->assertEquals($result, '1 0 6 00 0 51'); } + + public function diffForHumansProvider() + { + $now = Chronos::now(); + return [ + [$now, $now->addYears(11), '11 years before'], + [$now, $now->addYears(1), '1 year before'], + [$now, $now->addMonths(11), '11 months before'], + [$now, $now->addMonths(1), '1 month before'], + [$now, $now->addDays(8), '1 week before'], + [$now, $now->addDays(23), '3 weeks before'], + [$now, $now->addDays(1), '1 day before'], + [$now, $now->addDays(6), '6 days before'], + [$now, $now->addHours(1), '1 hour before'], + [$now, $now->addHours(5), '5 hours before'], + [$now, $now->addHours(23), '23 hours before'], + [$now, $now->addMinutes(1), '1 minute before'], + [$now, $now->addMinutes(5), '5 minutes before'], + [$now, $now->addMinutes(59), '59 minutes before'], + [$now, $now->addSeconds(1), '1 second before'], + [$now, $now->addSeconds(5), '5 seconds before'], + [$now, $now->addSeconds(59), '59 seconds before'], + + [$now, $now->subYears(11), '11 years after'], + [$now, $now->subYears(1), '1 year after'], + [$now, $now->subMonths(11), '11 months after'], + [$now, $now->subMonths(1), '1 month after'], + [$now, $now->subDays(8), '1 week after'], + [$now, $now->subDays(23), '3 weeks after'], + [$now, $now->subDays(1), '1 day after'], + [$now, $now->subDays(6), '6 days after'], + [$now, $now->subHours(1), '1 hour after'], + [$now, $now->subHours(5), '5 hours after'], + [$now, $now->subHours(23), '23 hours after'], + [$now, $now->subMinutes(1), '1 minute after'], + [$now, $now->subMinutes(5), '5 minutes after'], + [$now, $now->subMinutes(59), '59 minutes after'], + [$now, $now->subSeconds(1), '1 second after'], + [$now, $now->subSeconds(5), '5 seconds after'], + [$now, $now->subSeconds(59), '59 seconds after'], + ]; + } + + /** + * @dataProvider diffForHumansProvider + * @return void + */ + public function testDiffForHumansRelative($now, $date, $expected) + { + $this->assertSame($expected, $now->diffForHumans($date)); + } + + public function testDiffForHumansWithNow() + { + $this->wrapWithTestNow(function () { + $this->assertSame('1 second ago', Chronos::now()->subSeconds(1)->diffForHumans()); + $this->assertSame('1 second from now', Chronos::now()->addSeconds(1)->diffForHumans()); + }); + } + + public function testDiffForHumansWithNowAbsolute() + { + $this->wrapWithTestNow(function () { + $this->assertSame('1 second', Chronos::now()->subSeconds(1)->diffForHumans(null, true)); + $this->assertSame('1 second', Chronos::now()->addSeconds(1)->diffForHumans(null, true)); + }); + } }