*/ /** * A class to store Frequency-rules in. Will allow a easy way to find the * last and next occurrence of the rule. * * No - this is so not pretty. But.. ehh.. You do it better, and I will * gladly accept patches. * * Created by trail-and-error on the examples given in the RFC. * * TODO: Update to a better way of doing calculating the different options. * Instead of only keeping track of the best of the current dates found * it should instead keep a array of all the calculated dates within the * period. * This should fix the issues with multi-rule + multi-rule interference, * and make it possible to implement the SETPOS rule. * By pushing the next period onto the stack as the last option will * (hopefully) remove the need for the awful simpleMode * * @author Morten Fangel (C) 2008 * @author Michael Kahn (C) 2013 * @license http://creativecommons.org/licenses/by-sa/2.5/dk/deed.en_GB CC-BY-SA-DK */ class Freq { protected array $weekdays = [ 'MO' => 'monday', 'TU' => 'tuesday', 'WE' => 'wednesday', 'TH' => 'thursday', 'FR' => 'friday', 'SA' => 'saturday', 'SU' => 'sunday', ]; protected array $knownRules = [ 'month', 'weekno', 'day', 'monthday', 'yearday', 'hour', 'minute', ]; //others : 'setpos', 'second' protected array $ruleModifiers = ['wkst']; protected bool $simpleMode = true; protected array $rules = ['freq' => 'yearly', 'interval' => 1]; protected int $start = 0; protected string $freq = ''; protected array $excluded; //EXDATE protected array $added; //RDATE protected $cache; // getAllOccurrences() /** * Constructs a new Frequency-rule * * @param string|array $rule * @param int $start Unix-timestamp (important : Need to be the start of Event) * @param array $excluded of int (timestamps), see EXDATE documentation * @param array $added of int (timestamps), see RDATE documentation * @throws Exception */ public function __construct($rule, int $start, array $excluded = [], array $added = []) { $this->start = $start; $this->excluded = []; $rules = []; foreach ($rule as $k => $v) { $this->rules[strtolower($k)] = $v; } if (isset($this->rules['until']) && is_string($this->rules['until'])) { $this->rules['until'] = strtotime($this->rules['until']); } elseif ($this->rules['until'] instanceof DateTime) { $this->rules['until'] = $this->rules['until']->getTimestamp(); } $this->freq = strtolower($this->rules['freq']); foreach ($this->knownRules as $rule) { if (isset($this->rules['by' . $rule])) { if ($this->isPrerule($rule, $this->freq)) { $this->simpleMode = false; } } } if (!$this->simpleMode) { if (!(isset($this->rules['byday']) || isset($this->rules['bymonthday']) || isset($this->rules['byyearday']))) { $this->rules['bymonthday'] = date('d', $this->start); } } //set until, and cache if (isset($this->rules['count'])) { $cache[$ts] = $ts = $this->start; for ($n = 1; $n < $this->rules['count']; $n++) { $ts = $this->findNext($ts); $cache[$ts] = $ts; } $this->rules['until'] = $ts; //EXDATE if (!empty($excluded)) { foreach ($excluded as $ts) { unset($cache[$ts]); } } //RDATE if (!empty($added)) { $cache = array_unique(array_merge(array_values($cache), $added)); asort($cache); } $this->cache = array_values($cache); } $this->excluded = $excluded; $this->added = $added; } private function isPrerule(string $rule, string $freq): bool { if ($rule === 'year') { return false; } if ($rule === 'month' && $freq === 'yearly') { return true; } if ($rule === 'monthday' && in_array($freq, ['yearly', 'monthly']) && !isset($this->rules['byday'])) { return true; } // TODO: is it faster to do monthday first, and ignore day if monthday exists? - prolly by a factor of 4.. if ($rule === 'yearday' && $freq === 'yearly') { return true; } if ($rule === 'weekno' && $freq === 'yearly') { return true; } if ($rule === 'day' && in_array($freq, ['yearly', 'monthly', 'weekly'])) { return true; } if ($rule === 'hour' && in_array($freq, ['yearly', 'monthly', 'weekly', 'daily'])) { return true; } if ($rule === 'minute') { return true; } return false; } /** * Calculates the next time after the given offset that the rule * will apply. * * The approach to finding the next is as follows: * First we establish a timeframe to find timestamps in. This is * between $offset and the end of the period that $offset is in. * * We then loop though all the rules (that is a Prerule in the * current freq.), and finds the smallest timestamp inside the * timeframe. * * If we find something, we check if the date is a valid recurrence * (with validDate). If it is, we return it. Otherwise we try to * find a new date inside the same timeframe (but using the new- * found date as offset) * * If no new timestamps were found in the period, we try in the * next period * * @param int $offset * @return int|bool * @throws Exception */ public function findNext(int $offset) { if (!empty($this->cache)) { foreach ($this->cache as $ts) { if ($ts > $offset) { return $ts; } } } $debug = false; //make sure the offset is valid if ($offset === false || (isset($this->rules['until']) && $offset > $this->rules['until'])) { if ($debug) printf("STOP: %s\n", date('r', $offset)); return false; } $found = true; //set the timestamp of the offset (ignoring hours and minutes unless we want them to be //part of the calculations. if ($debug) printf("O: %s\n", date('r', $offset)); $hour = (in_array($this->freq, ['hourly', 'minutely']) && $offset > $this->start) ? date('H', $offset) : date( 'H', $this->start ); $minute = (($this->freq === 'minutely' || isset($this->rules['byminute'])) && $offset > $this->start) ? date( 'i', $offset ) : date('i', $this->start); $t = mktime($hour, $minute, date('s', $this->start), date('m', $offset), date('d', $offset), date('Y', $offset)); if ($debug) printf("START: %s\n", date('r', $t)); if ($this->simpleMode) { if ($offset < $t) { $ts = $t; if ($ts && in_array($ts, $this->excluded, true)) { $ts = $this->findNext($ts); } } else { $ts = $this->findStartingPoint($t, $this->rules['interval'], false); if (!$this->validDate($ts)) { $ts = $this->findNext($ts); } } return $ts; } //EOP needs to have the same TIME as START ($t) $tO = new DateTime('@' . $t, new DateTimeZone('UTC')); $eop = $this->findEndOfPeriod($offset); $eopO = new DateTime('@' . $eop, new DateTimeZone('UTC')); $eopO->setTime($tO->format('H'), $tO->format('i'), $tO->format('s')); $eop = $eopO->getTimestamp(); unset($eopO, $tO); if ($debug) { echo 'EOP: ' . date('r', $eop) . "\n"; } foreach ($this->knownRules as $rule) { if ($found && isset($this->rules['by' . $rule])) { if ($this->isPrerule($rule, $this->freq)) { $subRules = explode(',', $this->rules['by' . $rule]); $_t = null; foreach ($subRules as $subRule) { $imm = call_user_func_array([$this, "ruleBy$rule"], [$subRule, $t]); if ($imm === false) { break; } if ($debug) { printf("%s: %s A: %d\n", strtoupper($rule), date('r', $imm), intval($imm > $offset && $imm < $eop)); } if ($imm > $offset && $imm <= $eop && ($_t == null || $imm < $_t)) { $_t = $imm; } } if ($_t !== null) { $t = $_t; } else { $found = $this->validDate($t); } } } } if ($offset < $this->start && $this->start < $t) { $ts = $this->start; } elseif ($found && ($t != $offset)) { if ($this->validDate($t)) { if ($debug) echo 'OK' . "\n"; $ts = $t; } else { if ($debug) echo 'Invalid' . "\n"; $ts = $this->findNext($t); } } else { if ($debug) echo 'Not found' . "\n"; $ts = $this->findNext($this->findStartingPoint($offset, $this->rules['interval'])); } if ($ts && in_array($ts, $this->excluded, true)) { return $this->findNext($ts); } return $ts; } /** * Finds the starting point for the next rule. It goes $interval * 'freq' forward in time since the given offset * * @param int $offset * @param int $interval * @param boolean $truncate * @return int */ private function findStartingPoint(int $offset, int $interval, $truncate = true): int { $_freq = ($this->freq === 'daily') ? 'day__' : $this->freq; $t = '+' . $interval . ' ' . substr($_freq, 0, -2) . 's'; if ($_freq === 'monthly' && $truncate) { if ($interval > 1) { $offset = strtotime('+' . ($interval - 1) . ' months ', $offset); // FIXME return type int|false } $t = '+' . (date('t', $offset) - date('d', $offset) + 1) . ' days'; } $sp = strtotime($t, $offset); if ($truncate) { $sp = $this->truncateToPeriod($sp, $this->freq); } return $sp; } /** * Resets the timestamp to the beginning of the * period specified by freq * * Yes - the fall-through is on purpose! * * @param int $time * @param string $freq * @return int */ private function truncateToPeriod(int $time, string $freq): int { $date = getdate($time); switch ($freq) { case 'yearly': $date['mon'] = 1; case 'monthly': $date['mday'] = 1; case 'daily': $date['hours'] = 0; case 'hourly': $date['minutes'] = 0; case 'minutely': $date['seconds'] = 0; break; case 'weekly': if (date('N', $time) == 1) { // FIXME wrong compare, date return string|false $date['hours'] = 0; $date['minutes'] = 0; $date['seconds'] = 0; } else { $date = getdate(strtotime('last monday 0:00', $time)); } break; } return mktime($date['hours'], $date['minutes'], $date['seconds'], $date['mon'], $date['mday'], $date['year']); } private function validDate($t): bool { if (isset($this->rules['until']) && $t > $this->rules['until']) { return false; } if (in_array($t, $this->excluded, true)) { return false; } if (isset($this->rules['bymonth'])) { $months = explode(',', $this->rules['bymonth']); if (!in_array(date('m', $t), $months, true)) { return false; } } if (isset($this->rules['byday'])) { $days = explode(',', $this->rules['byday']); foreach ($days as $i => $k) { $days[$i] = $this->weekdays[preg_replace('/[^A-Z]/', '', $k)]; } if (!in_array(strtolower(date('l', $t)), $days, true)) { return false; } } if (isset($this->rules['byweekno'])) { $weeks = explode(',', $this->rules['byweekno']); if (!in_array(date('W', $t), $weeks, true)) { return false; } } if (isset($this->rules['bymonthday'])) { $weekdays = explode(',', $this->rules['bymonthday']); foreach ($weekdays as $i => $k) { if ($k < 0) { $weekdays[$i] = date('t', $t) + $k + 1; } } if (!in_array(date('d', $t), $weekdays, true)) { return false; } } if (isset($this->rules['byhour'])) { $hours = explode(',', $this->rules['byhour']); if (!in_array(date('H', $t), $hours, true)) { return false; } } return true; } /** * Finds the earliest timestamp possible outside this period. * * @param int $offset * @return int */ public function findEndOfPeriod($offset = 0) { return $this->findStartingPoint($offset, 1, false); } /** * Returns the previous (most recent) occurrence of the rule from the * given offset * * @param int $offset * @return int * @throws Exception */ public function previousOccurrence(int $offset) { if (!empty($this->cache)) { $t2 = $this->start; foreach ($this->cache as $ts) { if ($ts >= $offset) { return $t2; } $t2 = $ts; } } else { $ts = $this->start; while (($t2 = $this->findNext($ts)) < $offset) { if ($t2 == false) { break; } $ts = $t2; } } return $ts; } /** * Returns the next occurrence of this rule after the given offset * * @param int $offset * @return int * @throws Exception */ public function nextOccurrence(int $offset) { if ($offset < $this->start) { return $this->firstOccurrence(); } return $this->findNext($offset); } /** * Finds the first occurrence of the rule. * * @return int timestamp * @throws Exception */ public function firstOccurrence() { $t = $this->start; if (in_array($t, $this->excluded)) { $t = $this->findNext($t); } return $t; } /** * Finds the absolute last occurrence of the rule from the given offset. * Builds also the cache, if not set before... * * @return int timestamp * @throws Exception */ public function lastOccurrence() { //build cache if not done $this->getAllOccurrences(); //return last timestamp in cache return end($this->cache); } /** * Returns all timestamps array(), build the cache if not made before * * @return array * @throws Exception */ public function getAllOccurrences() { if (empty($this->cache)) { $cache = []; //build cache $next = $this->firstOccurrence(); while ($next) { $cache[] = $next; $next = $this->findNext($next); } if (!empty($this->added)) { $cache = array_unique(array_merge($cache, $this->added)); asort($cache); } $this->cache = $cache; } return $this->cache; } /** * Applies the BYDAY rule to the given timestamp * * @param string $rule * @param int $t * @return int */ private function ruleByDay(string $rule, int $t): int { $dir = ($rule[0] === '-') ? -1 : 1; $dir_t = ($dir === 1) ? 'next' : 'last'; $d = $this->weekdays[substr($rule, -2)]; $s = $dir_t . ' ' . $d . ' ' . date('H:i:s', $t); if ($rule == substr($rule, -2)) { if (date('l', $t) == ucfirst($d)) { $s = 'today ' . date('H:i:s', $t); } $_t = strtotime($s, $t); if ($_t == $t && in_array($this->freq, ['weekly', 'monthly', 'yearly'])) { // Yes. This is not a great idea.. but hey, it works.. for now $s = 'next ' . $d . ' ' . date('H:i:s', $t); $_t = strtotime($s, $_t); } return $_t; } else { $_f = $this->freq; if (isset($this->rules['bymonth']) && $this->freq === 'yearly') { $this->freq = 'monthly'; } if ($dir === -1) { $_t = $this->findEndOfPeriod($t); } else { $_t = $this->truncateToPeriod($t, $this->freq); } $this->freq = $_f; $c = preg_replace('/[^0-9]/', '', $rule); $c = ($c == '') ? 1 : $c; $n = $_t; while ($c > 0) { if ($dir === 1 && $c == 1 && date('l', $t) == ucfirst($d)) { $s = 'today ' . date('H:i:s', $t); } $n = strtotime($s, $n); $c--; } return $n; } } private function ruleByMonth($rule, int $t) { $_t = mktime(date('H', $t), date('i', $t), date('s', $t), $rule, date('d', $t), date('Y', $t)); if ($t == $_t && isset($this->rules['byday'])) { // TODO: this should check if one of the by*day's exists, and have a multi-day value return false; } else { return $_t; } } private function ruleByMonthday($rule, int $t) { if ($rule < 0) { $rule = date('t', $t) + $rule + 1; } return mktime(date('H', $t), date('i', $t), date('s', $t), date('m', $t), $rule, date('Y', $t)); } private function ruleByYearday($rule, int $t) { if ($rule < 0) { $_t = $this->findEndOfPeriod(); $d = '-'; } else { $_t = $this->truncateToPeriod($t, $this->freq); $d = '+'; } $s = $d . abs($rule - 1) . ' days ' . date('H:i:s', $t); return strtotime($s, $_t); } private function ruleByWeekno($rule, int $t) { if ($rule < 0) { $_t = $this->findEndOfPeriod(); $d = '-'; } else { $_t = $this->truncateToPeriod($t, $this->freq); $d = '+'; } $sub = (date('W', $_t) == 1) ? 2 : 1; $s = $d . abs($rule - $sub) . ' weeks ' . date('H:i:s', $t); $_t = strtotime($s, $_t); return $_t; } private function ruleByHour($rule, int $t) { return mktime($rule, date('i', $t), date('s', $t), date('m', $t), date('d', $t), date('Y', $t)); } private function ruleByMinute($rule, int $t) { return mktime(date('h', $t), $rule, date('s', $t), date('m', $t), date('d', $t), date('Y', $t)); } }