1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9namespace Piwik;
10
11use Piwik\Container\StaticContainer;
12use Piwik\Period\Factory;
13use Piwik\Period\Range;
14use Piwik\Translation\Translator;
15
16/**
17 * Date range representation.
18 *
19 * Piwik allows users to view aggregated statistics for single days and for date
20 * ranges consisting of several days. When requesting data, a **date** string and
21 * a **period** string must be used to specify the date range that the data regards.
22 * This is the class Piwik uses to represent and manipulate those date ranges.
23 *
24 * There are five types of periods in Piwik: day, week, month, year and range,
25 * where **range** is any date range. The reason the other periods exist instead
26 * of just **range** is that Piwik will pre-archive reports for days, weeks, months
27 * and years, while every custom date range is archived on-demand.
28 *
29 * @api
30 */
31abstract class Period
32{
33    /**
34     * Array of subperiods
35     * @var Period[]
36     */
37    protected $subperiods = array();
38    protected $subperiodsProcessed = false;
39
40    /**
41     * @var string
42     */
43    protected $label = null;
44
45    /**
46     * @var Date
47     */
48    protected $date = null;
49
50    /**
51     * @var Translator
52     */
53    protected $translator;
54
55    /**
56     * Constructor.
57     *
58     * @param Date $date
59     * @ignore
60     */
61    public function __construct(Date $date)
62    {
63        $this->date = clone $date;
64
65        $this->translator = StaticContainer::get('Piwik\Translation\Translator');
66    }
67
68    public function __sleep()
69    {
70        return [
71            'date',
72        ];
73    }
74
75    public function __wakeup()
76    {
77        $this->translator = StaticContainer::get('Piwik\Translation\Translator');
78    }
79
80    /**
81     * Returns true if `$dateString` and `$period` represent multiple periods.
82     *
83     * Will return true for date/period combinations where date references multiple
84     * dates and period is not `'range'`. For example, will return true for:
85     *
86     * - **date** = `2012-01-01,2012-02-01` and **period** = `'day'`
87     * - **date** = `2012-01-01,2012-02-01` and **period** = `'week'`
88     * - **date** = `last7` and **period** = `'month'`
89     *
90     * etc.
91     *
92     * @static
93     * @param  $dateString string The **date** query parameter value.
94     * @param  $period string The **period** query parameter value.
95     * @return boolean
96     */
97    public static function isMultiplePeriod($dateString, $period)
98    {
99        return is_string($dateString)
100            && (preg_match('/^(last|previous){1}([0-9]*)$/D', $dateString, $regs)
101                || Range::parseDateRange($dateString))
102            && $period != 'range';
103    }
104
105    /**
106     * Checks the given date format whether it is a correct date format and if not, throw an exception.
107     *
108     * For valid date formats have a look at the {@link \Piwik\Date::factory()} method and
109     * {@link isMultiplePeriod()} method.
110     *
111     * @param string $dateString
112     * @throws \Exception If `$dateString` is in an invalid format or if the time is before
113     *                   Tue, 06 Aug 1991.
114     */
115    public static function checkDateFormat($dateString)
116    {
117        if (self::isMultiplePeriod($dateString, 'day')) {
118            return;
119        }
120
121        Date::factory($dateString);
122    }
123
124    /**
125     * Returns the first day of the period.
126     *
127     * @return Date
128     */
129    public function getDateStart()
130    {
131        $this->generate();
132
133        if (count($this->subperiods) == 0) {
134            return $this->getDate();
135        }
136
137        $periods = $this->getSubperiods();
138
139        /** @var $currentPeriod Period */
140        $currentPeriod = $periods[0];
141        while ($currentPeriod->getNumberOfSubperiods() > 0) {
142            $periods       = $currentPeriod->getSubperiods();
143            $currentPeriod = $periods[0];
144        }
145
146        return $currentPeriod->getDate();
147    }
148
149    /**
150     * Returns the start date & time of this period.
151     *
152     * @return Date
153     */
154    public function getDateTimeStart()
155    {
156        return $this->getDateStart()->getStartOfDay();
157    }
158
159    /**
160     * Returns the end date & time of this period.
161     *
162     * @return Date
163     */
164    public function getDateTimeEnd()
165    {
166        return $this->getDateEnd()->getEndOfDay();
167    }
168
169    /**
170     * Returns the last day of the period.
171     *
172     * @return Date
173     */
174    public function getDateEnd()
175    {
176        $this->generate();
177
178        if (count($this->subperiods) == 0) {
179            return $this->getDate();
180        }
181
182        $periods = $this->getSubperiods();
183
184        /** @var $currentPeriod Period */
185        $currentPeriod = $periods[count($periods) - 1];
186        while ($currentPeriod->getNumberOfSubperiods() > 0) {
187            $periods       = $currentPeriod->getSubperiods();
188            $currentPeriod = $periods[count($periods) - 1];
189        }
190
191        return $currentPeriod->getDate();
192    }
193
194    /**
195     * Returns the period ID.
196     *
197     * @return int A unique integer for this type of period.
198     */
199    public function getId()
200    {
201        return Piwik::$idPeriods[$this->getLabel()];
202    }
203
204    /**
205     * Returns the label for the current period.
206     *
207     * @return string `"day"`, `"week"`, `"month"`, `"year"`, `"range"`
208     */
209    public function getLabel()
210    {
211        return $this->label;
212    }
213
214    /**
215     * @return Date
216     */
217    protected function getDate()
218    {
219        return $this->date;
220    }
221
222    protected function generate()
223    {
224        $this->subperiodsProcessed = true;
225    }
226
227    /**
228     * Returns the number of available subperiods.
229     *
230     * @return int
231     */
232    public function getNumberOfSubperiods()
233    {
234        $this->generate();
235        return count($this->subperiods);
236    }
237
238    /**
239     * Returns the set of Period instances that together make up this period. For a year,
240     * this would be 12 months. For a month this would be 28-31 days. Etc.
241     *
242     * @return Period[]
243     */
244    public function getSubperiods()
245    {
246        $this->generate();
247        return $this->subperiods;
248    }
249
250    /**
251     * Returns whether the date `$date` is within the current period or not.
252     *
253     * Note: the time component of the period's dates and `$date` is ignored.
254     *
255     * @param Date $today
256     * @return bool
257     */
258    public function isDateInPeriod(Date $date)
259    {
260        $ts = $date->getStartOfDay()->getTimestamp();
261        return $ts >= $this->getDateStart()->getStartOfDay()->getTimestamp()
262            && $ts < $this->getDateEnd()->addDay(1)->getStartOfDay()->getTimestamp();
263    }
264
265    /**
266     * Returns whether the given period date range intersects with this one.
267     *
268     * @param Period $other
269     * @return bool
270     */
271    public function isPeriodIntersectingWith(Period $other)
272    {
273        return !($this->getDateEnd()->getTimestamp() < $other->getDateStart()->getTimestamp()
274            || $this->getDateStart()->getTimestamp() > $other->getDateEnd()->getTimestamp());
275    }
276
277    /**
278     * Returns the start day and day after the end day for this period in the given timezone.
279     *
280     * @param Date[] $timezone
281     */
282    public function getBoundsInTimezone(string $timezone)
283    {
284        $date1 = $this->getDateTimeStart()->setTimezone($timezone);
285        $date2 = $this->getDateTimeEnd()->setTimezone($timezone);
286
287        return [$date1, $date2];
288    }
289
290    /**
291     * Add a date to the period.
292     *
293     * Protected because adding periods after initialization is not supported.
294     *
295     * @param \Piwik\Period $period Valid Period object
296     * @ignore
297     */
298    protected function addSubperiod($period)
299    {
300        $this->subperiods[] = $period;
301    }
302
303    /**
304     * Returns a list of strings representing the current period.
305     *
306     * @param string $format The format of each individual day.
307     * @return array|string An array of string dates that this period consists of.
308     */
309    public function toString($format = "Y-m-d")
310    {
311        $this->generate();
312
313        $dateString = array();
314        foreach ($this->subperiods as $period) {
315            $childPeriodStr = $period->toString($format);
316            if (is_array($childPeriodStr)) {
317                $childPeriodStr = implode(",", $childPeriodStr);
318            }
319
320            $dateString[] = $childPeriodStr;
321        }
322
323        return $dateString;
324    }
325
326    /**
327     * See {@link toString()}.
328     *
329     * @return string
330     */
331    public function __toString()
332    {
333        return implode(",", $this->toString());
334    }
335
336    /**
337     * Returns a pretty string describing this period.
338     *
339     * @return string
340     */
341    abstract public function getPrettyString();
342
343    /**
344     * Returns a short string description of this period that is localized with the currently used
345     * language.
346     *
347     * @return string
348     */
349    abstract public function getLocalizedShortString();
350
351    /**
352     * Returns a long string description of this period that is localized with the currently used
353     * language.
354     *
355     * @return string
356     */
357    abstract public function getLocalizedLongString();
358
359    /**
360     * Returns the label of the period type that is one size smaller than this one, or null if
361     * it's the smallest.
362     *
363     * Range periods and other such 'period collections' are not considered as separate from
364     * the value type of the collection. So a range period will return the result of the
365     * subperiod's `getImmediateChildPeriodLabel()` method.
366     *
367     * @ignore
368     * @return string|null
369     */
370    abstract public function getImmediateChildPeriodLabel();
371
372    /**
373     * Returns the label of the period type that is one size bigger than this one, or null
374     * if it's the biggest.
375     *
376     * Range periods and other such 'period collections' are not considered as separate from
377     * the value type of the collection. So a range period will return the result of the
378     * subperiod's `getParentPeriodLabel()` method.
379     *
380     * @ignore
381     */
382    abstract public function getParentPeriodLabel();
383
384    /**
385     * Returns the date range string comprising two dates
386     *
387     * @return string eg, `'2012-01-01,2012-01-31'`.
388     */
389    public function getRangeString()
390    {
391        $dateStart = $this->getDateStart();
392        $dateEnd   = $this->getDateEnd();
393
394        return $dateStart->toString("Y-m-d") . "," . $dateEnd->toString("Y-m-d");
395    }
396
397    /**
398     * @param string $format
399     *
400     * @return mixed
401     */
402    protected function getTranslatedRange($format)
403    {
404        $dateStart = $this->getDateStart();
405        $dateEnd = $this->getDateEnd();
406        list($formatStart, $formatEnd) = $this->explodeFormat($format);
407
408        $string = $dateStart->getLocalized($formatStart);
409        $string .= $dateEnd->getLocalized($formatEnd, false);
410
411        return $string;
412    }
413
414    /**
415     * Explodes the given format into two pieces. One that can be user for start date and the other for end date
416     *
417     * @param $format
418     * @return array
419     */
420    protected function explodeFormat($format)
421    {
422        $intervalTokens = array(
423            array('d', 'E', 'C'),
424            array('M', 'L'),
425            array('y')
426        );
427
428        $offset = strlen($format);
429        // replace string literals encapsulated by ' with same country of *
430        $cleanedFormat = preg_replace_callback('/(\'[^\']+\')/', array($this, 'replaceWithStars'), $format);
431
432        // search for first duplicate date field
433        foreach ($intervalTokens AS $tokens) {
434            if (preg_match_all('/[' . implode('|', $tokens) . ']+/', $cleanedFormat, $matches, PREG_OFFSET_CAPTURE) &&
435                count($matches[0]) > 1 && $offset > $matches[0][1][1]
436            ) {
437                $offset = $matches[0][1][1];
438            }
439        }
440
441        return array(substr($format, 0, $offset), substr($format, $offset));
442    }
443
444    private function replaceWithStars($matches)
445    {
446        return str_repeat("*", strlen($matches[0]));
447    }
448
449    protected function getRangeFormat($short = false)
450    {
451        $maxDifference = 'D';
452        if ($this->getDateStart()->toString('y') != $this->getDateEnd()->toString('y')) {
453            $maxDifference = 'Y';
454        } elseif ($this->getDateStart()->toString('m') != $this->getDateEnd()->toString('m')) {
455            $maxDifference = 'M';
456        }
457
458        $dateTimeFormatProvider = StaticContainer::get('Piwik\Intl\Data\Provider\DateTimeFormatProvider');
459
460        return $dateTimeFormatProvider->getRangeFormatPattern($short, $maxDifference);
461    }
462
463    /**
464     * Returns all child periods that exist within this periods entire date range. Cascades
465     * downwards over all period types that are smaller than this one. For example, month periods
466     * will cascade to week and day periods and year periods will cascade to month, week and day
467     * periods.
468     *
469     * The method will not return periods that are outside the range of this period.
470     *
471     * @return Period[]
472     * @ignore
473     */
474    public function getAllOverlappingChildPeriods()
475    {
476        return $this->getAllOverlappingChildPeriodsInRange($this->getDateStart(), $this->getDateEnd());
477    }
478
479    private function getAllOverlappingChildPeriodsInRange(Date $dateStart, Date $dateEnd)
480    {
481        $result = array();
482
483        $childPeriodType = $this->getImmediateChildPeriodLabel();
484        if (empty($childPeriodType)) {
485            return $result;
486        }
487
488        $childPeriods = Factory::build($childPeriodType, $dateStart->toString() . ',' . $dateEnd->toString());
489        return array_merge($childPeriods->getSubperiods(), $childPeriods->getAllOverlappingChildPeriodsInRange($dateStart, $dateEnd));
490    }
491}
492