1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie;
13
14 /**
15 * Representation of a date and time.
16 *
17 * <pre>
18 * <?php
19 *
20 * // Let's say that _now_ is 2013-02-03 21:03:45 in Paris
21 *
22 * use ICanBoogie\DateTime;
23 *
24 * date_default_timezone_set('EST'); // set local time zone to Eastern Standard Time
25 *
26 * $time = new DateTime('now', 'Europe/Paris');
27 *
28 * echo $time; // 2013-02-03T21:03:45+0100
29 * echo $time->utc; // 2013-02-03T20:03:45Z
30 * echo $time->local; // 2013-02-03T15:03:45-0500
31 * echo $time->utc->local; // 2013-02-03T15:03:45-0500
32 * echo $time->utc->is_utc; // true
33 * echo $time->utc->is_local; // false
34 * echo $time->local->is_utc; // false
35 * echo $time->local->is_local; // true
36 * echo $time->is_dst; // false
37 *
38 * echo $time->as_rss; // Sun, 03 Feb 2013 21:03:45 +0100
39 * echo $time->as_db; // 2013-02-03 21:03:45
40 *
41 * echo $time->as_time; // 21:03:45
42 * echo $time->utc->as_time; // 20:03:45
43 * echo $time->local->as_time; // 15:03:45
44 * echo $time->utc->local->as_time; // 15:03:45
45 *
46 * echo $time->quarter; // 1
47 * echo $time->week; // 5
48 * echo $time->day; // 3
49 * echo $time->minute; // 3
50 * echo $time->is_monday; // false
51 * echo $time->is_saturday; // true
52 * echo $time->is_today; // true
53 * echo $time->tomorrow; // 2013-02-04T00:00:00+0100
54 * echo $time->tomorrow->is_future // true
55 * echo $time->yesterday; // 2013-02-02T00:00:00+0100
56 * echo $time->yesterday->is_past // true
57 * echo $time->monday; // 2013-01-28T00:00:00+0100
58 * echo $time->sunday; // 2013-02-03T00:00:00+0100
59 *
60 * echo $time->timestamp; // 1359921825
61 * echo $time; // 2013-02-03T21:03:45+0100
62 * $time->timestamp += 3600 * 4;
63 * echo $time; // 2013-02-04T01:03:45+0100
64 *
65 * echo $time->zone; // Europe/Paris
66 * echo $time->zone->offset; // 3600
67 * echo $time->zone->location; // FR,48.86667,2.33333
68 * echo $time->zone->location->latitude; // 48.86667
69 * $time->zone = 'Asia/Tokyo';
70 * echo $time; // 2013-02-04T09:03:45+0900
71 *
72 * $time->hour += 72;
73 * echo "Rendez-vous in 72 hours: $time"; // Rendez-vous in 72 hours: 2013-02-07T05:03:45+0900
74 * </pre>
75 *
76 * Empty dates are also supported:
77 *
78 * <pre>
79 * <?php
80 *
81 * $time = new DateTime('0000-00-00', 'utc');
82 * // or
83 * $time = DateTime::none();
84 * echo $time; // -0001-11-30T00:00:00Z
85 * echo $time->is_empty; // true
86 * echo $time->as_date; // 0000-00-00
87 * echo $time->as_db; // 0000-00-00 00:00:00
88 *
89 * </pre>
90 *
91 * @property int $timestamp Unix timestamp.
92 * @property int $day Day of the month.
93 * @property int $hour Hour of the day.
94 * @property int $minute Minute of the hour.
95 * @property int $month Month of the year.
96 * @property-read int $quarter Quarter of the year.
97 * @property int $second Second of the minute.
98 * @property-read int $week Week of the year.
99 * @property-read int $weekday Day of the week.
100 * @property int $year Year.
101 * @property-read int $year_day Day of the year.
102 * @property-read bool $is_monday `true` if the instance represents Monday.
103 * @property-read bool $is_tuesday `true` if the instance represents Tuesday.
104 * @property-read bool $is_wednesday `true` if the instance represents Wednesday.
105 * @property-read bool $is_thursday `true` if the instance represents Thursday.
106 * @property-read bool $is_friday `true` if the instance represents Friday.
107 * @property-read bool $is_saturday `true` if the instance represents Satruday.
108 * @property-read bool $is_sunday `true` if the instance represents Sunday.
109 * @property-read bool $is_today `true` if the instance is today.
110 * @property-read bool $is_past `true` if the instance lies in the past.
111 * @property-read bool $is_future `true` if the instance lies in the future.
112 * @property-read bool $is_empty `true` if the instance represents an empty date such as "0000-00-00" or "0000-00-00 00:00:00".
113 * @property-read DateTime $tomorrow A new instance representing the next day. Time is reseted to 00:00:00.
114 * @property-read DateTime $yesterday A new instance representing the previous day. Time is reseted to 00:00:00.
115 * @property-read DateTime $monday A new instance representing Monday of the week. Time is reseted to 00:00:00.
116 * @property-read DateTime $tuesday A new instance representing Tuesday of the week. Time is reseted to 00:00:00.
117 * @property-read DateTime $wednesday A new instance representing Wednesday of the week. Time is reseted to 00:00:00.
118 * @property-read DateTime $thursday A new instance representing Thursday of the week. Time is reseted to 00:00:00.
119 * @property-read DateTime $friday A new instance representing Friday of the week. Time is reseted to 00:00:00.
120 * @property-read DateTime $saturday A new instance representing Saturday of the week. Time is reseted to 00:00:00.
121 * @property-read DateTime $sunday A new instance representing Sunday of the week. Time is reseted to 00:00:00.
122 *
123 * @property-read string $as_atom The instance formatted according to {@link ATOM}.
124 * @property-read string $as_cookie The instance formatted according to {@link COOKIE}.
125 * @property-read string $as_iso8601 The instance formatted according to {@link ISO8601}.
126 * @property-read string $as_rfc822 The instance formatted according to {@link RFC822}.
127 * @property-read string $as_rfc850 The instance formatted according to {@link RFC850}.
128 * @property-read string $as_rfc1036 The instance formatted according to {@link RFC1036}.
129 * @property-read string $as_rfc1123 The instance formatted according to {@link RFC1123}.
130 * @property-read string $as_rfc2822 The instance formatted according to {@link RFC2822}.
131 * @property-read string $as_rfc3339 The instance formatted according to {@link RFC3339}.
132 * @property-read string $as_rss The instance formatted according to {@link RSS}.
133 * @property-read string $as_w3c The instance formatted according to {@link W3C}.
134 * @property-read string $as_db The instance formatted according to {@link DB}.
135 * @property-read string $as_number The instance formatted according to {@link NUMBER}.
136 * @property-read string $as_date The instance formatted according to {@link DATE}.
137 * @property-read string $as_time The instance formatted according to {@link TIME}.
138 *
139 * @property TimeZone $zone The timezone of the instance.
140 * @property-read DateTime $utc A new instance in the UTC timezone.
141 * @property-read DateTime $local A new instance in the local timezone.
142 * @property-read bool $is_utc `true` if the instance is in the UTC timezone.
143 * @property-read bool $is_local `true` if the instance is in the local timezone.
144 * @property-read bool $is_dst `true` if time occurs during Daylight Saving Time in its time zone.
145 *
146 * @method string format_as_atom() format_as_atom() Formats the instance according to {@link ATOM}.
147 * @method string format_as_cookie() format_as_cookie() Formats the instance according to {@link COOKIE}.
148 * @method string format_as_iso8601() format_as_iso8601() Formats the instance according to {@link ISO8601}.
149 * @method string format_as_rfc822() format_as_rfc822() Formats the instance according to {@link RFC822}.
150 * @method string format_as_rfc850() format_as_rfc850() Formats the instance according to {@link RFC850}.
151 * @method string format_as_rfc1036() format_as_rfc1036() Formats the instance according to {@link RFC1036}.
152 * @method string format_as_rfc1123() format_as_rfc1123() Formats the instance according to {@link RFC1123}.
153 * @method string format_as_rfc2822() format_as_rfc2822() Formats the instance according to {@link RFC2822}.
154 * @method string format_as_rfc3339() format_as_rfc3339() Formats the instance according to {@link RFC3339}.
155 * @method string format_as_rss() format_as_rss() Formats the instance according to {@link RSS}.
156 * @method string format_as_w3c() format_as_w3c() Formats the instance according to {@link W3C}.
157 * @method string format_as_db() format_as_db() Formats the instance according to {@link DB}.
158 * @method string format_as_number() format_as_number() Formats the instance according to {@link NUMBER}.
159 * @method string format_as_date() format_as_date() Formats the instance according to {@link DATE}.
160 * @method string format_as_time() format_as_time() Formats the instance according to {@link TIME}.
161 *
162 * @see http://en.wikipedia.org/wiki/ISO_8601
163 */
164 class DateTime extends \DateTime
165 {
166 /**
167 * DB (example: 2013-02-03 20:59:03)
168 *
169 * @var string
170 */
171 const DB = 'Y-m-d H:i:s';
172
173 /**
174 * Number (example: 20130203205903)
175 *
176 * @var string
177 */
178 const NUMBER = 'YmdHis';
179
180 /**
181 * Date (example: 2013-02-03)
182 *
183 * @var string
184 */
185 const DATE = 'Y-m-d';
186
187 /**
188 * Time (example: 20:59:03)
189 *
190 * @var string
191 */
192 const TIME = 'H:i:s';
193
194 /**
195 * Creates a {@link DateTime} instance from a source.
196 *
197 * <pre>
198 * <?php
199 *
200 * use ICanBoogie\DateTime;
201 *
202 * DateTime::from(new \DateTime('2001-01-01 01:01:01', new \DateTimeZone('Europe/Paris')));
203 * DateTime::from('2001-01-01 01:01:01', 'Europe/Paris');
204 * DateTime::from('now');
205 * </pre>
206 *
207 * @param mixed $source
208 * @param mixed $timezone The time zone to use to create the time. The value is ignored if the
209 * source is an instance of {@link \DateTime}.
210 *
211 * @return \ICanBoogie\DateTime
212 */
213 static public function from($source, $timezone=null)
214 {
215 if ($source instanceof static)
216 {
217 return clone $source;
218 }
219 else if ($source instanceof \DateTime)
220 {
221 return new static($source->format(self::DB), $source->getTimezone());
222 }
223
224 return new static($source, $timezone);
225 }
226
227 /**
228 * Returns an instance with the current local time and the local time zone.
229 *
230 * @return \ICanBoogie\DateTime
231 */
232 static public function now()
233 {
234 return new static();
235 }
236
237 /**
238 * Returns an instance representing an empty date ("0000-00-00").
239 *
240 * <pre>
241 * <?php
242 *
243 * use ICanBoogie\DateTime;
244 *
245 * $d = DateTime::none();
246 * $d->is_empty; // true
247 * $d->zone->name; // "UTC"
248 *
249 * $d = DateTime::none('Asia/Tokyo');
250 * $d->is_empty; // true
251 * $d->zone->name; // "Asia/Tokio"
252 * </pre>
253 *
254 * @param \DateTimeZone|string $timezone The time zone in which the empty date is created.
255 * Defaults to "UTC".
256 *
257 * @return \ICanBoogie\DateTime
258 */
259 static public function none($timezone='utc')
260 {
261 return new static('0000-00-00', $timezone);
262 }
263
264 /**
265 * If the time zone is specified as a string a {@link \DateTimeZone} instance is created and
266 * used instead.
267 *
268 * <pre>
269 * <?php
270 *
271 * use ICanBoogie\DateTime;
272 *
273 * new DateTime('2001-01-01 01:01:01', new \DateTimeZone('Europe/Paris')));
274 * new DateTime('2001-01-01 01:01:01', 'Europe/Paris');
275 * new DateTime;
276 * </pre>
277 *
278 * @param string $time Defaults to "now".
279 * @param \DateTimeZone|string|null $timezone
280 */
281 public function __construct($time='now', $timezone=null)
282 {
283 if (is_string($timezone))
284 {
285 $timezone = new \DateTimeZone($timezone);
286 }
287
288 #
289 # PHP 5.3.3 considers null $timezone as an error and will complain that it is not
290 # a \DateTimeZone instance.
291 #
292
293 $timezone === null ? parent::__construct($time) : parent::__construct($time, $timezone);
294 }
295
296 public function __get($property)
297 {
298 if (strpos($property, 'as_') === 0)
299 {
300 return call_user_func(array($this, 'format_' . $property));
301 }
302
303 switch ($property)
304 {
305 case 'timestamp':
306 return $this->getTimestamp();
307
308 case 'year':
309 return (int) $this->format('Y');
310 case 'quarter':
311 return floor(($this->month - 1) / 3) + 1;
312 case 'month':
313 return (int) $this->format('m');
314 case 'week':
315 return (int) $this->format('W');
316 case 'year_day':
317 return (int) $this->format('z') + 1;
318 case 'weekday':
319 return (int) $this->format('w') ?: 7;
320 case 'day':
321 return (int) $this->format('d');
322 case 'hour':
323 return (int) $this->format('H');
324 case 'minute':
325 return (int) $this->format('i');
326 case 'second':
327 return (int) $this->format('s');
328 case 'is_monday':
329 return $this->weekday == 1;
330 case 'is_tuesday':
331 return $this->weekday == 2;
332 case 'is_wednesday':
333 return $this->weekday == 3;
334 case 'is_thursday':
335 return $this->weekday == 4;
336 case 'is_friday':
337 return $this->weekday == 5;
338 case 'is_saturday':
339 return $this->weekday == 6;
340 case 'is_sunday':
341 return $this->weekday == 7;
342 case 'is_today':
343 $now = new static('now', $this->zone);
344 return $this->as_date === $now->as_date;
345 case 'is_past':
346 return $this < new static('now', $this->zone);
347 case 'is_future':
348 return $this > new static('now', $this->zone);
349 case 'is_empty':
350 return $this->year == -1 && $this->month == 11 && $this->day == 30;
351 case 'tomorrow':
352 $time = clone $this;
353 $time->modify('+1 day');
354 $time->setTime(0, 0, 0);
355 return $time;
356 case 'yesterday':
357 $time = clone $this;
358 $time->modify('-1 day');
359 $time->setTime(0, 0, 0);
360 return $time;
361
362 /*
363 * days
364 */
365 case 'monday':
366 case 'tuesday':
367 case 'wednesday':
368 case 'thursday':
369 case 'friday':
370 case 'saturday':
371 case 'sunday':
372
373 return $this->{ 'get_' . $property }();
374
375 case 'zone':
376 return TimeZone::from($this->getTimezone());
377 case 'utc':
378 case 'local':
379 $time = clone $this;
380 $time->setTimezone($property);
381 return $time;
382 case 'is_utc':
383 return $this->zone->name == 'UTC';
384 case 'is_local':
385 return $this->zone->name == date_default_timezone_get();
386 case 'is_dst':
387 $timestamp = $this->timestamp;
388 $transitions = $this->zone->getTransitions($timestamp, $timestamp);
389 return $transitions[0]['isdst'];
390 }
391
392 if (class_exists('ICanBoogie\PropertyNotDefined'))
393 {
394 throw new PropertyNotDefined(array($property, $this));
395 }
396 else
397 {
398 throw new \RuntimeException("Property is not defined: $property.");
399 }
400 }
401
402 /**
403 * Returns Monday of the week.
404 *
405 * @return \ICanBoogie\DateTime
406 */
407 protected function get_monday()
408 {
409 $time = clone $this;
410 $day = $time->weekday;
411
412 if ($day != 1)
413 {
414 $time->modify('-' . ($day - 1) . ' day');
415 }
416
417 $time->setTime(0, 0, 0);
418
419 return $time;
420 }
421
422 /**
423 * Returns Tuesday of the week.
424 *
425 * @return \ICanBoogie\DateTime
426 */
427 protected function get_tuesday()
428 {
429 return $this->monday->modify('+1 day');
430 }
431
432 /**
433 * Returns Wednesday of the week.
434 *
435 * @return \ICanBoogie\DateTime
436 */
437 protected function get_wednesday()
438 {
439 return $this->monday->modify('+2 day');
440 }
441
442 /**
443 * Returns Thursday of the week.
444 *
445 * @return \ICanBoogie\DateTime
446 */
447 protected function get_thursday()
448 {
449 return $this->monday->modify('+3 day');
450 }
451
452 /**
453 * Returns Friday of the week.
454 *
455 * @return \ICanBoogie\DateTime
456 */
457 protected function get_friday()
458 {
459 return $this->monday->modify('+4 day');
460 }
461
462 /**
463 * Returns Saturday of the week.
464 *
465 * @return \ICanBoogie\DateTime
466 */
467 protected function get_saturday()
468 {
469 return $this->monday->modify('+5 day');
470 }
471
472 /**
473 * Returns Sunday of the week.
474 *
475 * @return \ICanBoogie\DateTime
476 */
477 protected function get_sunday()
478 {
479 $time = clone $this;
480 $day = $time->weekday;
481
482 if ($day != 7)
483 {
484 $time->modify('+' . (7 - $day) . ' day');
485 }
486
487 $time->setTime(0, 0, 0);
488
489 return $time;
490 }
491
492 /**
493 * Sets the {@link $year}, {@link $month}, {@link $day}, {@link $hour}, {@link $minute},
494 * {@link $second}, {@link $timestamp} and {@link $zone} properties.
495 *
496 * @throws PropertyNotWritable in attempt to set a read-only property.
497 * @throws PropertyNotDefined in attempt to set an unsupported property.
498 */
499 public function __set($property, $value)
500 {
501 static $readonly = array('quarter', 'week', 'year_day', 'weekday',
502 'tomorrow', 'yesterday', 'utc', 'local');
503
504 switch ($property)
505 {
506 case 'year':
507 case 'month':
508 case 'day':
509 case 'hour':
510 case 'minute':
511 case 'second':
512 $this->change(array($property => $value));
513 return;
514
515 case 'timestamp':
516 $this->setTimestamp($value);
517 return;
518
519 case 'zone':
520 $this->setTimezone($value);
521 return;
522 }
523
524 if (strpos($property, 'is_') === 0 || strpos($property, 'as_') === 0 || in_array($property, $readonly) || method_exists($this, 'get_' . $property))
525 {
526 if (class_exists('ICanBoogie\PropertyNotWritable'))
527 {
528 throw new PropertyNotWritable(array($property, $this));
529 }
530 else
531 {
532 throw new \RuntimeException("Property is not writeable: $property.");
533 }
534 }
535
536 if (class_exists('ICanBoogie\PropertyNotDefined'))
537 {
538 throw new PropertyNotDefined(array($property, $this));
539 }
540 else
541 {
542 throw new \RuntimeException("Property is not defined: $property.");
543 }
544 }
545
546 /**
547 * Handles the `format_as_*` methods.
548 *
549 * If the format is {@link RFC822} or {@link RFC1123} and the time zone is equivalent to GMT,
550 * the offset `+0000` is replaced by `GMT` according to the specs.
551 *
552 * If the format is {@link ISO8601} and the time zone is equivalent to UTC, the offset `+0000`
553 * is replaced by `Z` according to the specs.
554 *
555 * @throws \BadMethodCallException in attempt to call an unsupported method.
556 */
557 public function __call($method, $arguments)
558 {
559 if (strpos($method, 'format_as_') === 0)
560 {
561 $as = strtoupper(substr($method, strlen('format_as_')));
562 $format = constant(__CLASS__ . '::' . $as);
563 $value = $this->format($format);
564
565 if ($as == 'RFC822' || $as == 'RFC1123')
566 {
567 $value = str_replace('+0000', 'GMT', $value);
568 }
569 else if ($as == 'ISO8601')
570 {
571 $value = str_replace('+0000', 'Z', $value);
572 }
573
574 return $value;
575 }
576
577 throw new \BadMethodCallException("Unsupported method: $method.");
578 }
579
580 /**
581 * Returns the datetime formated as {@link ISO8601}.
582 */
583 public function __toString()
584 {
585 return $this->as_iso8601;
586 }
587
588 /**
589 * The timezone can be specified as a string.
590 *
591 * If the timezone is `local` the timezone returned by {@link date_default_timezone_get()} is
592 * used instead.
593 */
594 public function setTimezone($timezone)
595 {
596 if ($timezone === 'local')
597 {
598 $timezone = date_default_timezone_get();
599 }
600
601 if (!($timezone instanceof \DateTimeZone))
602 {
603 $timezone = new \DateTimeZone($timezone);
604 }
605
606 return parent::setTimezone($timezone);
607 }
608
609 /**
610 * Modifies the properties of the instance occording to the options.
611 *
612 * The following properties can be updated: {@link $year}, {@link $month}, {@link $day},
613 * {@link $hour}, {@link $minute} and {@link $second}.
614 *
615 * Note: Values exceeding ranges are added to their parent values.
616 *
617 * <pre>
618 * <?php
619 *
620 * use ICanBoogie\DateTime;
621 *
622 * $time = new DateTime('now');
623 * $time->change(array('year' => 2000, 'second' => 0));
624 * </pre>
625 *
626 * @param array $options
627 * @param bool $cascade If `true`, time options (`hour`, `minute`, `second`) reset
628 * cascadingly, so if only the hour is passed, then minute and second is set to 0. If the hour
629 * and minute is passed, then second is set to 0.
630 */
631 public function change(array $options, $cascade=false)
632 {
633 static $default_options = array
634 (
635 'year' => null,
636 'month' => null,
637 'day' => null,
638 'hour' => null,
639 'minute' => null,
640 'second' => null
641 );
642
643 extract(array_intersect_key($options + $default_options, $default_options));
644
645 if ($cascade)
646 {
647 if ($hour !== null && $minute === null)
648 {
649 $minute = 0;
650 }
651
652 if ($minute !== null && $second === null)
653 {
654 $second = 0;
655 }
656 }
657
658 if ($year !== null || $month !== null || $day !== null)
659 {
660 $this->setDate
661 (
662 $year === null ? $this->year : $year,
663 $month === null ? $this->month : $month,
664 $day === null ? $this->day : $day
665 );
666 }
667
668 if ($hour !== null || $minute !== null || $second !== null)
669 {
670 $this->setTime
671 (
672 $hour === null ? $this->hour : $hour,
673 $minute === null ? $this->minute : $minute,
674 $second === null ? $this->second : $second
675 );
676 }
677
678 return $this;
679 }
680
681 /**
682 * If the instance represents an empty date and the format is {@link DATE} or {@link DB},
683 * an empty date is returned, respectively "0000-00-00" and "0000-00-00 00:00:00". Note that
684 * the time information is discarted for {@link DB}. This only apply to {@link DATE} and
685 * {@link DB} formats. For instance {@link RSS} will return the following string:
686 * "Wed, 30 Nov -0001 00:00:00 +0000".
687 */
688 public function format($format)
689 {
690 if (($format == self::DATE || $format == self::DB) && $this->is_empty)
691 {
692 if ($format == self::DATE)
693 {
694 return '0000-00-00';
695 }
696 else
697 {
698 return '0000-00-00 00:00:00';
699 }
700 }
701
702 return parent::format($format);
703 }
704 }