Alphred
  • Namespace
  • Class
  • Tree
  • Deprecated
  • Todo
  • Download

Namespaces

  • Alphred
  • None

Classes

  • Alphred
  1 <?php
  2 /**
  3  * Contains Date class for Alphred
  4  *
  5  * PHP version 5
  6  *
  7  * @package    Alphred
  8  * @copyright  Shawn Patrick Rice 2014
  9  * @license    http://opensource.org/licenses/MIT  MIT
 10  * @version    1.0.0
 11  * @author     Shawn Patrick Rice <rice@shawnrice.org>
 12  * @link       http://www.github.com/shawnrice/alphred
 13  * @link       http://shawnrice.github.io/alphred
 14  * @since      File available since Release 1.0.0
 15  *
 16  */
 17 
 18 namespace Alphred;
 19 
 20 /**
 21  * Provides text filters for date objects
 22  *
 23  * This class should be cleaned up quite a bit, and it needs to be made pluggable
 24  * so that it can be used by languages other than English. But, _I think_ right now
 25  * it is good enough to be released because it falls into "special sauce" rather
 26  * than necessary functionality.
 27  *
 28  * @todo Abstract the time dictionaries so that they can be translated
 29  * @todo Add in a less precise version of "seconds to human time"
 30  * @todo Make these work with dates before Jan 1, 1970
 31  *
 32  */
 33 class Date {
 34 
 35     private static $legend_english = [
 36         'millenium' => [ 'multiple' => 'millenia',  'value' => 31536000000 ],
 37         'century'   => [ 'multiple' => 'centuries', 'value' => 3153600000  ],
 38         'decade'    => [ 'multiple' => 'decades',   'value' => 315360000   ],
 39         'year'      => [ 'multiple' => 'years',     'value' => 31536000    ],
 40         'month'     => [ 'multiple' => 'months',    'value' => 2592000     ],
 41         'week'      => [ 'multiple' => 'weeks',     'value' => 604800      ],
 42         'day'       => [ 'multiple' => 'days',      'value' => 86400       ],
 43         'hour'      => [ 'multiple' => 'hours',     'value' => 3600        ],
 44         'minute'    => [ 'multiple' => 'minutes',   'value' => 60          ],
 45         'second'    => [ 'multiple' => 'seconds',   'value' => 1           ]
 46     ];
 47 
 48     /**
 49      * Returns a slightly modified array of the difference between two dates
 50      *
 51      * @since 1.0.0
 52      * @todo Fix for values before Jan 1, 1970
 53      *
 54      * @param  int $date1 a date in seconds
 55      * @param  int $date2 a date in seconds
 56      * @return array  an array that represents the difference in granular units
 57      */
 58     private function diff_a_date( $date1, $date2 ) {
 59 
 60         $date1 = new \DateTime( date( 'D, d M Y H:i:s', $date1 ) );
 61         $date2 = new \DateTime( date( 'D, d M Y H:i:s', $date2 ) );
 62         $diff  = $date1->diff( $date2 );
 63 
 64         $millenia = floor( $diff->y / 1000 );
 65         $diff->y = $diff->y % 1000;
 66         $centuries = floor( $diff->y / 100 );
 67         $diff->y = $diff->y % 100;
 68         $decades = floor( $diff->y / 10 );
 69         $diff->y = $diff->y % 10;
 70 
 71         return [
 72             'units' => [
 73                 'decades' => $decades,
 74                 'years'   => $diff->y,
 75                 'months'  => $diff->m,
 76                 // Calculate weeks
 77                 'weeks'   => floor( $diff->d / 7 ),
 78                 // Calculate leftover days
 79                 'days'    => $diff->d % 7,
 80                 'hours'   => $diff->h,
 81                 'minutes' => $diff->i,
 82                 'seconds' => $diff->s
 83             ],
 84             // Is the date in the past or the future?
 85             'tense' => ( ( 0 === $diff->invert ) ? 'past' : 'future' )
 86         ];
 87 
 88     }
 89 
 90     /**
 91      * Converts a time diff into a human readable approximation
 92      *
 93      * @since 1.0.0
 94      * @todo Fix for values before Jan 1, 1970
 95      * @todo make available for non-English languages
 96      *
 97      * @param int     $seconds a date represented in seconds since the unix epoch
 98      * @param string  $dictionary what language to use (currently unsupported and ignored)
 99      * @return string       a fuzzy time
100      */
101     public function fuzzy_ago( $seconds, $dictionary = 'english' ) {
102 
103         if ( $seconds < 0 ) {
104             return false;
105         }
106 
107         // Do a quick diff
108         $diff = self::diff_a_date( $seconds, time() );
109         // Get the tense
110         $tense = $diff['tense'];
111         // Get the units
112         $times = $diff['units'];
113         // We want it a bit more granular...
114 
115         // Table of how many are in the next
116         $post_units = [
117             'seconds'   => 60, // 60 seconds in a minute
118             'minutes'   => 60, // 60 minutes in an hour
119             'hours'     => 24, // 24 hours in a day
120             'days'      => 7,  // 7 days in a week
121             'weeks'     => 4,  // 4 weeks in a month
122             'months'    => 12, // 12 months in a year
123             'years'     => 10, // 10 years in a decade
124             'decades'   => 10, // 10 decades in a century
125             'centuries' => 10, // 10 centuries in a millenia
126         ];
127 
128         // Plural => singular translation table
129         $singular = [
130             'seconds'   => 'second',
131             'minutes'   => 'minute',
132             'hours'     => 'hour',
133             'days'      => 'day',
134             'weeks'     => 'week',
135             'months'    => 'month',
136             'years'     => 'year',
137             'decades'   => 'decade',
138             'centuries' => 'century',
139         ];
140 
141         // It's weird to say "last minute," so we'll say "a minute ago," etc...
142         $special = [
143             'seconds' => [ 'past' => 'just now',         'future' => 'in a second' ],
144             'minutes' => [ 'past' => 'a minute ago', 'future' => 'in a minute' ],
145             'hours'   => [ 'past' => 'an hour ago',  'future' => 'in an hour'  ],
146             'days'    => [ 'past' => 'yesterday',    'future' => 'tomorrow'    ]
147         ];
148 
149         // Set preliminary tense prefix and suffix strings
150         if ( 'past' === $tense ) {
151             $tense_prefix = '';
152             $tense_suffix = ' ago';
153         } else {
154             $tense_prefix = 'in ';
155             $tense_suffix = '';
156         }
157 
158         // We're going to define two thresholds to use. These will indicate whether or not we
159         // should use the next unit up to define the time.
160         $threshold1 = 0.6;
161         $threshold2 = 0.8;
162 
163         // Cycle through the array to try to find the right values
164         foreach( $times as $unit => $value ) :
165             if ( ( 0 == $value ) && ( ! isset( $main_unit ) ) ) {
166                 $previous_unit = $unit;
167             } else if ( isset( $main_unit ) ) {
168                 $next_unit = $unit;
169                 $next_value = $value;
170                 break;
171             }
172             if ( ( 0 != $value ) && ( ! isset( $main_unit ) ) ) {
173                 $main_unit = $unit;
174                 $main_value = $value;
175             }
176         endforeach;
177 
178         // Add on the remainder of the "next unit" so that we can get it in base 10
179         $main_value = $main_value + ( $next_value / $post_units[ $next_unit ] );
180 
181         // So, we've defined two thresholds that will have us "round up" to the next
182         // unit (i.e. day -> week and week->month). Check, first, if they're close enough
183         // that we should use the greater unit.
184         if ( $main_value / $post_units[ $main_unit ] > $threshold2 ) {
185             // The first threshold ($threshold2) rounds to "almost a {next unit}"
186             // So, "almost a week" instead of "5 days ago"
187             if ( 'hours' == $singular[ $previous_unit ] ) {
188                 $string = "almost an {$singular[ $previous_unit ]}";
189             } else {
190                 $string = "almost a {$singular[ $previous_unit ]}";
191             }
192         } else if ( $main_value / $post_units[ $main_unit ] > $threshold1 ) {
193             // The second threshold rounds to "last {next unit}"
194             // So, "last week" or "next week" instead of "4 days ago"
195 
196             if ( isset( $special[ $previous_unit ] ) ) {
197                 $string = $special[ $previous_unit ][ $tense ];
198                 $tense_prefix = '';
199                 $tense_suffix = '';
200             } else {
201                 $string = "{$singular[ $previous_unit ]}";
202                 if ( $tense_prefix ) {
203                     $tense_prefix = 'next ';
204                 } else {
205                     $tense_prefix = 'last ';
206                     $tense_suffix = '';
207                 }
208             }
209         } else {
210             // If it's close enough to 1, then we'll use a singular
211             if ( 1 == $main_value || 1 == round( $main_value ) ) {
212                 if ( isset( $special[ $main_unit ] ) ) {
213                     $string = $special[ $main_unit ][ $tense ];
214                     $tense_prefix = '';
215                     $tense_suffix = '';
216                 } else {
217                     $string = "a {$singular[ $main_unit ]}";
218                 }
219             } else if ( 2 == $main_value || 2 == round( $main_value ) ) {
220                 // If it's close enough to 2, then we'll use 'a couple'
221                 $string = "a couple {$main_unit}";
222             } else {
223                 // We'll default to 'a few' because other cases should have already been taken care of.
224                 $string = "a few {$main_unit}";
225             }
226         }
227 
228         // We're going to return a string that has a prefix, the main string, and a suffix.
229         // The prefix and suffix may be empty, but that depends on whether or not it's in the future
230         // or the past and a few other things.
231         return "{$tense_prefix}{$string}{$tense_suffix}";
232 
233     }
234 
235     /**
236      * Converts seconds to a human readable string or an array
237      *
238      * @param  integer  $seconds  a number of seconds
239      * @param  boolean  $words    whether or not the numbers should be numerals or words
240      * @param  string   $type     either 'string' or 'array'
241      * @return string|array             a string or an array, depending on $type
242      */
243     public function seconds_to_human_time( $seconds, $words = false, $type = 'string' ) {
244         $data = [];
245         $legend = self::$legend_english;
246 
247         // Start with the greatest values and whittle down until we're left with seconds
248         foreach ( $legend as $singular => $values ) :
249             // If the seconds is greater than the unit, then do some math
250             if ( $seconds >= $values['value'] ) {
251                 // How many units are in those seconds?
252                 $value = floor( $seconds / $values['value'] );
253                 if ( $words ) {
254                     // We want words, not numbers, so convert to words
255                     $value = Date::convert_number_to_words( $value );
256                 }
257                 // Did we get single or multiple?
258                 if ( $seconds / $values['value'] >= 2 ) {
259                     // Use plural units
260                     $data[ $values['multiple'] ] = $value;
261                 } else {
262                     // Use singlur units
263                     $data[ $singular ] = $value;
264                 }
265                 // Remove what we just converted and continue
266                 $seconds = $seconds % $values['value'];
267             }
268         endforeach;
269 
270         // If we want this as an array, then return that
271         if ( 'array' == $type ) {
272             return $data;
273         }
274 
275         // We want a string, so let's convert it to one with an Oxford Comma
276         // because Oxford Commas are important. If you don't agree, then look here:
277         // http://stephentall.org/2011/09/19/oxford-comma/
278         // If you still don't agree, "fuck off," says the grammarian.
279         // This. is. not. optional.
280         return Text::add_commas_to_list( $data, true );
281     }
282 
283     /**
284      * Explains how long ago something happened...
285      *
286      * This also works with the future.
287      *
288      * @since 1.0.0
289      * @todo Make this work with values before 1 Jan, 1970
290      *
291      * @param  integer  $seconds  a number of seconds
292      * @param  boolean  $words      whether or not to return numerals or the word-equivalent
293      * @return string             a string indicating a time in words
294      */
295     public function ago( $seconds, $words = false ) {
296         $tense = 'past';
297         $seconds = ( time() - $seconds ); // this needs to be converted with the date function
298         if ( $seconds < 0 ) {
299             $tense = 'future';
300             $seconds = abs( $seconds ); // We need a positive number
301         }
302 
303         $string = Date::seconds_to_human_time( $seconds, $words, 'string' );
304         if ( 'past' == $tense ) {
305             return "{$string} ago";
306         } else {
307             return "in {$string}";
308         }
309     }
310 
311     /**
312      * Converts a number to words
313      *
314      * @todo Add in an option for a shorter version...
315      * @todo Add in translation options so that we don't support _only_ English
316      *
317      * @param  int $number a number
318      * @return string      the number, but, as words
319      */
320     public function convert_number_to_words( $number, $dictionary = 'english' ) {
321         // This is a complex function, but I'm not sure if it can be simplified.
322         // adapted from http://www.karlrixon.co.uk/writing/convert-numbers-to-words-with-php/
323         $hyphen      = '-';
324         $conjunction = ' and ';
325         $separator   = ', ';
326         $negative    = 'negative ';
327         $decimal     = ' point ';
328         // This is our map of numerals to letters
329         $dictionary  = [
330             0                   => 'zero',
331             1                   => 'one',
332             2                   => 'two',
333             3                   => 'three',
334             4                   => 'four',
335             5                   => 'five',
336             6                   => 'six',
337             7                   => 'seven',
338             8                   => 'eight',
339             9                   => 'nine',
340             10                  => 'ten',
341             11                  => 'eleven',
342             12                  => 'twelve',
343             13                  => 'thirteen',
344             14                  => 'fourteen',
345             15                  => 'fifteen',
346             16                  => 'sixteen',
347             17                  => 'seventeen',
348             18                  => 'eighteen',
349             19                  => 'nineteen',
350             20                  => 'twenty',
351             30                  => 'thirty',
352             40                  => 'fourty',
353             50                  => 'fifty',
354             60                  => 'sixty',
355             70                  => 'seventy',
356             80                  => 'eighty',
357             90                  => 'ninety',
358             100                 => 'hundred',
359             1000                => 'thousand',
360             1000000             => 'million',
361             1000000000          => 'billion',
362             1000000000000       => 'trillion',
363             1000000000000000    => 'quadrillion',
364             1000000000000000000 => 'quintillion'
365         ];
366 
367         if ( ! is_numeric( $number ) ) {
368             // You didn't feed this a number
369             return false;
370         }
371 
372         if ( ( $number >= 0 && (int) $number < 0 ) || (int) $number < 0 - PHP_INT_MAX ) {
373             // overflow
374             trigger_error(
375                 'convert_number_to_words only accepts numbers between -' . PHP_INT_MAX . ' and ' . PHP_INT_MAX,
376                 E_USER_WARNING
377             );
378             return false;
379         }
380 
381         if ( $number < 0 ) {
382             // The number is negative, so re-run the function with the positive value but prepend the negative sign
383             return $negative . Date::convert_number_to_words( abs( $number ) );
384         }
385 
386         $string = $fraction = null;
387 
388         if ( false !== strpos( $number, '.' ) ) {
389             list( $number, $fraction ) = explode( '.', $number );
390         }
391 
392         // We're going to run through what we have now
393         switch ( true ) {
394             case $number < 21:
395                 $string = $dictionary[ $number ];
396                 break;
397             case $number < 100:
398                 $tens   = ( (int) ( $number / 10 ) ) * 10;
399                 $units  = $number % 10;
400                 $string = $dictionary[ $tens ];
401                 if ( $units ) {
402                     $string .= $hyphen . $dictionary[ $units ];
403                 }
404                 break;
405             case $number < 1000:
406                 $hundreds  = $number / 100;
407                 $remainder = $number % 100;
408                 $string = $dictionary[ $hundreds ] . ' ' . $dictionary[100];
409                 if ( $remainder ) {
410                     // We have some leftover number, so let's run the function again on what's left
411                     $string .= $conjunction . Date::convert_number_to_words( $remainder );
412                 }
413                 break;
414             default:
415                 $baseUnit = pow( 1000, floor( log( $number, 1000 ) ) );
416                 $numBaseUnits = (int) ( $number / $baseUnit );
417                 $remainder = $number % $baseUnit;
418                 $string = Date::convert_number_to_words( $numBaseUnits ) . ' ' . $dictionary[ $baseUnit ];
419                 if ( $remainder ) {
420                     $string .= $remainder < 100 ? $conjunction : $separator;
421                     // We have some leftover number, so let's run the function again on what's left
422                     $string .= Date::convert_number_to_words( $remainder );
423                 }
424                 break;
425         }
426 
427         if ( null !== $fraction && is_numeric( $fraction ) ) {
428             $string .= $decimal;
429             $words = [];
430             foreach ( str_split( (string) $fraction ) as $number ) {
431                 $words[] = $dictionary[ $number ];
432             }
433             $string .= implode( ' ', $words );
434         }
435 
436         return $string;
437     }
438 
439 }
Alphred API documentation generated by ApiGen