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 }