From cf1ad350ffd9ead7e4a6a54a65929be6c085ed12 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 9 Feb 2024 13:36:31 -0700 Subject: [PATCH 01/22] Makes Time::isStrftimeFormat() public Signed-off-by: Jon Stovell --- Sources/Time.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Time.php b/Sources/Time.php index ddc449eaea..f1ffc4c98f 100644 --- a/Sources/Time.php +++ b/Sources/Time.php @@ -987,6 +987,17 @@ public static function getDateOrTimeFormat(string $type = '', string $format = ' return self::datetimePartialFormat($type, $format); } + /** + * Figures out whether the passed format is a strftime format. + * + * @param string $format The format string. + * @return bool Whether it is a strftime format. + */ + public static function isStrftimeFormat(string $format): bool + { + return (bool) preg_match('/' . self::REGEX_STRFTIME . '/', $format); + } + /** * Backward compatibility wrapper for the format method. * @@ -1275,17 +1286,6 @@ protected static function datetimePartialFormat(string $type, string $format): s return self::$formats[$orig_format][$type]; } - - /** - * Figures out whether the passed format is a strftime format. - * - * @param string $format The format string. - * @return bool Whether it is a strftime format. - */ - protected static function isStrftimeFormat(string $format): bool - { - return (bool) preg_match('/' . self::REGEX_STRFTIME . '/', $format); - } } ?> \ No newline at end of file From 9627ad8b6afc3cc75480485e74f57d3f0b6dc4dc Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Wed, 27 Dec 2023 19:11:46 -0700 Subject: [PATCH 02/22] Implements SMF\TimeInterval Signed-off-by: Jon Stovell --- Sources/TimeInterval.php | 277 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 Sources/TimeInterval.php diff --git a/Sources/TimeInterval.php b/Sources/TimeInterval.php new file mode 100644 index 0000000000..ed1442e634 --- /dev/null +++ b/Sources/TimeInterval.php @@ -0,0 +1,277 @@ +[\d\.]+)Y)?(?:(?P[\d\.]+)M)?(?:(?P[\d\.]+)W)?(?:(?P[\d\.]+)D)?(?:T(?:(?P[\d\.]+)H)?(?:(?P[\d\.]+)M)?(?:(?P[\d\.]+)S)?)?/', $duration, $matches) && $matches[0] !== 'P'; + + // Next, check for alt format (e.g. 'P0000-00-01T02:00:00'). + if (!$valid) { + $valid = preg_match('/P(?P\d+)-?(?P\d+)-?(?P\d+)-?(?P\d+)T(?P\d+):?(?P\d+):?(?P[\d\.]+)/', $duration, $matches); + } + + if (!$valid) { + throw new \ValueError(); + } + + // Clean up $matches. + $matches = array_map( + // Quick way to cast to int or float without extra logic. + fn ($v) => $v + 0, + // Filter out the stuff we don't need. + array_filter( + $matches, + fn ($v, $k) => !is_int($k) && $v !== '', + ARRAY_FILTER_USE_BOTH, + ), + ); + + // For simplicity, convert weeks to days. + if (!empty($matches['w'])) { + $matches['d'] = ($matches['d'] ?? 0) + $matches['w'] * 7; + unset($matches['w']); + } + + // Figure out if we have any fractional values. + $frac = [ + 'prop' => null, + 'value' => null, + ]; + + $props = [ + 's' => [ + 'frac_prop' => 'f', + 'multiplier' => 1, + 'unit' => 'S', + ], + 'i' => [ + 'frac_prop' => 's', + 'multiplier' => 60, + 'unit' => 'M', + ], + 'h' => [ + 'frac_prop' => 'i', + 'multiplier' => 60, + 'unit' => 'H', + ], + 'd' => [ + 'frac_prop' => 'h', + 'multiplier' => 24, + 'unit' => 'D', + ], + 'm' => [ + 'frac_prop' => 'd', + // This is calibrated so that 'P0.5M' means 'P15D' but 'P0.99M' means 'P28D'. + 'multiplier' => !isset($matches['m']) || fmod($matches['m'], 1.0) <= 0.5 ? 30 : 32 - (4 * fmod($matches['m'], 1.0)), + 'unit' => 'M', + ], + 'y' => [ + 'frac_prop' => 'm', + 'multiplier' => 12, + 'unit' => 'Y', + ], + ]; + + $can_be_fractional = true; + + foreach ($props as $prop => $info) { + if (!isset($matches[$prop])) { + continue; + } + + if (is_float($matches[$prop])) { + if (!$can_be_fractional) { + throw new \ValueError(); + } + + $frac['prop'] = $info['frac_prop']; + $frac['value'] = round(($matches[$prop] - (int) $matches[$prop]) * $info['multiplier'], 6); + + $matches[$prop] = (int) $matches[$prop]; + } + + // ISO 8601 only allows the smallest provided unit to be fractional. + $can_be_fractional = false; + } + + if (!isset($frac['prop'])) { + // If we have no fractional values, construction is easy. + parent::__construct($duration); + } else { + // Rebuild $duration without the fractional value. + $duration = 'P'; + + foreach (array_reverse($props) as $prop => $info) { + if ($prop === 'h') { + $duration .= 'T'; + } + + if (!empty($matches[$prop])) { + $duration .= $matches[$prop] . $info['unit']; + } + } + + // Construct. + parent::__construct(rtrim($duration, 'PT')); + + // Finally, set the fractional value. + $this->{$frac['prop']} += $frac['value']; + } + } + + /** + * Formats the object as a string so it can be reconstructed later. + * + * @return string A ISO 8601 duration string suitable for reconstructing + * this object. + */ + public function __toString(): string + { + $format = 'P'; + + foreach (['y', 'm', 'd', 'h', 'i', 's'] as $prop) { + if ($prop === 'h') { + $format .= 'T'; + } + + if (!empty($this->{$prop}) || ($prop === 's' && !empty($this->f))) { + $format .= '%' . $prop . ($prop === 'i' ? 'M' : strtoupper($prop)); + } + } + + $string = rtrim($this->format($format), 'PT'); + + if ($string === '') { + $string = 'PT0S'; + } + + if (!empty($this->f)) { + $string = preg_replace_callback('/\d+(?=S)/', fn ($m) => $m[0] + $this->f, $string); + } + + return $string; + } + + /** + * Formats the object as a string that can be parsed by strtotime(). + * + * @return string A strtotime parsable string suitable for reconstructing + * this object. + */ + public function toParsable(): string + { + $result = []; + + $props = [ + 'invert' => null, + 'y' => 'years', + 'm' => 'months', + 'd' => 'days', + 'h' => 'hours', + 'i' => 'minutes', + 's' => 'seconds', + 'f' => 'microseconds', + ]; + + foreach ($props as $prop => $string) { + if (empty($this->{$prop})) { + continue; + } + + switch ($prop) { + case 'invert': + $result[] = '-'; + break; + + default: + $result[] = $this->format('%' . $prop) . ' ' . $string; + break; + } + } + + if (empty($result)) { + $result[] = '0 seconds'; + } + + return implode(' ', $result); + } + + /** + * Converts this interval to a number of seconds. + * + * Because months have variable lengths, leap years exist, etc., it is + * necessary to provide a reference date that the interval will measure + * from in order to calculate the exact number of seconds. + * + * @param \DateTimeInterface $when Reference date that this interval will + * be added to in order to calculate the exact number of seconds. + * @return int|float Number of seconds in this interval, counting from the + * reference date. + */ + public function toSeconds(\DateTimeInterface $when): int|float + { + $later = \DateTime::createFromInterface($when); + $later->add($this); + + $fmt = !empty($this->f) ? 'U.u' : 'U'; + + return ($later->format($fmt) - $when->format($fmt)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Convert a \DateInterval object into a TimeInterval object. + * + * @param string $object A \DateInterval object. + * @return TimeInterval A TimeInterval object. + */ + public static function createFromDateInterval(\DateInterval $object): static + { + $new = new TimeInterval('P0D'); + + foreach (['y', 'm', 'd', 'h', 'i', 's', 'f', 'invert'] as $prop) { + $new->{$prop} = $object->{$prop}; + } + + return $new; + } +} + +?> \ No newline at end of file From 7f48a4cf6bfeb3f4a6cf4d7a00acab3fb8f4ead5 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 7 Dec 2023 11:44:02 -0700 Subject: [PATCH 03/22] Kill off the deprecated 'span' event property It's a relic from before the calendar supported times, it doesn't work reliably for events with times, and even for all-day events its behaviour can be somewhat odd. Signed-off-by: Jon Stovell --- Sources/Event.php | 80 +++++++++++------------------------------------ 1 file changed, 18 insertions(+), 62 deletions(-) diff --git a/Sources/Event.php b/Sources/Event.php index ce8361b54f..e0152ba320 100644 --- a/Sources/Event.php +++ b/Sources/Event.php @@ -570,11 +570,6 @@ public function __set(string $prop, mixed $value): void break; - case 'span': - case 'num_days': - $this->setNumDays($value); - break; - // These computed properties are read-only. case 'tz_abbrev': case 'new': @@ -722,11 +717,6 @@ public function __get(string $prop): mixed $value = $this->start->tz_abbrev; break; - case 'span': - case 'num_days': - $value = $this->getNumDays(); - break; - case 'new': $value = !isset($this->id) || $this->id < 1; break; @@ -833,10 +823,6 @@ public function __isset(string $prop): bool case 'end_iso_gmdate': return property_exists($this, 'end'); - case 'span': - case 'num_days': - return property_exists($this, 'start') && property_exists($this, 'end'); - case 'new': case 'is_selected': case 'href': @@ -1087,48 +1073,6 @@ public static function remove(int $id): void * Internal methods ******************/ - /** - * Gets the number of days across which this event occurs. - * - * For example, if the event starts and ends on the same day, span is 1. - * If the event starts Monday night and ends Wednesday morning, span is 3. - * - * @return int Number of days that this event spans, or 0 on error. - */ - protected function getNumDays(): int - { - if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface) || $this->end->getTimestamp() < $this->start->getTimestamp()) { - return 0; - } - - return ((int) $this->start->diff($this->end)->format('%a')) + ($this->end->format('H') < $this->start->format('H') ? 2 : 1); - } - - /** - * Adjusts the end date so that the number of days across which this event - * occurs will be $num_days. - * - * @param int $num_days The target number of days the event should span. - * If $num_days is set to a value less than 1, no change will be made. - * This method imposes no upper limit on $num_days, but if $num_days - * exceeds the value of Config::$modSettings['cal_maxspan'], other parts - * of this class will impose that limit. - */ - protected function setNumDays(int $num_days): void - { - if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface) || $num_days < 1) { - return; - } - - $current_span = $this->getNumDays(); - - if ($current_span == $num_days) { - return; - } - - $this->end->modify($current_span < $num_days ? '+' : '-' . ($num_days - $current_span) . ' days'); - } - /** * Ensures that the start and end dates have a sane relationship. */ @@ -1145,8 +1089,8 @@ protected function fixEndDate(): void } // If the event is too long, cap it at the max. - if (!empty(Config::$modSettings['cal_maxspan']) && $this->getNumDays() > Config::$modSettings['cal_maxspan']) { - $this->setNumDays(Config::$modSettings['cal_maxspan']); + if (!empty(Config::$modSettings['cal_maxspan']) && $this->start->diff($this->end)->format('%a') > Config::$modSettings['cal_maxspan']) { + $this->end = (clone $this->start)->modify('+' . Config::$modSettings['cal_maxspan'] . ' days'); } } @@ -1432,15 +1376,27 @@ function ($key) { } // Make sure we use valid values for everything - if (!isset($input['end_date'])) { - $input['end_date'] = $input['start_date']; - } - if ($input['allday'] || !isset($input['start_time'])) { $input['allday'] = true; $input['start_time'] = '00:00:00'; } + if (!isset($input['end_date'])) { + if (isset($input['span'])) { + $start = new \DateTimeImmutable($input['start_date'] . (empty($input['allday']) ? ' ' . $input['start_time'] . ' ' . $input['timezone'] : '')); + + $end = $start->modify('+' . max(0, (int) ($input['span'] - 1)) . ' days'); + + $input['end_date'] = $end->format('Y-m-d'); + + if (!$input['allday']) { + $input['end_time'] = $end->format('H:i:s'); + } + } else { + $input['end_date'] = $input['start_date']; + } + } + if ($input['allday'] || !isset($input['end_time'])) { $input['allday'] = true; $input['end_time'] = $input['start_time']; From 6b0aa4b3684b1a4dcaae7b682e3d6e102a09bac8 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 7 Dec 2023 12:54:13 -0700 Subject: [PATCH 04/22] Moves SMF\Event to SMF\Calendar\Event Signed-off-by: Jon Stovell --- Sources/Actions/Calendar.php | 2 +- Sources/Actions/Display.php | 2 +- Sources/Actions/Post.php | 4 +-- Sources/Actions/Post2.php | 2 +- Sources/{ => Calendar}/Event.php | 42 +++++++++++++++++++------------- Sources/Calendar/index.php | 9 +++++++ 6 files changed, 39 insertions(+), 22 deletions(-) rename Sources/{ => Calendar}/Event.php (97%) create mode 100644 Sources/Calendar/index.php diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 38a3a4bfdd..6a03dcf0f3 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -18,10 +18,10 @@ use SMF\Board; use SMF\BrowserDetector; use SMF\Cache\CacheApi; +use SMF\Calendar\Event; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\ErrorHandler; -use SMF\Event; use SMF\IntegrationHook; use SMF\Lang; use SMF\Theme; diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index c234039183..32979cb5aa 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -19,11 +19,11 @@ use SMF\Attachment; use SMF\Board; use SMF\Cache\CacheApi; +use SMF\Calendar\Event; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\Editor; use SMF\ErrorHandler; -use SMF\Event; use SMF\IntegrationHook; use SMF\Lang; use SMF\Msg; diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index 55f690b3c7..c9171e8ce6 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -19,12 +19,12 @@ use SMF\BBCodeParser; use SMF\Board; use SMF\Cache\CacheApi; +use SMF\Calendar\Event; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\Draft; use SMF\Editor; use SMF\ErrorHandler; -use SMF\Event; use SMF\IntegrationHook; use SMF\Lang; use SMF\Msg; @@ -732,7 +732,7 @@ protected function initiateEvent(): void // Start loading up the event info. if (isset($_REQUEST['eventid'])) { - list(Utils::$context['event']) = Event::load((int) $_REQUEST['eventid']); + Utils::$context['event'] = current(Event::load((int) $_REQUEST['eventid'])); } if (!isset(Utils::$context['event']) || !(Utils::$context['event'] instanceof Event)) { diff --git a/Sources/Actions/Post2.php b/Sources/Actions/Post2.php index ba0714c164..86eb07f19a 100644 --- a/Sources/Actions/Post2.php +++ b/Sources/Actions/Post2.php @@ -20,11 +20,11 @@ use SMF\Board; use SMF\BrowserDetector; use SMF\Cache\CacheApi; +use SMF\Calendar\Event; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\Draft; use SMF\ErrorHandler; -use SMF\Event; use SMF\IntegrationHook; use SMF\Lang; use SMF\Logging; diff --git a/Sources/Event.php b/Sources/Calendar/Event.php similarity index 97% rename from Sources/Event.php rename to Sources/Calendar/Event.php index e0152ba320..8246f58585 100644 --- a/Sources/Event.php +++ b/Sources/Calendar/Event.php @@ -13,10 +13,18 @@ declare(strict_types=1); -namespace SMF; +namespace SMF\Calendar; use SMF\Actions\Calendar; +use SMF\ArrayAccessHelper; +use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\ErrorHandler; +use SMF\IntegrationHook; +use SMF\Time; +use SMF\TimeZone; +use SMF\User; +use SMF\Utils; /** * Represents a calendar event. @@ -845,7 +853,7 @@ public function __isset(string $prop): bool /** * Loads events by ID number or by topic. * - * @param int|array $id ID number of the event or topic. + * @param int $id ID number of the event or topic. * @param bool $is_topic If true, $id is the topic ID. Default: false. * @param bool $use_permissions Whether to use permissions. Default: true. * @return array|bool Instances of this class for the loaded events. @@ -1292,21 +1300,21 @@ protected static function standardizeEventOptions(array $input): array $input['timezone'] = $tz->getName(); - foreach (['start', 'end'] as $var) { + foreach (['start', 'end'] as $prefix) { // Input might come as individual parameters... - $year = $input[$var . '_year'] ?? null; - $month = $input[$var . '_month'] ?? null; - $day = $input[$var . '_day'] ?? null; - $hour = $input[$var . '_hour'] ?? null; - $minute = $input[$var . '_minute'] ?? null; - $second = $input[$var . '_second'] ?? null; + $year = $input[$prefix . '_year'] ?? null; + $month = $input[$prefix . '_month'] ?? null; + $day = $input[$prefix . '_day'] ?? null; + $hour = $input[$prefix . '_hour'] ?? null; + $minute = $input[$prefix . '_minute'] ?? null; + $second = $input[$prefix . '_second'] ?? null; // ... or as datetime strings ... - $datetime_string = $input[$var . '_datetime'] ?? null; + $datetime_string = $input[$prefix . '_datetime'] ?? null; // ... or as date strings and time strings. - $date_string = $input[$var . '_date'] ?? null; - $time_string = $input[$var . '_time'] ?? null; + $date_string = $input[$prefix . '_date'] ?? null; + $time_string = $input[$prefix . '_time'] ?? null; // If the date and time were given in individual parameters, combine them. if (empty($time_string) && isset($hour, $minute, $second)) { @@ -1357,16 +1365,16 @@ function ($key) { $time_is_valid = isset($hour, $minute, $second) && $hour >= 0 && $hour < 25 && $minute >= 0 && $minute < 60 && $second >= 0 && $second < 60; // Replace whatever was supplied with our validated strings. - foreach (['year', 'month', 'day', 'hour', 'minute', 'second', 'date', 'time', 'datetime'] as $key) { - unset($input[$var . '_' . $key]); + foreach (['year', 'month', 'day', 'hour', 'minute', 'second', 'date', 'time', 'datetime'] as $var) { + unset($input[$prefix . '_' . $var]); } if ($date_is_valid) { - $input[$var . '_date'] = sprintf('%04d-%02d-%02d', $year, $month, $day); + $input[$prefix . '_date'] = sprintf('%04d-%02d-%02d', $year, $month, $day); } if ($time_is_valid && !$input['allday']) { - $input[$var . '_time'] = sprintf('%02d:%02d:%02d', $hour, $minute, $second); + $input[$prefix . '_time'] = sprintf('%02d:%02d:%02d', $hour, $minute, $second); } } @@ -1383,7 +1391,7 @@ function ($key) { if (!isset($input['end_date'])) { if (isset($input['span'])) { - $start = new \DateTimeImmutable($input['start_date'] . (empty($input['allday']) ? ' ' . $input['start_time'] . ' ' . $input['timezone'] : '')); + $start = new \DateTimeImmutable($input['start_date'] . (empty($input['allday']) ? ' ' . $input['start_time'] . ' ' . $input['timezone'] : '')); $end = $start->modify('+' . max(0, (int) ($input['span'] - 1)) . ' days'); diff --git a/Sources/Calendar/index.php b/Sources/Calendar/index.php new file mode 100644 index 0000000000..976d292448 --- /dev/null +++ b/Sources/Calendar/index.php @@ -0,0 +1,9 @@ + \ No newline at end of file From 28deff237ece56737903c6c27f94c622b98702ba Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 7 Dec 2023 13:14:35 -0700 Subject: [PATCH 05/22] Support displaying multiple events attached to one topic UI doesn't yet support adding multiple events to a topic, but that can come later. Signed-off-by: Jon Stovell --- Sources/Calendar/Event.php | 3 +-- Themes/default/Display.template.php | 2 +- Themes/default/css/index.css | 11 +++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index 8246f58585..e213f17056 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -869,7 +869,6 @@ public static function load(int $id, bool $is_topic = false, bool $use_permissio self::$keep_all = true; $query_customizations['where'][] = 'cal.id_' . ($is_topic ? 'topic' : 'event') . ' = {int:id}'; - $query_customizations['limit'] = 1; $query_customizations['params']['id'] = $id; if ($use_permissions) { @@ -947,7 +946,7 @@ public static function get(string $low_date, string $high_date, bool $use_permis 'cal.start_date <= {date:high_date}', 'cal.end_date >= {date:low_date}', ]; - $order = $query_customizations['order'] ?? []; + $order = $query_customizations['order'] ?? ['cal.start_date']; $group = $query_customizations['group'] ?? []; $limit = $query_customizations['limit'] ?? 0; $params = $query_customizations['params'] ?? [ diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index 68fbed9667..56864dc583 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -180,7 +180,7 @@ function template_main()

', Lang::$txt['calendar_linked_events'], '

-
+
    '; foreach (Utils::$context['linked_calendar_events'] as $event) diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index 3493cbb612..8eb939e6bf 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -3796,6 +3796,17 @@ h3.titlebg, h4.titlebg, .titlebg, h3.subbg, h4.subbg, .subbg { p.information img { vertical-align: middle; } +.information.events { + padding: 0; +} +.information.events ul { + display: flex; + flex-flow: row wrap; +} +.information.events li { + margin: 12px; + flex: 1 0 auto; +} #messageindex .information { border-radius: 0; margin: 0; From 57363c58df59fe7c7e08155c7d991bdcd10d16c4 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 7 Dec 2023 13:06:12 -0700 Subject: [PATCH 06/22] Implements SMF\Calendar\RRule and SMF\Calendar\RecurrenceIterator Signed-off-by: Jon Stovell --- Sources/Calendar/RRule.php | 520 +++++++ Sources/Calendar/RecurrenceIterator.php | 1658 +++++++++++++++++++++++ 2 files changed, 2178 insertions(+) create mode 100644 Sources/Calendar/RRule.php create mode 100644 Sources/Calendar/RecurrenceIterator.php diff --git a/Sources/Calendar/RRule.php b/Sources/Calendar/RRule.php new file mode 100644 index 0000000000..79ccdcc68f --- /dev/null +++ b/Sources/Calendar/RRule.php @@ -0,0 +1,520 @@ + $value) { + $prop = strtolower($prop); + + switch ($prop) { + case 'freq': + $value = strtoupper($value); + + if (!in_array($value, self::FREQUENCIES)) { + continue 2; + } + + break; + + case 'wkst': + $value = strtoupper($value); + + if (!in_array($value, self::WEEKDAYS)) { + continue 2; + } + + break; + + case 'until': + if (strpos($value, 'TZID') !== false) { + foreach (explode(':', $value) as $value_part) { + if (strpos($value_part, 'TZID') !== false) { + $tzid = str_replace('TZID=', '', $value_part); + + if (in_array($tzid, self::UTC_SYNONYMS)) { + $tzid = 'UTC'; + } + + try { + $tz = new \DateTimeZone($tzid); + } catch (\Throwable $e) { + continue 3; + } + } else { + $value = $value_part; + } + } + + $value = (new \DateTimeImmutable(substr($value, 0, -1), $tz))->setTimezone(new \DateTimeZone('UTC')); + + $this->until_type = RecurrenceIterator::TYPE_ABSOLUTE; + } elseif (substr($value, -1) === 'Z') { + $value = new \DateTimeImmutable(substr($value, 0, -1), new \DateTimeZone('UTC')); + + $this->until_type = RecurrenceIterator::TYPE_ABSOLUTE; + } else { + $this->until_type = strpos($value, 'T') !== false ? RecurrenceIterator::TYPE_FLOATING : RecurrenceIterator::TYPE_ALLDAY; + + $value = new \DateTime($value); + } + + break; + + case 'count': + case 'interval': + $value = max(1, (int) $value); + break; + + case 'bysecond': + $value = array_filter( + array_map('intval', explode(',', $value)), + // 60 is allowed because of leap seconds. + fn ($v) => $v >= 0 && $v <= 60, + ); + sort($value); + break; + + case 'byminute': + $value = array_filter( + array_map('intval', explode(',', $value)), + fn ($v) => $v >= 0 && $v < 60, + ); + sort($value); + break; + + case 'byhour': + $value = array_filter( + array_map('intval', explode(',', $value)), + fn ($v) => $v >= 0 && $v < 24, + ); + sort($value); + break; + + case 'byday': + $value = array_filter( + explode(',', $value), + function ($v) { + // Simple case. + if (in_array($v, self::WEEKDAYS)) { + return true; + } + + // E.g: '-1TH' for 'last Thursday of the month'. + return (bool) (preg_match('/^[+-]?\d+(SU|MO|TU|WE|TH|FR|SA)/', $v)); + }, + ); + break; + + case 'bymonthday': + $value = array_filter( + array_map('intval', explode(',', $value)), + fn ($v) => $v >= -31 && $v <= 31 && $v !== 0, + ); + usort( + $value, + function ($a, $b) { + $a += $a < 0 ? 62 : 0; + $b += $b < 0 ? 62 : 0; + + return $a <=> $b; + }, + ); + break; + + case 'byyearday': + $value = array_filter( + array_map('intval', explode(',', $value)), + // 366 allowed because of leap years. + fn ($v) => $v >= -366 && $v <= 366 && $v !== 0, + ); + break; + + case 'byweekno': + $value = array_filter( + array_map('intval', explode(',', $value)), + fn ($v) => $v >= -53 && $v <= 53 && $v !== 0, + ); + break; + + case 'bymonth': + $value = array_filter( + array_map('intval', explode(',', $value)), + fn ($v) => $v >= 1 && $v <= 12, + ); + break; + + case 'bysetpos': + $value = array_map('intval', explode(',', $value)); + break; + + default: + break; + } + + if (property_exists($this, $prop)) { + $this->{$prop} = $value; + } + } + + // Some rule parts are subject to interdependent conditions. + + // BYWEEKNO only applies when the frequency is YEARLY. + if ($this->freq !== 'YEARLY') { + unset($this->byweekno); + } + + // BYYEARDAY never applies with these frequencies. + if (in_array($this->freq, ['DAILY', 'WEEKLY', 'MONTHLY'])) { + unset($this->byyearday); + } + + // BYMONTHDAY never applies when the frequency is WEEKLY. + if ($this->freq === 'WEEKLY') { + unset($this->bymonthday); + } + + // BYDAY can only have integer modifiers in certain cases. + if ( + !empty($this->byday) + && ( + // Can't have integer modifiers with frequencies besides these. + !in_array($this->freq, ['MONTHLY', 'YEARLY']) + // Can't have integer modifiers in combination with BYWEEKNO. + || isset($this->byweekno) + ) + ) { + foreach ($this->byday as $value) { + if (!in_array($value, self::WEEKDAYS)) { + unset($this->byday); + break; + } + } + } + + // BYSETPOS can only be used in conjunction with another BY*** rule part. + if ( + !isset($this->bysecond) + && !isset($this->byminute) + && !isset($this->byhour) + && !isset($this->byday) + && !isset($this->bymonthday) + && !isset($this->byyearday) + && !isset($this->byweekno) + && !isset($this->bymonth) + ) { + unset($this->bysetpos); + } + } + + /** + * Allows this object to be handled like a string. + */ + public function __toString(): string + { + $rrule = []; + + $parts = [ + 'FREQ', + 'INTERVAL', + 'UNTIL', + 'COUNT', + 'BYMONTH', + 'BYWEEKNO', + 'BYYEARDAY', + 'BYMONTHDAY', + 'BYDAY', + 'BYHOUR', + 'BYMINUTE', + 'BYSECOND', + 'BYSETPOS', + 'WKST', + ]; + + foreach ($parts as $part) { + unset($value); + $prop = strtolower($part); + + if (!isset($this->{$prop})) { + continue; + } + + switch ($prop) { + case 'freq': + $value = strtoupper($this->{$prop}); + break; + + // Skip if default. + case 'interval': + $value = $this->{$prop} > 1 ? $this->{$prop} : null; + break; + + // Skip if default or irrelevant. + case 'wkst': + $value = strtoupper($this->wkst); + + if ( + // Skip if default. + $value === 'MO' + // Skip if irrelevant. + || !in_array($this->freq, ['WEEKLY', 'YEARLY']) + || ($this->freq === 'WEEKLY' && empty($this->byday)) + || ($this->freq === 'YEARLY' && empty($this->byweekno)) + ) { + $value = null; + } + break; + + case 'bysecond': + case 'byminute': + case 'byhour': + case 'byday': + case 'bymonthday': + case 'byyearday': + case 'byweekno': + case 'bymonth': + case 'bysetpos': + $value = implode(',', $this->{$prop}); + break; + + // Force the time zone to UTC, just in case someone changed it. + case 'until': + if ($this->until_type === RecurrenceIterator::TYPE_ALLDAY) { + $value = $this->until->setTimezone(new \DateTimeZone('UTC'))->format('Ymd'); + } elseif ($this->until_type === RecurrenceIterator::TYPE_FLOATING) { + $value = $this->until->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\THis'); + } else { + $value = $this->until->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\THis\Z'); + } + break; + + default: + $value = (string) $this->{$prop}; + break; + } + + if (isset($value) && strlen($value) > 0) { + $rrule[] = $part . '=' . $value; + } + } + + return implode(';', $rrule); + } +} + +?> \ No newline at end of file diff --git a/Sources/Calendar/RecurrenceIterator.php b/Sources/Calendar/RecurrenceIterator.php new file mode 100644 index 0000000000..0ed40fcddc --- /dev/null +++ b/Sources/Calendar/RecurrenceIterator.php @@ -0,0 +1,1658 @@ + 'Monday', + 'TU' => 'Tuesday', + 'WE' => 'Wednesday', + 'TH' => 'Thursday', + 'FR' => 'Friday', + 'SA' => 'Saturday', + 'SU' => 'Sunday', + ]; + + /** + * @var array + * + * Used to calculate a default value for $this->view_end. + * + * These are only used when the RRule repeats forever and no value was + * specified for the constructor's $view parameter. + * + * Keys are possible values of $this->rrule->freq. + * Values are the number of times to add $this->frequency_interval to + * $this->view_start in order to calculate $this->view_end. + * + * These defaults are entirely arbitrary choices by the developer. ;) + */ + public const DEFAULT_COUNTS = [ + 'YEARLY' => 10, + 'MONTHLY' => 12, + 'WEEKLY' => 52, + 'DAILY' => 365, + 'HOURLY' => 720, + 'MINUTELY' => 1440, + 'SECONDLY' => 3600, + ]; + + /********************* + * Internal properties + *********************/ + + /** + * @var array + * + * Info about how to process the various BYxxx rule parts (except BYSETPOS). + * + * As RFC 5545 says: + * + * "BYxxx rule parts modify the recurrence in some manner. BYxxx rule parts + * for a period of time that is the same or greater than the frequency + * generally reduce or limit the number of occurrences of the recurrence + * generated. [...] BYxxx rule parts for a period of time less than the + * frequency generally increase or expand the number of occurrences of the + * recurrence." + * + * For more info on these rule parts and how they modify the recurrence, see + * RFC 5545, §3.3.10. + * + * Note: the order of the elements in this array matters. Do not change it. + */ + private array $by = [ + 'bysecond' => [ + 'fmt' => 's', + 'type' => 'int', + 'is_expansion' => false, + ], + 'byminute' => [ + 'fmt' => 'i', + 'type' => 'int', + 'is_expansion' => false, + ], + 'byhour' => [ + 'fmt' => 'H', + 'type' => 'int', + 'is_expansion' => false, + ], + 'byday' => [ + 'fmt' => 'D', + 'type' => 'string', + 'adjust' => '$current_value = strtoupper(substr($current_value, 0, 2));', + 'is_expansion' => false, + ], + 'bymonthday' => [ + 'fmt' => 'j', + 'type' => 'int', + 'is_expansion' => false, + ], + 'byyearday' => [ + 'fmt' => 'z', + 'type' => 'int', + 'adjust' => '$current_value++;', + 'is_expansion' => false, + ], + 'byweekno' => [ + 'fmt' => 'W', + 'type' => 'int', + 'is_expansion' => true, + ], + 'bymonth' => [ + 'fmt' => 'm', + 'type' => 'int', + 'is_expansion' => false, + ], + ]; + + /** + * @var RRule + * + * The recurrence rule for this event. + */ + private RRule $rrule; + + /** + * @var \DateTimeInterface + * + * Date of the first occurrence of the event. + */ + private \DateTimeInterface $dtstart; + + /** + * @var int + * + * The type of event (normal, floating, or all day), as indicated by one of + * this class's TYPE_* constants. + */ + private int $type; + + /** + * @var array + * + * Timestamps of dates to add to the recurrence set. + * These are dates besides the ones generated from the RRule. + */ + private array $rdates = []; + + /** + * @var array + * + * Timestamps of dates to exclude from the recurrence set. + */ + private array $exdates = []; + + /** + * @var \DateInterval + * + * How far to jump ahead for each main iteration of the recurrence rule. + * + * Derived from $this->rrule->freq and $this->rrule->interval. + */ + private \DateInterval $frequency_interval; + + /** + * @var int|float + * + * Used for sanity checks. + * + * Value may change based on $this->rrule->freq and/or $this->rrule->count. + */ + private int|float $max_occurrences; + + /** + * @var \DateTimeImmutable + * + * Occurrences before this date will be skipped when returning results. + */ + private \DateTimeInterface $view_start; + + /** + * @var \DateTimeImmutable + * + * Occurrences after this date will be skipped when returning results. + */ + private \DateTimeInterface $view_end; + + /** + * @var \DateTimeImmutable + * + * Occurrences before this date will not be calculated. + * + * This value is typically one frequency interval before $this->view_start. + */ + private \DateTimeInterface $limit_before; + + /** + * @var \DateTimeImmutable + * + * Occurrences after this date will not be calculated. + * + * This value is typically one frequency interval after $this->view_end. + */ + private \DateTimeInterface $limit_after; + + /** + * @var array + * + * Date string records of all valid occurrences. + */ + private array $occurrences = []; + + /** + * @var array + * + * Date string records of the initial recurrence set generated from the + * RRule before any RDates or ExDates are applied. + */ + private array $rrule_occurrences = []; + + /** + * @var string + * + * \DateTime format to use for the records in $this->occurrences. + */ + private string $record_format = 'Ymd\THis\Z'; + + /** + * @var int + * + * Iterator key. Points to the current element of $this->occurrences when + * iterating over an instance of this class. + */ + private int $key = 0; + + /**************** + * Public methods + ****************/ + + /** + * Constructor. + * + * @param RRule $rrule The recurrence rule for this event. + * + * @param \DateTimeInterface $dtstart Date of the event's first occurrence. + * + * @param ?\DateInterval $view Length of the period for which dates will be + * shown. For example, use \DateInterval('P1M') to show one month's worth + * of occurrences, \DateInterval('P1W') to show one week's worth, etc. + * If null, will be determined automatically. + * + * @param ?\DateTimeInterface $view_start Lower limit for dates to be shown. + * Iterating over the object will never return values before this date. + * If null, will be set to $this->dtstart. + * + * @param ?int $type One of this class's TYPE_* constants, or null to set + * it automatically based on the UNTIL value of the RRule. If this is + * null and the RRule's UNTIL value is null, default is TYPE_ABSOLUTE. + * + * @param ?array $rdates Arbitrary dates to add to the recurrence set. + * Elements must be arrays containing an instance of \DateTimeInterface + * and an optional \DateInterval. + * Used to make exceptions to the general recurrence rule. + * + * @param ?array $exdates Dates to exclude from the recurrence set. + * Elements must be instances of \DateTimeInterface. + * Used to make exceptions to the general recurrence rule. + */ + public function __construct( + RRule $rrule, + \DateTimeInterface $dtstart, + ?\DateInterval $view = null, + ?\DateTimeInterface $view_start = null, + ?int $type = null, + array $rdates = [], + array $exdates = [], + ) { + $this->rrule = $rrule; + + $this->type = isset($type) && in_array($type, range(0, 2)) ? $type : ($this->rrule->until_type ?? self::TYPE_ABSOLUTE); + + $this->dtstart = $this->type === self::TYPE_ABSOLUTE ? \DateTimeImmutable::createFromInterface($dtstart) : \DateTime::createFromInterface($dtstart); + $this->view_start = $this->type === self::TYPE_ABSOLUTE ? \DateTimeImmutable::createFromInterface($view_start ?? $this->dtstart) : \DateTime::createFromInterface($view_start ?? $this->dtstart); + + $this->setFrequencyInterval(); + + // We were given a view duration. + if (isset($view)) { + $this->view_end = (clone $this->view_start)->add($view); + } + // Rule contained an UNTIL value, so use that as our view end. + elseif (isset($this->rrule->until)) { + $this->view_end = $this->type === self::TYPE_ABSOLUTE ? \DateTimeImmutable::createFromInterface($this->rrule->until) : \DateTime::createFromInterface($this->rrule->until); + } + // Figure out the view end based on the count value. + else { + $this->view_end = clone $this->view_start; + + // Add the frequency interval enough times to cover the specified + // number of occurrences, or the default number if unspecified. + for ($i = 0; $i < ($this->rrule->count ?? self::DEFAULT_COUNTS[$this->rrule->freq]); $i++) { + $this->view_end = $this->view_end->add($this->frequency_interval); + } + } + + // If given a view duration and an UNTIL value, end at whichever comes first. + if (isset($this->rrule->until) && $this->rrule->until < $this->view_end) { + $this->view_end = $this->type === self::TYPE_ABSOLUTE ? \DateTimeImmutable::createFromInterface($this->rrule->until) : \DateTime::createFromInterface($this->rrule->until); + } + + // Set some limits. + $this->limit_before = !empty($this->rrule->bysetpos) ? (clone $this->dtstart)->sub($this->frequency_interval) : $this->dtstart; + $this->limit_after = (clone $this->view_end)->add($this->frequency_interval); + $this->max_occurrences = $this->rrule->count ?? self::DEFAULT_COUNTS[$this->rrule->freq] * 1000; + + // Figure out the appropriate way to record the occurrences. + switch ($this->type) { + case self::TYPE_ALLDAY: + $this->record_format = 'Ymd'; + break; + + case self::TYPE_FLOATING: + $this->record_format = 'Ymd\THis'; + break; + + default: + $this->record_format = 'Ymd\THis\Z'; + break; + } + + // Finalize values in $this->by. + switch ($this->rrule->freq) { + case 'YEARLY': + $this->by['bymonth']['is_expansion'] = true; + $this->by['byyearday']['is_expansion'] = true; + $this->by['bymonthday']['is_expansion'] = true; + + if (empty($this->rrule->bymonthday) && empty($this->rrule->byyearday)) { + $this->by['byday']['is_expansion'] = true; + } + $this->by['byhour']['is_expansion'] = true; + $this->by['byminute']['is_expansion'] = true; + $this->by['bysecond']['is_expansion'] = true; + break; + + case 'MONTHLY': + $this->by['bymonthday']['is_expansion'] = true; + + if (empty($this->rrule->bymonthday)) { + $this->by['byday']['is_expansion'] = true; + } + $this->by['byhour']['is_expansion'] = true; + $this->by['byminute']['is_expansion'] = true; + $this->by['bysecond']['is_expansion'] = true; + break; + + case 'WEEKLY': + $this->by['byday']['is_expansion'] = true; + $this->by['byhour']['is_expansion'] = true; + $this->by['byminute']['is_expansion'] = true; + $this->by['bysecond']['is_expansion'] = true; + break; + + case 'DAILY': + $this->by['byhour']['is_expansion'] = true; + $this->by['byminute']['is_expansion'] = true; + $this->by['bysecond']['is_expansion'] = true; + break; + + case 'HOURLY': + $this->by['byminute']['is_expansion'] = true; + $this->by['bysecond']['is_expansion'] = true; + break; + + case 'MINUTELY': + $this->by['bysecond']['is_expansion'] = true; + break; + + case 'SECONDLY': + break; + } + + // First, calculate occurrence dates based on the recurrence rule. + $this->calculate(); + + // Remember the initially calculated recurrence set. We may need it later. + $this->rrule_occurrences = $this->occurrences; + + // Next, add any manually specified dates. + foreach ($rdates as $rdate) { + if ($rdate[0] instanceof \DateTimeInterface) { + $this->add(...$rdate); + } + } + + // Finally, remove any excluded dates. + foreach ($exdates as $exdate) { + if ($exdate instanceof \DateTimeInterface) { + $this->remove($exdate); + } + } + } + + /** + * Returns a copy of $this->dtstart. + */ + public function getDtStart(): \DateTimeInterface + { + return clone $this->dtstart; + } + + /** + * Returns a copy of the recurrence rule. + */ + public function getRRule(): RRule + { + return clone $this->rrule; + } + + /** + * Returns a copy of $this->rdates. + */ + public function getRDates(): array + { + return $this->rdates; + } + + /** + * Returns a copy of $this->exdates. + */ + public function getExDates(): array + { + return $this->exdates; + } + + /** + * Returns occurrences generated by the RRule only. + */ + public function getRRuleOccurrences(): array + { + return $this->rrule_occurrences; + } + + /** + * Adds an arbitrary date to the recurrence set. + * + * Used for making exceptions to the general recurrence rule. + * Note that calling this method always rewinds the iterator key. + * + * @param \DateTimeInterface $date The date to add. + * @param ?\DateInterval $duration Optional duration for this occurrence. + * Only necessary if the duration for this occurrence differs from the + * usual duration of the event. + */ + public function add(\DateTimeInterface $date, ?\DateInterval $duration = null): void + { + $this->rewind(); + + if ($date < $this->view_start || $date >= $this->view_end) { + return; + } + + $string = (clone $date)->setTimezone(new \DateTimeZone('UTC'))->format($this->record_format); + + if (in_array($string, $this->occurrences)) { + return; + } + + // Re-adding a date that was previously removed. + if (in_array($string, $this->exdates)) { + $this->exdates = array_values(array_diff($this->exdates, [$string])); + } + // Adding a new date. + else { + if (isset($duration)) { + $string .= '/' . (string) ($duration instanceof TimeInterval ? $duration : TimeInterval::createFromDateInterval($duration)); + } + + $this->rdates[] = $string; + + // Increment max_occurrences so that we don't drop any occurrences + // generated from the RRule. + $this->max_occurrences++; + } + + $this->record($date); + } + + /** + * Removes a date from the recurrence set. + * + * Used for making exceptions to the general recurrence rule. + * Note that calling this method always rewinds the iterator key. + * + * @param \DateTimeInterface $date The date to remove. + */ + public function remove(\DateTimeInterface $date): void + { + $this->rewind(); + + $string = $date->format($this->record_format); + + $is_rdate = false; + + foreach ($this->rdates as $key => $rdate) { + if (str_starts_with($rdate, $string)) { + $is_rdate = true; + unset($this->rdates[$key]); + $this->rdates = array_values($this->rdates); + } + } + + if (!$is_rdate && !in_array($string, $this->exdates)) { + $this->exdates[] = $string; + } + + $this->occurrences = array_values(array_diff($this->occurrences, [$string])); + } + + /** + * Checks whether the given date/time occurs in the recurrence set. + * + * @param \DateTimeInterface $date A date. + * @return bool Whether the date occurs in the recurrence set. + */ + public function dateOccurs(\DateTimeInterface $date): bool + { + return in_array((clone $date)->setTimezone(new \DateTimeZone('UTC'))->format($this->record_format), $this->occurrences); + } + + /** + * Moves the iterator key one step forward, and then returns a + * DateTimeInterface object for the newly selected element in the event's + * recurrence set. + * + * @return \DateTimeInterface|false The next occurrence, or false if there + * are no more occurrences. + */ + public function getNext(): \DateTimeInterface|bool + { + $this->next(); + + return $this->current(); + } + + /** + * Moves the iterator key one step backward, and then returns a + * DateTimeInterface object for the newly selected element in the event's + * recurrence set. + * + * @return \DateTimeInterface|false The previous occurrence, or false if + * there is no previous occurrence. + */ + public function getPrev(): \DateTimeInterface|bool + { + $this->prev(); + + return $this->current(); + } + + /** + * Returns a \DateTimeInterface object for the currently selected element + * in the event's recurrence set. + * + * If $this->type is self::TYPE_FLOATING or self::TYPE_ALLDAY, the returned + * object will be a mutable \DateTime instance so that its time zone can be + * changed to the viewing user's current time zone. + * + * Otherwise, the returned object will a \DateTimeImmutable instance. + * + * @return \DateTimeInterface|false The date of the occurrence, or false on + * error. + */ + public function current(): \DateTimeInterface|bool + { + if (!$this->valid()) { + return false; + } + + // For TYPE_ABSOLUTE, return a \DateTimeImmutable object. + if ($this->type === self::TYPE_ABSOLUTE) { + $occurrence = new \DateTimeImmutable($this->occurrences[$this->key]); + $occurrence = $occurrence->setTimezone($this->dtstart->getTimezone()); + } + // For TYPE_FLOATING and TYPE_ALLDAY, return a \DateTime object. + else { + $occurrence = new \DateTime( + $this->occurrences[$this->key], + $this->dtstart->getTimezone(), + ); + } + + return $occurrence; + } + + /** + * Checks whether the current value of the iterator key is valid. + * + * @return bool Whether the current value of the iterator key is valid. + */ + public function valid(): bool + { + return isset($this->occurrences[$this->key]); + } + + /** + * Get the current value of the iterator key. + * + * @return int The current value of the iterator key. + */ + public function key(): int + { + return $this->key; + } + + /** + * Moves the iterator key one step forward. + */ + public function next(): void + { + $this->key++; + } + + /** + * Moves the iterator key one step backward. + */ + public function prev(): void + { + $this->key--; + + $this->key = max(0, $this->key); + } + + /** + * Moves the iterator key back to the beginning. + */ + public function rewind(): void + { + $this->key = 0; + } + + /** + * Moves the iterator key to the end. + */ + public function end(): void + { + $this->key = !empty($this->occurrences) ? max(array_keys($this->occurrences)) : 0; + } + + /** + * Moves the iterator key to a specific position. + * + * If the requested key is invalid, will go to the closest valid one. + * + * @return int The new value of the iterator key. + */ + public function setKey(int $key): int + { + // Clamp to valid range. + $this->key = min(max($key, 0), (!empty($this->occurrences) ? max(array_keys($this->occurrences)) : 0)); + + // If still not valid, find one that is. + while (!$this->valid() && $this->key > 0) { + $this->prev(); + } + + return $this->key; + } + + /** + * Finds the iterator key that corresponds to the requested value. + * + * @param string $needle The value to search for. + * @return int|false The key for the requested value, or false on error. + */ + public function search(string $needle): int|bool + { + return array_search($needle, $this->occurrences); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Calculates the week number of a date according to RFC 5545. + */ + public static function calculateWeekNum(\DateTimeInterface $current, string $wkst): int + { + // If $wkst is Monday, we can skip the extra work. + if ($wkst === 'MO') { + return (int) $current->format('W'); + } + + $temp = \DateTime::createFromInterface($current); + + $wkst_diff = array_search($wkst, array_keys(self::WEEKDAY_NAMES)); + $wkst_diff = ($wkst_diff >= 5 ? $wkst_diff - 7 : $wkst_diff) * -1; + + $temp->modify(sprintf('%1$+d days', $wkst_diff)); + + $weeknum = $temp->format('W'); + + return (int) $weeknum; + } + + /****************** + * Internal methods + ******************/ + + /** + * Sets the value of $this->frequency_interval. + */ + private function setFrequencyInterval(): void + { + $interval_string = 'P'; + + if (in_array($this->rrule->freq, ['HOURLY', 'MINUTELY', 'SECONDLY'])) { + $interval_string .= 'T'; + } + + $interval_string .= $this->rrule->interval; + + $interval_string .= substr($this->rrule->freq, 0, 1); + + $this->frequency_interval = new \DateInterval($interval_string); + } + + /** + * Adds an occurrence to $this->occurrences. + * + * @return bool True if the occurrence is a new addition; false if it is + * a duplicate or out of bounds. + */ + private function record(\DateTimeInterface $occurrence): bool + { + // If it is too late, don't add it. + if ($occurrence > $this->limit_after) { + return false; + } + + $string = (clone $occurrence)->setTimezone(new \DateTimeZone('UTC'))->format($this->record_format); + + // If it is already in the array, don't add it. + if (in_array($string, $this->occurrences)) { + return false; + } + + // Add and sort. + $this->occurrences[] = $string; + sort($this->occurrences); + + // If the RRule contains a BYSETPOS rule part, we can't truncate yet. + if (!empty($this->rrule->bysetpos)) { + return true; + } + + // If we don't need to truncate the array, return true now. + if ($this->max_occurrences >= count($this->occurrences)) { + return true; + } + + // We have too many occurrences, so truncate the array. + array_splice($this->occurrences, $this->max_occurrences); + + // Is this occurrence still in the array? + return in_array($string, $this->occurrences); + } + + /** + * Calculates occurrences based on the recurrence rule. + */ + private function calculate(): void + { + if ($this->view_end < $this->view_start) { + return; + } + + $current = \DateTime::createFromInterface($this->dtstart); + + $this->jumpToVisible($current); + + $max = $this->max_occurrences; + + // Calculate all the occurrences and store them in $this->occurrences. + while ( + $current <= $this->limit_after + && ( + !empty($this->rrule->bysetpos) + || count($this->occurrences) < $max + ) + ) { + if ($current < $this->view_start) { + $this->nextInterval($current); + + // If $this->max_occurrences was derived from an RRule's COUNT, + // then skipped occurrences still count against the maximum. + if (!empty($this->rrule->count)) { + $max--; + } + + continue; + } + + // Get the expansions. + $expansions = []; + + foreach ($this->expand($current) as $expanded) { + if ($this->limit($expanded)) { + $this->record($expanded); + } + + // Failsafe if we get stuck in a loop. + $formatted = $expanded->format($this->record_format); + + if (($expansions[$formatted] = ($expansions[$formatted] ?? 0) + 1) > 2) { + break; + } + } + + if ($this->limit($current)) { + $this->record($current); + } + + // Step forward one frequency interval. + $this->nextInterval($current); + } + + // Apply BYSETPOS limitation. + $this->limitBySetPos(); + + // Final cleanup. + $view_start = $this->view_start->format($this->record_format); + $view_end = $this->view_end->format($this->record_format); + + $this->occurrences = array_values(array_filter( + $this->occurrences, + fn ($occurrence) => $view_start <= $occurrence && $view_end >= $occurrence, + )); + + if ($this->max_occurrences < count($this->occurrences)) { + array_splice($this->occurrences, $this->max_occurrences); + } + } + + /** + * If possible, move $current ahead in order to skip values before + * $this->view_start. + * + * @param \DateTime &$current + */ + private function jumpToVisible(\DateTime &$current): void + { + if ( + // Don't jump ahead if $current is aleady in the visible range. + $current >= $this->view_start + // Can't jump ahead if recurrence rule requires counting the occurrences. + || isset($this->rrule->count) + ) { + return; + } + + // Special handling for WEEKLY. + if ($this->rrule->freq === 'WEEKLY') { + $weekdays = !empty($this->rrule->byday) ? array_map(fn ($weekday) => substr($weekday, -2), $this->rrule->byday) : [strtoupper(substr($current->format('D'), 0, 2))]; + + $current->setDate( + (int) $this->view_start->format('Y'), + (int) $this->view_start->format('m'), + (int) $this->view_start->format('d'), + ); + + while (!in_array(strtoupper(substr($current->format('D'), 0, 2)), $weekdays)) { + $current->modify('-1 day'); + } + + return; + } + + // Everything else. + foreach ($this->by as $prop => $info) { + if (!$info['is_expansion']) { + continue; + } + + if (!empty($this->rrule->{$prop}) && count($this->rrule->{$prop}) > 1) { + return; + } + } + + switch ($this->rrule->freq) { + case 'SECONDLY': + $s = (int) $this->view_start->format('s'); + // no break + + case 'MINUTELY': + $i = (int) $this->view_start->format('i'); + // no break + + case 'HOURLY': + $h = (int) $this->view_start->format('h'); + // no break + + case 'DAILY': + $d = (int) $this->view_start->format('d'); + // no break + + case 'MONTHLY': + $m = (int) $this->view_start->format('m'); + // no break + + case 'YEARLY': + $y = (int) $this->view_start->format('Y'); + } + + $current->setDate($y, $m ?? (int) $current->format('m'), $d ?? (int) $current->format('d')); + + if (isset($h)) { + $current->setTime($h, $i ?? (int) $current->format('i'), $s ?? (int) $current->format('s')); + } + } + + /** + * Move $current ahead to the next interval. + * + * Handles months of varying lengths according to RFC 5545's rules. + * + * @param \DateTime &$current + */ + private function nextInterval(\DateTime &$current): void + { + // Monthly is complicated. + if ($this->rrule->freq == 'MONTHLY') { + $months_to_add = 0; + $next_monthday = !empty($this->rrule->bymonthday) ? 1 : (int) $current->format('d'); + $test = \DateTimeImmutable::createFromMutable($current); + + do { + $months_to_add += $this->rrule->interval; + $next = clone $current; + $next->setDate((int) $next->format('Y'), (int) $next->format('m') + $months_to_add, (int) $next_monthday); + } while ($test->setDate((int) $test->format('Y'), (int) $test->format('m') + $months_to_add, (int) $next_monthday)->format('m') % 12 != (($test->format('m') + $months_to_add) % 12)); + + $current = $next; + } + // Everything else is straightforward. + else { + $current->add($this->frequency_interval); + } + } + + /** + * Checks whether a calculated occurrence should be included based on BYxxx + * rule parts in the RRule. + * + * Limitations cause occurrences that would normally occur according the + * RRule's stated frequency to be skipped. For example, if the frequency is + * set to HOURLY, that normally means that the event recurs on minute 0 of + * every hour (or whatever minute was set in DTSTART) of every day. But if + * the BYDAY rule part is set to 'TU,TH', then occurrences are limited so + * that the event recurs only at every hour during Tuesdays and Thursdays. + * Occurrences that happen on any other day of the week are skipped. + * + * @param \DateTime $current A occurrence whose value we want to check. + * @return bool Whether the occurrence is allowed. + */ + private function limit(\DateTime $current): bool + { + if ($current < $this->limit_before || $current > $this->limit_after) { + return false; + } + + $valid = true; + + foreach ($this->by as $prop => $info) { + if (empty($this->rrule->{$prop})) { + continue; + } + + // Get the current value. + $current_value = $current->format($info['fmt']); + + // Coerce the value to the expected type. + settype($current_value, $info['type']); + + // Make any necessary adjustment to the value. + if (!empty($info['adjust'])) { + eval($info['adjust']); + } + + // What are the allowed values? + $allowed_values = $this->rrule->{$prop}; + + // BYDAY values could have numerical modifiers prepended to them. + // We only want the plain weekday abbrevations here. + if ($prop === 'byday') { + foreach ($allowed_values as &$allowed_value) { + $allowed_value = substr($allowed_value, -2); + } + } + + // These types of values can be negative to indicate that they are + // counting from the end. Convert to real values. + switch ($prop) { + case 'byyearday': + foreach ($allowed_values as &$allowed_value) { + if ($allowed_value < 0) { + $allowed_value += $current->format('L') ? 367 : 366; + } + } + break; + + case 'bymonthday': + foreach ($allowed_values as &$allowed_value) { + if ($allowed_value < 0) { + $allowed_value += $current->format('t') + 1; + } + } + break; + + case 'byweekno': + // Are there 52 or 53 numbered weeks in this year? + // Checking Dec 28 using a Monday for our week start + // will always give us the right answer. + $num_weeks = (new \DateTime($current->format('Y') . '-12-28'))->format('W'); + + foreach ($allowed_values as &$allowed_value) { + if ($allowed_value < 0) { + $allowed_value += $num_weeks + 1; + } + } + break; + } + + $valid &= in_array($current_value, $allowed_values); + } + + return (bool) $valid; + } + + /** + * Applies BYSETPOS limitation to the reccurrence set. + */ + private function limitBySetPos(): void + { + if (empty($this->rrule->bysetpos)) { + return; + } + + switch ($this->rrule->freq) { + case 'YEARLY': + $pattern = '/^(\d{4})/'; + break; + + case 'MONTHLY': + $pattern = '/^\d{4}(\d{2})/'; + break; + + case 'DAILY': + $pattern = '/^\d{6}(\d{2})/'; + break; + + case 'HOURLY': + $pattern = '/T(\d{2})/'; + break; + + case 'MINUTELY': + $pattern = '/T\d{2}(\d{2})/'; + break; + + case 'SECONDLY': + $pattern = '/T\d{4}(\d{2})/'; + break; + } + + $groups = []; + + $prev_val = null; + $group_key = -1; + + foreach ($this->occurrences as $string) { + // Weekly. + if (!isset($pattern)) { + $val = self::calculateWeekNum(new \DateTimeImmutable($string), $this->rrule->wkst); + } + // Everything else. + else { + preg_match($pattern, $string, $matches); + $val = $matches[1]; + } + + if ($val !== $prev_val) { + $group_key++; + } + + $groups[$group_key][] = $string; + + $prev_val = $val; + } + + // BYSETPOS starts with an index of 1, not 0, for positive values, + // so we have to adjust it. + $bysetpos = array_map( + fn ($setpos) => $setpos < 0 ? (int) $setpos : (int) $setpos - 1, + $this->rrule->bysetpos, + ); + + $occurrences = []; + + foreach ($groups as $group_key => $strings) { + foreach ($bysetpos as $setpos) { + $occurrences = array_merge($occurrences, array_slice($strings, $setpos, 1)); + } + } + + $this->occurrences = $occurrences; + } + + /** + * Finds additional occurrences based on any BYxxx rule parts in the RRule. + * + * Expansions are occurrences that happen inside one iteration of the + * RRule's stated frequency. For example, if the frequency is set to HOURLY, + * that normally means that the event recurs on minute 0 of every hour (or + * whatever minute was set in DTSTART). But if the RRule's BYMINUTE property + * is set to '0,30', then the occurrences are expanded so that the event + * recurs at minute 0 and minute 30 of every hour. + * + * @param \DateTime $current An occurrence of the event to expand. + * @param string $break_after Name of a $this->by element. Used during + * recursive calls to this method. + * @return Generator<\DateTimeImmutable> + */ + private function expand(\DateTime $current, ?string $break_after = null): \Generator + { + foreach ($this->by as $prop => $info) { + // Do the expansions. + if (!empty($this->rrule->{$prop}) && $info['is_expansion']) { + $temp = \DateTime::createFromInterface($current); + + switch ($prop) { + case 'bymonth': + foreach ($this->rrule->{$prop} as $value) { + if ($value != $temp->format('m')) { + $temp->setDate((int) $temp->format('Y'), (int) $value, (int) $temp->format('d')); + + yield $temp; + + foreach ($this->expand($temp, 'byweekno') as $temp2) { + yield $temp2; + } + } + } + break; + + case 'byweekno': + foreach ($this->rrule->{$prop} as $value) { + // Unfortunately, we can't use PHP's interpretation + // of week numbering. PHP always numbers weeks based + // on a Monday start. In contrast, RFC 5545 allows + // arbitrary week starts and adjusts the week + // numbering based on that. So we have to figure it + // out manually. + $temp->setDate((int) $temp->format('Y'), 1, 1); + + $first_wkst = 1; + + while (strtoupper(substr($temp->format('D'), 0, 2)) !== $this->rrule->wkst) { + $temp->modify('+ 1 day'); + $first_wkst++; + } + + if ($first_wkst >= 4) { + $temp->modify('- 7 days'); + } + + $weeknum = 1; + + while (++$weeknum < $value) { + $temp->modify('+ 7 days'); + } + + yield $temp; + + foreach ($this->expand($temp, 'byyearday') as $temp2) { + yield $temp2; + } + } + break; + + case 'byyearday': + $current_value = $current->format('z'); + eval($info['adjust']); + + foreach ($this->rrule->{$prop} as $value) { + if ($value != $current_value) { + $temp = \DateTime::createFromFormat('Y z H:i:s e', $temp->format('Y ') . ($value - 1) . $temp->format(' H:i:s e')); + + yield $temp; + + foreach ($this->expand($temp, 'bymonthday') as $temp2) { + yield $temp2; + } + } + } + break; + + case 'bymonthday': + foreach ($this->rrule->{$prop} as $value) { + if ($value < 0) { + $value += $temp->format('t') + 1; + } + + if ($value != $temp->format('d')) { + $temp->setDate((int) $temp->format('Y'), (int) $temp->format('m'), (int) $value); + + yield $temp; + + foreach ($this->expand($temp, 'byday') as $temp2) { + yield $temp2; + } + } + } + break; + + case 'byday': + $current_value = $temp->format('D'); + eval($info['adjust']); + + // Special handling for yearly. + if ($this->rrule->freq === 'YEARLY') { + if (!empty($this->rrule->bymonth)) { + foreach ($this->expandMonthByDay($temp, $this->rrule->byday, $current_value) as $temp2) { + yield $temp2; + } + } else { + foreach ($this->expandYearByDay($temp, $this->rrule->byday, $current_value) as $temp2) { + yield $temp2; + } + } + } + // Special handling for monthly. + elseif ($this->rrule->freq === 'MONTHLY') { + foreach ($this->expandMonthByDay($temp, $this->rrule->byday, $current_value) as $temp2) { + yield $temp2; + } + } + // Special handling for weekly. + elseif ($this->rrule->freq === 'WEEKLY') { + $weeknum = self::calculateWeekNum($temp, $this->rrule->wkst); + + // Move temp to start of week. + $temp->modify('+ 1 day'); + $temp->modify('previous ' . self::WEEKDAY_NAMES[$this->rrule->wkst] . ' ' . $temp->format('H:i:s e')); + + $temp_value = strtoupper(substr($temp->format('D'), 0, 2)); + + foreach ($this->sortWeekdays($this->rrule->byday) as $value) { + if ($value != $temp_value) { + $temp->modify('next ' . self::WEEKDAY_NAMES[$value] . ' ' . $temp->format('H:i:s e')); + } + + if (self::calculateWeekNum($temp, $this->rrule->wkst) === $weeknum) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + yield $temp2; + } + } + } + } + // Everything else. + else { + foreach ($this->rrule->byday as $value) { + if ($value != $current_value) { + $temp->modify('next ' . self::WEEKDAY_NAMES[$value] . ' ' . $temp->format('H:i:s e')); + + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + yield $temp2; + } + } + } + } + break; + + case 'byhour': + foreach ($this->rrule->byhour as $value) { + if ($value != $temp->format('H')) { + $temp->setTime((int) $value, (int) $temp->format('i'), (int) $temp->format('s')); + + yield $temp; + + foreach ($this->expand($temp, 'byminute') as $temp2) { + yield $temp2; + } + } + } + break; + + case 'byminute': + foreach ($this->rrule->byminute as $value) { + if ($value != $temp->format('i')) { + $temp->setTime((int) $temp->format('H'), (int) $value, (int) $temp->format('s')); + + yield $temp; + + foreach ($this->expand($temp, 'bysecond') as $temp2) { + yield $temp2; + } + } + } + break; + + case 'bysecond': + foreach ($this->rrule->bysecond as $value) { + if ($value != $temp->format('s')) { + $temp->setTime((int) $temp->format('H'), (int) $temp->format('i'), (int) $value); + + yield $temp; + } + } + break; + } + } + + if (!empty($break_after) && $break_after == $prop) { + break; + } + } + } + + /** + * Used when expanding an occurrence that is part of a monthly recurrence + * set that has a byday rule, or part of a yearly recurrence set that has + * both a bymonth rule and a byday rule. + * + * @param \DateTime $current An occurrence of the event to expand. + * @param array $expansion_values Values from the byday rule. + * @param string The abbreviated name of the $current occurrence's weekday. + * @return Generator<\DateTimeImmutable> + */ + private function expandMonthByDay(\DateTime $current, array $expansion_values, string $current_value): \Generator + { + $upperlimit = clone $current; + + if ($this->frequency_interval->m === 1) { + $upperlimit->add($this->frequency_interval); + } elseif (!empty($this->rrule->bymonth) && in_array((($upperlimit->format('m') + 1) % 12), $this->rrule->bymonth)) { + $upperlimit->modify('last day of ' . $upperlimit->format('F H:i:s e')); + $upperlimit->modify('+ 1 day'); + } else { + $upperlimit->setDate((int) $upperlimit->format('Y'), (int) $upperlimit->format('m'), 1); + $upperlimit->modify('last day of ' . $upperlimit->format('F H:i:s e')); + } + + $expansion_values = $this->sortWeekdays($expansion_values); + + foreach ($expansion_values as $k => $v) { + // Separate out the numerical modifer (if any) from the day name. + preg_match('/^([+-]?\d*)(MO|TU|WE|TH|FR|SA|SU)?/', $v, $matches); + + $expansion_values[$k] = [ + 'modifier' => (int) ($matches[1] ?? 0), + 'weekday' => $matches[2], + ]; + } + + $temp = clone $current; + + $key = 0; + $i = 0; + + while ($temp <= $upperlimit) { + // Positive modifer means nth weekday of the month. + // E.g.: '2TH' means the second Thursday. + if ($expansion_values[$key]['modifier'] > 0) { + // To work nicely with PHP's parsing of 'next ', + // go to last day of previous month, then walk forward. + $temp->setDate((int) $temp->format('Y'), (int) $temp->format('m'), 1); + $temp->modify('- 1 day'); + + // Go to first occurrence of the weekday in the month. + $temp->modify('next ' . self::WEEKDAY_NAMES[$expansion_values[$key]['weekday']] . ' ' . $temp->format('H:i:s e')); + + // Move forward to the requested occurrence. + if ($expansion_values[$key]['modifier'] > 1) { + $temp->modify('+ ' . (($expansion_values[$key]['modifier'] - 1) * 7) . ' days'); + } + + if ($temp <= $upperlimit && $temp >= $this->limit_before) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + if ($temp2 <= $upperlimit && $temp2 >= $this->limit_before) { + yield $temp2; + } + } + } + + if ($key === count($expansion_values) - 1) { + break; + } + } + // Negative modifer means nth last weekday of the month. + // E.g.: '-2TH' means the second last Thursday. + elseif ($expansion_values[$key]['modifier'] < 0) { + // To work nicely with PHP's parsing of 'previous ', + // go to first day of next month, then walk backward. + $temp->setDate((int) $temp->format('Y'), (int) $temp->format('m') + 1, 1); + + // Go to last occurrence of the weekday in the month. + $temp->modify('previous ' . self::WEEKDAY_NAMES[$expansion_values[$key]['weekday']] . ' ' . $temp->format('H:i:s e')); + + // Move backward to the requested occurrence. + if ($expansion_values[$key]['modifier'] < -1) { + $temp->modify('- ' . ((abs($expansion_values[$key]['modifier']) - 1) * 7) . ' days'); + } + + if ($temp <= $upperlimit && $temp >= $this->limit_before) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + if ($temp2 <= $upperlimit && $temp2 >= $this->limit_before) { + yield $temp2; + } + } + } + + if ($key === count($expansion_values) - 1) { + break; + } + } + // No modifer means every matching weekday. + // E.g.: 'TH' means every Thursday. + else { + // On the first iteration of this loop only, go to the last day + // of the previous month. + if ($i === 0) { + $temp->setDate((int) $temp->format('Y'), (int) $temp->format('m'), 1); + $temp->modify('- 1 day'); + } + + $temp->modify('+ 1 day'); + + if ($temp <= $upperlimit && $temp >= $this->limit_before) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + if ($temp2 <= $upperlimit && $temp2 >= $this->limit_before) { + yield $temp2; + } + } + } + } + + $key++; + $key %= count($expansion_values); + $i++; + } + } + + /** + * Used when expanding an occurrence that is part of a yearly recurrence + * set that has a byday rule but not a bymonth rule. + * + * @param \DateTime $current An occurrence of the event to expand. + * @param array $expansion_values Values from the byday rule. + * @param string The abbreviated name of the $current occurrence's weekday. + * @return Generator<\DateTimeImmutable> + */ + private function expandYearByDay(\DateTime $current, array $expansion_values, string $current_value): \Generator + { + $upperlimit = clone $current; + $upperlimit->add($this->frequency_interval); + + $expansion_values = $this->sortWeekdays($expansion_values); + + foreach ($expansion_values as $k => $v) { + // Separate out the numerical modifer (if any) from the day name. + preg_match('/^([+-]?\d*)(MO|TU|WE|TH|FR|SA|SU)?/', $v, $matches); + + $expansion_values[$k] = [ + 'modifier' => (int) ($matches[1] ?? 0), + 'weekday' => $matches[2], + ]; + } + + $temp = clone $current; + + $key = 0; + + while ($temp <= $upperlimit) { + // Positive modifer means nth weekday of the year. + // E.g.: '2TH' means the second Thursday. + if ($expansion_values[$key]['modifier'] > 0) { + // To work nicely with PHP's parsing of 'next ', + // go to last day of previous year, then walk forward. + $temp->setDate((int) $temp->format('Y'), 1, 1); + $temp->modify('- 1 day'); + + // Go to first occurrence of the weekday in the year. + $temp->modify('next ' . self::WEEKDAY_NAMES[$expansion_values[$key]['weekday']] . ' ' . $temp->format('H:i:s e')); + + // Move forward to the requested occurrence. + if ($expansion_values[$key]['modifier'] > 1) { + $temp->modify('+ ' . (($expansion_values[$key]['modifier'] - 1) * 7) . ' days'); + } + + if ($temp <= $upperlimit) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + if ($temp2 <= $upperlimit) { + yield $temp2; + } + } + } + } + // Negative modifer means nth last weekday of the year. + // E.g.: '-2TH' means the second last Thursday. + elseif ($expansion_values[$key]['modifier'] < 0) { + // To work nicely with PHP's parsing of 'previous ', + // go to first day of next year, then walk backward. + $temp->setDate((int) $temp->format('Y'), 12, 31); + $temp->modify('+ 1 day'); + + // Go to last occurrence of the weekday in the year. + $temp->modify('previous ' . self::WEEKDAY_NAMES[$expansion_values[$key]['weekday']] . ' ' . $temp->format('H:i:s e')); + + // Move backward to the requested occurrence. + if ($expansion_values[$key]['modifier'] < -1) { + $temp->modify('- ' . ((abs($expansion_values[$key]['modifier']) - 1) * 7) . ' days'); + } + + if ($temp <= $upperlimit) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + if ($temp2 <= $upperlimit) { + yield $temp2; + } + } + } + } + // No modifer means every matching weekday. + // E.g.: 'TH' means every Thursday. + else { + $temp->modify('next ' . self::WEEKDAY_NAMES[$expansion_values[$key]['weekday']] . ' ' . $temp->format('H:i:s e')); + + if ($temp <= $upperlimit) { + yield $temp; + + foreach ($this->expand($temp, 'byhour') as $temp2) { + if ($temp2 <= $upperlimit) { + yield $temp2; + } + } + } + } + + $key++; + $key %= count($expansion_values); + } + } + + /** + * Sorts an array of weekday abbreviations so that $this->rrule->wkst is + * always the first item. + * + * @param array $weekdays An array of weekday abbreviations. + * @return array Sorted version of $weekdays. + */ + private function sortWeekdays(array $weekdays): array + { + $weekday_abbrevs = array_keys(self::WEEKDAY_NAMES); + + while (current($weekday_abbrevs) !== $this->rrule->wkst) { + $temp = array_shift($weekday_abbrevs); + $weekday_abbrevs[] = $temp; + } + + // Handle strings with numerical modifers correctly. + $temp = []; + + foreach ($weekdays as $weekday) { + $temp[substr($weekday, -2)][] = $weekday; + } + + // Remove weekday abbreviations that aren't in $weekdays. + $weekday_abbrevs = array_intersect($weekday_abbrevs, array_keys($temp)); + + // Rebuild $weekdays. + $weekdays = []; + + foreach ($weekday_abbrevs as $abbrev) { + $weekdays = array_merge($weekdays, $temp[$abbrev]); + } + + return $weekdays; + } +} + +?> \ No newline at end of file From 6acffac3238b9443a067de203f576107a4304e4c Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 7 Dec 2023 13:12:35 -0700 Subject: [PATCH 07/22] Database changes to support recurring events Signed-off-by: Jon Stovell --- other/install.php | 27 ++++++++++++ other/install_3-0_MySQL.sql | 20 ++++++--- other/install_3-0_PostgreSQL.sql | 18 +++++--- other/upgrade.php | 27 ++++++++++++ other/upgrade_3-0_MySQL.sql | 76 ++++++++++++++++++++++++++++++++ other/upgrade_3-0_PostgreSQL.sql | 75 +++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 11 deletions(-) diff --git a/other/install.php b/other/install.php index 035ddd52b6..fc7d026027 100644 --- a/other/install.php +++ b/other/install.php @@ -23,6 +23,7 @@ use SMF\Url; use SMF\User; use SMF\Utils; +use SMF\Uuid; define('SMF_VERSION', '3.0 Alpha 1'); define('SMF_FULL_VERSION', 'SMF ' . SMF_VERSION); @@ -1485,6 +1486,32 @@ function DatabasePopulation() ['id_smiley', 'smiley_set'], ); + // Set the UID column for calendar events. + $calendar_updates = []; + $request = Db::$db->query( + '', + 'SELECT id_event, uid + FROM {db_prefix}calendar', + [], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + if ($row['uid'] === '') { + $calendar_updates[] = ['id_event' => $row['id_event'], 'uid' => (string) new Uuid()]; + } + } + Db::$db->free_result($request); + + foreach ($calendar_updates as $calendar_update) { + Db::$db->query( + '', + 'UPDATE {db_prefix}calendar + SET uid = {string:uid} + WHERE id_event = {int:id_event}', + $calendar_update, + ); + } + // Let's optimize those new tables, but not on InnoDB, ok? if (!$has_innodb) { $tables = Db::$db->list_tables(Db::$db->name, Db::$db->prefix . '%'); diff --git a/other/install_3-0_MySQL.sql b/other/install_3-0_MySQL.sql index fca8f7057e..875b8ec083 100644 --- a/other/install_3-0_MySQL.sql +++ b/other/install_3-0_MySQL.sql @@ -163,16 +163,24 @@ CREATE TABLE {$db_prefix}board_permissions_view CREATE TABLE {$db_prefix}calendar ( id_event SMALLINT UNSIGNED AUTO_INCREMENT, - start_date date NOT NULL DEFAULT '1004-01-01', - end_date date NOT NULL DEFAULT '1004-01-01', id_board SMALLINT UNSIGNED NOT NULL DEFAULT '0', id_topic MEDIUMINT UNSIGNED NOT NULL DEFAULT '0', title VARCHAR(255) NOT NULL DEFAULT '', id_member MEDIUMINT UNSIGNED NOT NULL DEFAULT '0', - start_time time, - end_time time, + start_date DATE NOT NULL DEFAULT '1004-01-01', + end_date DATE NOT NULL DEFAULT '1004-01-01', + start_time TIME, + end_time TIME, timezone VARCHAR(80), location VARCHAR(255) NOT NULL DEFAULT '', + duration VARCHAR(32) NOT NULL DEFAULT '', + rrule VARCHAR(1024) NOT NULL DEFAULT 'FREQ=YEARLY;COUNT=1', + rdates TEXT NOT NULL, + exdates TEXT NOT NULL, + adjustments JSON DEFAULT NULL; + sequence SMALLINT UNSIGNED NOT NULL DEFAULT '0'; + uid VARCHAR(255) NOT NULL DEFAULT '', + type TINYINT UNSIGNED NOT NULL DEFAULT '0'; PRIMARY KEY (id_event), INDEX idx_start_date (start_date), INDEX idx_end_date (end_date), @@ -1991,8 +1999,8 @@ VALUES ('smfVersion', '{$smf_version}'), ('boardindex_max_depth', '5'), ('cal_enabled', '0'), ('cal_showInTopic', '1'), - ('cal_maxyear', '2030'), - ('cal_minyear', '2008'), + ('cal_maxyear', '2040'), + ('cal_minyear', '2018'), ('cal_daysaslink', '0'), ('cal_defaultboard', ''), ('cal_showholidays', '1'), diff --git a/other/install_3-0_PostgreSQL.sql b/other/install_3-0_PostgreSQL.sql index 86b7340766..3165afe740 100644 --- a/other/install_3-0_PostgreSQL.sql +++ b/other/install_3-0_PostgreSQL.sql @@ -324,16 +324,24 @@ CREATE SEQUENCE {$db_prefix}calendar_seq; CREATE TABLE {$db_prefix}calendar ( id_event smallint DEFAULT nextval('{$db_prefix}calendar_seq'), - start_date date NOT NULL DEFAULT '1004-01-01', - end_date date NOT NULL DEFAULT '1004-01-01', id_board smallint NOT NULL DEFAULT '0', id_topic int NOT NULL DEFAULT '0', title varchar(255) NOT NULL DEFAULT '', id_member int NOT NULL DEFAULT '0', + start_date date NOT NULL DEFAULT '1004-01-01', + end_date date NOT NULL DEFAULT '1004-01-01', start_time time, end_time time, timezone varchar(80), - location VARCHAR(255) NOT NULL DEFAULT '', + location varchar(255) NOT NULL DEFAULT '', + duration varchar(32) NOT NULL DEFAULT '', + rrule varchar(1024) NOT NULL DEFAULT 'FREQ=YEARLY;COUNT=1', + rdates text NOT NULL, + exdates text NOT NULL, + adjustments jsonb DEFAULT NULL; + sequence smallint NOT NULL DEFAULT '0'; + uid VARCHAR(255) NOT NULL DEFAULT '', + type smallint NOT NULL DEFAULT '0'; PRIMARY KEY (id_event) ); @@ -2547,8 +2555,8 @@ VALUES ('smfVersion', '{$smf_version}'), ('boardindex_max_depth', '5'), ('cal_enabled', '0'), ('cal_showInTopic', '1'), - ('cal_maxyear', '2030'), - ('cal_minyear', '2008'), + ('cal_maxyear', '2040'), + ('cal_minyear', '2018'), ('cal_daysaslink', '0'), ('cal_defaultboard', ''), ('cal_showholidays', '1'), diff --git a/other/upgrade.php b/other/upgrade.php index a24ff9dfbe..e3b548ff55 100644 --- a/other/upgrade.php +++ b/other/upgrade.php @@ -20,6 +20,7 @@ use SMF\TaskRunner; use SMF\User; use SMF\Utils; +use SMF\Uuid; use SMF\WebFetch\WebFetchApi; // Version information... @@ -1908,6 +1909,32 @@ function DatabaseChanges() $_GET['substep'] = 0; + // Set the UID column for calendar events. + $calendar_updates = []; + $request = Db::$db->query( + '', + 'SELECT id_event, uid + FROM {db_prefix}calendar', + [], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + if ($row['uid'] === '') { + $calendar_updates[] = ['id_event' => $row['id_event'], 'uid' => (string) new Uuid()]; + } + } + Db::$db->free_result($request); + + foreach ($calendar_updates as $calendar_update) { + Db::$db->query( + '', + 'UPDATE {db_prefix}calendar + SET uid = {string:uid} + WHERE id_event = {int:id_event}', + $calendar_update, + ); + } + // So the template knows we're done. if (!$support_js) { $upcontext['changes_complete'] = true; diff --git a/other/upgrade_3-0_MySQL.sql b/other/upgrade_3-0_MySQL.sql index 45ed54ef59..79b486df9f 100644 --- a/other/upgrade_3-0_MySQL.sql +++ b/other/upgrade_3-0_MySQL.sql @@ -89,3 +89,79 @@ foreach (Config::$modSettings as $variable => $value) { } ---} ---# + + +/******************************************************************************/ +--- Adding support for recurring events... +/******************************************************************************/ + +---# Add duration, rrule, rdates, and exdates columns to calendar table +ALTER TABLE {$db_prefix}calendar +MODIFY COLUMN start_date DATE AFTER id_member; +ADD COLUMN duration VARCHAR(32) NOT NULL DEFAULT ''; +ADD COLUMN rrule VARCHAR(1024) NOT NULL DEFAULT 'FREQ=YEARLY;COUNT=1'; +ADD COLUMN rdates TEXT NOT NULL; +ADD COLUMN exdates TEXT NOT NULL; +ADD COLUMN adjustments JSON DEFAULT NULL; +ADD COLUMN sequence SMALLINT UNSIGNED NOT NULL DEFAULT '0'; +ADD COLUMN uid VARCHAR(255) NOT NULL DEFAULT '', +ADD COLUMN type TINYINT UNSIGNED NOT NULL DEFAULT '0'; +---# + +---# Set duration and rrule values and change end_date +---{ + $updates = []; + + $request = Db::$db->query( + '', + 'SELECT id_event, start_date, end_date, start_time, end_time, timezone + FROM {db_prefix}calendar', + [] + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $row = array_diff($row, array_filter($row, 'is_null')); + + $allday = !isset($row['start_time']) || !isset($row['end_time']) || !isset($row['timezone']) || !in_array($row['timezone'], timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC)); + + $start = new \DateTime($row['start_date'] . (!$allday ? ' ' . $row['start_time'] . ' ' . $row['timezone'] : '')); + $end = new \DateTime($row['end_date'] . (!$allday ? ' ' . $row['end_time'] . ' ' . $row['timezone'] : '')); + + if ($allday) { + $end->modify('+1 day'); + } + + $duration = date_diff($start, $end); + + $format = ''; + foreach (['y', 'm', 'd', 'h', 'i', 's'] as $part) { + if ($part === 'h') { + $format .= 'T'; + } + + if (!empty($duration->{$part})) { + $format .= '%' . $part . ($part === 'i' ? 'M' : strtoupper($part)); + } + } + $format = rtrim('P' . $format, 'PT'); + + $updates[$row['id_event']] = [ + 'id_event' => $row['id_event'], + 'duration' => $duration->format($format), + 'end_date' => $end->format('Y-m-d'), + 'rrule' => 'FREQ=YEARLY;COUNT=1', + ]; + } + Db::$db->free_result($request); + + foreach ($updates as $id_event => $changes) { + Db::$db->query( + '', + 'UPDATE {db_prefix}calendar + SET duration = {string:duration}, end_date = {date:end_date}, rrule = {string:rrule} + WHERE id_event = {int:id_event}', + $changes + ); + } +---} +---# \ No newline at end of file diff --git a/other/upgrade_3-0_PostgreSQL.sql b/other/upgrade_3-0_PostgreSQL.sql index 45ed54ef59..089ec17dc4 100644 --- a/other/upgrade_3-0_PostgreSQL.sql +++ b/other/upgrade_3-0_PostgreSQL.sql @@ -89,3 +89,78 @@ foreach (Config::$modSettings as $variable => $value) { } ---} ---# + +/******************************************************************************/ +--- Adding support for recurring events... +/******************************************************************************/ + +---# Add duration, rrule, rdates, and exdates columns to calendar table +ALTER TABLE {$db_prefix}calendar +ADD COLUMN IF NOT EXISTS duration varchar(32) NOT NULL DEFAULT ''; +ADD COLUMN IF NOT EXISTS rrule varchar(1024) NOT NULL DEFAULT 'FREQ=YEARLY;COUNT=1'; +ADD COLUMN IF NOT EXISTS rdates text NOT NULL; +ADD COLUMN IF NOT EXISTS exdates text NOT NULL; +ADD COLUMN IF NOT EXISTS adjustments jsonb DEFAULT NULL; +ADD COLUMN IF NOT EXISTS sequence smallint NOT NULL DEFAULT '0'; +ADD COLUMN IF NOT EXISTS uid varchar(255) NOT NULL DEFAULT '', +ADD COLUMN IF NOT EXISTS type smallint NOT NULL DEFAULT '0'; + +---# + +---# Set duration and rrule values and change end_date +---{ + $updates = []; + + $request = Db::$db->query( + '', + 'SELECT id_event, start_date, end_date, start_time, end_time, timezone + FROM {db_prefix}calendar', + [] + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $row = array_diff($row, array_filter($row, 'is_null')); + + $allday = !isset($row['start_time']) || !isset($row['end_time']) || !isset($row['timezone']) || !in_array($row['timezone'], timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC)); + + $start = new \DateTime($row['start_date'] . (!$allday ? ' ' . $row['start_time'] . ' ' . $row['timezone'] : '')); + $end = new \DateTime($row['end_date'] . (!$allday ? ' ' . $row['end_time'] . ' ' . $row['timezone'] : '')); + + if ($allday) { + $end->modify('+1 day'); + } + + $duration = date_diff($start, $end); + + $format = ''; + foreach (['y', 'm', 'd', 'h', 'i', 's'] as $part) { + if ($part === 'h') { + $format .= 'T'; + } + + if (!empty($duration->{$part})) { + $format .= '%' . $part . ($part === 'i' ? 'M' : strtoupper($part)); + } + } + $format = rtrim('P' . $format, 'PT'); + + $updates[$row['id_event']] = [ + 'id_event' => $row['id_event'], + 'duration' => $duration->format($format), + 'end_date' => $end->format('Y-m-d'), + 'rrule' => 'FREQ=YEARLY;COUNT=1', + ]; + } + Db::$db->free_result($request); + + foreach ($updates as $id_event => $changes) { + Db::$db->query( + '', + 'UPDATE {db_prefix}calendar + SET duration = {string:duration}, end_date = {date:end_date}, rrule = {string:rrule} + WHERE id_event = {int:id_event}', + $changes + ); + } +---} +---# \ No newline at end of file From 141dc8083ca8cfe44157f5e8901f8fd6ec8124ee Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Mon, 11 Dec 2023 01:13:17 -0700 Subject: [PATCH 08/22] Adds support for showing recurring events Signed-off-by: Jon Stovell --- Sources/Actions/Calendar.php | 48 +- Sources/Actions/Display.php | 14 +- Sources/Actions/Post.php | 2 +- Sources/Calendar/Event.php | 1245 +++++++++++++++++--------- Sources/Calendar/EventOccurrence.php | 476 ++++++++++ Themes/default/Calendar.template.php | 17 +- other/upgrade_3-0_MySQL.sql | 5 + other/upgrade_3-0_PostgreSQL.sql | 5 + 8 files changed, 1365 insertions(+), 447 deletions(-) create mode 100644 Sources/Calendar/EventOccurrence.php diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 6a03dcf0f3..437b311765 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -26,6 +26,7 @@ use SMF\Lang; use SMF\Theme; use SMF\Time; +use SMF\TimeInterval; use SMF\TimeZone; use SMF\Topic; use SMF\User; @@ -118,7 +119,7 @@ public function show(): void Theme::loadTemplate('Calendar'); Theme::loadCSSFile('calendar.css', ['force_current' => false, 'validate' => true, 'rtl' => 'calendar.rtl.css'], 'smf_calendar'); - // Did the specify an individual event ID? If so, let's splice the year/month in to what we would otherwise be doing. + // Did they specify an individual event ID? If so, let's splice the year/month in to what we would otherwise be doing. if (isset($_GET['event'])) { $evid = (int) $_GET['event']; @@ -378,7 +379,6 @@ public function post(): void 'topic' => 0, 'title' => Utils::entitySubstr($_REQUEST['evtitle'], 0, 100), 'location' => Utils::entitySubstr($_REQUEST['event_location'], 0, 255), - 'member' => User::$me->id, ]; Event::create($eventOptions); } @@ -455,13 +455,14 @@ public function post(): void // An all day event? Set up some nice defaults in case the user wants to change that if (Utils::$context['event']->allday == true) { + $now = Time::create('now'); Utils::$context['event']->tz = User::getTimezone(); - Utils::$context['event']->start->modify(Time::create('now')->format('%H:%M:%S')); - Utils::$context['event']->end->modify(Time::create('now + 1 hour')->format('%H:%M:%S')); + Utils::$context['event']->start->modify(Time::create('now')->format('H:i:s')); + Utils::$context['event']->duration = new TimeInterval('PT' . ($now->format('H') < 23 ? '1H' : (59 - $now->format('i')) . 'M')); } // Need this so the user can select a timezone for the event. - Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->start_date); + Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->start_datetime); // If the event's timezone is not in SMF's standard list of time zones, try to fix it. Utils::$context['event']->fixTimezone(); @@ -767,27 +768,31 @@ public static function getBirthdayRange(string $low_date, string $high_date): ar */ public static function getEventRange(string $low_date, string $high_date, bool $use_permissions = true): array { - $events = []; + $occurrences = []; - $one_day = date_interval_create_from_date_string('1 day'); - $tz = timezone_open(User::getTimezone()); + $one_day = new \DateInterval('P1D'); + $tz = new \DateTimeZone(User::getTimezone()); + $high_date = (new \DateTimeImmutable($high_date . ' +1 day'))->format('Y-m-d'); - foreach (Event::loadRange($low_date, $high_date, $use_permissions) as $event) { - $cal_date = new Time($event->start_date_local, $tz); + foreach (Event::getOccurrencesInRange($low_date, $high_date, $use_permissions) as $occurrence) { + $cal_date = new Time($occurrence->start_date_local, $tz); - while ($cal_date->getTimestamp() <= $event->end->getTimestamp() && $cal_date->format('Y-m-d') <= $high_date) { - $events[$cal_date->format('Y-m-d')][] = $event; - date_add($cal_date, $one_day); + while ( + $cal_date->getTimestamp() < $occurrence->end->getTimestamp() + && $cal_date->format('Y-m-d') < $high_date + ) { + $occurrences[$cal_date->format('Y-m-d')][] = $occurrence; + $cal_date->add($one_day); } } - foreach ($events as $mday => $array) { - $events[$mday][count($array) - 1]['is_last'] = true; + foreach ($occurrences as $mday => $array) { + $occurrences[$mday][count($array) - 1]['is_last'] = true; } - ksort($events); + ksort($occurrences); - return $events; + return $occurrences; } /** @@ -1212,19 +1217,22 @@ public static function getCalendarList(string $start_date, string $end_date, arr $calendarGrid['holidays'] = $calendarOptions['show_holidays'] ? self::getHolidayRange($start_date, $end_date) : []; $calendarGrid['events'] = $calendarOptions['show_events'] ? self::getEventRange($start_date, $end_date) : []; - // Get rid of duplicate events + // Get rid of duplicate events. + // This does not get rid of SEPARATE occurrences of a recurring event. + // Instead, it gets rid of duplicates of the SAME occurrence, which can + // happen when the event duration extends beyond midnight. $temp = []; foreach ($calendarGrid['events'] as $date => $date_events) { foreach ($date_events as $event_key => $event_val) { - if (in_array($event_val['id'], $temp)) { + if (in_array($event_val['id'] . ' ' . $event_val['start']->format('c'), $temp)) { unset($calendarGrid['events'][$date][$event_key]); if (empty($calendarGrid['events'][$date])) { unset($calendarGrid['events'][$date]); } } else { - $temp[] = $event_val['id']; + $temp[] = $event_val['id'] . ' ' . $event_val['start']->format('c'); } } } diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index 32979cb5aa..a2f840a9d6 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -1059,8 +1059,18 @@ protected function setupTemplate(): void protected function loadEvents(): void { // If we want to show event information in the topic, prepare the data. - if (User::$me->allowedTo('calendar_view') && !empty(Config::$modSettings['cal_showInTopic']) && !empty(Config::$modSettings['cal_enabled'])) { - Utils::$context['linked_calendar_events'] = Event::load(Topic::$info->id, true); + if ( + User::$me->allowedTo('calendar_view') + && !empty(Config::$modSettings['cal_showInTopic']) + && !empty(Config::$modSettings['cal_enabled']) + ) { + foreach(Event::load(Topic::$info->id, true) as $event) { + if (($occurrence = $event->getUpcomingOccurrence()) === false) { + $occurrence = $event->getLastOccurrence(); + } + + Utils::$context['linked_calendar_events'][] = $occurrence; + } if (!empty(Utils::$context['linked_calendar_events'])) { Utils::$context['linked_calendar_events'][count(Utils::$context['linked_calendar_events']) - 1]['is_last'] = true; diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index c9171e8ce6..1ac0774948 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -776,7 +776,7 @@ protected function initiateEvent(): void } // Need this so the user can select a timezone for the event. - Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->start_date); + Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->timestamp); // If the event's timezone is not in SMF's standard list of time zones, try to fix it. Utils::$context['event']->fixTimezone(); diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index e213f17056..a41962fba4 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -21,17 +21,16 @@ use SMF\Db\DatabaseApi as Db; use SMF\ErrorHandler; use SMF\IntegrationHook; +use SMF\Theme; use SMF\Time; +use SMF\TimeInterval; use SMF\TimeZone; use SMF\User; use SMF\Utils; +use SMF\Uuid; /** - * Represents a calendar event. - * - * @todo Implement recurring events. - * @todo Use this class to represent holidays and birthdays. They're all-day - * events, after all. + * Represents a (possibly recurring) calendar event, birthday, or holiday. */ class Event implements \ArrayAccess { @@ -41,10 +40,9 @@ class Event implements \ArrayAccess * Class constants *****************/ - public const TYPE_EVENT_SIMPLE = 0; - public const TYPE_EVENT_RECURRING = 1; // Not yet implemented. + public const TYPE_EVENT = 0; + public const TYPE_HOLIDAY = 1; // Not yet implemented. public const TYPE_BIRTHDAY = 2; // Not yet implemented. - public const TYPE_HOLIDAY = 4; // Not yet implemented. /******************* * Public properties @@ -57,27 +55,62 @@ class Event implements \ArrayAccess */ public int $id; + /** + * @var string + * + * This event's UID string. (Note: UID is not synonymous with UUID!) + * + * For events that were created by SMF, the UID will in fact be a UUID. + * But if the event was imported from iCal data, it could be anything. + */ + public string $uid; + + /** + * @var string + * + * The recurrence ID of the first individual occurrence of the event. + * + * See RFC 5545, section 3.8.4.4. + */ + public string $id_first; + /** * @var int * * This event's type. * Value must be one of this class's TYPE_* constants. */ - public int $type = self::TYPE_EVENT_SIMPLE; + public int $type = self::TYPE_EVENT; /** * @var \SMF\Time * - * An SMF\Time object representing the start of the event. + * A Time object representing the start of the event's first occurrence. + */ + public Time $start; + + /** + * @var SMF\TimeInterval + * + * A TimeInterval object representing the duration of each occurrence of + * the event. + */ + public TimeInterval $duration; + + /** + * @var RecurrenceIterator + * + * A RecurrenceIterator object to get individual occurrences of the event. */ - public \SMF\Time $start; + public RecurrenceIterator $recurrence_iterator; /** * @var \SMF\Time * - * An SMF\Time object representing the end of the event. + * A Time object representing the date after which no further occurrences + * of the event happen. */ - public \SMF\Time $end; + public Time $recurrence_end; /** * @var bool @@ -179,6 +212,7 @@ class Event implements \ArrayAccess protected array $prop_aliases = [ 'id_event' => 'id', 'eventid' => 'id', + 'end_date' => 'recurrence_end', 'id_board' => 'board', 'id_topic' => 'topic', 'id_first_msg' => 'msg', @@ -193,17 +227,40 @@ class Event implements \ArrayAccess 'end_object' => 'end', ]; - /**************************** - * Internal static properties - ****************************/ + /** + * @var \DateTimeImmutable + * + * Occurrences before this date will be skipped when returning results. + */ + protected \DateTimeInterface $view_start; /** - * @var bool + * @var \DateTimeImmutable + * + * Occurrences after this date will be skipped when returning results. + */ + protected \DateTimeInterface $view_end; + + /** + * @var string * - * If true, Event::get() will not destroy instances after yielding them. - * This is used internally by Event::load(). + * The recurrence rule for the RecurrenceIterator. */ - protected static bool $keep_all = false; + protected string $rrule = ''; + + /** + * @var array + * + * Arbitrary dates to add to the recurrence set. + */ + protected array $rdates = []; + + /** + * @var array + * + * Arbitrary dates to exclude from the recurrence set. + */ + protected array $exdates = []; /**************** * Public methods @@ -214,21 +271,49 @@ class Event implements \ArrayAccess * * @param int $id The ID number of the event. * @param array $props Properties to set for this event. - * @return object An instance of this class. */ public function __construct(int $id = 0, array $props = []) { // Preparing default data to show in the calendar posting form. if ($id < 0) { $this->id = $id; - $props['start_timestamp'] = time(); - $props['end_timestamp'] = time() + 3600; - $props['timezone'] = User::getTimezone(); - $props['member'] = User::$me->id; - $props['name'] = User::$me->name; + $props['start'] = new Time('now ' . User::getTimezone()); + $props['duration'] = new TimeInterval('PT1H'); + $props['recurrence_end'] = (clone $props['start'])->add($props['duration']); + $props['member'] = $props['member'] ?? User::$me->id; + $props['name'] = $props['name'] ?? User::$me->name; } // Creating a new event. elseif ($id == 0) { + if (!isset($props['start']) || !($props['start'] instanceof \DateTimeInterface)) { + ErrorHandler::fatalLang('invalid_date', false); + } elseif (!($props['start'] instanceof Time)) { + $props['start'] = Time::createFromInterface($props['start']); + } + + if (!isset($props['duration'])) { + if (!isset($props['end']) || !($props['end'] instanceof \DateTimeInterface)) { + ErrorHandler::fatalLang('invalid_date', false); + } else { + $props['duration'] = $props['start']->diff($props['end']); + unset($props['end']); + } + } + + if (!isset($props['recurrence_end']) && isset($props['duration'])) { + $props['recurrence_end'] = (clone $props['start'])->add($props['duration']); + } + + if (!isset($props['rrule'])) { + $props['rrule'] = 'FREQ=YEARLY;COUNT=1'; + } else { + // The RRule's week start value can affect recurrence results, + // so make sure to save it using the current user's preference. + $props['rrule'] = new RRule($props['rrule']); + $props['rrule']->wkst = RRule::WEEKDAYS[$start_day = ((Theme::$current->options['calendar_start_day'] ?? 0) + 6) % 7]; + $props['rrule'] = (string) $props['rrule']; + } + $props['member'] = $props['member'] ?? User::$me->id; $props['name'] = $props['name'] ?? User::$me->name; } @@ -238,14 +323,81 @@ public function __construct(int $id = 0, array $props = []) self::$loaded[$this->id] = $this; } - $this->set($props); + $props['rdates'] = array_filter($props['rdates'] ?? []); + $props['exdates'] = array_filter($props['exdates'] ?? []); - // This shouldn't happen, but just in case... - if (!isset($this->start)) { - $this->start = new Time('today'); - $this->end = new Time('today'); - $this->allday = true; + // Set essential properties. + $this->uid = empty($props['uid']) ? (string) new Uuid() : $props['uid']; + + $this->rrule = empty($props['rrule']) ? 'FREQ=YEARLY;COUNT=1' : $props['rrule']; + + $this->start = $props['start'] instanceof Time ? $props['start'] : Time::createFromInterface($props['start']); + + $this->allday = !empty($props['allday']); + + $this->duration = TimeInterval::createFromDateInterval($props['duration']) ?? new TimeInterval(!empty($this->allday) ? 'P1D' : 'PT1H'); + + if (isset($props['view_start'])) { + $this->view_start = $props['view_start'] instanceof \DateTimeInterface ? $props['view_start'] : new \DateTimeImmutable($props['view_start']); + } else { + $this->view_start = clone $this->start; + } + + if (isset($props['view_end'])) { + $this->view_end = $props['view_end'] instanceof \DateTimeInterface ? $props['view_end'] : new \DateTimeImmutable($props['view_end']); + } else { + $this->view_end = (clone $this->view_start)->add(new TimeInterval('P1Y')); + } + + if (!empty($props['rdates'])) { + $this->rdates = is_array($props['rdates']) ? $props['rdates'] : explode(',', $props['rdates']); + + $vs = $this->view_start->format('Ymd'); + $ve = $this->view_end->format('Ymd'); + + foreach ($this->rdates as $key => $rdate) { + $d = substr($rdate, 0, 8); + + if ($d < $vs || $d > $ve) { + unset($this->rdates[$key]); + + continue; + } + + $rdate = explode('/', $rdate); + $this->rdates[$key] = [ + new \DateTimeImmutable($rdate[0]), + isset($rdate[1]) ? new TimeInterval($rdate[1]) : null, + ]; + } + } + + if (!empty($props['exdates'])) { + $this->exdates = is_array($props['exdates']) ? $props['exdates'] : explode(',', $props['exdates']); + + foreach ($this->exdates as $key => $exdate) { + $this->exdates[$key] = new \DateTimeImmutable($exdate); + } } + + unset( + $props['rrule'], + $props['start'], + $props['allday'], + $props['duration'], + $props['view_start'], + $props['view_end'], + $props['rdates'], + $props['exdates'], + $props['adjustments'], + ); + + $this->id_first = $this->allday ? $this->start->format('Ymd') : (clone $this->start)->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\\THis\\Z'); + + $this->createRecurrenceIterator(); + + // Set any other properties. + $this->set($props); } /** @@ -262,33 +414,42 @@ public function save(): void 'end_date' => 'date', 'id_board' => 'int', 'id_topic' => 'int', - 'title' => 'string-60', + 'title' => 'string-255', 'id_member' => 'int', 'location' => 'string-255', + 'duration' => 'string-32', + 'rrule' => 'string', + 'rdates' => 'string', + 'exdates' => 'string', + 'uid' => 'string-255', + 'type' => 'int', ]; $params = [ $this->start->format('Y-m-d'), - $this->end->format('Y-m-d'), + (clone $this->recurrence_end)->sub($this->duration)->format('Y-m-d'), $this->board, $this->topic, - $this->title, + Utils::truncate($this->title, 255), $this->member, - $this->location, + Utils::truncate($this->location, 255), + (string) $this->duration, + (string) ($this->recurrence_iterator->getRRule()), + implode(',', $this->recurrence_iterator->getRDates()), + implode(',', $this->recurrence_iterator->getExDates()), + $this->uid, + $this->type, ]; if (!$this->allday) { $columns['start_time'] = 'time'; $params[] = $this->start->format('H:i:s'); - $columns['end_time'] = 'time'; - $params[] = $this->end->format('H:i:s'); - $columns['timezone'] = 'string'; $params[] = $this->start->format('e'); } - IntegrationHook::call('integrate_create_event', [(array) $this, &$columns, &$params]); + IntegrationHook::call('integrate_create_event', [$this, &$columns, &$params]); $this->id = Db::$db->insert( '', @@ -306,33 +467,41 @@ public function save(): void $set = [ 'start_date = {date:start_date}', 'end_date = {date:end_date}', - 'title = SUBSTRING({string:title}, 1, 60)', + 'title = {string:title}', 'id_board = {int:id_board}', 'id_topic = {int:id_topic}', - 'location = SUBSTRING({string:location}, 1, 255)', + 'location = {string:location}', + 'duration = {string:duration}', + 'rrule = {string:rrule}', + 'rdates = {string:rdates}', + 'exdates = {string:exdates}', + 'uid = {string:uid}', + 'type = {int:type}', ]; $params = [ 'id' => $this->id, 'start_date' => $this->start->format('Y-m-d'), - 'end_date' => $this->end->format('Y-m-d'), - 'title' => $this->title, - 'location' => $this->location, + 'end_date' => (clone $this->recurrence_end)->sub($this->duration)->format('Y-m-d'), + 'title' => Utils::truncate($this->title, 255), + 'location' => Utils::truncate($this->location, 255), 'id_board' => $this->board, 'id_topic' => $this->topic, + 'duration' => (string) $this->duration, + 'rrule' => (string) ($this->recurrence_iterator->getRRule()), + 'rdates' => implode(',', $this->recurrence_iterator->getRDates()), + 'exdates' => implode(',', $this->recurrence_iterator->getExDates()), + 'uid' => $this->uid, + 'type' => $this->type, ]; if ($this->allday) { $set[] = 'start_time = NULL'; - $set[] = 'end_time = NULL'; $set[] = 'timezone = NULL'; } else { $set[] = 'start_time = {time:start_time}'; $params['start_time'] = $this->start->format('H:i:s'); - $set[] = 'end_time = {time:end_time}'; - $params['end_time'] = $this->end->format('H:i:s'); - $set[] = 'timezone = {string:timezone}'; $params['timezone'] = $this->start->format('e'); } @@ -354,251 +523,342 @@ public function save(): void ]); } + // /** + // * @todo Builds an iCalendar document for the event, including all + // * recurrence info. + // * + // * @return string An iCalendar VEVENT document. + // */ + // public function getVEvent(): string + // { + // return ''; + // } + /** + * Returns a generator that yields all occurrences of the event between + * $this->view_start and $this->view_end. * + * @return Generator Iterating over result gives + * EventOccurrence instances. */ - public function getVEvent(): string + public function getAllVisibleOccurrences(): \Generator { - // Check the title isn't too long - iCal requires some formatting if so. - $title = str_split($this->title, 30); + // Where are we currently? + $orig_key = $this->recurrence_iterator->key(); - foreach ($title as $id => $line) { - if ($id != 0) { - $title[$id] = ' ' . $title[$id]; - } + // Go to the start. + $this->recurrence_iterator->rewind(); + + while ($this->recurrence_iterator->valid()) { + yield $this->createOccurrence($this->recurrence_iterator->current()); + $this->recurrence_iterator->next(); } - // This is what we will be sending later. - $filecontents = []; - $filecontents[] = 'BEGIN:VEVENT'; - $filecontents[] = 'ORGANIZER;CN="' . $this->name . '":MAILTO:' . Config::$webmaster_email; - $filecontents[] = 'DTSTAMP:' . date('Ymd\\THis\\Z', time()); - $filecontents[] = 'DTSTART' . (!$this->allday ? ';TZID=' . $this->tz : ';VALUE=DATE') . ':' . $this->start->format('Ymd' . ($this->allday ? '' : '\\THis')); + // Go back to where we were before. + $this->recurrence_iterator->setKey($orig_key); + } - // Event has a duration/ - if ( - (!$this->allday && $this->start_iso_gmdate != $this->end_iso_gmdate) - || ($this->allday && $this->start_date != $this->end_date) - ) { - $filecontents[] = 'DTEND' . (!$this->allday ? ';TZID=' . $this->tz : ';VALUE=DATE') . ':' . $this->end->format('Ymd' . ($this->allday ? '' : '\\THis')); + /** + * Gets the next occurrence of the event after the date given by $when. + * + * @param ?\DateTimeInterface $when The moment from which we should start + * looking for the next occurrence. If null, uses now. + * @return EventOccurrence|false An EventOccurrence object, or false if no + * occurrences happen after $when. + */ + public function getUpcomingOccurrence(?\DateTimeInterface $when = null): EventOccurrence|false + { + if (!isset($when)) { + $when = new \DateTimeImmutable('now'); } - // Event has changed? Advance the sequence for this UID. - if ($this->sequence > 0) { - $filecontents[] = 'SEQUENCE:' . $this->sequence; + if (!$this->recurrence_iterator->valid() || $this->recurrence_iterator->current() > $when) { + $this->recurrence_iterator->rewind(); } - if (!empty($this->location)) { - $filecontents[] = 'LOCATION:' . str_replace(',', '\\,', $this->location); + if (!$this->recurrence_iterator->valid()) { + return false; } - $filecontents[] = 'SUMMARY:' . implode('', $title); - $filecontents[] = 'UID:' . $this->id . '@' . str_replace(' ', '-', Config::$mbname); - $filecontents[] = 'END:VEVENT'; + while ($this->recurrence_iterator->valid() && $this->recurrence_iterator->current() < $when) { + $this->recurrence_iterator->next(); - return implode("\n", $filecontents); + if (!$this->recurrence_iterator->valid()) { + return false; + } + } + + return $this->createOccurrence($this->recurrence_iterator->current()); } /** + * Gets the first occurrence of the event. * + * @return EventOccurrence|false EventOccurrence object, or false on error. */ - public function fixTimezone(): void + public function getFirstOccurrence(): EventOccurrence|false { - $all_timezones = Utils::$context['all_timezones'] ?? TimeZone::list($this->start_date); + // Where are we currently? + $orig_key = $this->recurrence_iterator->key(); - if (!isset($all_timezones[$this->timezone])) { - $later = strtotime('@' . $this->start_timestamp . ' + 1 year'); - $tzinfo = timezone_transitions_get(timezone_open($this->timezone), $this->start_timestamp, $later); - - $found = false; + // Go to the start. + $this->recurrence_iterator->rewind(); - foreach ($all_timezones as $possible_tzid => $dummy) { - // Ignore the "-----" option - if (empty($possible_tzid)) { - continue; - } - - $possible_tzinfo = timezone_transitions_get(timezone_open($possible_tzid), $this->start_timestamp, $later); + // Create the occurrence object. + if ($this->recurrence_iterator->valid()) { + $occurrence = $this->createOccurrence($this->recurrence_iterator->current()); + } - if ($tzinfo === $possible_tzinfo) { - $this->timezone = $possible_tzid; - $found = true; - break; - } - } + // Go back to where we were before. + $this->recurrence_iterator->setKey($orig_key); - // Hm. That's weird. Well, just prepend it to the list and let the user deal with it. - if (!$found) { - $all_timezones = [$this->timezone => '[UTC' . $this->start->format('P') . '] - ' . $this->timezone] + $all_timezones; - } - } + // Return the occurrence object, or false on error. + return $occurrence ?? false; } /** - * Sets custom properties. + * Gets the last occurrence of the event. * - * @param string $prop The property name. - * @param mixed $value The value to set. + * @return EventOccurrence|false EventOccurrence object, or false on error. */ - public function __set(string $prop, mixed $value): void + public function getLastOccurrence(): EventOccurrence|false { - if (property_exists($this, $prop)) { - $type = isset($this->{$prop}) ? gettype($this->{$prop}) : null; + // Where are we currently? + $orig_key = $this->recurrence_iterator->key(); - if (!empty($type)) { - settype($value, $type); - } + // Go to the end. + $this->recurrence_iterator->end(); - $this->{$prop} = $value; - } elseif (array_key_exists($prop, $this->prop_aliases)) { - // Can't unset a virtual property. - if (is_null($value)) { - return; - } + // Create the occurrence object. + if ($this->recurrence_iterator->valid()) { + $occurrence = $this->createOccurrence($this->recurrence_iterator->current()); + } - $real_prop = $this->prop_aliases[$prop]; + // Go back to where we were before. + $this->recurrence_iterator->setKey($orig_key); - if (strpos($real_prop, '!') === 0) { - $real_prop = ltrim($real_prop, '!'); - $value = !$value; - } + // Return the occurrence object, or false on error. + return $occurrence ?? false; + } - if (strpos($real_prop, '[') !== false) { - $real_prop = explode('[', rtrim($real_prop, ']')); + /** + * Gets an occurrence of the event by its recurrence ID. + * + * @param string $id The recurrence ID string. + * @return EventOccurrence|false EventOccurrence object, or false on error. + */ + public function getOccurrence(string $id): EventOccurrence|false + { + // Where are we currently? + $orig_key = $this->recurrence_iterator->key(); - $this->{$real_prop[0]}[$real_prop[1]] = $value; - } else { - if ($real_prop == 'id') { - $this->{$real_prop} = (int) $value; - } else { - settype($value, gettype($this->{$real_prop})); - $this->{$real_prop} = $value; - } - } - } else { - // For simplicity's sake... - if (in_array($prop, ['year', 'month', 'day', 'hour', 'minute', 'second'])) { - $prop = 'start_' . $prop; - } + // Search for the requested ID. + if (($key = $this->recurrence_iterator->search($id)) !== false) { + // Select the requested occurrence. + $this->recurrence_iterator->setKey($key); - if (($start_end = substr($prop, 0, (int) strpos($prop, '_'))) !== 'end') { - $start_end = 'start'; - } + // Create the occurrence object. + $occurrence = $this->createOccurrence($this->recurrence_iterator->current()); + } - if (!isset($this->{$start_end})) { - $this->{$start_end} = new Time(); - } + // Go back to where we were before. + $this->recurrence_iterator->setKey($orig_key); - switch ($prop) { - case 'start_datetime': - case 'end_datetime': - $this->{$start_end}->datetime = $value; - break; + // Return the occurrence object, or false on error. + return $occurrence ?? false; + } - case 'start_date': - case 'end_date': - $this->{$start_end}->date = $value; - break; + /** + * Sets custom properties. + * + * @param string $prop The property name. + * @param mixed $value The value to set. + */ + public function __set(string $prop, mixed $value): void + { + if (!isset($this->start)) { + $this->start = new Time(); + } - case 'start_time': - case 'end_time': - $this->{$start_end}->time = $value; - break; + if (!isset($this->duration)) { + $this->duration = new TimeInterval(!empty($this->allday) ? 'P1D' : 'PT1H'); + } - case 'start_date_orig': - case 'end_date_orig': - $this->{$start_end}->date_orig = $value; - break; + if (str_starts_with($prop, 'end') || str_starts_with($prop, 'last')) { + $end = (clone $this->start)->add($this->duration); + } - case 'start_time_orig': - case 'end_time_orig': - $this->{$start_end}->time_orig = $value; - break; + if (str_starts_with($prop, 'last')) { + $last = (clone $end)->modify('-1 ' . ($this->allday ? 'day' : 'second')); + } - case 'start_date_local': - case 'end_date_local': - $this->{$start_end}->date_local = $value; - break; + switch ($prop) { + // Special handling for stuff that affects start. + case 'start': + if ($value instanceof \DateTimeInterface) { + $this->start = Time::createFromInterface($value); + } + break; - case 'start_time_local': - case 'end_time_local': - $this->{$start_end}->time_local = $value; - break; + case 'datetime': + case 'date': + case 'date_local': + case 'date_orig': + case 'time': + case 'time_local': + case 'time_orig': + case 'year': + case 'month': + case 'day': + case 'hour': + case 'minute': + case 'second': + case 'timestamp': + case 'iso_gmdate': + case 'tz': + case 'tzid': + case 'timezone': + $this->start->{$prop} = $value; + break; - case 'start_year': - case 'end_year': - $this->{$start_end}->year = $value; - break; + case 'start_datetime': + case 'start_date': + case 'start_date_local': + case 'start_date_orig': + case 'start_time': + case 'start_time_local': + case 'start_time_orig': + case 'start_year': + case 'start_month': + case 'start_day': + case 'start_hour': + case 'start_minute': + case 'start_second': + case 'start_timestamp': + case 'start_iso_gmdate': + $this->start->{substr($prop, 6)} = $value; + break; - case 'start_month': - case 'end_month': - $this->{$start_end}->month = $value; - break; + // Special handling for duration. + case 'duration': + if (!($value instanceof \DateInterval)) { + try { + $value = new TimeInterval((string) $value); + } catch (\Throwable $e) { + break; + } + } elseif (!($value instanceof TimeInterval)) { + $value = TimeInterval::createFromDateInterval($value); + } + $this->duration = $value; + break; - case 'start_day': - case 'end_day': - $this->{$start_end}->day = $value; - break; + case 'end': + if (!($value instanceof \DateTimeInterface)) { + try { + $value = new \DateTimeImmutable((is_numeric($value) ? '@' : '') . $value); + } catch (\Throwable $e) { + break; + } + } + $this->duration = $this->start->diff($value); + break; - case 'start_hour': - case 'end_hour': - $this->{$start_end}->hour = $value; - break; + case 'end_datetime': + case 'end_date': + case 'end_date_local': + case 'end_date_orig': + case 'end_time': + case 'end_time_local': + case 'end_time_orig': + case 'end_year': + case 'end_month': + case 'end_day': + case 'end_hour': + case 'end_minute': + case 'end_second': + case 'end_timestamp': + case 'end_iso_gmdate': + $end->{substr($prop, 4)} = $value; + $this->duration = $this->start->diff($end); + break; - case 'start_minute': - case 'end_minute': - $this->{$start_end}->minute = $value; - break; + case 'last': + if (!($value instanceof \DateTimeInterface)) { + try { + $value = new \DateTimeImmutable((is_numeric($value) ? '@' : '') . $value); + } catch (\Throwable $e) { + break; + } + } + $this->duration = $this->start->diff($value->modify('+1 ' . ($this->allday ? 'day' : 'second'))); + break; - case 'start_second': - case 'end_second': - $this->{$start_end}->second = $value; - // no break + case 'last_datetime': + case 'last_date': + case 'last_date_local': + case 'last_date_orig': + case 'last_time': + case 'last_time_local': + case 'last_time_orig': + case 'last_year': + case 'last_month': + case 'last_day': + case 'last_hour': + case 'last_minute': + case 'last_second': + case 'last_timestamp': + case 'last_iso_gmdate': + $last->{substr($prop, 5)} = $value; + $end = (clone $last)->modify('+1 ' . ($this->allday ? 'day' : 'second')); + $this->duration = $this->start->diff($end); + break; - case 'start_timestamp': - case 'end_timestamp': - $this->{$start_end}->timestamp = $value; - break; + // Special handling for stuff that affects recurrence. + case 'rrule': + case 'view_start': + case 'view_end': + $this->{$prop} = $value; + $this->createRecurrenceIterator(); + break; - case 'start_iso_gmdate': - case 'end_iso_gmdate': - $this->{$start_end}->iso_gmdate = $value; - break; + case 'rdates': + $this->rdates = is_array($value) ? $value : explode(',', (string) $value); - case 'tz': - case 'tzid': - case 'timezone': - if ($value instanceof \DateTimeZone) { - $this->start->setTimezone($value); - $this->end->setTimezone($value); - } else { - $this->start->timezone = $value; - $this->end->timezone = $value; - } + foreach ($this->rdates as $key => $rdate) { + $rdate = explode('/', $rdate); + $this->rdates[$key] = [ + new \DateTimeImmutable($rdate[0]), + isset($rdate[1]) ? new TimeInterval($rdate[1]) : null, + ]; + } + $this->createRecurrenceIterator(); + break; - break; + case 'exdates': + $this->exdates = is_array($value) ? $value : explode(',', (string) $value); - // These computed properties are read-only. - case 'tz_abbrev': - case 'new': - case 'is_selected': - case 'href': - case 'link': - case 'can_edit': - case 'modify_href': - case 'can_export': - case 'export_href': - break; + foreach ($this->exdates as $key => $exdate) { + $this->exdates[$key] = new \DateTimeImmutable($exdate); + } + $this->createRecurrenceIterator(); + break; - default: - $this->custom[$prop] = $value; - break; - } - } + // These computed properties are read-only. + case 'new': + case 'is_selected': + case 'href': + case 'link': + case 'can_edit': + case 'modify_href': + case 'can_export': + case 'export_href': + break; - // Ensure that the dates still make sense with each other. - if (isset($this->start, $this->end)) { - self::fixEndDate(); + // Everything else. + default: + $this->customPropertySet($prop, $value); + break; } } @@ -606,123 +866,101 @@ public function __set(string $prop, mixed $value): void * Gets custom property values. * * @param string $prop The property name. + * @return mixed The property value. */ public function __get(string $prop): mixed { - if (property_exists($this, $prop)) { - return $this->{$prop} ?? null; + if (str_starts_with($prop, 'end') || str_starts_with($prop, 'last')) { + $end = (clone $this->start)->add($this->duration); } - if (array_key_exists($prop, $this->prop_aliases)) { - $real_prop = $this->prop_aliases[$prop]; - - if (($not = strpos($real_prop, '!') === 0)) { - $real_prop = ltrim($real_prop, '!'); - } - - if (strpos($real_prop, '[') !== false) { - $real_prop = explode('[', rtrim($real_prop, ']')); - - $value = $this->{$real_prop[0]}[$real_prop[1]]; - } else { - $value = $this->{$real_prop}; - } - - return $not ? !$value : $value; - } - - if (in_array($prop, ['year', 'month', 'day', 'hour', 'minute', 'second'])) { - $prop = 'start_' . $prop; - } - - if (($start_end = substr($prop, 0, (int) strpos($prop, '_'))) !== 'end') { - $start_end = 'start'; + if (str_starts_with($prop, 'last')) { + $last = (clone $end)->modify('-1 ' . ($this->allday ? 'day' : 'second')); } switch ($prop) { - case 'start_datetime': - case 'end_datetime': - $value = $this->{$start_end}->datetime; + case 'datetime': + case 'date': + case 'date_local': + case 'date_orig': + case 'time': + case 'time_local': + case 'time_orig': + case 'year': + case 'month': + case 'day': + case 'hour': + case 'minute': + case 'second': + case 'timestamp': + case 'iso_gmdate': + case 'tz': + case 'tzid': + case 'timezone': + case 'tz_abbrev': + $value = $this->start->{$prop}; break; + case 'start_datetime': case 'start_date': - case 'end_date': - $value = $this->{$start_end}->date; - break; - case 'start_date_local': - case 'end_date_local': - $value = $this->{$start_end}->date_local; - break; - case 'start_date_orig': - case 'end_date_orig': - $value = $this->{$start_end}->date_orig; - break; - case 'start_time': - case 'end_time': - $value = $this->{$start_end}->time; - break; - case 'start_time_local': - case 'end_time_local': - $value = $this->{$start_end}->time_local; - break; - case 'start_time_orig': - case 'end_time_orig': - $value = $this->{$start_end}->time_orig; - break; - case 'start_year': - case 'end_year': - $value = $this->{$start_end}->format('Y'); - break; - case 'start_month': - case 'end_month': - $value = $this->{$start_end}->format('m'); - break; - case 'start_day': - case 'end_day': - $value = $this->{$start_end}->format('d'); - break; - case 'start_hour': - case 'end_hour': - $value = $this->{$start_end}->format('H'); - break; - case 'start_minute': - case 'end_minute': - $value = $this->{$start_end}->format('i'); - break; - case 'start_second': - case 'end_second': - $value = $this->{$start_end}->format('s'); + case 'start_timestamp': + case 'start_iso_gmdate': + $value = $this->start->{substr($prop, 6)}; break; - case 'start_timestamp': - case 'end_timestamp': - $value = $this->{$start_end}->getTimestamp() - ($this->allday ? $this->{$start_end}->getTimestamp() % 86400 : 0); + case 'end': + $value = $end; break; - case 'start_iso_gmdate': + case 'end_datetime': + case 'end_date': + case 'end_date_local': + case 'end_date_orig': + case 'end_time': + case 'end_time_local': + case 'end_time_orig': + case 'end_year': + case 'end_month': + case 'end_day': + case 'end_hour': + case 'end_minute': + case 'end_second': + case 'end_timestamp': case 'end_iso_gmdate': - $value = $this->allday ? preg_replace('/T\d\d:\d\d:\d\d/', 'T00:00:00', $this->{$start_end}->iso_gmdate) : $this->{$start_end}->iso_gmdate; + $value = $end->{substr($prop, 4)}; break; - case 'tz': - case 'tzid': - case 'timezone': - $value = $this->start->timezone; + case 'last': + $value = $last; break; - case 'tz_abbrev': - $value = $this->start->tz_abbrev; + case 'last_datetime': + case 'last_date': + case 'last_date_local': + case 'last_date_orig': + case 'last_time': + case 'last_time_local': + case 'last_time_orig': + case 'last_year': + case 'last_month': + case 'last_day': + case 'last_hour': + case 'last_minute': + case 'last_second': + case 'last_timestamp': + case 'last_iso_gmdate': + $value = $last->{substr($prop, 5)}; break; case 'new': @@ -757,11 +995,25 @@ public function __get(string $prop): mixed $value = Config::$scripturl . '?action=calendar;sa=ical;eventid=' . $this->id . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']; break; + case 'rrule': + $value = isset($this->recurrence_iterator) ? $this->recurrence_iterator->getRRule() : null; + break; + + case 'rdates': + $value = isset($this->recurrence_iterator) ? $this->recurrence_iterator->getRDates() : null; + break; + + case 'exdates': + $value = isset($this->recurrence_iterator) ? $this->recurrence_iterator->getExDates() : null; + break; + default: - $value = $this->custom[$prop] ?? null; + $value = $this->customPropertyGet($prop); break; } + unset($end, $last); + return $value; } @@ -769,67 +1021,80 @@ public function __get(string $prop): mixed * Checks whether a custom property has been set. * * @param string $prop The property name. + * @return bool Whether the property has been set. */ public function __isset(string $prop): bool { - if (property_exists($this, $prop)) { - return isset($this->{$prop}); - } - - if (array_key_exists($prop, $this->prop_aliases)) { - $real_prop = ltrim($this->prop_aliases[$prop], '!'); - - if (strpos($real_prop, '[') !== false) { - $real_prop = explode('[', rtrim($real_prop, ']')); - - return isset($this->{$real_prop[0]}[$real_prop[1]]); - } - - return isset($this->{$real_prop}); - } - - if (in_array($prop, ['year', 'month', 'day', 'hour', 'minute', 'second'])) { - $prop = 'start_' . $prop; - } - switch ($prop) { case 'start_datetime': case 'start_date': + case 'start_date_local': + case 'start_date_orig': case 'start_time': + case 'start_time_local': + case 'start_time_orig': case 'start_year': case 'start_month': case 'start_day': case 'start_hour': case 'start_minute': case 'start_second': - case 'start_date_local': - case 'start_date_orig': - case 'start_time_local': - case 'start_time_orig': case 'start_timestamp': case 'start_iso_gmdate': + case 'datetime': + case 'date': + case 'date_local': + case 'date_orig': + case 'time': + case 'time_local': + case 'time_orig': + case 'year': + case 'month': + case 'day': + case 'hour': + case 'minute': + case 'second': + case 'timestamp': + case 'iso_gmdate': case 'tz': case 'tzid': case 'timezone': case 'tz_abbrev': - return property_exists($this, 'start'); + return isset($this->start); + case 'end': case 'end_datetime': case 'end_date': + case 'end_date_local': + case 'end_date_orig': case 'end_time': + case 'end_time_local': + case 'end_time_orig': case 'end_year': case 'end_month': case 'end_day': case 'end_hour': case 'end_minute': case 'end_second': - case 'end_date_local': - case 'end_date_orig': - case 'end_time_local': - case 'end_time_orig': case 'end_timestamp': case 'end_iso_gmdate': - return property_exists($this, 'end'); + case 'last': + case 'last_datetime': + case 'last_date': + case 'last_date_local': + case 'last_date_orig': + case 'last_time': + case 'last_time_local': + case 'last_time_orig': + case 'last_year': + case 'last_month': + case 'last_day': + case 'last_hour': + case 'last_minute': + case 'last_second': + case 'last_timestamp': + case 'last_iso_gmdate': + return isset($this->start, $this->duration); case 'new': case 'is_selected': @@ -842,7 +1107,42 @@ public function __isset(string $prop): bool return true; default: - return isset($this->custom[$prop]); + return $this->customPropertyIsset($prop); + } + } + + /** + * + */ + public function fixTimezone(): void + { + $all_timezones = TimeZone::list($this->start->date); + + if (!isset($all_timezones[$this->start->timezone])) { + $later = strtotime('@' . $this->start->timestamp . ' + 1 year'); + $tzinfo = (new \DateTimeZone($this->start->timezone))->getTransitions($this->start->timestamp, $later); + + $found = false; + + foreach ($all_timezones as $possible_tzid => $dummy) { + // Ignore the "-----" option + if (empty($possible_tzid)) { + continue; + } + + $possible_tzinfo = (new \DateTimeZone($possible_tzid))->getTransitions($this->start->timestamp, $later); + + if ($tzinfo === $possible_tzinfo) { + $this->start->timezone = $possible_tzid; + $found = true; + break; + } + } + + // Hm. That's weird. Well, just prepend it to the list and let the user deal with it. + if (!$found) { + $all_timezones = [$this->start->timezone => '[UTC' . $this->start->format('P') . '] - ' . $this->start->timezone] + $all_timezones; + } } } @@ -851,82 +1151,120 @@ public function __isset(string $prop): bool ***********************/ /** - * Loads events by ID number or by topic. + * Loads an event by ID number or by topic ID. * * @param int $id ID number of the event or topic. * @param bool $is_topic If true, $id is the topic ID. Default: false. * @param bool $use_permissions Whether to use permissions. Default: true. - * @return array|bool Instances of this class for the loaded events. + * @return array Instances of this class for the loaded events. */ - public static function load(int $id, bool $is_topic = false, bool $use_permissions = true): array|bool + public static function load(int $id, bool $is_topic = false, bool $use_permissions = true): array { if ($id <= 0) { - return $is_topic ? false : [new self($id)]; + return []; } - $loaded = []; + if (!$is_topic && isset(self::$loaded[$id])) { + return [self::$loaded[$id]]; + } - self::$keep_all = true; + $loaded = []; - $query_customizations['where'][] = 'cal.id_' . ($is_topic ? 'topic' : 'event') . ' = {int:id}'; - $query_customizations['params']['id'] = $id; + $selects = [ + 'cal.*', + 'b.id_board', + 'b.member_groups', + 't.id_first_msg', + 't.approved', + 'm.modified_time', + 'mem.real_name', + ]; + $joins = [ + 'LEFT JOIN {db_prefix}boards AS b ON (b.id_board = cal.id_board)', + 'LEFT JOIN {db_prefix}topics AS t ON (t.id_topic = cal.id_topic)', + 'LEFT JOIN {db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)', + 'LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = cal.id_member)', + ]; + $where = ['cal.id_' . ($is_topic ? 'topic' : 'event') . ' = {int:id}']; + $order = ['cal.start_date']; + $group = []; + $limit = 0; + $params = [ + 'id' => $id, + 'no_board_link' => 0, + ]; if ($use_permissions) { - $query_customizations['where'][] = '(cal.id_board = {int:no_board_link} OR {query_wanna_see_board})'; - - $query_customizations['params']['no_board_link'] = 0; + $where[] = '(cal.id_board = {int:no_board_link} OR {query_wanna_see_board})'; } - foreach (self::get('', '', $use_permissions, $query_customizations) as $event) { - $loaded[] = $event; - } + IntegrationHook::call('integrate_query_event', [&$selects, &$params, &$joins, &$where, &$order, &$group, &$limit]); - self::$keep_all = false; + foreach(self::queryData($selects, $params, $joins, $where, $order, $group, $limit) as $row) { + // If the attached topic is not approved then for the moment pretend it doesn't exist. + if (!empty($row['id_first_msg']) && Config::$modSettings['postmod_active'] && !$row['approved']) { + continue; + } - return $loaded; - } + unset($row['approved']); - /** - * Loads events within the given date range. - * - * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. - * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. - * @param bool $use_permissions Whether to use permissions. Default: true. - * @param array $query_customizations Customizations to the SQL query. - * @return array Instances of this class for the loaded events. - */ - public static function loadRange(string $low_date, string $high_date, bool $use_permissions = true, array $query_customizations = []): array - { - $loaded = []; + $id = (int) $row['id_event']; + $row['use_permissions'] = $use_permissions; - self::$keep_all = true; + $rrule = new RRule($row['rrule']); - foreach (self::get($low_date, $high_date, $use_permissions, $query_customizations) as $event) { - $loaded[$event->id] = $event; - } + switch ($rrule->freq) { + case 'SECONDLY': + $unit = 'seconds'; + break; + + case 'MINUTELY': + $unit = 'minutes'; + break; + + case 'HOURLY': + $unit = 'hours'; + break; + + case 'DAILY': + $unit = 'days'; + break; + + case 'WEEKLY': + $unit = 'weeks'; + break; + + case 'MONTHLY': + $unit = 'months'; + break; + + default: + $unit = 'years'; + break; + } - self::$keep_all = false; + $row['view_end'] = new \DateTimeImmutable('now + ' . (RecurrenceIterator::DEFAULT_COUNTS[$rrule->freq]) . ' ' . $unit); + + $loaded[] = (new self($id, $row)); + } - // Return the instances we just loaded. return $loaded; } /** * Generator that yields instances of this class. * - * @todo SMF does not yet take advantage of this generator very well. - * Instead of loading all the events in the range via Event::loadRange(), - * it would be better to call Event::get() directly in order to reduce - * memory load. - * * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. * @param bool $use_permissions Whether to use permissions. Default: true. * @param array $query_customizations Customizations to the SQL query. - * @return \Generator Iterating over result gives Event instances. + * @return Generator Iterating over result gives Event instances. */ public static function get(string $low_date, string $high_date, bool $use_permissions = true, array $query_customizations = []): \Generator { + $low_date = !empty($low_date) ? $low_date : '1000-01-01'; + $high_date = !empty($high_date) ? $high_date : '9999-12-31'; + $selects = $query_customizations['selects'] ?? [ 'cal.*', 'b.id_board', @@ -945,6 +1283,7 @@ public static function get(string $low_date, string $high_date, bool $use_permis $where = $query_customizations['where'] ?? [ 'cal.start_date <= {date:high_date}', 'cal.end_date >= {date:low_date}', + 'type = {int:type}', ]; $order = $query_customizations['order'] ?? ['cal.start_date']; $group = $query_customizations['group'] ?? []; @@ -953,6 +1292,7 @@ public static function get(string $low_date, string $high_date, bool $use_permis 'high_date' => $high_date, 'low_date' => $low_date, 'no_board_link' => 0, + 'type' => self::TYPE_EVENT, ]; if ($use_permissions) { @@ -972,10 +1312,29 @@ public static function get(string $low_date, string $high_date, bool $use_permis $id = (int) $row['id_event']; $row['use_permissions'] = $use_permissions; + $row['view_start'] = new \DateTimeImmutable($low_date); + $row['view_end'] = new \DateTimeImmutable($high_date); + yield (new self($id, $row)); + } + } - if (!self::$keep_all) { - unset(self::$loaded[$id]); + /** + * Gets events within the given date range, and returns a generator that + * yields all occurrences of those events within that range. + * + * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. + * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. + * @param bool $use_permissions Whether to use permissions. Default: true. + * @param array $query_customizations Customizations to the SQL query. + * @return Generator Iterating over result gives + * EventOccurrence instances. + */ + public static function getOccurrencesInRange(string $low_date, string $high_date, bool $use_permissions = true, array $query_customizations = []): \Generator + { + foreach (self::get($low_date, $high_date, $use_permissions, $query_customizations) as $event) { + foreach ($event->getAllVisibleOccurrences() as $occurrence) { + yield $occurrence; } } } @@ -995,7 +1354,7 @@ public static function create(array $eventOptions): void } // Set the start and end dates. - self::setStartEnd($eventOptions); + self::setStartAndDuration($eventOptions); $event = new self(0, $eventOptions); $event->save(); @@ -1041,8 +1400,8 @@ public static function modify(int $id, array &$eventOptions): void $eventOptions[$key] = Utils::htmlspecialchars($eventOptions[$key] ?? '', ENT_QUOTES); } - // Set the new start and end dates. - self::setStartEnd($eventOptions); + // Set the new start date and duration. + self::setStartAndDuration($eventOptions); list($event) = self::load($id); $event->set($eventOptions); @@ -1081,24 +1440,63 @@ public static function remove(int $id): void ******************/ /** - * Ensures that the start and end dates have a sane relationship. + * Sets $this->recurrence_iterator, but only if all necessary properties + * have been set. + * + * @return bool Whether the recurrence iterator was created successfully. */ - protected function fixEndDate(): void + protected function createRecurrenceIterator(): bool { - // Must always use the same time zone for both dates. - if ($this->end->format('e') !== $this->start->format('e')) { - $this->end->setTimezone($this->start->getTimezone()); + static $args_hash; + + if ( + empty($this->rrule) + || !isset($this->start, $this->view_start, $this->view_end, $this->allday) + ) { + return false; } - // End date can't be before the start date. - if ($this->end->getTimestamp() < $this->start->getTimestamp()) { - $this->end->setTimestamp($this->start->getTimestamp()); + $temp_hash = md5($this->rrule . $this->start->format('c') . $this->view_start->format('c') . $this->view_end->format('c') . (int) $this->allday); + + if (isset($this->recurrence_iterator, $args_hash) && $args_hash === $temp_hash) { + return true; } - // If the event is too long, cap it at the max. - if (!empty(Config::$modSettings['cal_maxspan']) && $this->start->diff($this->end)->format('%a') > Config::$modSettings['cal_maxspan']) { - $this->end = (clone $this->start)->modify('+' . Config::$modSettings['cal_maxspan'] . ' days'); + $args_hash = $temp_hash; + + $this->recurrence_iterator = new RecurrenceIterator( + new RRule($this->rrule), + $this->start, + $this->view_start->diff($this->view_end), + $this->view_start, + $this->allday ? RecurrenceIterator::TYPE_ALLDAY : RecurrenceIterator::TYPE_ABSOLUTE, + $this->rdates ?? [], + $this->exdates ?? [], + ); + + return true; + } + + /** + * + * @param \DateTimeInterface $start The start time. + * @param ?TimeInterval $duration Custom duration for this occurrence. If + * this is left null, the duration of the parent event will be used. + * @return EventOccurrence + */ + protected function createOccurrence(\DateTimeInterface $start, ?TimeInterval $duration = null): EventOccurrence + { + $props = [ + 'start' => Time::createFromInterface($start), + ]; + + if (isset($duration)) { + $props['duration'] = $duration; } + + $props['id'] = $this->allday ? $props['start']->format('Ymd') : (clone $props['start'])->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\\THis\\Z'); + + return new EventOccurrence($this->id, $props); } /************************* @@ -1145,14 +1543,22 @@ protected static function queryData(array $selects, array $params = [], array $j $row = array_diff($row, array_filter($row, 'is_null')); // Is this an all-day event? - $row['allday'] = !isset($row['start_time']) || !isset($row['end_time']) || !isset($row['timezone']) || !in_array($row['timezone'], timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC)); + $row['allday'] = !isset($row['start_time']) || !isset($row['timezone']) || !in_array($row['timezone'], timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC)); - // Replace time and date scalars with Time objects. - $row['start'] = new Time($row['start_date'] . (!$row['allday'] ? ' ' . $row['start_time'] . ' ' . $row['timezone'] : '')); + // Replace start time and date scalars with a Time object. + $row['start'] = new Time($row['start_date'] . (!$row['allday'] ? ' ' . $row['start_time'] . ' ' . $row['timezone'] : ' ' . User::getTimezone())); + unset($row['start_date'], $row['start_time'], $row['timezone']); - $row['end'] = new Time($row['end_date'] . (!$row['allday'] ? ' ' . $row['end_time'] . ' ' . $row['timezone'] : '')); + // Replace duration string with a DateInterval object. + $row['duration'] = new TimeInterval($row['duration']); - unset($row['start_date'], $row['start_time'], $row['end_date'], $row['end_time'], $row['timezone']); + // end_date actually means the recurrence end date. + $row['recurrence_end'] = (clone $row['start'])->modify($row['end_date'])->add($row['duration']); + unset($row['end_date']); + + // Are there any adjustments to the calculated recurrence dates? + $row['rdates'] = explode(',', $row['rdates'] ?? ''); + $row['exdates'] = explode(',', $row['exdates'] ?? ''); // The groups should be an array. $row['member_groups'] = isset($row['member_groups']) ? explode(',', $row['member_groups']) : []; @@ -1174,7 +1580,7 @@ protected static function queryData(array $selects, array $params = [], array $j * @param array $eventOptions An array of optional time and date parameters * (span, start_year, end_month, etc., etc.) */ - protected static function setStartEnd(array &$eventOptions): void + protected static function setStartAndDuration(array &$eventOptions): void { // Convert unprefixed time unit parameters to start_* parameters. foreach (['year', 'month', 'day', 'hour', 'minute', 'second'] as $key) { @@ -1213,6 +1619,13 @@ protected static function setStartEnd(array &$eventOptions): void // Ensure 'allday' is a boolean. $eventOptions['allday'] = !empty($eventOptions['allday']); + // Now replace 'end' with 'duration'. + if ($eventOptions['allday']) { + $eventOptions['end']->modify('+1 day'); + } + $eventOptions['duration'] = $eventOptions['start']->diff($eventOptions['end']); + unset($eventOptions['end']); + // Unset all null values and all scalar date/time parameters. $scalars = [ 'year', diff --git a/Sources/Calendar/EventOccurrence.php b/Sources/Calendar/EventOccurrence.php new file mode 100644 index 0000000000..2914e26731 --- /dev/null +++ b/Sources/Calendar/EventOccurrence.php @@ -0,0 +1,476 @@ + 'id_event', + 'id_board' => 'board', + 'id_topic' => 'topic', + 'id_first_msg' => 'msg', + 'sequence' => 'modified_time', + 'id_member' => 'member', + 'poster' => 'member', + 'real_name' => 'name', + 'realname' => 'name', + 'member_groups' => 'groups', + 'allowed_groups' => 'groups', + 'start_object' => 'start', + 'end_object' => 'end', + ]; + + /**************** + * Public methods + ****************/ + + /** + * Constructor. + * + * @param int $id_event The ID number of the parent event. + * @param array $props Properties to set for this occurrence. + * @return EventOccurrence An instance of this class. + */ + public function __construct(int $id_event = 0, array $props = []) + { + $this->id_event = $id_event; + + if (!isset($props['start'])) { + throw new \ValueError(); + } + + $this->set($props); + + if (!isset($this->id)) { + $this->id = $this->allday ? $this->start->format('Ymd') : (clone $this->start)->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\\THis\\Z'); + } + } + + /** + * Builds an iCalendar document for this occurrence of the event. + * + * @return string An iCalendar VEVENT document. + */ + public function getVEvent(): string + { + // Check the title isn't too long - iCal requires some formatting if so. + $title = str_split($this->title, 30); + + foreach ($title as $id => $line) { + if ($id != 0) { + $title[$id] = ' ' . $title[$id]; + } + } + + // This is what we will be sending later. + $filecontents = []; + $filecontents[] = 'BEGIN:VEVENT'; + $filecontents[] = 'ORGANIZER;CN="' . $this->name . '":MAILTO:' . Config::$webmaster_email; + $filecontents[] = 'DTSTAMP:' . date('Ymd\\THis\\Z', time()); + $filecontents[] = 'DTSTART' . ($this->allday ? ';VALUE=DATE' : (!in_array($this->tz, RRule::UTC_SYNONYMS) ? ';TZID=' . $this->tz : '')) . ':' . $this->start->format('Ymd' . ($this->allday ? '' : '\\THis' . (in_array($this->tz, RRule::UTC_SYNONYMS) ? '\\Z' : ''))); + + // Event has a duration/ + if ( + (!$this->allday && $this->start_iso_gmdate != $this->end_iso_gmdate) + || ($this->allday && $this->start_date != $this->end_date) + ) { + $filecontents[] = 'DTEND' . ($this->allday ? ';VALUE=DATE' : (!in_array($this->tz, RRule::UTC_SYNONYMS) ? ';TZID=' . $this->tz : '')) . ':' . $this->end->format('Ymd' . ($this->allday ? '' : '\\THis' . (in_array($this->tz, RRule::UTC_SYNONYMS) ? '\\Z' : ''))); + } + + // Event has changed? Advance the sequence for this UID. + if ($this->sequence > 0) { + $filecontents[] = 'SEQUENCE:' . $this->sequence; + } + + if (!empty($this->location)) { + $filecontents[] = 'LOCATION:' . str_replace(',', '\\,', $this->location); + } + + $filecontents[] = 'SUMMARY:' . implode('', $title); + $filecontents[] = 'UID:' . $this->uid; + $filecontents[] = 'RECURRENCE-ID' . ($this->allday ? ';VALUE=DATE' : '') . ':' . $this->id; + $filecontents[] = 'END:VEVENT'; + + return implode("\n", $filecontents); + } + + /** + * Sets custom properties. + * + * @param string $prop The property name. + * @param mixed $value The value to set. + */ + public function __set(string $prop, $value): void + { + if (!isset($this->start)) { + $this->start = new Time(); + } + + if (str_starts_with($prop, 'end')) { + $end = (clone $this->start)->add($this->duration); + } + + switch ($prop) { + // Special handling for stuff that affects start. + case 'start': + if ($value instanceof \DateTimeInterface) { + $this->start = Time::createFromInterface($value); + } + break; + + case 'datetime': + case 'date': + case 'date_local': + case 'date_orig': + case 'time': + case 'time_local': + case 'time_orig': + case 'year': + case 'month': + case 'day': + case 'hour': + case 'minute': + case 'second': + case 'timestamp': + case 'iso_gmdate': + case 'tz': + case 'tzid': + case 'timezone': + $this->start->{$prop} = $value; + break; + + case 'start_datetime': + case 'start_date': + case 'start_date_local': + case 'start_date_orig': + case 'start_time': + case 'start_time_local': + case 'start_time_orig': + case 'start_year': + case 'start_month': + case 'start_day': + case 'start_hour': + case 'start_minute': + case 'start_second': + case 'start_timestamp': + case 'start_iso_gmdate': + $this->start->{substr($prop, 6)} = $value; + break; + + // Special handling for duration. + case 'duration': + if (!($value instanceof \DateInterval)) { + try { + $value = new \DateInterval((string) $value); + } catch (\Throwable $e) { + break; + } + } + $this->custom['duration'] = $value; + break; + + case 'end': + if (!($value instanceof \DateTimeInterface)) { + try { + $value = new \DateTimeImmutable((is_numeric($value) ? '@' : '') . $value); + } catch (\Throwable $e) { + break; + } + } + $this->custom['duration'] = $this->start->diff($value); + break; + + case 'end_datetime': + case 'end_date': + case 'end_date_local': + case 'end_date_orig': + case 'end_time': + case 'end_time_local': + case 'end_time_orig': + case 'end_year': + case 'end_month': + case 'end_day': + case 'end_hour': + case 'end_minute': + case 'end_second': + case 'end_timestamp': + case 'end_iso_gmdate': + $end->{substr($prop, 4)} = $value; + $this->custom['duration'] = $this->start->diff($end); + break; + + // These properties are read-only. + case 'age': + case 'uid': + case 'tz_abbrev': + case 'new': + case 'is_selected': + case 'href': + case 'link': + case 'can_edit': + case 'modify_href': + case 'can_export': + case 'export_href': + break; + + default: + $this->customPropertySet($prop, $value); + break; + } + } + + /** + * Gets custom property values. + * + * @param string $prop The property name. + */ + public function __get(string $prop): mixed + { + if (str_starts_with($prop, 'end')) { + $end = (clone $this->start)->add($this->custom['duration'] ?? $this->getParentEvent()->duration); + } + + switch ($prop) { + case 'datetime': + case 'date': + case 'date_local': + case 'date_orig': + case 'time': + case 'time_local': + case 'time_orig': + case 'year': + case 'month': + case 'day': + case 'hour': + case 'minute': + case 'second': + case 'timestamp': + case 'iso_gmdate': + case 'tz': + case 'tzid': + case 'timezone': + case 'tz_abbrev': + return $this->start->{$prop}; + + case 'start_datetime': + case 'start_date': + case 'start_date_local': + case 'start_date_orig': + case 'start_time': + case 'start_time_local': + case 'start_time_orig': + case 'start_year': + case 'start_month': + case 'start_day': + case 'start_hour': + case 'start_minute': + case 'start_second': + case 'start_timestamp': + case 'start_iso_gmdate': + return $this->start->{substr($prop, 6)}; + + case 'end': + return $end; + + case 'end_datetime': + case 'end_date': + case 'end_date_local': + case 'end_date_orig': + case 'end_time': + case 'end_time_local': + case 'end_time_orig': + case 'end_year': + case 'end_month': + case 'end_day': + case 'end_hour': + case 'end_minute': + case 'end_second': + case 'end_timestamp': + case 'end_iso_gmdate': + return $end->{substr($prop, 4)}; + + // These inherit from the parent event unless overridden for this occurrence. + case 'allday': + case 'duration': + case 'title': + case 'location': + return $this->custom[$prop] ?? $this->getParentEvent()->{$prop}; + + // These always inherit from the parent event. + case 'uid': + case 'type': + case 'board': + case 'topic': + case 'msg': + case 'modified_time': + case 'member': + case 'name': + case 'groups': + case 'new': + case 'is_selected': + case 'href': + case 'link': + case 'can_edit': + case 'modify_href': + case 'can_export': + case 'export_href': + return $this->getParentEvent()->{$prop}; + + default: + return $this->customPropertyGet($prop); + } + } + + /** + * Checks whether a custom property has been set. + * + * @param string $prop The property name. + */ + public function __isset(string $prop): bool + { + if (in_array($prop, ['year', 'month', 'day', 'hour', 'minute', 'second'])) { + $prop = 'start_' . $prop; + } + + switch ($prop) { + case 'start_datetime': + case 'start_date': + case 'start_time': + case 'start_year': + case 'start_month': + case 'start_day': + case 'start_hour': + case 'start_minute': + case 'start_second': + case 'start_date_local': + case 'start_date_orig': + case 'start_time_local': + case 'start_time_orig': + case 'start_timestamp': + case 'start_iso_gmdate': + case 'tz': + case 'tzid': + case 'timezone': + case 'tz_abbrev': + case 'end_datetime': + case 'end_date': + case 'end_time': + case 'end_year': + case 'end_month': + case 'end_day': + case 'end_hour': + case 'end_minute': + case 'end_second': + case 'end_date_local': + case 'end_date_orig': + case 'end_time_local': + case 'end_time_orig': + case 'end_timestamp': + case 'end_iso_gmdate': + return property_exists($this, 'start'); + + case 'uid': + case 'type': + case 'allday': + case 'duration': + case 'title': + case 'location': + case 'board': + case 'topic': + case 'msg': + case 'modified_time': + case 'member': + case 'name': + case 'groups': + case 'new': + case 'is_selected': + case 'href': + case 'link': + case 'can_edit': + case 'modify_href': + case 'can_export': + case 'export_href': + return true; + + default: + return $this->customPropertyIsset($prop); + } + } + + /** + * Retrieves the Event that this EventOccurrence is an occurrence of. + * + * @return Event The parent Event. + */ + public function getParentEvent(): Event + { + if (!isset(Event::$loaded[$this->id_event])) { + Event::load($this->id_event); + } + + return Event::$loaded[$this->id_event]; + } +} + +?> \ No newline at end of file diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index f67631e5dd..4bb7f53a6c 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -115,7 +115,7 @@ function template_show_upcoming_list($grid_name) if (!empty($event['allday'])) { - echo '', ($event['start_date'] != $event['end_date']) ? ' – ' : ''; + echo '', ($event['start_date_local'] < $event['last_date_local']) ? ' – ' : ''; } else { @@ -431,12 +431,13 @@ function($a, $b) ', $event['link'], '
    '; - if (!empty($event['start_time_local']) && $event['start_date'] == $day['date']) + if (!empty($event['allday'])) { + echo Lang::$txt['calendar_allday']; + } elseif (!empty($event['start_time_local']) && $event['start_date'] == $day['date']) { echo trim(str_replace(':00 ', ' ', $event['start_time_local'])); - elseif (!empty($event['end_time_local']) && $event['end_date'] == $day['date']) + } elseif (!empty($event['end_time_local']) && $event['end_date'] == $day['date']) { echo strtolower(Lang::$txt['ends']), ' ', trim(str_replace(':00 ', ' ', $event['end_time_local'])); - elseif (!empty($event['allday'])) - echo Lang::$txt['calendar_allday']; + } echo ' '; @@ -483,9 +484,9 @@ function($a, $b) elseif ($is_mini === false) { if (empty($current_month_started) && !empty(Utils::$context['calendar_grid_prev'])) - echo '', Utils::$context['calendar_grid_prev']['last_of_month'] - $calendar_data['shift']-- +1, ''; + echo '', Utils::$context['calendar_grid_prev']['last_of_month'] - $calendar_data['shift']-- +1, ''; elseif (!empty($current_month_started) && !empty(Utils::$context['calendar_grid_next'])) - echo '', $current_month_started + 1 == $count ? (!empty($calendar_data['short_month_titles']) ? Lang::$txt['months_short'][Utils::$context['calendar_grid_next']['current_month']] . ' ' : Lang::$txt['months_titles'][Utils::$context['calendar_grid_next']['current_month']] . ' ') : '', $final_count++, ''; + echo '', $current_month_started + 1 == $count ? (!empty($calendar_data['short_month_titles']) ? Lang::$txt['months_short'][Utils::$context['calendar_grid_next']['current_month']] . ' ' : Lang::$txt['months_titles'][Utils::$context['calendar_grid_next']['current_month']] . ' ') : '', $final_count++, ''; } // Close this day and increase var count. @@ -559,7 +560,7 @@ function template_show_week_grid($grid_name) // Our actual month... echo ' '; diff --git a/other/upgrade_3-0_MySQL.sql b/other/upgrade_3-0_MySQL.sql index 79b486df9f..5763d05165 100644 --- a/other/upgrade_3-0_MySQL.sql +++ b/other/upgrade_3-0_MySQL.sql @@ -164,4 +164,9 @@ ADD COLUMN type TINYINT UNSIGNED NOT NULL DEFAULT '0'; ); } ---} +---# + +---# Drop end_time column from calendar table +ALTER TABLE {$db_prefix}calendar +DROP COLUMN end_time; ---# \ No newline at end of file diff --git a/other/upgrade_3-0_PostgreSQL.sql b/other/upgrade_3-0_PostgreSQL.sql index 089ec17dc4..38f6527175 100644 --- a/other/upgrade_3-0_PostgreSQL.sql +++ b/other/upgrade_3-0_PostgreSQL.sql @@ -163,4 +163,9 @@ ADD COLUMN IF NOT EXISTS type smallint NOT NULL DEFAULT '0'; ); } ---} +---# + +---# Drop end_time column from calendar table +ALTER TABLE {$db_prefix}calendar +DROP COLUMN end_time; ---# \ No newline at end of file From 1f8f8725ca9ade5efe48bcfc7624f526070620e8 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Mon, 11 Dec 2023 01:14:07 -0700 Subject: [PATCH 09/22] Adds support for creating recurring events Signed-off-by: Jon Stovell --- Languages/en_US/General.php | 18 +- Sources/Actions/Calendar.php | 12 +- Sources/Actions/Post.php | 12 +- Sources/Calendar/Event.php | 472 ++++++++++++++-- Sources/Calendar/RecurrenceIterator.php | 8 + Themes/default/Calendar.template.php | 77 +-- Themes/default/EventEditor.template.php | 425 +++++++++++++++ Themes/default/Post.template.php | 50 +- Themes/default/css/index.css | 43 +- Themes/default/css/responsive.css | 66 +-- Themes/default/scripts/event.js | 682 ++++++++++++++++++++++++ 11 files changed, 1618 insertions(+), 247 deletions(-) create mode 100644 Themes/default/EventEditor.template.php create mode 100644 Themes/default/scripts/event.js diff --git a/Languages/en_US/General.php b/Languages/en_US/General.php index 905a812c32..06e5998b79 100644 --- a/Languages/en_US/General.php +++ b/Languages/en_US/General.php @@ -709,7 +709,7 @@ $txt['calendar_month'] = 'Month'; $txt['calendar_year'] = 'Year'; $txt['calendar_day'] = 'Day'; -$txt['calendar_event_title'] = 'Event Title'; +$txt['calendar_event_title'] = 'Title'; $txt['calendar_event_options'] = 'Event Options'; $txt['calendar_post_in'] = 'Post in'; $txt['calendar_edit'] = 'Edit Event'; @@ -735,6 +735,22 @@ $txt['calendar_list'] = 'List'; $txt['calendar_empty'] = 'There are no events to display.'; +$txt['calendar_repeat_recurrence_label'] = 'Repeats'; +$txt['calendar_repeat_interval_label'] = 'Every'; +$txt['calendar_repeat_bymonthday_label'] = 'On'; +$txt['calendar_repeat_byday_label'] = 'On'; +$txt['calendar_repeat_bymonth_label'] = 'In'; +$txt['calendar_repeat_rrule_presets'] = ['never' => 'Never', 'FREQ=DAILY' => 'Every day', 'FREQ=WEEKLY' => 'Every week', 'FREQ=MONTHLY' => 'Every month', 'FREQ=YEARLY' => 'Every year', 'custom' => 'Custom...']; +$txt['calendar_repeat_frequency_units'] = ['YEARLY' => 'year(s)', 'MONTHLY' => 'month(s)', 'WEEKLY' => 'week(s)', 'DAILY' => 'day(s)', 'HOURLY' => 'hour(s)', 'MINUTELY' => 'minute(s)', 'SECONDLY' => 'second(s)']; +$txt['calendar_repeat_until_options'] = ['forever' => 'Forever', 'until' => 'Until', 'count' => 'Number of times']; +$txt['calendar_repeat_byday_num_options'] = [1 => 'the first', 2 => 'the second', 3 => 'the third', 4 => 'the fourth', 5 => 'the fifth', -1 => 'the last', -2 => 'the second last']; +$txt['calendar_repeat_weekday'] = 'weekday'; +$txt['calendar_repeat_weekend_day'] = 'weekend day'; +$txt['calendar_repeat_add_condition'] = 'Add Another'; +$txt['calendar_repeat_advanced_options_label'] = 'More options...'; +$txt['calendar_repeat_rdates_label'] = 'Additional dates'; +$txt['calendar_repeat_exdates_label'] = 'Skipped dates'; + $txt['movetopic_change_subject'] = 'Change the topic’s subject'; $txt['movetopic_new_subject'] = 'New subject'; $txt['movetopic_change_all_subjects'] = 'Change every message’s subject'; diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 437b311765..7cd4c5c250 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -493,15 +493,9 @@ public function post(): void 'name' => Utils::$context['page_title'], ]; - self::loadDatePicker('#event_time_input .date_input'); - self::loadTimePicker('#event_time_input .time_input', Time::getShortTimeFormat()); - self::loadDatePair('#event_time_input', 'date_input', 'time_input'); - Theme::addInlineJavaScript(' - $("#allday").click(function(){ - $("#start_time").attr("disabled", this.checked); - $("#end_time").attr("disabled", this.checked); - $("#tz").attr("disabled", this.checked); - });', true); + Theme::loadTemplate('EventEditor'); + Theme::addJavaScriptVar('monthly_byday_items', (string) (count(Utils::$context['event']->byday_items) - 1)); + Theme::loadJavaScriptFile('event.js', ['defer' => true], 'smf_event'); } /** diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index 1ac0774948..e11942ce33 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -781,15 +781,9 @@ protected function initiateEvent(): void // If the event's timezone is not in SMF's standard list of time zones, try to fix it. Utils::$context['event']->fixTimezone(); - Calendar::loadDatePicker('#event_time_input .date_input'); - Calendar::loadTimePicker('#event_time_input .time_input', $time_string); - Calendar::loadDatePair('#event_time_input', 'date_input', 'time_input'); - Theme::addInlineJavaScript(' - $("#allday").click(function(){ - $("#start_time").attr("disabled", this.checked); - $("#end_time").attr("disabled", this.checked); - $("#tz").attr("disabled", this.checked); - }); ', true); + Theme::loadTemplate('EventEditor'); + Theme::addJavaScriptVar('monthly_byday_items', (string) (count(Utils::$context['event']->byday_items) - 1)); + Theme::loadJavaScriptFile('event.js', ['defer' => true], 'smf_event'); Utils::$context['event']->board = !empty(Board::$info->id) ? Board::$info->id : (int) Config::$modSettings['cal_defaultboard']; Utils::$context['event']->topic = !empty(Topic::$info->id) ? Topic::$info->id : 0; diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index a41962fba4..7ef390ae88 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -21,6 +21,7 @@ use SMF\Db\DatabaseApi as Db; use SMF\ErrorHandler; use SMF\IntegrationHook; +use SMF\Lang; use SMF\Theme; use SMF\Time; use SMF\TimeInterval; @@ -31,6 +32,8 @@ /** * Represents a (possibly recurring) calendar event, birthday, or holiday. + * + * @todo Add support for editing specific instances as exceptions to the RRule. */ class Event implements \ArrayAccess { @@ -104,14 +107,6 @@ class Event implements \ArrayAccess */ public RecurrenceIterator $recurrence_iterator; - /** - * @var \SMF\Time - * - * A Time object representing the date after which no further occurrences - * of the event happen. - */ - public Time $recurrence_end; - /** * @var bool * @@ -182,6 +177,60 @@ class Event implements \ArrayAccess */ public array $groups = []; + /** + * @var array + * + * Weekdays sorted according to the WKST value, or the current user's + * preferences if WKST is not already set. + */ + public array $sorted_weekdays = []; + + /** + * @var array + * + * Possible values for the RRule select menu in the UI. + * + * The descriptions will be overwritten using the language strings in + * Lang::$txt['calendar_repeat_rrule_presets'] + */ + public array $rrule_presets = [ + 'never' => 'Never', + 'FREQ=DAILY' => 'Every day', + 'FREQ=WEEKLY' => 'Every week', + 'FREQ=MONTHLY' => 'Every month', + 'FREQ=YEARLY' => 'Every year', + 'custom' => 'Custom...', + ]; + + /** + * @var array + * + * Maps frequency values to unit strings. + * + * The descriptions will be overwritten using the language strings in + * Lang::$txt['calendar_repeat_frequency_units'] + */ + public array $frequency_units = [ + 'DAILY' => 'day(s)', + 'WEEKLY' => 'week(s)', + 'MONTHLY' => 'month(s)', + 'YEARLY' => 'year(s)', + ]; + + /** + * @var array + * + * Possible values for the BYDAY_num select menu in the UI. + */ + public array $byday_num_options = []; + + /** + * @var array + * + * Existing values for the BYDAY_* select menus in the UI. + */ + public array $byday_items = []; + /************************** * Public static properties **************************/ @@ -212,7 +261,6 @@ class Event implements \ArrayAccess protected array $prop_aliases = [ 'id_event' => 'id', 'eventid' => 'id', - 'end_date' => 'recurrence_end', 'id_board' => 'board', 'id_topic' => 'topic', 'id_first_msg' => 'msg', @@ -274,53 +322,56 @@ class Event implements \ArrayAccess */ public function __construct(int $id = 0, array $props = []) { - // Preparing default data to show in the calendar posting form. - if ($id < 0) { - $this->id = $id; - $props['start'] = new Time('now ' . User::getTimezone()); - $props['duration'] = new TimeInterval('PT1H'); - $props['recurrence_end'] = (clone $props['start'])->add($props['duration']); - $props['member'] = $props['member'] ?? User::$me->id; - $props['name'] = $props['name'] ?? User::$me->name; - } - // Creating a new event. - elseif ($id == 0) { - if (!isset($props['start']) || !($props['start'] instanceof \DateTimeInterface)) { - ErrorHandler::fatalLang('invalid_date', false); - } elseif (!($props['start'] instanceof Time)) { - $props['start'] = Time::createFromInterface($props['start']); - } + // Just in case someone passes -2 or something. + $id = max(-1, $id); + + switch ($id) { + // Preparing default data to show in the calendar posting form. + case -1: + $this->id = $id; + $props['start'] = $props['start'] ?? new Time('now ' . User::getTimezone()); + $props['duration'] = $props['duration'] ?? new TimeInterval('PT1H'); + $props['member'] = $props['member'] ?? User::$me->id; + $props['name'] = $props['name'] ?? User::$me->name; + break; - if (!isset($props['duration'])) { - if (!isset($props['end']) || !($props['end'] instanceof \DateTimeInterface)) { + // Creating a new event. + case 0: + if (!isset($props['start']) || !($props['start'] instanceof \DateTimeInterface)) { ErrorHandler::fatalLang('invalid_date', false); + } elseif (!($props['start'] instanceof Time)) { + $props['start'] = Time::createFromInterface($props['start']); + } + + if (!isset($props['duration'])) { + if (!isset($props['end']) || !($props['end'] instanceof \DateTimeInterface)) { + ErrorHandler::fatalLang('invalid_date', false); + } else { + $props['duration'] = $props['start']->diff($props['end']); + unset($props['end']); + } + } + + if (!isset($props['rrule'])) { + $props['rrule'] = 'FREQ=YEARLY;COUNT=1'; } else { - $props['duration'] = $props['start']->diff($props['end']); - unset($props['end']); + // The RRule's week start value can affect recurrence results, + // so make sure to save it using the current user's preference. + $props['rrule'] = new RRule($props['rrule']); + $props['rrule']->wkst = RRule::WEEKDAYS[$start_day = ((Theme::$current->options['calendar_start_day'] ?? 0) + 6) % 7]; + $props['rrule'] = (string) $props['rrule']; } - } - if (!isset($props['recurrence_end']) && isset($props['duration'])) { - $props['recurrence_end'] = (clone $props['start'])->add($props['duration']); - } + $props['member'] = $props['member'] ?? User::$me->id; + $props['name'] = $props['name'] ?? User::$me->name; - if (!isset($props['rrule'])) { - $props['rrule'] = 'FREQ=YEARLY;COUNT=1'; - } else { - // The RRule's week start value can affect recurrence results, - // so make sure to save it using the current user's preference. - $props['rrule'] = new RRule($props['rrule']); - $props['rrule']->wkst = RRule::WEEKDAYS[$start_day = ((Theme::$current->options['calendar_start_day'] ?? 0) + 6) % 7]; - $props['rrule'] = (string) $props['rrule']; - } + break; - $props['member'] = $props['member'] ?? User::$me->id; - $props['name'] = $props['name'] ?? User::$me->name; - } - // Loading an existing event. - else { - $this->id = $id; - self::$loaded[$this->id] = $this; + // Loading an existing event. + default: + $this->id = $id; + self::$loaded[$this->id] = $this; + break; } $props['rdates'] = array_filter($props['rdates'] ?? []); @@ -398,6 +449,65 @@ public function __construct(int $id = 0, array $props = []) // Set any other properties. $this->set($props); + + // Now set all the options for the UI. + foreach ($this->frequency_units as $freq => $unit) { + $this->frequency_units[$freq] = Lang::$txt['calendar_repeat_frequency_units'][$freq] ?? $unit; + } + + // Our Lang::$txt arrays use Sunday = 0, but ISO day numbering uses Monday = 0. + $this->sorted_weekdays = array_flip(RRule::WEEKDAYS); + + while (key($this->sorted_weekdays) != RRule::WEEKDAYS[((Theme::$current->options['calendar_start_day'] ?? 0) + 6) % 7]) { + $temp_key = key($this->sorted_weekdays); + $temp_val = array_shift($this->sorted_weekdays); + $this->sorted_weekdays[$temp_key] = $temp_val; + } + + foreach ($this->sorted_weekdays as $abbrev => $iso_num) { + $txt_key = ($iso_num + 1) % 7; + $this->sorted_weekdays[$abbrev] = [ + 'iso_num' => $iso_num, + 'txt_key' => $txt_key, + 'abbrev' => $abbrev, + 'short' => Lang::$txt['days_short'][$txt_key], + 'long' => Lang::$txt['days'][$txt_key], + ]; + } + + foreach (Lang::$txt['calendar_repeat_rrule_presets'] as $rrule => $description) { + if (isset($this->rrule_presets[$rrule])) { + $this->rrule_presets[$rrule] = $description; + } + } + + $this->byday_num_options = Lang::$txt['calendar_repeat_byday_num_options']; + + uksort( + $this->byday_num_options, + function ($a, $b) { + if ($a < 0 && $b > 0) { + return 1; + } + + if ($a > 0 && $b < 0) { + return -1; + } + + return abs($a) <=> abs($b); + }, + ); + + // Populate $this->byday_items. + if (!empty($this->recurrence_iterator->getRRule()->byday)) { + foreach ($this->recurrence_iterator->getRRule()->byday as $item) { + list($num, $name) = preg_split('/(?=MO|TU|WE|TH|FR|SA|SU)/', $item); + $num = empty($num) ? 1 : (int) $num; + $this->byday_items[] = ['num' => $num, 'name' => $name]; + } + } else { + $this->byday_items[] = ['num' => 0, 'name' => '']; + } } /** @@ -405,10 +515,40 @@ public function __construct(int $id = 0, array $props = []) */ public function save(): void { - $is_edit = !empty($this->id); + $is_edit = ($this->id ?? 0) > 0; + + if (!empty($this->recurrence_iterator->getRRule()->until)) { + // When we have an until value, life is easy. + $recurrence_end = Time::createFromInterface($this->recurrence_iterator->getRRule()->until)->modify('-1 second'); + } elseif (!empty($this->recurrence_iterator->getRRule()->count)) { + // Save current values. + $view_start = clone $this->view_start; + $view_end = clone $this->view_end; + $recurrence_iterator = clone $this->recurrence_iterator; + + // Make new recurrence iterator that gets all occurrences. + $this->rrule = (string) $recurrence_iterator->getRRule(); + $this->view_start = clone $this->start; + $this->view_end = new Time('9999-12-31'); + + unset($this->recurrence_iterator); + $this->createRecurrenceIterator(); + + // Get last occurrence. + $this->recurrence_iterator->end(); + $recurrence_end = Time::createFromInterface($this->recurrence_iterator->current()); + + // Put everything back. + $this->view_start = $view_start; + $this->view_end = $view_end; + $this->recurrence_iterator = $recurrence_iterator; + } else { + // Forever. + $recurrence_end = new Time('9999-12-31'); + } // Saving a new event. - if (empty($this->id)) { + if (!$is_edit) { $columns = [ 'start_date' => 'date', 'end_date' => 'date', @@ -427,7 +567,7 @@ public function save(): void $params = [ $this->start->format('Y-m-d'), - (clone $this->recurrence_end)->sub($this->duration)->format('Y-m-d'), + $recurrence_end->format('Y-m-d'), $this->board, $this->topic, Utils::truncate($this->title, 255), @@ -451,6 +591,10 @@ public function save(): void IntegrationHook::call('integrate_create_event', [$this, &$columns, &$params]); + if (isset($this->id)) { + unset(self::$loaded[$this->id]); + } + $this->id = Db::$db->insert( '', '{db_prefix}calendar', @@ -482,7 +626,7 @@ public function save(): void $params = [ 'id' => $this->id, 'start_date' => $this->start->format('Y-m-d'), - 'end_date' => (clone $this->recurrence_end)->sub($this->duration)->format('Y-m-d'), + 'end_date' => $recurrence_end->format('Y-m-d'), 'title' => Utils::truncate($this->title, 255), 'location' => Utils::truncate($this->location, 255), 'id_board' => $this->board, @@ -534,6 +678,33 @@ public function save(): void // return ''; // } + /** + * Adds an arbitrary date to the recurrence set. + * + * Used for making exceptions to the general recurrence rule. + * + * @param \DateTimeInterface $date The date to add. + * @param ?\DateInterval $duration Optional duration for this occurrence. + * Only necessary if the duration for this occurrence differs from the + * usual duration of the event. + */ + public function addOccurrence(\DateTimeInterface $date, ?\DateInterval $duration = null): void + { + $this->recurrence_iterator->add($date, $duration); + } + + /** + * Removes a date from the recurrence set. + * + * Used for making exceptions to the general recurrence rule. + * + * @param \DateTimeInterface $date The date to remove. + */ + public function removeOccurrence(\DateTimeInterface $date): void + { + $this->recurrence_iterator->remove($date); + } + /** * Returns a generator that yields all occurrences of the event between * $this->view_start and $this->view_end. @@ -999,6 +1170,22 @@ public function __get(string $prop): mixed $value = isset($this->recurrence_iterator) ? $this->recurrence_iterator->getRRule() : null; break; + case 'rrule_preset': + if (isset($this->recurrence_iterator)) { + $value = $this->recurrence_iterator->getRRule(); + } else { + $value = null; + break; + } + + if (($value->count ?? 0) === 1) { + $value = 'never'; + } else { + unset($value->count, $value->until, $value->until_type, $value->wkst); + $value = (string) $value; + } + break; + case 'rdates': $value = isset($this->recurrence_iterator) ? $this->recurrence_iterator->getRDates() : null; break; @@ -1354,9 +1541,17 @@ public static function create(array $eventOptions): void } // Set the start and end dates. - self::setStartAndDuration($eventOptions); + self::setRequestedStartAndDuration($eventOptions); + + $eventOptions['view_start'] = \DateTimeImmutable::createFromInterface($eventOptions['start']); + $eventOptions['view_end'] = new \DateTimeImmutable('9999-12-31T23:59:59 UTC'); + + self::setRequestedRRule($eventOptions); $event = new self(0, $eventOptions); + + self::setRequestedRDatesAndExDates($event); + $event->save(); // If this isn't tied to a topic, we need to notify people about it. @@ -1401,10 +1596,18 @@ public static function modify(int $id, array &$eventOptions): void } // Set the new start date and duration. - self::setStartAndDuration($eventOptions); + self::setRequestedStartAndDuration($eventOptions); + + $eventOptions['view_start'] = \DateTimeImmutable::createFromInterface($eventOptions['start']); + $eventOptions['view_end'] = new \DateTimeImmutable('9999-12-31T23:59:59 UTC'); + + self::setRequestedRRule($eventOptions); list($event) = self::load($id); $event->set($eventOptions); + + self::setRequestedRDatesAndExDates($event); + $event->save(); } @@ -1552,8 +1755,7 @@ protected static function queryData(array $selects, array $params = [], array $j // Replace duration string with a DateInterval object. $row['duration'] = new TimeInterval($row['duration']); - // end_date actually means the recurrence end date. - $row['recurrence_end'] = (clone $row['start'])->modify($row['end_date'])->add($row['duration']); + // end_date is only used for narrowing the query. unset($row['end_date']); // Are there any adjustments to the calculated recurrence dates? @@ -1580,7 +1782,7 @@ protected static function queryData(array $selects, array $params = [], array $j * @param array $eventOptions An array of optional time and date parameters * (span, start_year, end_month, etc., etc.) */ - protected static function setStartAndDuration(array &$eventOptions): void + protected static function setRequestedStartAndDuration(array &$eventOptions): void { // Convert unprefixed time unit parameters to start_* parameters. foreach (['year', 'month', 'day', 'hour', 'minute', 'second'] as $key) { @@ -1677,6 +1879,160 @@ protected static function setStartAndDuration(array &$eventOptions): void } } + /** + * Set the RRule for a posted event for insertion into the database. + * + * @param array $eventOptions An array of optional time and date parameters + * (span, start_year, end_month, etc., etc.) + */ + protected static function setRequestedRRule(array &$eventOptions): void + { + if (isset($_REQUEST['COUNT']) && (int) $_REQUEST['COUNT'] <= 1) { + $_REQUEST['RRULE'] = 'never'; + } + + if (!empty($_REQUEST['RRULE']) && $_REQUEST['RRULE'] !== 'custom') { + if ($_REQUEST['RRULE'] === 'never') { + unset($_REQUEST['RRULE']); + $eventOptions['rrule'] = 'FREQ=YEARLY;COUNT=1'; + + return; + } + + if (!empty($_REQUEST['UNTIL'])) { + $_REQUEST['RRULE'] .= ';UNTIL=' . $_REQUEST['UNTIL']; + } elseif (!empty($_REQUEST['COUNT'])) { + $_REQUEST['RRULE'] .= ';COUNT=' . $_REQUEST['COUNT']; + } + + try { + $eventOptions['rrule'] = new RRule(Utils::htmlspecialchars($_REQUEST['RRULE'])); + + if ( + $eventOptions['rrule']->freq === 'WEEKLY' + || !empty($eventOptions['rrule']->byday) + ) { + $eventOptions['rrule']->wkst = RRule::WEEKDAYS[((Theme::$current->options['calendar_start_day'] ?? 0) + 6) % 7]; + } + + $eventOptions['rrule'] = (string) $eventOptions['rrule']; + } catch (\Throwable $e) { + unset($_REQUEST['RRULE']); + $eventOptions['rrule'] = 'FREQ=YEARLY;COUNT=1'; + } + } elseif (in_array($_REQUEST['FREQ'] ?? null, RRule::FREQUENCIES)) { + $rrule = []; + + if (isset($_REQUEST['BYDAY_num'], $_REQUEST['BYDAY_name'])) { + foreach ($_REQUEST['BYDAY_num'] as $key => $value) { + // E.g. "second Tuesday" = "BYDAY=2TU" + if (!str_contains($_REQUEST['BYDAY_name'][$key], ',')) { + $_REQUEST['BYDAY'][$key] = ((int) $_REQUEST['BYDAY_num'][$key]) . $_REQUEST['BYDAY_name'][$key]; + } + // E.g. "last weekday" = "BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" + else { + $_REQUEST['BYDAY'] = []; + $_REQUEST['BYDAY'][0] = $_REQUEST['BYDAY_name'][$key]; + $_REQUEST['BYSETPOS'] = $_REQUEST['BYDAY_num'][$key]; + break; + } + } + + $_REQUEST['BYDAY'] = implode(',', array_unique($_REQUEST['BYDAY'])); + unset($_REQUEST['BYDAY_num'], $_REQUEST['BYDAY_name']); + } + + foreach ( + [ + 'FREQ', + 'INTERVAL', + 'UNTIL', + 'COUNT', + 'BYMONTH', + 'BYWEEKNO', + 'BYYEARDAY', + 'BYMONTHDAY', + 'BYDAY', + 'BYHOUR', + 'BYMINUTE', + 'BYSECOND', + 'BYSETPOS', + ] as $part + ) { + if (isset($_REQUEST[$part])) { + if (is_array($_REQUEST[$part])) { + $rrule[] = $part . '=' . Utils::htmlspecialchars(implode(',', $_REQUEST[$part])); + } else { + $rrule[] = $part . '=' . Utils::htmlspecialchars($_REQUEST[$part]); + } + } + } + + $rrule = implode(';', $rrule); + + try { + $eventOptions['rrule'] = new RRule(Utils::htmlspecialchars($rrule)); + + if ( + $eventOptions['rrule']->freq === 'WEEKLY' + || !empty($eventOptions['rrule']->byday) + ) { + $eventOptions['rrule']->wkst = RRule::WEEKDAYS[((Theme::$current->options['calendar_start_day'] ?? 0) + 6) % 7]; + } + + $eventOptions['rrule'] = (string) $eventOptions['rrule']; + } catch (\Throwable $e) { + $eventOptions['rrule'] = 'FREQ=YEARLY;COUNT=1'; + } + + unset($rrule); + } + } + + /** + * Set the RDates for a posted event for insertion into the database. + * + * @param Event $event An event that is being created or modified. + */ + protected static function setRequestedRDatesAndExDates(Event $event): void + { + // Clear out all existing RDates and ExDates. + foreach ($event->recurrence_iterator->getRDates() as $rdate) { + $event->removeOccurrence(new \DateTimeImmutable($rdate)); + } + + foreach ($event->recurrence_iterator->getExDates() as $exdate) { + $event->addOccurrence(new \DateTimeImmutable($exdate)); + } + + // Add all the RDates and ExDates. + foreach (['RDATE', 'EXDATE'] as $date_type) { + if (!isset($_REQUEST[$date_type . '_date'])) { + continue; + } + + foreach ($_REQUEST[$date_type . '_date'] as $key => $date) { + if (empty($date)) { + continue; + } + + if (empty($event->allday) && isset($_REQUEST[$date_type . '_time'][$key])) { + $date = new Time($date . 'T' . $_REQUEST[$date_type . '_time'][$key] . ' ' . $event->start->format('e')); + } else { + $date = new Time($date . ' ' . $event->start->format('e')); + } + + $date->setTimezone(new \DateTimeZone('UTC')); + + if ($date_type === 'RDATE') { + $event->addOccurrence($date); + } else { + $event->removeOccurrence($date); + } + } + } + } + /** * Standardizes various forms of input about start and end times. * diff --git a/Sources/Calendar/RecurrenceIterator.php b/Sources/Calendar/RecurrenceIterator.php index 0ed40fcddc..8bc53b2282 100644 --- a/Sources/Calendar/RecurrenceIterator.php +++ b/Sources/Calendar/RecurrenceIterator.php @@ -462,6 +462,14 @@ public function getDtStart(): \DateTimeInterface return clone $this->dtstart; } + /** + * Returns a copy of $this->frequency_interval. + */ + public function getFrequencyInterval(): \DateInterval + { + return clone $this->frequency_interval; + } + /** * Returns a copy of the recurrence rule. */ diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index 4bb7f53a6c..90fa9eafd0 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -805,82 +805,9 @@ function template_event_post() '; echo ' -
    -
    - ', Lang::$txt['calendar_event_title'], ' - -
    -
    - -
    -
    '; +
    '; - // If this is a new event let the user specify which board they want the linked post to be put into. - if (Utils::$context['event']['new'] && !empty(Utils::$context['event']['categories'])) - { - echo ' -
    -
    - ', Lang::$txt['calendar_post_in'], ' - - -
    -
    '; - } - - // Note to theme writers: The JavaScript expects the input fields for the start and end dates & times to be contained in a wrapper element with the id "event_time_input" - echo ' -
    -
    - ', Lang::$txt['calendar_event_options'], ' -
    -
    - ', Lang::$txt['start'], ' - - -
    -
    - ', Lang::$txt['end'], ' - - -
    -
    -
    -
    - - -
    -
    - ', Lang::$txt['calendar_timezone'], ' - -
    -
    -
    - ', Lang::$txt['location'], ' - -
    -
    '; + template_event_options(); echo ' '; diff --git a/Themes/default/EventEditor.template.php b/Themes/default/EventEditor.template.php new file mode 100644 index 0000000000..655f501eaa --- /dev/null +++ b/Themes/default/EventEditor.template.php @@ -0,0 +1,425 @@ + + ', Lang::$txt['calendar_event_options'], ' + '; + + // If this is a new event let the user specify which board they want the linked post to be put into. + if (!empty(Utils::$context['event']->new) && !empty(Utils::$context['event']->categories)) { + echo ' +
    +
    + +
    +
    + board) ? ' checked' : ''), ' onclick="toggleLinked(this.form);"> + +
    +
    '; + } + + // Basic event info + echo ' +
    +
    + ', Lang::$txt['calendar_event_title'], ' +
    +
    + +
    + +
    + +
    +
    + +
    +
    '; + + // Date and time info. + echo ' +
    +
    +
    + +
    +
    + allday) ? ' checked' : '', '> + +
    + +
    + +
    +
    + + allday) ? ' disabled' : '', '> +
    + +
    + +
    +
    + + allday) ? ' disabled' : '', '> + +
    + +
    + +
    +
    '; + + // Setting max-width on selects inside floating elements can be flaky, + // so we need to calculate the width value manually. + echo ' + +
    +
    '; + + // Recurring event options. + echo ' +
    '; + + // RRULE presets. + echo ' +
    + +
    +
    + '; + + // When to end the recurrence. + echo ' + + + recurrence_iterator->getRRule()->until) ? ' value="' . Utils::$context['event']->recurrence_iterator->getRRule()->until->format('Y-m-d') . '"' : ' disabled', '> + recurrence_iterator->getRRule()->count ?? 0) > 1 ? ' value="' . Utils::$context['event']->recurrence_iterator->getRRule()->count . '"' : ' value="1" disabled', '> + +
    +
    '; + + // Custom frequency and interval (e.g. "every 2 weeks") + echo ' +
    +
    + +
    +
    + + +
    +
    '; + + // Custom yearly options. + echo ' +
    +
    + +
    +
    +
    '; + + for ($i = 1; $i <= 12; $i++) { + echo ' + '; + + if ($i % 6 === 0) { + echo ' +
    +
    '; + } + } + + echo ' +
    +
    +
    '; + + // Custom monthly options. + echo ' +
    '; + + // Custom monthly: by day of month. + echo ' +
    + +
    +
    +
    +
    +
    '; + + for ($i = 1; $i <= 31; $i++) { + echo ' + '; + + if ($i % 7 === 0) { + echo ' +
    +
    '; + } + } + + echo ' +
    +
    +
    +
    '; + + // Custom monthly: by weekday and offset (e.g. "the second Tuesday") + echo ' +
    + +
    +
    +
    '; + + foreach (Utils::$context['event']->byday_items as $byday_item_key => $byday_item) { + echo ' +
    + + +
    '; + } + + echo ' +
    + + +
    +
    '; + + // Custom weekly options. + echo ' +
    +
    + +
    +
    '; + + foreach (Utils::$context['event']->sorted_weekdays as $weekday) { + echo ' + '; + } + + echo ' +
    +
    '; + + // Advanced options. + echo ' +
    recurrence_iterator->getRDates()) || !empty(Utils::$context['event']->recurrence_iterator->getEXDates()) ? ' open' : '') . '> + ', Lang::$txt['calendar_repeat_advanced_options_label'], ''; + + // Arbitrary dates to add to the recurrence set. + echo ' +
    +
    + +
    +
    +
    '; + + foreach (Utils::$context['event']->recurrence_iterator->getRDates() as $key => $rdate) { + $rdate = new SMF\Time($rdate); + $rdate->setTimezone(Utils::$context['event']->start->getTimezone()); + + echo ' +
    + + + +
    '; + } + + echo ' +
    + +
    +
    '; + + // Dates to exclude from the recurrence set. + echo ' +
    +
    + +
    +
    +
    '; + + foreach (Utils::$context['event']->recurrence_iterator->getExDates() as $key => $exdate) { + $exdate = new SMF\Time($exdate); + + echo ' +
    + + +
    '; + } + + echo ' +
    + + +
    +
    '; + + echo ' +
    + '; +} + +?> \ No newline at end of file diff --git a/Themes/default/Post.template.php b/Themes/default/Post.template.php index c9f2bd0550..5cccc71d1e 100644 --- a/Themes/default/Post.template.php +++ b/Themes/default/Post.template.php @@ -142,57 +142,13 @@ function addPollOption() // Are you posting a calendar event? if (Utils::$context['make_event']) { - // Note to theme writers: The JavaScripts expect the input fields for the start and end dates & times to be contained in a wrapper element with the id "event_time_input" echo '
    -
    -
    - ', Lang::$txt['calendar_event_options'], ' - -
    -
    - ', Lang::$txt['calendar_event_title'], ' - -
    -
    -
    -
    -
    - ', Lang::$txt['start'], ' - - -
    -
    - ', Lang::$txt['end'], ' - - -
    -
    -
    -
    - - -
    -
    - ', Lang::$txt['calendar_timezone'], ' - -
    -
    -
    -
    -
    - ', Lang::$txt['location'], ' - -
    -
    -
    + echo '
    '; } diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index 8eb939e6bf..77e4ef4000 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -1515,18 +1515,39 @@ ul li.greeting { } #post_event input[type="checkbox"] { height: auto; + vertical-align: initial; } #post_event input[type="text"][disabled] { color: transparent; } -#post_event select, #event_options input[type="text"], #tz { - max-width: calc(100% - 75px); +#tz { + max-width: 100%; +} +#rrule_end { + white-space: nowrap; +} +.rrule_input.inline_block { + vertical-align: top; +} +.byday_label, +.bymonth_label, +.bymonthday_label { + white-space: nowrap; } -#post_event select, #evtitle, #event_location { - width: calc(100% - 75px); +.byday_label span, +.bymonth_label span, +.bymonthday_label span { + display: inline-block; +} +.bymonth_label span { + width: 4ch; } -#post_event input[type="checkbox"] + select { - max-width: calc(100% - 95px); +.bymonthday_label span { + width: 2ch; + text-align: center; +} +.bymonthday_label input:disabled + span { + opacity: 0.5; } /* Styles for the recent messages section. @@ -3502,19 +3523,25 @@ form#postmodify .roundframe { padding: 6px; overflow: hidden; } -#post_header dt { +#post_header dt, +#event_options dt { float: left; padding: 0; width: 15%; margin: 6px 0 0 0; font-weight: bold; } -#post_header dd { +#post_header dd, +#event_options dd { float: left; padding: 0; width: 83%; margin: 4px 0; } +#post_header input[type="text"], +#event_basic_info input[type="text"] { + width: 100%; +} #post_header img { vertical-align: middle; } diff --git a/Themes/default/css/responsive.css b/Themes/default/css/responsive.css index 5757c83d49..abe23147e3 100644 --- a/Themes/default/css/responsive.css +++ b/Themes/default/css/responsive.css @@ -48,28 +48,6 @@ #top_info .welcome { display: none; } - /* Calendar */ - #event_time_options { - width: 44%; - } - #event_title { - padding: 0; - } - #evtitle { - width: 98%; - } - .event_options_left, .event_options_right { - display: block; - max-width: unset; - width: unset; - } - #event_title input[type="text"] { - width: 100%; - } - #post_event #event_board select { - width: calc(100% - 90px); - max-width: unset; - } } /* We have shared things... */ @@ -369,8 +347,13 @@ form#postmodify .roundframe, #post_event .roundframe { padding: 5px; } - #post_header input { - width: 100%; + #post_header input, + #event_options input[type="text"], + #event_options input[type="date"], + #event_options input[type="time"], + #event_options select { +/* width: 100% !important; */ +/* max-width: initial; */ } #post_confirm_buttons .smalltext { display: none; @@ -580,13 +563,13 @@ @media (min-width: 481px) and (max-width: 560px) { /* Calendar */ #event_time_options { - width: 40%; +/* width: 40%; */ } #event_title, #event_board { - width: 100%; +/* width: 100%; */ } #evtitle { - width: 98%; +/* width: 98%; */ } } @@ -666,12 +649,26 @@ } /* Post Section */ - #post_header dd { + #post_header dd, + #event_options dd { width: 55%; } - #post_header dt { + #post_header dt, + #event_options dt { width: 35%; } + #rrule_options dd, + #rrule_end { + display: inline-flex; + flex-flow: column; + } + .byday_label span { + width: 3ch; + } + .bymonth_label, + .bymonthday_label { + float: left; + } img#icons { margin: 0 0 0 5px; } @@ -803,17 +800,6 @@ float: none; } - /* Calendar */ - .event_options_left, .event_options_right { - width: 100%; - } - #event_title, #event_board { - width: 100%; - } - #evtitle { - width: 98%; - } - /* Menu tests */ #header_news_lists_preview, tr[id^="list_news_lists_"] td:nth-child(even), #header_smiley_set_list_default, #header_smiley_set_list_url, #header_smiley_set_list_check, diff --git a/Themes/default/scripts/event.js b/Themes/default/scripts/event.js new file mode 100644 index 0000000000..16b65adacc --- /dev/null +++ b/Themes/default/scripts/event.js @@ -0,0 +1,682 @@ +window.addEventListener("DOMContentLoaded", updateEventUI); + +for (const elem of document.querySelectorAll("#start_date, #start_time, #end_date, #end_time, #allday, #tz, #rrule, #freq, #end_option, #monthly_option_type_bymonthday, #monthly_option_type_byday, #byday_num_select_0, #byday_name_select_0, #weekly_options .rrule_input[name=\'BYDAY\[\]\'], #monthly_options .rrule_input[name=\'BYDAY_num\[\]\'], #monthly_options .rrule_input[name=\'BYDAY_name\[\]\'], #monthly_options .rrule_input[name=\'BYMONTHDAY\[\]\']")) { + elem.addEventListener("change", updateEventUI); +} + +document.getElementById("event_add_byday").addEventListener("click", addByDayItem); + +for (const elem of document.querySelectorAll("#event_add_rdate, #event_add_exdate")) { + elem.addEventListener("click", addRDateOrExDate); +} + +for (const elem of document.querySelectorAll("#rdate_list a, #exdate_list a")) { + elem.addEventListener("click", removeRDateOrExDate); +} + +let rdates_count = document.querySelectorAll("#rdate_list input[type='date']").length; +let exdates_count = document.querySelectorAll("#exdate_list input[type='date']").length; + +let current_start_date = new Date(document.getElementById("start_date").value + 'T' + document.getElementById("start_time").value); +let current_end_date = new Date(document.getElementById("end_date").value + 'T' + document.getElementById("end_time").value); + +const weekday_abbrevs = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + +// Update all the parts of the event editing UI. +function updateEventUI() +{ + let start_date = new Date(document.getElementById("start_date").value + 'T' + document.getElementById("start_time").value); + let end_date = new Date(document.getElementById("end_date").value + 'T' + document.getElementById("end_time").value); + + let weekday = getWeekday(start_date); + + // Disable or enable the time-related fields as necessary. + document.getElementById("start_time").disabled = document.getElementById("allday").checked; + document.getElementById("end_time").disabled = document.getElementById("allday").checked; + document.getElementById("tz").disabled = document.getElementById("allday").checked; + + // Reset the recurring event options to be hidden and disabled. + // We'll turn the appropriate ones back on below. + for (const elem of document.querySelectorAll(".rrule_input_wrapper")) { + elem.style.display = "none"; + } + + for (const elem of document.querySelectorAll(".rrule_input")) { + elem.disabled = true; + } + + // If using a custom RRule, show the relevant options. + if (document.getElementById("rrule").value === "custom") { + // Show select menu for FREQ options. + document.getElementById("freq_interval_options").style.display = ""; + + for (const elem of document.querySelectorAll("#freq_interval_options .rrule_input")) { + elem.disabled = false; + } + + // Show additional options based on the selected frequency. + switch (document.getElementById("freq").value) { + case "YEARLY": + for (const elem of document.querySelectorAll("#yearly_options .rrule_input")) { + elem.disabled = false; + } + + // If necessary, align date fields to BYMONTH inputs. + if (this.name == "BYMONTH[]") { + let checked_months = []; + + for (const elem of document.querySelectorAll("#yearly_options .rrule_input[name=\'BYMONTH\[\]\']")) { + if (elem.checked) { + checked_months.push(elem.id.substring(8)); + } + } + + let found = false; + + let m; + for (m of checked_months) { + if (m >= (start_date.getMonth() + 1)) { + found = true; + break; + } + } + + if (!found) { + checked_months.reverse(); + for (m of checked_months) { + if (m < (start_date.getMonth() + 1)) { + found = true; + break; + } + } + } + + if (found) { + let temp_date = new Date( + start_date.getFullYear(), + m - 1, + start_date.getDate(), + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + start_date = updateStartDate(temp_date); + end_date = updateEndDate(temp_date, end_date); + } + } + + // Ensure the yearly BYMONTH inputs align with start_date. + if (!document.getElementById("bymonth_" + (start_date.getMonth() + 1)).checked) { + for (const elem of document.querySelectorAll(".rrule_input[name=\'BYMONTH\[\]\']")) { + elem.checked = false; + } + } + document.getElementById("bymonth_" + (start_date.getMonth() + 1)).checked = true; + + document.getElementById("yearly_options").style.display = ""; + // no break + + case "MONTHLY": + // Enable both radio buttons to allow choosing the type of montly recurrence. + for (const elem of document.querySelectorAll("#monthly_options input[name=\'monthly_option_type\']")) { + elem.disabled = false; + } + + // Make sure one of the radio buttons is checked. + if ( + !document.getElementById("monthly_option_type_bymonthday").checked + && !document.getElementById("monthly_option_type_byday").checked + ) { + document.getElementById("monthly_option_type_bymonthday").checked = true; + } + + // Enable either the BYMONTHDAY inputs or BYDAY_* select menus. + for (const elem of document.querySelectorAll("#month_bymonthday_options .rrule_input")) { + elem.disabled = document.getElementById("monthly_option_type_byday").checked; + } + + for (const elem of document.querySelectorAll("#month_byday_options .rrule_input")) { + elem.disabled = !document.getElementById("monthly_option_type_byday").checked; + + if (elem.classList.contains('button')) { + elem.style.display = document.getElementById("monthly_option_type_byday").checked ? "" : "none"; + } + } + + // Need to know the current values in the BYDAY_* select menus. + let byday_num = document.getElementById("byday_num_select_0").value; + let byday_name = document.getElementById("byday_name_select_0").value; + + // Need to know the last day of the month for stuff below. + const end_of_month = new Date( + start_date.getFullYear(), + start_date.getMonth() + 1, + 0, + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + let selected_days = []; + + for (const weekday_abbrev of weekday_abbrevs) { + if (byday_name.includes(weekday_abbrev)) { + selected_days.push(weekday_abbrevs.indexOf(weekday_abbrev)); + } + } + + // If necessary, align date fields to BYDAY_* selection. + if (this.id === "byday_num_select_0" || this.id === "byday_name_select_0") { + let temp_date = getNewStartDateByDay(start_date, end_of_month, byday_num, selected_days); + + if (temp_date.getMonth() !== start_date.getMonth() && byday_num == 5) { + enableOrDisableFifth(); + byday_num = document.getElementById("byday_num_select_0").value; + byday_name = document.getElementById("byday_name_select_0").value; + selected_days = [weekday_abbrevs.indexOf(byday_name)]; + + temp_date = getNewStartDateByDay(start_date, end_of_month, byday_num, selected_days); + } + + if (temp_date.getMonth() === start_date.getMonth()) { + start_date = updateStartDate(temp_date); + end_date = updateEndDate(start_date, end_date); + } + } + + // If necessary, align date fields to BYMONTHDAY inputs. + if (this.name == "BYMONTHDAY[]" && !document.getElementById("bymonthday_" + start_date.getDate()).checked) { + let checked_dates = []; + + for (const elem of document.querySelectorAll("#monthly_options .rrule_input[name=\'BYMONTHDAY\[\]\']")) { + if (elem.checked) { + checked_dates.push(elem.id.substring(11)); + } + } + + let found = false; + + let d; + for (d of checked_dates) { + if (d >= start_date.getDate()) { + found = true; + break; + } + } + + if (!found) { + checked_dates.reverse(); + for (d of checked_dates) { + if (d < start_date.getDate()) { + found = true; + break; + } + } + } + + if (found) { + let temp_date = new Date( + start_date.getFullYear(), + start_date.getMonth(), + d, + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + start_date = updateStartDate(temp_date); + end_date = updateEndDate(temp_date, end_date); + } + } + + // Update weekday in case it changed. + weekday = getWeekday(start_date); + + // If necessary, reset the BYMONTHDAY inputs. + if (!document.getElementById("bymonthday_" + start_date.getDate()).checked) { + for (const elem of document.querySelectorAll("#monthly_options .rrule_input[name=\'BYMONTHDAY\[\]\']")) { + elem.checked = false; + } + } + + // Ensure the BYMONTHDAY input for start_date is checked. + document.getElementById("bymonthday_" + start_date.getDate()).checked = true; + + // If necessary, update the BYDAY_* select menus. + if (!byday_name.includes(weekday)) { + for (const elem of document.querySelectorAll("#byday_name_select_0 .byday_name_" + weekday)) { + elem.selected = true; + } + + byday_name = weekday; + selected_days = [weekday_abbrevs.indexOf(weekday)]; + } + + if (byday_num < 0) { + let temp_byday_num = 0; + + for (let d = end_of_month.getDate(); d > start_date.getDate(); d--) { + let temp_date = new Date( + start_date.getFullYear(), + start_date.getMonth(), + d, + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + if (selected_days.includes(temp_date.getDay())) { + ++temp_byday_num; + } + } + + ++temp_byday_num; + + if (temp_byday_num <= 2) { + for (const elem of document.querySelectorAll("#byday_num_select_0 .byday_num_neg" + temp_byday_num)) { + elem.selected = true; + } + } else { + byday_num = 0; + byday_name = weekday; + selected_days = [weekday_abbrevs.indexOf(weekday)]; + for (const elem of document.querySelectorAll("#byday_name_select_0 .byday_name_" + weekday)) { + elem.selected = true; + } + } + } + + if (byday_num >= 0) { + let temp_byday_num = 0; + + for (let d = 1; d < start_date.getDate(); d++) { + let temp_date = new Date( + start_date.getFullYear(), + start_date.getMonth(), + d, + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + if (selected_days.includes(temp_date.getDay())) { + ++temp_byday_num; + } + } + + ++temp_byday_num; + + for (const elem of document.querySelectorAll("#byday_num_select_0 .byday_num_" + temp_byday_num)) { + elem.selected = true; + } + } + + document.getElementById("monthly_options").style.display = ""; + + if (document.getElementById("freq").value === "YEARLY") { + document.getElementById("dt_monthly_option_type_bymonthday").style.display = "none"; + document.getElementById("dd_monthly_option_type_bymonthday").style.display = "none"; + document.getElementById("monthly_option_type_bymonthday").disabled = true; + document.getElementById("monthly_option_type_byday").type = "checkbox"; + } else { + document.getElementById("dt_monthly_option_type_bymonthday").style.display = ""; + document.getElementById("dd_monthly_option_type_bymonthday").style.display = ""; + document.getElementById("monthly_option_type_byday").type = "radio"; + } + break; + + case "WEEKLY": + for (const elem of document.querySelectorAll("#weekly_options .rrule_input")) { + elem.disabled = false; + } + + if (this.name == "BYDAY[]" && !document.getElementById("byday_" + weekday).checked) { + let checked_days = []; + + for (const elem of document.querySelectorAll("#weekly_options .rrule_input[name=\'BYDAY\[\]\']")) { + if (elem.checked) { + checked_days.push(elem.id.substring(6)); + } + } + + let cd; + for (cd of checked_days) { + if (weekday_abbrevs.indexOf(cd) > weekday_abbrevs.indexOf(weekday)) { + found = true; + break; + } + } + + if (!found) { + checked_days.reverse(); + for (cd of checked_days) { + if (weekday_abbrevs.indexOf(cd) < weekday_abbrevs.indexOf(weekday)) { + found = true; + break; + } + } + } + + if (found) { + let temp_date = start_date; + + // Rewind to previous Sunday or first day of month. + while (temp_date.getDay() > 0) { + temp_date.setDate(temp_date.getDate() - 1); + + if (temp_date.getMonth() < start_date.getMonth()) { + temp_date.setDate(temp_date.getDate() + 1); + break; + } + } + + // Now step forward until we get to the day we need. + while (temp_date.getDay() != weekday_abbrevs.indexOf(cd)) { + temp_date.setDate(temp_date.getDate() + 1); + } + + start_date = updateStartDate(temp_date); + end_date = updateEndDate(start_date, end_date); + } + } + + // Update weekday in case it changed. + weekday = getWeekday(start_date); + + // If necessary, reset the BYDAY values. + if (!document.getElementById("byday_" + weekday).checked) { + for (const elem of document.querySelectorAll("#weekly_options .rrule_input[name=\'BYDAY\[\]\']")) { + elem.checked = false; + } + } + + document.getElementById("byday_" + weekday).checked = true; + document.getElementById("weekly_options").style.display = ""; + break; + + } + } else { + document.getElementById("freq_interval_options").style.display = "none"; + document.getElementById("freq").value = "DAILY"; + + for (const elem of document.querySelectorAll(".rrule_input_wrapper")) { + elem.style.display = "none"; + } + + for (const elem of document.querySelectorAll(".rrule_input")) { + elem.disabled = true; + } + + for (const elem of document.querySelectorAll("#rrule_options .rrule_input")) { + elem.disabled = false; + } + + for (const elem of document.querySelectorAll(".rrule_input[name=\'BYMONTHDAY\[\]\']")) { + elem.checked = false; + } + } + + end_date = updateEndDate(start_date, end_date); + + // Show the basic RRule select menu. + for (const elem of document.querySelectorAll(".rrule_options")) { + elem.style.display = ""; + } + + for (const elem of document.querySelectorAll("#rrule")) { + elem.disabled = false; + } + + // If necessary, show the options for RRule end. + if (document.getElementById("rrule").value !== "never") { + const end_option = document.getElementById("end_option"); + const until = document.getElementById("until"); + const count = document.getElementById("count"); + + document.getElementById("rrule_end").style.display = ""; + + end_option.disabled = false; + end_option.style.display = ""; + + until.disabled = (end_option.value !== "until"); + until.required = (end_option.value === "until"); + until.style.display = (end_option.value !== "until") ? "none" : ""; + + count.disabled = (end_option.value !== "count"); + count.required = (end_option.value === "count"); + count.style.display = (end_option.value !== "count") ? "none" : ""; + } + + enableOrDisableFifth(); + + // Show or hide the options for additional and excluded dates. + if ( + document.getElementById("rrule") + && document.getElementById("rrule").value === "never" + && document.querySelector("#advanced_options input") + && document.querySelector("#advanced_options input").value === "" + ) { + document.getElementById("advanced_options").style.display = "none"; + + for (const elem of document.querySelectorAll("#advanced_options input")) { + elem.disabled = true; + } + } else { + document.getElementById("advanced_options").style.display = ""; + + for (const elem of document.querySelectorAll("#advanced_options input")) { + elem.disabled = false; + } + } +} + +// Updates start_date and start_time elements to new values. +function updateStartDate(start_date) +{ + document.getElementById("start_date").value = start_date.getFullYear() + '-' + (start_date.getMonth() < 9 ? '0' : '') + (start_date.getMonth() + 1) + '-' + (start_date.getDate() < 10 ? '0' : '') + start_date.getDate(); + + return start_date; +} + +// If start_date or start_time elements changed, automatically updates +// end_date and end_time elements to preserve the event duration. +function updateEndDate(start_date, end_date) +{ + if (current_start_date.getTime() !== start_date.getTime()) { + const start_diff = start_date.getTime() - current_start_date.getTime(); + + end_date.setTime(end_date.getTime() + start_diff); + + document.getElementById("end_date").value = end_date.getFullYear() + '-' + (end_date.getMonth() < 9 ? '0' : '') + (end_date.getMonth() + 1) + '-' + (end_date.getDate() < 10 ? '0' : '') + end_date.getDate(); + document.getElementById("end_time").value = end_date.toTimeString().substring(0, 5); + + document.getElementById("end_date").min = document.getElementById("start_date").value; + + if (document.getElementById("start_date").value === document.getElementById("end_date").value) { + document.getElementById("end_time").min = document.getElementById("start_time").value; + } else { + document.getElementById("end_time").removeAttribute("min"); + } + } + + // Ensure start and end have a sane relationship. + if (start_date.getTime() > end_date.getTime()) { + const current_duration = current_end_date.getTime() - current_start_date.getTime(); + + end_date = start_date; + end_date.setTime(end_date.getTime() + current_duration); + + document.getElementById("end_date").value = end_date.getFullYear() + '-' + (end_date.getMonth() < 9 ? '0' : '') + (end_date.getMonth() + 1) + '-' + (end_date.getDate() < 10 ? '0' : '') + end_date.getDate(); + document.getElementById("end_time").value = end_date.toTimeString().substring(0, 5); + } + + // If necessary, also update the UNTIL field. + let until = new Date(document.getElementById("until").value + "T23:59:59.999"); + + document.getElementById("until").min = document.getElementById("start_date").value; + + if (start_date.getTime() > until.getTime()) { + document.getElementById("until").value = document.getElementById("end_date").value; + } + + // Remember any changes to start and end dates. + current_start_date = start_date; + current_end_date = end_date; + + return end_date; +} + +// Gets the weekday abbreviation of a date. +function getWeekday(date) +{ + return weekday_abbrevs[date.getDay()]; +} + +// +function getNewStartDateByDay(start_date, end_of_month, byday_num, selected_days) +{ + let temp_date = new Date( + start_date.getFullYear(), + start_date.getMonth(), + byday_num > 0 ? 1 : end_of_month.getDate(), + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + let offset = 0; + + while ( + ( + !selected_days.includes(temp_date.getDay()) + || offset != byday_num + ) + && temp_date.getMonth() === start_date.getMonth() + ) { + if (selected_days.includes(temp_date.getDay())) { + offset = offset + (byday_num > 0 ? 1 : -1); + + if (offset == byday_num) { + break; + } + } + + temp_date.setDate(temp_date.getDate() + (byday_num > 0 ? 1 : -1)); + } + + return temp_date; +} + +// Determine whether the BYDAY_num select menu's "fifth" option should be enabled or not. +function enableOrDisableFifth() +{ + const start_date = new Date(document.getElementById("start_date").value + 'T' + document.getElementById("start_time").value); + + const end_of_month = new Date( + start_date.getFullYear(), + start_date.getMonth() + 1, + 0, + start_date.getHours(), + start_date.getMinutes(), + start_date.getSeconds(), + start_date.getMilliseconds() + ); + + const byday_name = document.getElementById("byday_name_select_0").value; + + let selected_days = []; + + for (const weekday_abbrev of weekday_abbrevs) { + if (byday_name.includes(weekday_abbrev)) { + selected_days.push(weekday_abbrevs.indexOf(weekday_abbrev)); + } + } + + let enable_fifth = false; + let temp_date = end_of_month; + while (temp_date.getDate() > 28) { + if (selected_days.includes(temp_date.getDay())) { + enable_fifth = true; + } + + temp_date.setDate(temp_date.getDate() - 1); + } + + for (const elem_fifth of document.querySelectorAll("#byday_num_select_0 .byday_num_5")) { + elem_fifth.disabled = !enable_fifth; + // If "fifth" is selected but disabled, change selection to "last" instead. + if (elem_fifth.disabled && elem_fifth.selected) { + elem_fifth.selected = false; + + for (const elem_neg1 of document.querySelectorAll("#byday_num_select_0 .byday_num_neg1")) { + elem_neg1.selected = true; + } + } + } +} + +function addByDayItem() +{ + if ("content" in document.createElement("template")) { + const container = document.querySelector("#month_byday_options .rrule_input"); + const template = document.getElementById("byday_template"); + + const clone = template.content.cloneNode(true); + + const byday_num_select = clone.querySelector(".byday_num_select"); + byday_num_select.id = "byday_num_select_" + (++monthly_byday_items); + + const byday_name_select = clone.querySelector(".byday_name_select"); + byday_name_select.id = "byday_name_select_" + monthly_byday_items; + + container.appendChild(clone); + } +} + +function addRDateOrExDate() +{ + if ("content" in document.createElement("template")) { + const container = document.getElementById(this.dataset.container); + const template = document.getElementById("additional_dates_template"); + + let item_count; + if (this.dataset.inputname === 'RDATE') { + item_count = ++rdates_count; + } else if (this.dataset.inputname === 'EXDATE') { + item_count = ++exdates_count; + } else { + return; + } + + const clone = template.content.cloneNode(true); + + const date_input = clone.querySelector('input[type="date"]'); + date_input.name = this.dataset.inputname + "_date[" + (item_count) + "]"; + + const time_input = clone.querySelector('input[type="time"]'); + time_input.name = this.dataset.inputname + "_time[" + item_count + "]"; + + const removeButton = clone.querySelector('a.delete'); + removeButton.addEventListener("click", removeRDateOrExDate); + + container.appendChild(clone); + } +} + +function removeRDateOrExDate() +{ + if (this.closest("dl").id === 'rdates') { + --rdates_count; + } else if (this.closest("dl").id === 'exdates') { + --exdates_count; + } + + this.parentElement.remove(); +} \ No newline at end of file From 10408830c2c8f1189875dd803fe53fa65099e82c Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Fri, 15 Dec 2023 23:16:52 -0700 Subject: [PATCH 10/22] Use HTML5 date and time inputs throughout calendar UI Signed-off-by: Jon Stovell --- Sources/Actions/Calendar.php | 183 +----------------- Themes/default/Calendar.template.php | 6 +- Themes/default/scripts/calendar.js | 42 ++++ .../scripts/jquery-ui.datepicker.min.js | 7 - Themes/default/scripts/jquery.datepair.min.js | 7 - .../default/scripts/jquery.timepicker.min.js | 7 - 6 files changed, 51 insertions(+), 201 deletions(-) create mode 100644 Themes/default/scripts/calendar.js delete mode 100644 Themes/default/scripts/jquery-ui.datepicker.min.js delete mode 100755 Themes/default/scripts/jquery.datepair.min.js delete mode 100755 Themes/default/scripts/jquery.timepicker.min.js diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 7cd4c5c250..d30e7d0943 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -118,6 +118,7 @@ public function show(): void // This is gonna be needed... Theme::loadTemplate('Calendar'); Theme::loadCSSFile('calendar.css', ['force_current' => false, 'validate' => true, 'rtl' => 'calendar.rtl.css'], 'smf_calendar'); + Theme::loadJavaScriptFile('calendar.js', ['defer' => true], 'smf_calendar'); // Did they specify an individual event ID? If so, let's splice the year/month in to what we would otherwise be doing. if (isset($_GET['event'])) { @@ -913,10 +914,9 @@ public static function getTodayInfo(): array * @param string $selected_date A date in YYYY-MM-DD format * @param array $calendarOptions An array of calendar options * @param bool $is_previous Whether this is the previous month - * @param bool $has_picker Whether to add javascript to handle a date picker * @return array A large array containing all the information needed to show a calendar grid for the given month */ - public static function getCalendarGrid(string $selected_date, array $calendarOptions, bool $is_previous = false, bool $has_picker = true): array + public static function getCalendarGrid(string $selected_date, array $calendarOptions, bool $is_previous = false): array { $selected_object = new Time($selected_date . ' ' . User::getTimezone()); @@ -952,6 +952,7 @@ public static function getCalendarGrid(string $selected_date, array $calendarOpt 'disabled' => Config::$modSettings['cal_maxyear'] < $next_object->format('Y'), ], 'start_date' => $selected_object->format(Time::getDateFormat()), + 'iso_start_date' => $selected_object->format('Y-m-d'), ]; // Get today's date. @@ -1058,11 +1059,6 @@ public static function getCalendarGrid(string $selected_date, array $calendarOpt $calendarGrid['next_calendar']['href'] = Config::$scripturl . '?action=calendar;viewmonth;year=' . $calendarGrid['next_calendar']['year'] . ';month=' . $calendarGrid['next_calendar']['month'] . ';day=' . $calendarGrid['previous_calendar']['day']; - if ($has_picker) { - self::loadDatePicker('#calendar_navigation .date_input'); - self::loadDatePair('#calendar_navigation', 'date_input'); - } - return $calendarGrid; } @@ -1128,6 +1124,7 @@ public static function getCalendarWeek(string $selected_date, array $calendarOpt 'disabled' => Config::$modSettings['cal_maxyear'] < $next_object->format('Y'), ], 'start_date' => $selected_object->format(Time::getDateFormat()), + 'iso_start_date' => $selected_object->format('Y-m-d'), 'show_events' => $calendarOptions['show_events'], 'show_holidays' => $calendarOptions['show_holidays'], 'show_birthdays' => $calendarOptions['show_birthdays'], @@ -1176,9 +1173,6 @@ public static function getCalendarWeek(string $selected_date, array $calendarOpt $calendarGrid['next_week']['href'] = Config::$scripturl . '?action=calendar;viewweek;year=' . $calendarGrid['next_week']['year'] . ';month=' . $calendarGrid['next_week']['month'] . ';day=' . $calendarGrid['next_week']['day']; - self::loadDatePicker('#calendar_navigation .date_input'); - self::loadDatePair('#calendar_navigation', 'date_input', ''); - return $calendarGrid; } @@ -1201,10 +1195,12 @@ public static function getCalendarList(string $start_date, string $end_date, arr 'start_year' => $start_object->format('Y'), 'start_month' => $start_object->format('m'), 'start_day' => $start_object->format('d'), + 'iso_start_date' => $start_object->format('Y-m-d'), 'end_date' => $end_object->format(Time::getDateFormat()), 'end_year' => $end_object->format('Y'), 'end_month' => $end_object->format('m'), 'end_day' => $end_object->format('d'), + 'iso_end_date' => $end_object->format('Y-m-d'), ]; $calendarGrid['birthdays'] = $calendarOptions['show_birthdays'] ? self::getBirthdayRange($start_date, $end_date) : []; @@ -1243,176 +1239,9 @@ public static function getCalendarList(string $start_date, string $end_date, arr } } - self::loadDatePicker('#calendar_range .date_input'); - self::loadDatePair('#calendar_range', 'date_input', ''); - return $calendarGrid; } - /** - * Loads the necessary JavaScript and CSS to create a datepicker. - * - * @param string $selector A CSS selector for the input field(s) that the datepicker should be attached to. - * @param string $date_format The date format to use, in strftime() format. - */ - public static function loadDatePicker(string $selector = 'input.date_input', string $date_format = ''): void - { - if (empty($date_format)) { - $date_format = Time::getDateFormat(); - } - - // Convert to format used by datepicker - $date_format = strtr($date_format, [ - // Day - '%a' => 'D', '%A' => 'DD', '%e' => 'd', '%d' => 'dd', '%j' => 'oo', '%u' => '', '%w' => '', - // Week - '%U' => '', '%V' => '', '%W' => '', - // Month - '%b' => 'M', '%B' => 'MM', '%h' => 'M', '%m' => 'mm', - // Year - '%C' => '', '%g' => 'y', '%G' => 'yy', '%y' => 'y', '%Y' => 'yy', - // Time (we remove all of these) - '%H' => '', '%k' => '', '%I' => '', '%l' => '', '%M' => '', '%p' => '', '%P' => '', - '%r' => '', '%R' => '', '%S' => '', '%T' => '', '%X' => '', '%z' => '', '%Z' => '', - // Time and Date Stamps - '%c' => 'D, d M yy', '%D' => 'mm/dd/y', '%F' => 'yy-mm-dd', '%s' => '@', '%x' => 'D, d M yy', - // Miscellaneous - '%n' => ' ', '%t' => ' ', '%%' => '%', - ]); - - Theme::loadCSSFile('jquery-ui.datepicker.css', [], 'smf_datepicker'); - Theme::loadJavaScriptFile('jquery-ui.datepicker.min.js', ['defer' => true], 'smf_datepicker'); - Theme::addInlineJavaScript(' - $("' . $selector . '").datepicker({ - dateFormat: "' . $date_format . '", - autoSize: true, - isRTL: ' . (Utils::$context['right_to_left'] ? 'true' : 'false') . ', - constrainInput: true, - showAnim: "", - showButtonPanel: false, - yearRange: "' . Config::$modSettings['cal_minyear'] . ':' . Config::$modSettings['cal_maxyear'] . '", - hideIfNoPrevNext: true, - monthNames: ["' . implode('", "', Lang::$txt['months_titles']) . '"], - monthNamesShort: ["' . implode('", "', Lang::$txt['months_short']) . '"], - dayNames: ["' . implode('", "', Lang::$txt['days']) . '"], - dayNamesShort: ["' . implode('", "', Lang::$txt['days_short']) . '"], - dayNamesMin: ["' . implode('", "', Lang::$txt['days_short']) . '"], - prevText: "' . Lang::$txt['prev_month'] . '", - nextText: "' . Lang::$txt['next_month'] . '", - firstDay: ' . (!empty(Theme::$current->options['calendar_start_day']) ? Theme::$current->options['calendar_start_day'] : 0) . ', - });', true); - } - - /** - * Loads the necessary JavaScript and CSS to create a timepicker. - * - * @param string $selector A CSS selector for the input field(s) that the timepicker should be attached to. - * @param string $time_format A time format in strftime format - */ - public static function loadTimePicker(string $selector = 'input.time_input', string $time_format = ''): void - { - if (empty($time_format)) { - $time_format = Time::getTimeFormat(); - } - - // Format used for timepicker - $time_format = strtr($time_format, [ - '%H' => 'H', - '%k' => 'G', - '%I' => 'h', - '%l' => 'g', - '%M' => 'i', - '%p' => 'A', - '%P' => 'a', - '%r' => 'h:i:s A', - '%R' => 'H:i', - '%S' => 's', - '%T' => 'H:i:s', - '%X' => 'H:i:s', - ]); - - Theme::loadCSSFile('jquery.timepicker.css', [], 'smf_timepicker'); - Theme::loadJavaScriptFile('jquery.timepicker.min.js', ['defer' => true], 'smf_timepicker'); - Theme::addInlineJavaScript(' - $("' . $selector . '").timepicker({ - timeFormat: "' . $time_format . '", - showDuration: true, - maxTime: "23:59:59", - lang: { - am: "' . strtolower(Lang::$txt['time_am']) . '", - pm: "' . strtolower(Lang::$txt['time_pm']) . '", - AM: "' . strtoupper(Lang::$txt['time_am']) . '", - PM: "' . strtoupper(Lang::$txt['time_pm']) . '", - decimal: "' . Lang::$txt['decimal_separator'] . '", - mins: "' . Lang::$txt['minutes_short'] . '", - hr: "' . Lang::$txt['hour_short'] . '", - hrs: "' . Lang::$txt['hours_short'] . '", - } - });', true); - } - - /** - * Loads the necessary JavaScript for Datepair.js. - * - * Datepair.js helps to keep date ranges sane in the UI. - * - * @param string $container CSS selector for the containing element of the date/time inputs to be paired. - * @param string $date_class The CSS class of the date inputs to be paired. - * @param string $time_class The CSS class of the time inputs to be paired. - */ - public static function loadDatePair(string $container, string $date_class = '', string $time_class = ''): void - { - $container = (string) $container; - $date_class = (string) $date_class; - $time_class = (string) $time_class; - - if ($container == '') { - return; - } - - Theme::loadJavaScriptFile('jquery.datepair.min.js', ['defer' => true], 'smf_datepair'); - - $datepair_options = ''; - - // If we're not using a date input, we might as well disable these. - if ($date_class == '') { - $datepair_options .= ' - parseDate: function (el) {}, - updateDate: function (el, v) {},'; - } else { - $datepair_options .= ' - dateClass: "' . $date_class . '",'; - - // Customize Datepair to work with jQuery UI's datepicker. - $datepair_options .= ' - parseDate: function (el) { - var val = $(el).datepicker("getDate"); - if (!val) { - return null; - } - var utc = new Date(val); - return utc && new Date(utc.getTime() + (utc.getTimezoneOffset() * 60000)); - }, - updateDate: function (el, v) { - $(el).datepicker("setDate", new Date(v.getTime() - (v.getTimezoneOffset() * 60000))); - },'; - } - - // If not using a time input, disable time functions. - if ($time_class == '') { - $datepair_options .= ' - parseTime: function(input){}, - updateTime: function(input, dateObj){}, - setMinTime: function(input, dateObj){},'; - } else { - $datepair_options .= ' - timeClass: "' . $time_class . '",'; - } - - Theme::addInlineJavaScript(' - $("' . $container . '").datepair({' . $datepair_options . "\n\t});", true); - } - /** * Retrieve all events for the given days, independently of the users offset. * cache callback function used to retrieve the birthdays, holidays, and events between now and now + days_to_index. diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index 90fa9eafd0..6170c7992c 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -757,12 +757,12 @@ function template_calendar_top($calendar_data) echo '
    - '; + '; if (!empty($calendar_data['end_date'])) echo ' - ', strtolower(Lang::$txt['to']), ' - '; + ', Utils::strtolower(Lang::$txt['to']), ' + '; echo ' diff --git a/Themes/default/scripts/calendar.js b/Themes/default/scripts/calendar.js new file mode 100644 index 0000000000..510a94950a --- /dev/null +++ b/Themes/default/scripts/calendar.js @@ -0,0 +1,42 @@ +window.addEventListener("DOMContentLoaded", function() { + updateCalendarUI(); +}); + +document.getElementById("start_date").addEventListener("change", updateCalendarUI); +document.getElementById("end_date").addEventListener("change", updateCalendarUI); + +let current_start_date = new Date(document.getElementById("start_date").value + "T12:00:00"); +let current_end_date = new Date(document.getElementById("end_date").value + "T12:00:00"); + +// Update the date pickers in the calendar UI. +function updateCalendarUI() +{ + let start_date = new Date(document.getElementById("start_date").value + "T12:00:00"); + let end_date = new Date(document.getElementById("end_date").value + "T12:00:00"); + + if (this.id !== 'end_date') { + if (current_start_date.getTime() !== start_date.getTime()) { + const start_diff = start_date.getTime() - current_start_date.getTime(); + + end_date.setTime(end_date.getTime() + start_diff); + + document.getElementById("end_date").value = end_date.getFullYear() + '-' + (end_date.getMonth() < 9 ? '0' : '') + (end_date.getMonth() + 1) + '-' + (end_date.getDate() < 10 ? '0' : '') + end_date.getDate(); + } + } + + // Ensure start and end have a sane relationship. + if (start_date.getTime() > end_date.getTime()) { + const current_duration = current_end_date.getTime() - current_start_date.getTime(); + + end_date = start_date; + end_date.setTime(end_date.getTime() + current_duration); + + document.getElementById("end_date").value = end_date.getFullYear() + '-' + (end_date.getMonth() < 9 ? '0' : '') + (end_date.getMonth() + 1) + '-' + (end_date.getDate() < 10 ? '0' : '') + end_date.getDate(); + } + + document.getElementById("end_date").min = document.getElementById("start_date").value; + + // Remember any changes to start and end dates. + current_start_date = start_date; + current_end_date = end_date; +} \ No newline at end of file diff --git a/Themes/default/scripts/jquery-ui.datepicker.min.js b/Themes/default/scripts/jquery-ui.datepicker.min.js deleted file mode 100644 index af059439b3..0000000000 --- a/Themes/default/scripts/jquery-ui.datepicker.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! jQuery UI - v1.12.0 - 2016-08-30 -* https://jqueryui.com -* Includes: keycode.js, widgets/datepicker.js -* Copyright jQuery Foundation and other contributors; Licensed MIT */ - -(function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)})(function(t){function e(t){for(var e,i;t.length&&t[0]!==document;){if(e=t.css("position"),("absolute"===e||"relative"===e||"fixed"===e)&&(i=parseInt(t.css("zIndex"),10),!isNaN(i)&&0!==i))return i;t=t.parent()}return 0}function i(){this._curInst=null,this._keyEvent=!1,this._disabledInputs=[],this._datepickerShowing=!1,this._inDialog=!1,this._mainDivId="ui-datepicker-div",this._inlineClass="ui-datepicker-inline",this._appendClass="ui-datepicker-append",this._triggerClass="ui-datepicker-trigger",this._dialogClass="ui-datepicker-dialog",this._disableClass="ui-datepicker-disabled",this._unselectableClass="ui-datepicker-unselectable",this._currentClass="ui-datepicker-current-day",this._dayOverClass="ui-datepicker-days-cell-over",this.regional=[],this.regional[""]={closeText:"Done",prevText:"Prev",nextText:"Next",currentText:"Today",monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],weekHeader:"Wk",dateFormat:"mm/dd/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""},this._defaults={showOn:"focus",showAnim:"fadeIn",showOptions:{},defaultDate:null,appendText:"",buttonText:"...",buttonImage:"",buttonImageOnly:!1,hideIfNoPrevNext:!1,navigationAsDateFormat:!1,gotoCurrent:!1,changeMonth:!1,changeYear:!1,yearRange:"c-10:c+10",showOtherMonths:!1,selectOtherMonths:!1,showWeek:!1,calculateWeek:this.iso8601Week,shortYearCutoff:"+10",minDate:null,maxDate:null,duration:"fast",beforeShowDay:null,beforeShow:null,onSelect:null,onChangeMonthYear:null,onClose:null,numberOfMonths:1,showCurrentAtPos:0,stepMonths:1,stepBigMonths:12,altField:"",altFormat:"",constrainInput:!0,showButtonPanel:!1,autoSize:!1,disabled:!1},t.extend(this._defaults,this.regional[""]),this.regional.en=t.extend(!0,{},this.regional[""]),this.regional["en-US"]=t.extend(!0,{},this.regional.en),this.dpDiv=s(t("
    "))}function s(e){var i="button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a";return e.on("mouseout",i,function(){t(this).removeClass("ui-state-hover"),-1!==this.className.indexOf("ui-datepicker-prev")&&t(this).removeClass("ui-datepicker-prev-hover"),-1!==this.className.indexOf("ui-datepicker-next")&&t(this).removeClass("ui-datepicker-next-hover")}).on("mouseover",i,n)}function n(){t.datepicker._isDisabledDatepicker(a.inline?a.dpDiv.parent()[0]:a.input[0])||(t(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover"),t(this).addClass("ui-state-hover"),-1!==this.className.indexOf("ui-datepicker-prev")&&t(this).addClass("ui-datepicker-prev-hover"),-1!==this.className.indexOf("ui-datepicker-next")&&t(this).addClass("ui-datepicker-next-hover"))}function o(e,i){t.extend(e,i);for(var s in i)null==i[s]&&(e[s]=i[s]);return e}t.ui=t.ui||{},t.ui.version="1.12.0",t.ui.keyCode={BACKSPACE:8,COMMA:188,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,LEFT:37,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SPACE:32,TAB:9,UP:38},t.extend(t.ui,{datepicker:{version:"1.12.0"}});var a;t.extend(i.prototype,{markerClassName:"hasDatepicker",maxRows:4,_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(t){return o(this._defaults,t||{}),this},_attachDatepicker:function(e,i){var s,n,o;s=e.nodeName.toLowerCase(),n="div"===s||"span"===s,e.id||(this.uuid+=1,e.id="dp"+this.uuid),o=this._newInst(t(e),n),o.settings=t.extend({},i||{}),"input"===s?this._connectDatepicker(e,o):n&&this._inlineDatepicker(e,o)},_newInst:function(e,i){var n=e[0].id.replace(/([^A-Za-z0-9_\-])/g,"\\\\$1");return{id:n,input:e,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:i,dpDiv:i?s(t("
    ")):this.dpDiv}},_connectDatepicker:function(e,i){var s=t(e);i.append=t([]),i.trigger=t([]),s.hasClass(this.markerClassName)||(this._attachments(s,i),s.addClass(this.markerClassName).on("keydown",this._doKeyDown).on("keypress",this._doKeyPress).on("keyup",this._doKeyUp),this._autoSize(i),t.data(e,"datepicker",i),i.settings.disabled&&this._disableDatepicker(e))},_attachments:function(e,i){var s,n,o,a=this._get(i,"appendText"),r=this._get(i,"isRTL");i.append&&i.append.remove(),a&&(i.append=t(""+a+""),e[r?"before":"after"](i.append)),e.off("focus",this._showDatepicker),i.trigger&&i.trigger.remove(),s=this._get(i,"showOn"),("focus"===s||"both"===s)&&e.on("focus",this._showDatepicker),("button"===s||"both"===s)&&(n=this._get(i,"buttonText"),o=this._get(i,"buttonImage"),i.trigger=t(this._get(i,"buttonImageOnly")?t("").addClass(this._triggerClass).attr({src:o,alt:n,title:n}):t("").addClass(this._triggerClass).html(o?t("").attr({src:o,alt:n,title:n}):n)),e[r?"before":"after"](i.trigger),i.trigger.on("click",function(){return t.datepicker._datepickerShowing&&t.datepicker._lastInput===e[0]?t.datepicker._hideDatepicker():t.datepicker._datepickerShowing&&t.datepicker._lastInput!==e[0]?(t.datepicker._hideDatepicker(),t.datepicker._showDatepicker(e[0])):t.datepicker._showDatepicker(e[0]),!1}))},_autoSize:function(t){if(this._get(t,"autoSize")&&!t.inline){var e,i,s,n,o=new Date(2009,11,20),a=this._get(t,"dateFormat");a.match(/[DM]/)&&(e=function(t){for(i=0,s=0,n=0;t.length>n;n++)t[n].length>i&&(i=t[n].length,s=n);return s},o.setMonth(e(this._get(t,a.match(/MM/)?"monthNames":"monthNamesShort"))),o.setDate(e(this._get(t,a.match(/DD/)?"dayNames":"dayNamesShort"))+20-o.getDay())),t.input.attr("size",this._formatDate(t,o).length)}},_inlineDatepicker:function(e,i){var s=t(e);s.hasClass(this.markerClassName)||(s.addClass(this.markerClassName).append(i.dpDiv),t.data(e,"datepicker",i),this._setDate(i,this._getDefaultDate(i),!0),this._updateDatepicker(i),this._updateAlternate(i),i.settings.disabled&&this._disableDatepicker(e),i.dpDiv.css("display","block"))},_dialogDatepicker:function(e,i,s,n,a){var r,l,h,c,u,d=this._dialogInst;return d||(this.uuid+=1,r="dp"+this.uuid,this._dialogInput=t(""),this._dialogInput.on("keydown",this._doKeyDown),t("body").append(this._dialogInput),d=this._dialogInst=this._newInst(this._dialogInput,!1),d.settings={},t.data(this._dialogInput[0],"datepicker",d)),o(d.settings,n||{}),i=i&&i.constructor===Date?this._formatDate(d,i):i,this._dialogInput.val(i),this._pos=a?a.length?a:[a.pageX,a.pageY]:null,this._pos||(l=document.documentElement.clientWidth,h=document.documentElement.clientHeight,c=document.documentElement.scrollLeft||document.body.scrollLeft,u=document.documentElement.scrollTop||document.body.scrollTop,this._pos=[l/2-100+c,h/2-150+u]),this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px"),d.settings.onSelect=s,this._inDialog=!0,this.dpDiv.addClass(this._dialogClass),this._showDatepicker(this._dialogInput[0]),t.blockUI&&t.blockUI(this.dpDiv),t.data(this._dialogInput[0],"datepicker",d),this},_destroyDatepicker:function(e){var i,s=t(e),n=t.data(e,"datepicker");s.hasClass(this.markerClassName)&&(i=e.nodeName.toLowerCase(),t.removeData(e,"datepicker"),"input"===i?(n.append.remove(),n.trigger.remove(),s.removeClass(this.markerClassName).off("focus",this._showDatepicker).off("keydown",this._doKeyDown).off("keypress",this._doKeyPress).off("keyup",this._doKeyUp)):("div"===i||"span"===i)&&s.removeClass(this.markerClassName).empty(),a===n&&(a=null))},_enableDatepicker:function(e){var i,s,n=t(e),o=t.data(e,"datepicker");n.hasClass(this.markerClassName)&&(i=e.nodeName.toLowerCase(),"input"===i?(e.disabled=!1,o.trigger.filter("button").each(function(){this.disabled=!1}).end().filter("img").css({opacity:"1.0",cursor:""})):("div"===i||"span"===i)&&(s=n.children("."+this._inlineClass),s.children().removeClass("ui-state-disabled"),s.find("select.ui-datepicker-month, select.ui-datepicker-year").prop("disabled",!1)),this._disabledInputs=t.map(this._disabledInputs,function(t){return t===e?null:t}))},_disableDatepicker:function(e){var i,s,n=t(e),o=t.data(e,"datepicker");n.hasClass(this.markerClassName)&&(i=e.nodeName.toLowerCase(),"input"===i?(e.disabled=!0,o.trigger.filter("button").each(function(){this.disabled=!0}).end().filter("img").css({opacity:"0.5",cursor:"default"})):("div"===i||"span"===i)&&(s=n.children("."+this._inlineClass),s.children().addClass("ui-state-disabled"),s.find("select.ui-datepicker-month, select.ui-datepicker-year").prop("disabled",!0)),this._disabledInputs=t.map(this._disabledInputs,function(t){return t===e?null:t}),this._disabledInputs[this._disabledInputs.length]=e)},_isDisabledDatepicker:function(t){if(!t)return!1;for(var e=0;this._disabledInputs.length>e;e++)if(this._disabledInputs[e]===t)return!0;return!1},_getInst:function(e){try{return t.data(e,"datepicker")}catch(i){throw"Missing instance data for this datepicker"}},_optionDatepicker:function(e,i,s){var n,a,r,l,h=this._getInst(e);return 2===arguments.length&&"string"==typeof i?"defaults"===i?t.extend({},t.datepicker._defaults):h?"all"===i?t.extend({},h.settings):this._get(h,i):null:(n=i||{},"string"==typeof i&&(n={},n[i]=s),h&&(this._curInst===h&&this._hideDatepicker(),a=this._getDateDatepicker(e,!0),r=this._getMinMaxDate(h,"min"),l=this._getMinMaxDate(h,"max"),o(h.settings,n),null!==r&&void 0!==n.dateFormat&&void 0===n.minDate&&(h.settings.minDate=this._formatDate(h,r)),null!==l&&void 0!==n.dateFormat&&void 0===n.maxDate&&(h.settings.maxDate=this._formatDate(h,l)),"disabled"in n&&(n.disabled?this._disableDatepicker(e):this._enableDatepicker(e)),this._attachments(t(e),h),this._autoSize(h),this._setDate(h,a),this._updateAlternate(h),this._updateDatepicker(h)),void 0)},_changeDatepicker:function(t,e,i){this._optionDatepicker(t,e,i)},_refreshDatepicker:function(t){var e=this._getInst(t);e&&this._updateDatepicker(e)},_setDateDatepicker:function(t,e){var i=this._getInst(t);i&&(this._setDate(i,e),this._updateDatepicker(i),this._updateAlternate(i))},_getDateDatepicker:function(t,e){var i=this._getInst(t);return i&&!i.inline&&this._setDateFromField(i,e),i?this._getDate(i):null},_doKeyDown:function(e){var i,s,n,o=t.datepicker._getInst(e.target),a=!0,r=o.dpDiv.is(".ui-datepicker-rtl");if(o._keyEvent=!0,t.datepicker._datepickerShowing)switch(e.keyCode){case 9:t.datepicker._hideDatepicker(),a=!1;break;case 13:return n=t("td."+t.datepicker._dayOverClass+":not(."+t.datepicker._currentClass+")",o.dpDiv),n[0]&&t.datepicker._selectDay(e.target,o.selectedMonth,o.selectedYear,n[0]),i=t.datepicker._get(o,"onSelect"),i?(s=t.datepicker._formatDate(o),i.apply(o.input?o.input[0]:null,[s,o])):t.datepicker._hideDatepicker(),!1;case 27:t.datepicker._hideDatepicker();break;case 33:t.datepicker._adjustDate(e.target,e.ctrlKey?-t.datepicker._get(o,"stepBigMonths"):-t.datepicker._get(o,"stepMonths"),"M");break;case 34:t.datepicker._adjustDate(e.target,e.ctrlKey?+t.datepicker._get(o,"stepBigMonths"):+t.datepicker._get(o,"stepMonths"),"M");break;case 35:(e.ctrlKey||e.metaKey)&&t.datepicker._clearDate(e.target),a=e.ctrlKey||e.metaKey;break;case 36:(e.ctrlKey||e.metaKey)&&t.datepicker._gotoToday(e.target),a=e.ctrlKey||e.metaKey;break;case 37:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,r?1:-1,"D"),a=e.ctrlKey||e.metaKey,e.originalEvent.altKey&&t.datepicker._adjustDate(e.target,e.ctrlKey?-t.datepicker._get(o,"stepBigMonths"):-t.datepicker._get(o,"stepMonths"),"M");break;case 38:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,-7,"D"),a=e.ctrlKey||e.metaKey;break;case 39:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,r?-1:1,"D"),a=e.ctrlKey||e.metaKey,e.originalEvent.altKey&&t.datepicker._adjustDate(e.target,e.ctrlKey?+t.datepicker._get(o,"stepBigMonths"):+t.datepicker._get(o,"stepMonths"),"M");break;case 40:(e.ctrlKey||e.metaKey)&&t.datepicker._adjustDate(e.target,7,"D"),a=e.ctrlKey||e.metaKey;break;default:a=!1}else 36===e.keyCode&&e.ctrlKey?t.datepicker._showDatepicker(this):a=!1;a&&(e.preventDefault(),e.stopPropagation())},_doKeyPress:function(e){var i,s,n=t.datepicker._getInst(e.target);return t.datepicker._get(n,"constrainInput")?(i=t.datepicker._possibleChars(t.datepicker._get(n,"dateFormat")),s=String.fromCharCode(null==e.charCode?e.keyCode:e.charCode),e.ctrlKey||e.metaKey||" ">s||!i||i.indexOf(s)>-1):void 0},_doKeyUp:function(e){var i,s=t.datepicker._getInst(e.target);if(s.input.val()!==s.lastVal)try{i=t.datepicker.parseDate(t.datepicker._get(s,"dateFormat"),s.input?s.input.val():null,t.datepicker._getFormatConfig(s)),i&&(t.datepicker._setDateFromField(s),t.datepicker._updateAlternate(s),t.datepicker._updateDatepicker(s))}catch(n){}return!0},_showDatepicker:function(i){if(i=i.target||i,"input"!==i.nodeName.toLowerCase()&&(i=t("input",i.parentNode)[0]),!t.datepicker._isDisabledDatepicker(i)&&t.datepicker._lastInput!==i){var s,n,a,r,l,h,c;s=t.datepicker._getInst(i),t.datepicker._curInst&&t.datepicker._curInst!==s&&(t.datepicker._curInst.dpDiv.stop(!0,!0),s&&t.datepicker._datepickerShowing&&t.datepicker._hideDatepicker(t.datepicker._curInst.input[0])),n=t.datepicker._get(s,"beforeShow"),a=n?n.apply(i,[i,s]):{},a!==!1&&(o(s.settings,a),s.lastVal=null,t.datepicker._lastInput=i,t.datepicker._setDateFromField(s),t.datepicker._inDialog&&(i.value=""),t.datepicker._pos||(t.datepicker._pos=t.datepicker._findPos(i),t.datepicker._pos[1]+=i.offsetHeight),r=!1,t(i).parents().each(function(){return r|="fixed"===t(this).css("position"),!r}),l={left:t.datepicker._pos[0],top:t.datepicker._pos[1]},t.datepicker._pos=null,s.dpDiv.empty(),s.dpDiv.css({position:"absolute",display:"block",top:"-1000px"}),t.datepicker._updateDatepicker(s),l=t.datepicker._checkOffset(s,l,r),s.dpDiv.css({position:t.datepicker._inDialog&&t.blockUI?"static":r?"fixed":"absolute",display:"none",left:l.left+"px",top:l.top+"px"}),s.inline||(h=t.datepicker._get(s,"showAnim"),c=t.datepicker._get(s,"duration"),s.dpDiv.css("z-index",e(t(i))+1),t.datepicker._datepickerShowing=!0,t.effects&&t.effects.effect[h]?s.dpDiv.show(h,t.datepicker._get(s,"showOptions"),c):s.dpDiv[h||"show"](h?c:null),t.datepicker._shouldFocusInput(s)&&s.input.trigger("focus"),t.datepicker._curInst=s))}},_updateDatepicker:function(e){this.maxRows=4,a=e,e.dpDiv.empty().append(this._generateHTML(e)),this._attachHandlers(e);var i,s=this._getNumberOfMonths(e),o=s[1],r=17,l=e.dpDiv.find("."+this._dayOverClass+" a");l.length>0&&n.apply(l.get(0)),e.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width(""),o>1&&e.dpDiv.addClass("ui-datepicker-multi-"+o).css("width",r*o+"em"),e.dpDiv[(1!==s[0]||1!==s[1]?"add":"remove")+"Class"]("ui-datepicker-multi"),e.dpDiv[(this._get(e,"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl"),e===t.datepicker._curInst&&t.datepicker._datepickerShowing&&t.datepicker._shouldFocusInput(e)&&e.input.trigger("focus"),e.yearshtml&&(i=e.yearshtml,setTimeout(function(){i===e.yearshtml&&e.yearshtml&&e.dpDiv.find("select.ui-datepicker-year:first").replaceWith(e.yearshtml),i=e.yearshtml=null},0))},_shouldFocusInput:function(t){return t.input&&t.input.is(":visible")&&!t.input.is(":disabled")&&!t.input.is(":focus")},_checkOffset:function(e,i,s){var n=e.dpDiv.outerWidth(),o=e.dpDiv.outerHeight(),a=e.input?e.input.outerWidth():0,r=e.input?e.input.outerHeight():0,l=document.documentElement.clientWidth+(s?0:t(document).scrollLeft()),h=document.documentElement.clientHeight+(s?0:t(document).scrollTop());return i.left-=this._get(e,"isRTL")?n-a:0,i.left-=s&&i.left===e.input.offset().left?t(document).scrollLeft():0,i.top-=s&&i.top===e.input.offset().top+r?t(document).scrollTop():0,i.left-=Math.min(i.left,i.left+n>l&&l>n?Math.abs(i.left+n-l):0),i.top-=Math.min(i.top,i.top+o>h&&h>o?Math.abs(o+r):0),i},_findPos:function(e){for(var i,s=this._getInst(e),n=this._get(s,"isRTL");e&&("hidden"===e.type||1!==e.nodeType||t.expr.filters.hidden(e));)e=e[n?"previousSibling":"nextSibling"];return i=t(e).offset(),[i.left,i.top]},_hideDatepicker:function(e){var i,s,n,o,a=this._curInst;!a||e&&a!==t.data(e,"datepicker")||this._datepickerShowing&&(i=this._get(a,"showAnim"),s=this._get(a,"duration"),n=function(){t.datepicker._tidyDialog(a)},t.effects&&(t.effects.effect[i]||t.effects[i])?a.dpDiv.hide(i,t.datepicker._get(a,"showOptions"),s,n):a.dpDiv["slideDown"===i?"slideUp":"fadeIn"===i?"fadeOut":"hide"](i?s:null,n),i||n(),this._datepickerShowing=!1,o=this._get(a,"onClose"),o&&o.apply(a.input?a.input[0]:null,[a.input?a.input.val():"",a]),this._lastInput=null,this._inDialog&&(this._dialogInput.css({position:"absolute",left:"0",top:"-100px"}),t.blockUI&&(t.unblockUI(),t("body").append(this.dpDiv))),this._inDialog=!1)},_tidyDialog:function(t){t.dpDiv.removeClass(this._dialogClass).off(".ui-datepicker-calendar")},_checkExternalClick:function(e){if(t.datepicker._curInst){var i=t(e.target),s=t.datepicker._getInst(i[0]);(i[0].id!==t.datepicker._mainDivId&&0===i.parents("#"+t.datepicker._mainDivId).length&&!i.hasClass(t.datepicker.markerClassName)&&!i.closest("."+t.datepicker._triggerClass).length&&t.datepicker._datepickerShowing&&(!t.datepicker._inDialog||!t.blockUI)||i.hasClass(t.datepicker.markerClassName)&&t.datepicker._curInst!==s)&&t.datepicker._hideDatepicker()}},_adjustDate:function(e,i,s){var n=t(e),o=this._getInst(n[0]);this._isDisabledDatepicker(n[0])||(this._adjustInstDate(o,i+("M"===s?this._get(o,"showCurrentAtPos"):0),s),this._updateDatepicker(o))},_gotoToday:function(e){var i,s=t(e),n=this._getInst(s[0]);this._get(n,"gotoCurrent")&&n.currentDay?(n.selectedDay=n.currentDay,n.drawMonth=n.selectedMonth=n.currentMonth,n.drawYear=n.selectedYear=n.currentYear):(i=new Date,n.selectedDay=i.getDate(),n.drawMonth=n.selectedMonth=i.getMonth(),n.drawYear=n.selectedYear=i.getFullYear()),this._notifyChange(n),this._adjustDate(s)},_selectMonthYear:function(e,i,s){var n=t(e),o=this._getInst(n[0]);o["selected"+("M"===s?"Month":"Year")]=o["draw"+("M"===s?"Month":"Year")]=parseInt(i.options[i.selectedIndex].value,10),this._notifyChange(o),this._adjustDate(n)},_selectDay:function(e,i,s,n){var o,a=t(e);t(n).hasClass(this._unselectableClass)||this._isDisabledDatepicker(a[0])||(o=this._getInst(a[0]),o.selectedDay=o.currentDay=t("a",n).html(),o.selectedMonth=o.currentMonth=i,o.selectedYear=o.currentYear=s,this._selectDate(e,this._formatDate(o,o.currentDay,o.currentMonth,o.currentYear)))},_clearDate:function(e){var i=t(e);this._selectDate(i,"")},_selectDate:function(e,i){var s,n=t(e),o=this._getInst(n[0]);i=null!=i?i:this._formatDate(o),o.input&&o.input.val(i),this._updateAlternate(o),s=this._get(o,"onSelect"),s?s.apply(o.input?o.input[0]:null,[i,o]):o.input&&o.input.trigger("change"),o.inline?this._updateDatepicker(o):(this._hideDatepicker(),this._lastInput=o.input[0],"object"!=typeof o.input[0]&&o.input.trigger("focus"),this._lastInput=null)},_updateAlternate:function(e){var i,s,n,o=this._get(e,"altField");o&&(i=this._get(e,"altFormat")||this._get(e,"dateFormat"),s=this._getDate(e),n=this.formatDate(i,s,this._getFormatConfig(e)),t(o).val(n))},noWeekends:function(t){var e=t.getDay();return[e>0&&6>e,""]},iso8601Week:function(t){var e,i=new Date(t.getTime());return i.setDate(i.getDate()+4-(i.getDay()||7)),e=i.getTime(),i.setMonth(0),i.setDate(1),Math.floor(Math.round((e-i)/864e5)/7)+1},parseDate:function(e,i,s){if(null==e||null==i)throw"Invalid arguments";if(i="object"==typeof i?""+i:i+"",""===i)return null;var n,o,a,r,l=0,h=(s?s.shortYearCutoff:null)||this._defaults.shortYearCutoff,c="string"!=typeof h?h:(new Date).getFullYear()%100+parseInt(h,10),u=(s?s.dayNamesShort:null)||this._defaults.dayNamesShort,d=(s?s.dayNames:null)||this._defaults.dayNames,p=(s?s.monthNamesShort:null)||this._defaults.monthNamesShort,f=(s?s.monthNames:null)||this._defaults.monthNames,g=-1,m=-1,_=-1,v=-1,b=!1,y=function(t){var i=e.length>n+1&&e.charAt(n+1)===t;return i&&n++,i},w=function(t){var e=y(t),s="@"===t?14:"!"===t?20:"y"===t&&e?4:"o"===t?3:2,n="y"===t?s:1,o=RegExp("^\\d{"+n+","+s+"}"),a=i.substring(l).match(o);if(!a)throw"Missing number at position "+l;return l+=a[0].length,parseInt(a[0],10)},k=function(e,s,n){var o=-1,a=t.map(y(e)?n:s,function(t,e){return[[e,t]]}).sort(function(t,e){return-(t[1].length-e[1].length)});if(t.each(a,function(t,e){var s=e[1];return i.substr(l,s.length).toLowerCase()===s.toLowerCase()?(o=e[0],l+=s.length,!1):void 0}),-1!==o)return o+1;throw"Unknown name at position "+l},x=function(){if(i.charAt(l)!==e.charAt(n))throw"Unexpected literal at position "+l;l++};for(n=0;e.length>n;n++)if(b)"'"!==e.charAt(n)||y("'")?x():b=!1;else switch(e.charAt(n)){case"d":_=w("d");break;case"D":k("D",u,d);break;case"o":v=w("o");break;case"m":m=w("m");break;case"M":m=k("M",p,f);break;case"y":g=w("y");break;case"@":r=new Date(w("@")),g=r.getFullYear(),m=r.getMonth()+1,_=r.getDate();break;case"!":r=new Date((w("!")-this._ticksTo1970)/1e4),g=r.getFullYear(),m=r.getMonth()+1,_=r.getDate();break;case"'":y("'")?x():b=!0;break;default:x()}if(i.length>l&&(a=i.substr(l),!/^\s+/.test(a)))throw"Extra/unparsed characters found in date: "+a;if(-1===g?g=(new Date).getFullYear():100>g&&(g+=(new Date).getFullYear()-(new Date).getFullYear()%100+(c>=g?0:-100)),v>-1)for(m=1,_=v;;){if(o=this._getDaysInMonth(g,m-1),o>=_)break;m++,_-=o}if(r=this._daylightSavingAdjust(new Date(g,m-1,_)),r.getFullYear()!==g||r.getMonth()+1!==m||r.getDate()!==_)throw"Invalid date";return r},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:1e7*60*60*24*(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925)),formatDate:function(t,e,i){if(!e)return"";var s,n=(i?i.dayNamesShort:null)||this._defaults.dayNamesShort,o=(i?i.dayNames:null)||this._defaults.dayNames,a=(i?i.monthNamesShort:null)||this._defaults.monthNamesShort,r=(i?i.monthNames:null)||this._defaults.monthNames,l=function(e){var i=t.length>s+1&&t.charAt(s+1)===e;return i&&s++,i},h=function(t,e,i){var s=""+e;if(l(t))for(;i>s.length;)s="0"+s;return s},c=function(t,e,i,s){return l(t)?s[e]:i[e]},u="",d=!1;if(e)for(s=0;t.length>s;s++)if(d)"'"!==t.charAt(s)||l("'")?u+=t.charAt(s):d=!1;else switch(t.charAt(s)){case"d":u+=h("d",e.getDate(),2);break;case"D":u+=c("D",e.getDay(),n,o);break;case"o":u+=h("o",Math.round((new Date(e.getFullYear(),e.getMonth(),e.getDate()).getTime()-new Date(e.getFullYear(),0,0).getTime())/864e5),3);break;case"m":u+=h("m",e.getMonth()+1,2);break;case"M":u+=c("M",e.getMonth(),a,r);break;case"y":u+=l("y")?e.getFullYear():(10>e.getFullYear()%100?"0":"")+e.getFullYear()%100;break;case"@":u+=e.getTime();break;case"!":u+=1e4*e.getTime()+this._ticksTo1970;break;case"'":l("'")?u+="'":d=!0;break;default:u+=t.charAt(s)}return u},_possibleChars:function(t){var e,i="",s=!1,n=function(i){var s=t.length>e+1&&t.charAt(e+1)===i;return s&&e++,s};for(e=0;t.length>e;e++)if(s)"'"!==t.charAt(e)||n("'")?i+=t.charAt(e):s=!1;else switch(t.charAt(e)){case"d":case"m":case"y":case"@":i+="0123456789";break;case"D":case"M":return null;case"'":n("'")?i+="'":s=!0;break;default:i+=t.charAt(e)}return i},_get:function(t,e){return void 0!==t.settings[e]?t.settings[e]:this._defaults[e]},_setDateFromField:function(t,e){if(t.input.val()!==t.lastVal){var i=this._get(t,"dateFormat"),s=t.lastVal=t.input?t.input.val():null,n=this._getDefaultDate(t),o=n,a=this._getFormatConfig(t);try{o=this.parseDate(i,s,a)||n}catch(r){s=e?"":s}t.selectedDay=o.getDate(),t.drawMonth=t.selectedMonth=o.getMonth(),t.drawYear=t.selectedYear=o.getFullYear(),t.currentDay=s?o.getDate():0,t.currentMonth=s?o.getMonth():0,t.currentYear=s?o.getFullYear():0,this._adjustInstDate(t)}},_getDefaultDate:function(t){return this._restrictMinMax(t,this._determineDate(t,this._get(t,"defaultDate"),new Date))},_determineDate:function(e,i,s){var n=function(t){var e=new Date;return e.setDate(e.getDate()+t),e},o=function(i){try{return t.datepicker.parseDate(t.datepicker._get(e,"dateFormat"),i,t.datepicker._getFormatConfig(e))}catch(s){}for(var n=(i.toLowerCase().match(/^c/)?t.datepicker._getDate(e):null)||new Date,o=n.getFullYear(),a=n.getMonth(),r=n.getDate(),l=/([+\-]?[0-9]+)\s*(d|D|w|W|m|M|y|Y)?/g,h=l.exec(i);h;){switch(h[2]||"d"){case"d":case"D":r+=parseInt(h[1],10);break;case"w":case"W":r+=7*parseInt(h[1],10);break;case"m":case"M":a+=parseInt(h[1],10),r=Math.min(r,t.datepicker._getDaysInMonth(o,a));break;case"y":case"Y":o+=parseInt(h[1],10),r=Math.min(r,t.datepicker._getDaysInMonth(o,a))}h=l.exec(i)}return new Date(o,a,r)},a=null==i||""===i?s:"string"==typeof i?o(i):"number"==typeof i?isNaN(i)?s:n(i):new Date(i.getTime());return a=a&&"Invalid Date"==""+a?s:a,a&&(a.setHours(0),a.setMinutes(0),a.setSeconds(0),a.setMilliseconds(0)),this._daylightSavingAdjust(a)},_daylightSavingAdjust:function(t){return t?(t.setHours(t.getHours()>12?t.getHours()+2:0),t):null},_setDate:function(t,e,i){var s=!e,n=t.selectedMonth,o=t.selectedYear,a=this._restrictMinMax(t,this._determineDate(t,e,new Date));t.selectedDay=t.currentDay=a.getDate(),t.drawMonth=t.selectedMonth=t.currentMonth=a.getMonth(),t.drawYear=t.selectedYear=t.currentYear=a.getFullYear(),n===t.selectedMonth&&o===t.selectedYear||i||this._notifyChange(t),this._adjustInstDate(t),t.input&&t.input.val(s?"":this._formatDate(t))},_getDate:function(t){var e=!t.currentYear||t.input&&""===t.input.val()?null:this._daylightSavingAdjust(new Date(t.currentYear,t.currentMonth,t.currentDay));return e},_attachHandlers:function(e){var i=this._get(e,"stepMonths"),s="#"+e.id.replace(/\\\\/g,"\\");e.dpDiv.find("[data-handler]").map(function(){var e={prev:function(){t.datepicker._adjustDate(s,-i,"M")},next:function(){t.datepicker._adjustDate(s,+i,"M")},hide:function(){t.datepicker._hideDatepicker()},today:function(){t.datepicker._gotoToday(s)},selectDay:function(){return t.datepicker._selectDay(s,+this.getAttribute("data-month"),+this.getAttribute("data-year"),this),!1},selectMonth:function(){return t.datepicker._selectMonthYear(s,this,"M"),!1},selectYear:function(){return t.datepicker._selectMonthYear(s,this,"Y"),!1}};t(this).on(this.getAttribute("data-event"),e[this.getAttribute("data-handler")])})},_generateHTML:function(t){var e,i,s,n,o,a,r,l,h,c,u,d,p,f,g,m,_,v,b,y,w,k,x,C,D,T,I,M,P,S,N,H,A,z,O,E,W,F,L,R=new Date,Y=this._daylightSavingAdjust(new Date(R.getFullYear(),R.getMonth(),R.getDate())),B=this._get(t,"isRTL"),j=this._get(t,"showButtonPanel"),q=this._get(t,"hideIfNoPrevNext"),K=this._get(t,"navigationAsDateFormat"),U=this._getNumberOfMonths(t),V=this._get(t,"showCurrentAtPos"),X=this._get(t,"stepMonths"),$=1!==U[0]||1!==U[1],G=this._daylightSavingAdjust(t.currentDay?new Date(t.currentYear,t.currentMonth,t.currentDay):new Date(9999,9,9)),J=this._getMinMaxDate(t,"min"),Q=this._getMinMaxDate(t,"max"),Z=t.drawMonth-V,te=t.drawYear;if(0>Z&&(Z+=12,te--),Q)for(e=this._daylightSavingAdjust(new Date(Q.getFullYear(),Q.getMonth()-U[0]*U[1]+1,Q.getDate())),e=J&&J>e?J:e;this._daylightSavingAdjust(new Date(te,Z,1))>e;)Z--,0>Z&&(Z=11,te--);for(t.drawMonth=Z,t.drawYear=te,i=this._get(t,"prevText"),i=K?this.formatDate(i,this._daylightSavingAdjust(new Date(te,Z-X,1)),this._getFormatConfig(t)):i,s=this._canAdjustMonth(t,-1,te,Z)?""+i+"":q?"":""+i+"",n=this._get(t,"nextText"),n=K?this.formatDate(n,this._daylightSavingAdjust(new Date(te,Z+X,1)),this._getFormatConfig(t)):n,o=this._canAdjustMonth(t,1,te,Z)?""+n+"":q?"":""+n+"",a=this._get(t,"currentText"),r=this._get(t,"gotoCurrent")&&t.currentDay?G:Y,a=K?this.formatDate(a,r,this._getFormatConfig(t)):a,l=t.inline?"":"",h=j?"
    "+(B?l:"")+(this._isInRange(t,r)?"":"")+(B?"":l)+"
    ":"",c=parseInt(this._get(t,"firstDay"),10),c=isNaN(c)?0:c,u=this._get(t,"showWeek"),d=this._get(t,"dayNames"),p=this._get(t,"dayNamesMin"),f=this._get(t,"monthNames"),g=this._get(t,"monthNamesShort"),m=this._get(t,"beforeShowDay"),_=this._get(t,"showOtherMonths"),v=this._get(t,"selectOtherMonths"),b=this._getDefaultDate(t),y="",k=0;U[0]>k;k++){for(x="",this.maxRows=4,C=0;U[1]>C;C++){if(D=this._daylightSavingAdjust(new Date(te,Z,t.selectedDay)),T=" ui-corner-all",I="",$){if(I+="
    "}for(I+="
    "+(/all|left/.test(T)&&0===k?B?o:s:"")+(/all|right/.test(T)&&0===k?B?s:o:"")+this._generateMonthYearHeader(t,Z,te,J,Q,k>0||C>0,f,g)+"
    "+"",M=u?"":"",w=0;7>w;w++)P=(w+c)%7,M+="";for(I+=M+"",S=this._getDaysInMonth(te,Z),te===t.selectedYear&&Z===t.selectedMonth&&(t.selectedDay=Math.min(t.selectedDay,S)),N=(this._getFirstDayOfMonth(te,Z)-c+7)%7,H=Math.ceil((N+S)/7),A=$?this.maxRows>H?this.maxRows:H:H,this.maxRows=A,z=this._daylightSavingAdjust(new Date(te,Z,1-N)),O=0;A>O;O++){for(I+="",E=u?"":"",w=0;7>w;w++)W=m?m.apply(t.input?t.input[0]:null,[z]):[!0,""],F=z.getMonth()!==Z,L=F&&!v||!W[0]||J&&J>z||Q&&z>Q,E+="",z.setDate(z.getDate()+1),z=this._daylightSavingAdjust(z);I+=E+""}Z++,Z>11&&(Z=0,te++),I+="
    "+this._get(t,"weekHeader")+"=5?" class='ui-datepicker-week-end'":"")+">"+""+p[P]+"
    "+this._get(t,"calculateWeek")(z)+""+(F&&!_?" ":L?""+z.getDate()+"":""+z.getDate()+"")+"
    "+($?"
    "+(U[0]>0&&C===U[1]-1?"
    ":""):""),x+=I}y+=x}return y+=h,t._keyEvent=!1,y},_generateMonthYearHeader:function(t,e,i,s,n,o,a,r){var l,h,c,u,d,p,f,g,m=this._get(t,"changeMonth"),_=this._get(t,"changeYear"),v=this._get(t,"showMonthAfterYear"),b="
    ",y=""; -if(o||!m)y+=""+a[e]+"";else{for(l=s&&s.getFullYear()===i,h=n&&n.getFullYear()===i,y+=""}if(v||(b+=y+(!o&&m&&_?"":" ")),!t.yearshtml)if(t.yearshtml="",o||!_)b+=""+i+"";else{for(u=this._get(t,"yearRange").split(":"),d=(new Date).getFullYear(),p=function(t){var e=t.match(/c[+\-].*/)?i+parseInt(t.substring(1),10):t.match(/[+\-].*/)?d+parseInt(t,10):parseInt(t,10);return isNaN(e)?d:e},f=p(u[0]),g=Math.max(f,p(u[1]||"")),f=s?Math.max(f,s.getFullYear()):f,g=n?Math.min(g,n.getFullYear()):g,t.yearshtml+="",b+=t.yearshtml,t.yearshtml=null}return b+=this._get(t,"yearSuffix"),v&&(b+=(!o&&m&&_?"":" ")+y),b+="
    "},_adjustInstDate:function(t,e,i){var s=t.selectedYear+("Y"===i?e:0),n=t.selectedMonth+("M"===i?e:0),o=Math.min(t.selectedDay,this._getDaysInMonth(s,n))+("D"===i?e:0),a=this._restrictMinMax(t,this._daylightSavingAdjust(new Date(s,n,o)));t.selectedDay=a.getDate(),t.drawMonth=t.selectedMonth=a.getMonth(),t.drawYear=t.selectedYear=a.getFullYear(),("M"===i||"Y"===i)&&this._notifyChange(t)},_restrictMinMax:function(t,e){var i=this._getMinMaxDate(t,"min"),s=this._getMinMaxDate(t,"max"),n=i&&i>e?i:e;return s&&n>s?s:n},_notifyChange:function(t){var e=this._get(t,"onChangeMonthYear");e&&e.apply(t.input?t.input[0]:null,[t.selectedYear,t.selectedMonth+1,t])},_getNumberOfMonths:function(t){var e=this._get(t,"numberOfMonths");return null==e?[1,1]:"number"==typeof e?[1,e]:e},_getMinMaxDate:function(t,e){return this._determineDate(t,this._get(t,e+"Date"),null)},_getDaysInMonth:function(t,e){return 32-this._daylightSavingAdjust(new Date(t,e,32)).getDate()},_getFirstDayOfMonth:function(t,e){return new Date(t,e,1).getDay()},_canAdjustMonth:function(t,e,i,s){var n=this._getNumberOfMonths(t),o=this._daylightSavingAdjust(new Date(i,s+(0>e?e:n[0]*n[1]),1));return 0>e&&o.setDate(this._getDaysInMonth(o.getFullYear(),o.getMonth())),this._isInRange(t,o)},_isInRange:function(t,e){var i,s,n=this._getMinMaxDate(t,"min"),o=this._getMinMaxDate(t,"max"),a=null,r=null,l=this._get(t,"yearRange");return l&&(i=l.split(":"),s=(new Date).getFullYear(),a=parseInt(i[0],10),r=parseInt(i[1],10),i[0].match(/[+\-].*/)&&(a+=s),i[1].match(/[+\-].*/)&&(r+=s)),(!n||e.getTime()>=n.getTime())&&(!o||e.getTime()<=o.getTime())&&(!a||e.getFullYear()>=a)&&(!r||r>=e.getFullYear())},_getFormatConfig:function(t){var e=this._get(t,"shortYearCutoff");return e="string"!=typeof e?e:(new Date).getFullYear()%100+parseInt(e,10),{shortYearCutoff:e,dayNamesShort:this._get(t,"dayNamesShort"),dayNames:this._get(t,"dayNames"),monthNamesShort:this._get(t,"monthNamesShort"),monthNames:this._get(t,"monthNames")}},_formatDate:function(t,e,i,s){e||(t.currentDay=t.selectedDay,t.currentMonth=t.selectedMonth,t.currentYear=t.selectedYear);var n=e?"object"==typeof e?e:this._daylightSavingAdjust(new Date(s,i,e)):this._daylightSavingAdjust(new Date(t.currentYear,t.currentMonth,t.currentDay));return this.formatDate(this._get(t,"dateFormat"),n,this._getFormatConfig(t))}}),t.fn.datepicker=function(e){if(!this.length)return this;t.datepicker.initialized||(t(document).on("mousedown",t.datepicker._checkExternalClick),t.datepicker.initialized=!0),0===t("#"+t.datepicker._mainDivId).length&&t("body").append(t.datepicker.dpDiv);var i=Array.prototype.slice.call(arguments,1);return"string"!=typeof e||"isDisabled"!==e&&"getDate"!==e&&"widget"!==e?"option"===e&&2===arguments.length&&"string"==typeof arguments[1]?t.datepicker["_"+e+"Datepicker"].apply(t.datepicker,[this[0]].concat(i)):this.each(function(){"string"==typeof e?t.datepicker["_"+e+"Datepicker"].apply(t.datepicker,[this].concat(i)):t.datepicker._attachDatepicker(this,e)}):t.datepicker["_"+e+"Datepicker"].apply(t.datepicker,[this[0]].concat(i))},t.datepicker=new i,t.datepicker.initialized=!1,t.datepicker.uuid=(new Date).getTime(),t.datepicker.version="1.12.0",t.datepicker}); \ No newline at end of file diff --git a/Themes/default/scripts/jquery.datepair.min.js b/Themes/default/scripts/jquery.datepair.min.js deleted file mode 100755 index 1d42745623..0000000000 --- a/Themes/default/scripts/jquery.datepair.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * datepair.js v0.4.16 - A javascript plugin for intelligently selecting date and time ranges inspired by Google Calendar. - * Copyright (c) 2018 Jon Thornton - http://jonthornton.github.com/Datepair.js - * License: MIT - */ - -!function(a,b){"use strict";function c(a,b){var c=b||{};for(var d in a)d in c||(c[d]=a[d]);return c}function d(a,c){if(h)h(a).trigger(c);else{var d=b.createEvent("CustomEvent");d.initCustomEvent(c,!0,!0,{}),a.dispatchEvent(d)}}function e(a,b){return h?h(a).hasClass(b):a.classList.contains(b)}function f(a,b){this.dateDelta=null,this.timeDelta=null,this._defaults={startClass:"start",endClass:"end",timeClass:"time",dateClass:"date",defaultDateDelta:0,defaultTimeDelta:36e5,anchor:"start",parseTime:function(a){return h(a).timepicker("getTime")},updateTime:function(a,b){h(a).timepicker("setTime",b)},setMinTime:function(a,b){h(a).timepicker("option","minTime",b)},parseDate:function(a){return a.value&&h(a).datepicker("getDate")},updateDate:function(a,b){h(a).datepicker("update",b)}},this.container=a,this.settings=c(this._defaults,b),this.startDateInput=this.container.querySelector("."+this.settings.startClass+"."+this.settings.dateClass),this.endDateInput=this.container.querySelector("."+this.settings.endClass+"."+this.settings.dateClass),this.startTimeInput=this.container.querySelector("."+this.settings.startClass+"."+this.settings.timeClass),this.endTimeInput=this.container.querySelector("."+this.settings.endClass+"."+this.settings.timeClass),this.refresh(),this._bindChangeHandler()}var g=864e5,h=a.Zepto||a.jQuery;f.prototype={constructor:f,option:function(a,b){if("object"==typeof a)this.settings=c(this.settings,a);else if("string"==typeof a&&"undefined"!=typeof b)this.settings[a]=b;else if("string"==typeof a)return this.settings[a];this._updateEndMintime()},getTimeDiff:function(){var a=this.dateDelta+this.timeDelta;return!(a<0)||this.startDateInput&&this.endDateInput||(a+=g),a},refresh:function(){if(this.startDateInput&&this.startDateInput.value&&this.endDateInput&&this.endDateInput.value){var a=this.settings.parseDate(this.startDateInput),b=this.settings.parseDate(this.endDateInput);a&&b&&(this.dateDelta=b.getTime()-a.getTime())}if(this.startTimeInput&&this.startTimeInput.value&&this.endTimeInput&&this.endTimeInput.value){var c=this.settings.parseTime(this.startTimeInput),d=this.settings.parseTime(this.endTimeInput);c&&d&&(this.timeDelta=d.getTime()-c.getTime(),this._updateEndMintime())}},remove:function(){this._unbindChangeHandler()},_bindChangeHandler:function(){h?h(this.container).on("change.datepair",h.proxy(this.handleEvent,this)):this.container.addEventListener("change",this,!1)},_unbindChangeHandler:function(){h?h(this.container).off("change.datepair"):this.container.removeEventListener("change",this,!1)},handleEvent:function(a){this._unbindChangeHandler(),e(a.target,this.settings.dateClass)?""!=a.target.value?(this._dateChanged(a.target),this._timeChanged(a.target)):this.dateDelta=null:e(a.target,this.settings.timeClass)&&(""!=a.target.value?this._timeChanged(a.target):this.timeDelta=null),this._validateRanges(),this._updateEndMintime(),this._bindChangeHandler()},_dateChanged:function(a){if(this.startDateInput&&this.endDateInput){var b=this.settings.parseDate(this.startDateInput),c=this.settings.parseDate(this.endDateInput);if(b&&c)if("start"==this.settings.anchor&&e(a,this.settings.startClass)){var d=new Date(b.getTime()+this.dateDelta);this.settings.updateDate(this.endDateInput,d)}else if("end"==this.settings.anchor&&e(a,this.settings.endClass)){var d=new Date(c.getTime()-this.dateDelta);this.settings.updateDate(this.startDateInput,d)}else if(c0||0!=this.dateDelta)&&(e>=0&&this.timeDelta<0||e<0&&this.timeDelta>=0)&&("start"==this.settings.anchor?(this.settings.updateDate(this.endDateInput,new Date(c.getTime()+f)),this._dateChanged(this.endDateInput)):"end"==this.settings.anchor&&(this.settings.updateDate(this.startDateInput,new Date(d.getTime()-f)),this._dateChanged(this.startDateInput))),this.timeDelta=e}},_updateEndMintime:function(){if("function"==typeof this.settings.setMinTime){var a=null;"start"==this.settings.anchor&&(!this.dateDelta||this.dateDelta=0?d(this.container,"rangeSelected"):d(this.container,"rangeError"))}},a.Datepair=f}(window,document),function(a){a&&(a.fn.datepair=function(b){var c;return this.each(function(){var d=a(this),e=d.data("datepair"),f="object"==typeof b&&b;e||(e=new Datepair(this,f),d.data("datepair",e)),"remove"===b&&(c=e.remove(),d.removeData("datepair",e)),"string"==typeof b&&(c=e[b]())}),c||this},a("[data-datepair]").each(function(){var b=a(this);b.datepair(b.data())}))}(window.Zepto||window.jQuery); diff --git a/Themes/default/scripts/jquery.timepicker.min.js b/Themes/default/scripts/jquery.timepicker.min.js deleted file mode 100755 index d3a92eac00..0000000000 --- a/Themes/default/scripts/jquery.timepicker.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * jquery-timepicker v1.11.1 - A jQuery timepicker plugin inspired by Google Calendar. It supports both mouse and keyboard navigation. - * Copyright (c) 2016 Jon Thornton - http://jonthornton.github.com/jquery-timepicker/ - * License: MIT - */ - -!function(a){"object"==typeof exports&&exports&&"object"==typeof module&&module&&module.exports===exports?a(require("jquery")):"function"==typeof define&&define.amd?define(["jquery"],a):a(jQuery)}(function(a){function b(a){var b=a[0];return b.offsetWidth>0&&b.offsetHeight>0}function c(b){if(b.minTime&&(b.minTime=t(b.minTime)),b.maxTime&&(b.maxTime=t(b.maxTime)),b.durationTime&&"function"!=typeof b.durationTime&&(b.durationTime=t(b.durationTime)),"now"==b.scrollDefault)b.scrollDefault=function(){return b.roundingFunction(t(new Date),b)};else if(b.scrollDefault&&"function"!=typeof b.scrollDefault){var c=b.scrollDefault;b.scrollDefault=function(){return b.roundingFunction(t(c),b)}}else b.minTime&&(b.scrollDefault=function(){return b.roundingFunction(b.minTime,b)});if("string"===a.type(b.timeFormat)&&b.timeFormat.match(/[gh]/)&&(b._twelveHourTime=!0),b.showOnFocus===!1&&-1!=b.showOn.indexOf("focus")&&b.showOn.splice(b.showOn.indexOf("focus"),1),b.disableTimeRanges.length>0){for(var d in b.disableTimeRanges)b.disableTimeRanges[d]=[t(b.disableTimeRanges[d][0]),t(b.disableTimeRanges[d][1])];b.disableTimeRanges=b.disableTimeRanges.sort(function(a,b){return a[0]-b[0]});for(var d=b.disableTimeRanges.length-1;d>0;d--)b.disableTimeRanges[d][0]<=b.disableTimeRanges[d-1][1]&&(b.disableTimeRanges[d-1]=[Math.min(b.disableTimeRanges[d][0],b.disableTimeRanges[d-1][0]),Math.max(b.disableTimeRanges[d][1],b.disableTimeRanges[d-1][1])],b.disableTimeRanges.splice(d,1))}return b}function d(b){var c=b.data("timepicker-settings"),d=b.data("timepicker-list");if(d&&d.length&&(d.remove(),b.data("timepicker-list",!1)),c.useSelect){d=a("', 'params' => [ - 'id_holiday' => false, + 'id' => false, ], ], 'class' => 'centercol', @@ -228,51 +244,26 @@ public function edit(): void } // Submitting? - if (isset($_POST[Utils::$context['session_var']]) && (isset($_REQUEST['delete']) || $_REQUEST['title'] != '')) { + if (isset($_POST[Utils::$context['session_var']]) && (isset($_REQUEST['delete']) || ($_REQUEST['evtitle'] ?? '') != '')) { User::$me->checkSession(); SecurityToken::validate('admin-eh'); - // Not too long good sir? - $_REQUEST['title'] = Utils::entitySubstr(Utils::normalize($_REQUEST['title']), 0, 60); - $_REQUEST['holiday'] = isset($_REQUEST['holiday']) ? (int) $_REQUEST['holiday'] : 0; - - if (isset($_REQUEST['delete'])) { - Db::$db->query( - '', - 'DELETE FROM {db_prefix}calendar_holidays - WHERE id_holiday = {int:selected_holiday}', - [ - 'selected_holiday' => $_REQUEST['holiday'], - ], - ); + $_REQUEST['holiday'] = isset($_REQUEST['holiday']) ? (int) $_REQUEST['holiday'] : -1; + + if ($_REQUEST['holiday'] === -1) { + $eventOptions = [ + 'title' => Utils::entitySubstr($_REQUEST['evtitle'], 0, 100), + 'location' => Utils::entitySubstr($_REQUEST['event_location'], 0, 255), + ]; + Holiday::create($eventOptions); + } elseif (isset($_REQUEST['delete'])) { + Holiday::remove($_REQUEST['holiday']); } else { - $date = Time::strftime($_REQUEST['year'] <= 1004 ? '1004-%m-%d' : '%Y-%m-%d', mktime(0, 0, 0, (int) $_REQUEST['month'], (int) $_REQUEST['day'], (int) $_REQUEST['year'])); - - if (isset($_REQUEST['edit'])) { - Db::$db->query( - '', - 'UPDATE {db_prefix}calendar_holidays - SET event_date = {date:holiday_date}, title = {string:holiday_title} - WHERE id_holiday = {int:selected_holiday}', - [ - 'holiday_date' => $date, - 'selected_holiday' => $_REQUEST['holiday'], - 'holiday_title' => $_REQUEST['title'], - ], - ); - } else { - Db::$db->insert( - '', - '{db_prefix}calendar_holidays', - [ - 'event_date' => 'date', 'title' => 'string-60', - ], - [ - $date, $_REQUEST['title'], - ], - ['id_holiday'], - ); - } + $eventOptions = [ + 'title' => Utils::entitySubstr($_REQUEST['evtitle'], 0, 100), + 'location' => Utils::entitySubstr($_REQUEST['event_location'], 0, 255), + ]; + Holiday::modify($_REQUEST['holiday'], $eventOptions); } Config::updateModSettings([ @@ -285,43 +276,30 @@ public function edit(): void SecurityToken::create('admin-eh'); - // Default states... if (Utils::$context['is_new']) { - Utils::$context['holiday'] = [ - 'id' => 0, - 'day' => date('d'), - 'month' => date('m'), - 'year' => '0000', - 'title' => '', - ]; + Utils::$context['event'] = new Holiday(-1, ['rrule' => 'FREQ=YEARLY']); + } else { + Utils::$context['event'] = current(Holiday::load($_REQUEST['holiday'])); } - // If it's not new load the data. - else { - $request = Db::$db->query( - '', - 'SELECT id_holiday, YEAR(event_date) AS year, MONTH(event_date) AS month, DAYOFMONTH(event_date) AS day, title - FROM {db_prefix}calendar_holidays - WHERE id_holiday = {int:selected_holiday} - LIMIT 1', - [ - 'selected_holiday' => $_REQUEST['holiday'], - ], - ); - while ($row = Db::$db->fetch_assoc($request)) { - Utils::$context['holiday'] = [ - 'id' => $row['id_holiday'], - 'day' => $row['day'], - 'month' => $row['month'], - 'year' => $row['year'] <= 4 ? 0 : $row['year'], - 'title' => $row['title'], - ]; - } - Db::$db->free_result($request); + Utils::$context['event']->selected_occurrence = Utils::$context['event']->getFirstOccurrence(); + + // An all day event? Set up some nice defaults in case the user wants to change that + if (Utils::$context['event']->allday == true) { + Utils::$context['event']->selected_occurrence->tz = User::getTimezone(); + Utils::$context['event']->selected_occurrence->start->modify(Time::create('now')->format('%H:%M:%S')); + Utils::$context['event']->selected_occurrence->duration = new TimeInterval('PT1H'); } - // Last day for the drop down? - Utils::$context['holiday']['last_day'] = (int) Time::strftime('%d', mktime(0, 0, 0, Utils::$context['holiday']['month'] == 12 ? 1 : (int) Utils::$context['holiday']['month'] + 1, 0, Utils::$context['holiday']['month'] == 12 ? (int) Utils::$context['holiday']['year'] + 1 : (int) Utils::$context['holiday']['year'])); + // Need this so the user can select a timezone for the event. + Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->start_datetime); + + // If the event's timezone is not in SMF's standard list of time zones, try to fix it. + Utils::$context['event']->selected_occurrence->fixTimezone(); + + Theme::loadTemplate('EventEditor'); + Theme::addJavaScriptVar('monthly_byday_items', count(Utils::$context['event']->byday_items) - 1); + Theme::loadJavaScriptFile('event.js', ['defer' => true], 'smf_event'); } /** diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index d30e7d0943..ac9cb9a29f 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -19,6 +19,7 @@ use SMF\BrowserDetector; use SMF\Cache\CacheApi; use SMF\Calendar\Event; +use SMF\Calendar\Holiday; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\ErrorHandler; @@ -799,42 +800,12 @@ public static function getEventRange(string $low_date, string $high_date, bool $ */ public static function getHolidayRange(string $low_date, string $high_date): array { - // Get the lowest and highest dates for "all years". - if (substr($low_date, 0, 4) != substr($high_date, 0, 4)) { - $allyear_part = 'event_date BETWEEN {date:all_year_low} AND {date:all_year_dec} - OR event_date BETWEEN {date:all_year_jan} AND {date:all_year_high}'; - } else { - $allyear_part = 'event_date BETWEEN {date:all_year_low} AND {date:all_year_high}'; - } - - // Find some holidays... ;). - $result = Db::$db->query( - '', - 'SELECT event_date, YEAR(event_date) AS year, title - FROM {db_prefix}calendar_holidays - WHERE event_date BETWEEN {date:low_date} AND {date:high_date} - OR ' . $allyear_part, - [ - 'low_date' => $low_date, - 'high_date' => $high_date, - 'all_year_low' => '1004' . substr($low_date, 4), - 'all_year_high' => '1004' . substr($high_date, 4), - 'all_year_jan' => '1004-01-01', - 'all_year_dec' => '1004-12-31', - ], - ); $holidays = []; + $high_date = (new \DateTimeImmutable($high_date . ' +1 day'))->format('Y-m-d'); - while ($row = Db::$db->fetch_assoc($result)) { - if (substr($low_date, 0, 4) != substr($high_date, 0, 4)) { - $event_year = substr($row['event_date'], 5) < substr($high_date, 5) ? substr($high_date, 0, 4) : substr($low_date, 0, 4); - } else { - $event_year = substr($low_date, 0, 4); - } - - $holidays[$event_year . substr($row['event_date'], 4)][] = $row['title']; + foreach(Holiday::getOccurrencesInRange($low_date, $high_date) as $occurrence) { + $holidays[$occurrence->start->format('Y-m-d')][] = $occurrence; } - Db::$db->free_result($result); ksort($holidays); @@ -1233,7 +1204,7 @@ public static function getCalendarList(string $start_date, string $end_date, arr foreach (['birthdays', 'holidays'] as $type) { foreach ($calendarGrid[$type] as $date => $date_content) { // Make sure to apply no offsets - $date_local = preg_replace('~(?<=\s)0+(\d)~', '$1', trim(Time::create($date)->format($date_format), " \t\n\r\0\x0B,./;:<>()[]{}\\|-_=+")); + $date_local = Time::create($date)->format($date_format); $calendarGrid[$type][$date]['date_local'] = $date_local; } @@ -1534,76 +1505,15 @@ public static function getEventPoster(int $event_id): int|bool } /** - * Gets all of the holidays for the listing - * - * @param int $start The item to start with (for pagination purposes) - * @param int $items_per_page How many items to show on each page - * @param string $sort A string indicating how to sort the results - * @return array An array of holidays, each of which is an array containing the id, year, month, day and title of the holiday - */ - public static function list_getHolidays(int $start, int $items_per_page, string $sort): array - { - $request = Db::$db->query( - '', - 'SELECT id_holiday, YEAR(event_date) AS year, MONTH(event_date) AS month, DAYOFMONTH(event_date) AS day, title - FROM {db_prefix}calendar_holidays - ORDER BY {raw:sort} - LIMIT {int:start}, {int:max}', - [ - 'sort' => $sort, - 'start' => $start, - 'max' => $items_per_page, - ], - ); - $holidays = []; - - while ($row = Db::$db->fetch_assoc($request)) { - $holidays[] = $row; - } - Db::$db->free_result($request); - - return $holidays; - } - - /** - * Helper function to get the total number of holidays + * Backward compatibility wrapper for Holiday::remove(). * - * @return int The total number of holidays - */ - public static function list_getNumHolidays(): int - { - $request = Db::$db->query( - '', - 'SELECT COUNT(*) - FROM {db_prefix}calendar_holidays', - [ - ], - ); - list($num_items) = Db::$db->fetch_row($request); - Db::$db->free_result($request); - - return (int) $num_items; - } - - /** - * Remove a holiday from the calendar - * - * @param array $holiday_ids An array of IDs of holidays to delete + * @param array $holiday_ids An array of IDs of holidays to delete. */ public static function removeHolidays(array $holiday_ids): void { - Db::$db->query( - '', - 'DELETE FROM {db_prefix}calendar_holidays - WHERE id_holiday IN ({array_int:id_holiday})', - [ - 'id_holiday' => $holiday_ids, - ], - ); - - Config::updateModSettings([ - 'calendar_updated' => time(), - ]); + foreach ($holiday_ids as $holiday_id) { + Holiday::remove($holiday_id); + } } /** diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index 7ef390ae88..6d9b488c6b 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -31,7 +31,7 @@ use SMF\Uuid; /** - * Represents a (possibly recurring) calendar event, birthday, or holiday. + * Represents a (possibly recurring) calendar event. * * @todo Add support for editing specific instances as exceptions to the RRule. */ @@ -44,7 +44,7 @@ class Event implements \ArrayAccess *****************/ public const TYPE_EVENT = 0; - public const TYPE_HOLIDAY = 1; // Not yet implemented. + public const TYPE_HOLIDAY = 1; public const TYPE_BIRTHDAY = 2; // Not yet implemented. /******************* @@ -231,10 +231,55 @@ class Event implements \ArrayAccess */ public array $byday_items = []; + /** + * @var array + * + * Used to override the usual handling of RRule values. + * + * Value is an array containing a 'base' and a 'modifier'. + * The 'base' is one of the keys from self:special_rrules. + * The 'modifier' is a + or - sign followed by a duration string, or null. + */ + public array $special_rrule; + /************************** * Public static properties **************************/ + /** + * @var array + * + * Known special values for the 'rrule' field. These are used when dealing + * with recurring events whose recurrence patterns cannot be expressed using + * RFC 5545's notation. + * + * Keys are special strings that may be found in the 'rrule' field in the + * database. + * + * Values are arrays containing the following options: + * + * 'txt_key' indicates a Lang::$txt string for this special RRule. + * If not set, defaults to 'calendar_repeat_special'. + * + * 'group' indicates special RRules that should be listed as alternatives + * to each other. For example, 'group' => ['EASTER_W', 'EASTER_E'] means + * that the Western and Eastern ways of calculating the date of Easter + * should be listed as the two options for Easter. If not set, defaults to + * a list containing only the special string itself. + */ + public static array $special_rrules = [ + // Easter (Western) + 'EASTER_W' => [ + 'txt_key' => 'calendar_repeat_easter_w', + 'group' => ['EASTER_W', 'EASTER_E'], + ], + // Easter (Eastern) + 'EASTER_E' => [ + 'txt_key' => 'calendar_repeat_easter_e', + 'group' => ['EASTER_W', 'EASTER_E'], + ], + ]; + /** * @var array * @@ -246,6 +291,14 @@ class Event implements \ArrayAccess * Internal properties *********************/ + /** + * @var bool + * + * Whether this event is enabled. + * Always true for events and birthdays. May be false for holidays. + */ + protected bool $enabled = true; + /** * @var bool * @@ -325,6 +378,11 @@ public function __construct(int $id = 0, array $props = []) // Just in case someone passes -2 or something. $id = max(-1, $id); + // Give mods access early in the process. + IntegrationHook::call('integrate_construct_event', [$id, &$props]); + + $this->handleSpecialRRule($id, $props); + switch ($id) { // Preparing default data to show in the calendar posting form. case -1: @@ -508,6 +566,9 @@ function ($a, $b) { } else { $this->byday_items[] = ['num' => 0, 'name' => '']; } + + // Give mods access again at the end of the process. + IntegrationHook::call('integrate_constructed_event', [$this]); } /** @@ -547,6 +608,8 @@ public function save(): void $recurrence_end = new Time('9999-12-31'); } + $rrule = !empty($this->special_rrule) ? implode('', $this->special_rrule) : (string) $this->recurrence_iterator->getRRule(); + // Saving a new event. if (!$is_edit) { $columns = [ @@ -563,6 +626,7 @@ public function save(): void 'exdates' => 'string', 'uid' => 'string-255', 'type' => 'int', + 'enabled' => 'int', ]; $params = [ @@ -574,11 +638,12 @@ public function save(): void $this->member, Utils::truncate($this->location, 255), (string) $this->duration, - (string) ($this->recurrence_iterator->getRRule()), + $rrule, implode(',', $this->recurrence_iterator->getRDates()), implode(',', $this->recurrence_iterator->getExDates()), $this->uid, $this->type, + (int) $this->enabled, ]; if (!$this->allday) { @@ -621,6 +686,7 @@ public function save(): void 'exdates = {string:exdates}', 'uid = {string:uid}', 'type = {int:type}', + 'enabled = {int:enabled}', ]; $params = [ @@ -632,11 +698,12 @@ public function save(): void 'id_board' => $this->board, 'id_topic' => $this->topic, 'duration' => (string) $this->duration, - 'rrule' => (string) ($this->recurrence_iterator->getRRule()), + 'rrule' => $rrule, 'rdates' => implode(',', $this->recurrence_iterator->getRDates()), 'exdates' => implode(',', $this->recurrence_iterator->getExDates()), 'uid' => $this->uid, 'type' => $this->type, + 'enabled' => (int) $this->enabled, ]; if ($this->allday) { @@ -1171,6 +1238,11 @@ public function __get(string $prop): mixed break; case 'rrule_preset': + if (!empty($this->special_rrule)) { + $value = $this->special_rrule['base']; + break; + } + if (isset($this->recurrence_iterator)) { $value = $this->recurrence_iterator->getRRule(); } else { @@ -1642,6 +1714,79 @@ public static function remove(int $id): void * Internal methods ******************/ + /** + * Some holidays have special values in the 'rrule' field in the database. + * This method detects them and does whatever special handling is necessary. + */ + protected function handleSpecialRRule($id, &$props): void + { + if (!isset($props['rrule'])) { + return; + } + + list($base, $modifier) = array_pad(preg_split('/(?=[+-]P\w+$)/', $props['rrule']), 2, ''); + + // Do nothing with unrecognized ones. + if (!isset(self::$special_rrules[$base])) { + return; + } + + // Record what the special RRule value is. + $this->special_rrule = compact('base', 'modifier'); + + // Special RRules get special handling in the rrule_presets menu in the UI. + if (empty(self::$special_rrules[$base]['group'])) { + self::$special_rrules[$base]['group'] = [$base]; + } + + foreach (self::$special_rrules[$base]['group'] as $special_rrule) { + $props['rrule_presets'][Lang::$txt['calendar_repeat_special']][$special_rrule] = Lang::$txt['calendar_repeat_rrule_presets'][self::$special_rrules[$special_rrule]['txt_key']] ?? Lang::$txt[self::$special_rrules[$special_rrule]['txt_key']] ?? Lang::$txt['calendar_repeat_rrule_presets'][$special_rrule] ?? Lang::$txt[$special_rrule] ?? $special_rrule; + } + + switch ($base) { + case 'EASTER_W': + case 'EASTER_E': + // For Easter, we manually calculate the date for each visible year, + // then save that date as an RDate, and then update the RRule to + // pretend that it is otherwise a one day occurrence. + $low = isset($props['view_start']) ? Time::createFromInterface($props['view_start']) : new Time('first day of this month, midnight'); + $high = $props['view_end'] ?? new Time('first day of next month, midnight'); + + while ($low->format('Y') <= $high->format('Y')) { + $rdate = new Time(implode('-', Holiday::easter((int) $low->format('Y'), $base === 'EASTER_E' ? 'Eastern' : 'Western'))); + + if ($low->getTimestamp() >= $rdate->getTimestamp()) { + $rdate = new Time(implode('-', Holiday::easter((int) $low->format('Y') + 1, $base === 'EASTER_E' ? 'Eastern' : 'Western'))); + } + + if (!empty($modifier)) { + if (str_starts_with($modifier, '-')) { + $rdate->sub(new \DateInterval(substr($modifier, 1))); + } else { + $rdate->add(new \DateInterval(substr($modifier, 1))); + } + } + + $props['rdates'][] = $rdate->format('Ymd'); + + $low->modify('+1 year'); + } + + $props['rrule'] = 'FREQ=YEARLY;COUNT=1'; + break; + + default: + // Allow mods to handle other holidays with complex rules. + IntegrationHook::call('integrate_handle_special_rrule', [$id, &$props]); + + // Ensure the RRule is valid. + if (!str_starts_with($props['rrule'], 'FREQ=')) { + $props['rrule'] = 'FREQ=YEARLY;COUNT=1'; + } + break; + } + } + /** * Sets $this->recurrence_iterator, but only if all necessary properties * have been set. @@ -1681,6 +1826,7 @@ protected function createRecurrenceIterator(): bool } /** + * Creates an EventOccurrence object for the given start time. * * @param \DateTimeInterface $start The start time. * @param ?TimeInterval $duration Custom duration for this occurrence. If @@ -2005,6 +2151,11 @@ protected static function setRequestedRDatesAndExDates(Event $event): void $event->addOccurrence(new \DateTimeImmutable($exdate)); } + // Events with special RRules can't have RDates or ExDates. + if (!empty($event->special_rrule)) { + return; + } + // Add all the RDates and ExDates. foreach (['RDATE', 'EXDATE'] as $date_type) { if (!isset($_REQUEST[$date_type . '_date'])) { diff --git a/Sources/Calendar/Holiday.php b/Sources/Calendar/Holiday.php new file mode 100644 index 0000000000..05fdb2f88a --- /dev/null +++ b/Sources/Calendar/Holiday.php @@ -0,0 +1,569 @@ +id <= 0) { + foreach (self::$special_rrules as $special_rrule => $info) { + $this->rrule_presets[Lang::$txt['calendar_repeat_special']][$special_rrule] = Lang::$txt['calendar_repeat_rrule_presets'][$info['txt_key']] ?? Lang::$txt[$info['txt_key']] ?? Lang::$txt['calendar_repeat_rrule_presets'][$special_rrule] ?? Lang::$txt[$special_rrule] ?? $special_rrule; + } + + Theme::addJavaScriptVar('special_rrules', array_keys(self::$special_rrules), true); + } + } + + /** + * Sets custom properties. + * + * @param string $prop The property name. + * @param mixed $value The value to set. + */ + public function __set(string $prop, $value): void + { + parent::__set($prop, $value); + } + + /** + * Gets custom property values. + * + * @param string $prop The property name. + */ + public function __get(string $prop): mixed + { + return parent::__get($prop); + } + + /** + * Checks whether a custom property has been set. + * + * @param string $prop The property name. + */ + public function __isset(string $prop): bool + { + return parent::__isset($prop); + } + + /** + * Unsets custom properties. + * + * @param string $prop The property name. + */ + public function __unset(string $prop): void + { + parent::__unset($prop); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Loads holidays by ID number. + * + * @param int|array $id ID number of the holiday event. + * @param bool $is_topic Ignored. + * @param bool $use_permissions Ignored. + * @return array Instances of this class for the loaded events. + */ + public static function load(int $id, bool $is_topic = false, bool $use_permissions = true): array + { + if ($id <= 0) { + return []; + } + + if (isset(self::$loaded[$id])) { + return [self::$loaded[$id]]; + } + + $loaded = []; + + $selects = [ + 'cal.*', + ]; + $where = ['cal.id_event = {int:id}']; + $params = [ + 'id' => $id, + ]; + + foreach(self::queryData($selects, $params, [], $where) as $row) { + $id = (int) $row['id_event']; + $row['use_permissions'] = false; + + if (!empty($row['rdates'])) { + $max_rdate = max($row['rdates']); + $row['view_end'] = (new \DateTimeImmutable(substr($max_rdate, 0, strcspn($max_rdate, '/'))))->modify('+1 day'); + } + + $loaded[] = new self($id, $row); + } + + return $loaded; + } + + /** + * Generator that yields instances of this class. + * + * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. + * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. + * @param bool $only_enabled If true, only show enabled holidays. + * @param array $query_customizations Customizations to the SQL query. + * @return Generator Iterating over result gives Holiday instances. + */ + public static function get(string $low_date, string $high_date, bool $only_enabled = true, array $query_customizations = []): \Generator + { + $low_date = new \DateTimeImmutable(!empty($low_date) ? $low_date : 'first day of this month, midnight'); + $high_date = new \DateTimeImmutable(!empty($high_date) ? $high_date : 'first day of next month, midnight'); + + $selects = $query_customizations['selects'] ?? [ + 'cal.*', + ]; + $joins = $query_customizations['joins'] ?? []; + $where = $query_customizations['where'] ?? [ + 'type = {int:type}', + ]; + $order = $query_customizations['order'] ?? ['cal.id_event']; + $group = $query_customizations['group'] ?? []; + $limit = $query_customizations['limit'] ?? 0; + $params = $query_customizations['params'] ?? [ + 'type' => self::TYPE_HOLIDAY, + ]; + + if ($only_enabled) { + $where[] = 'enabled = {int:enabled}'; + $params['enabled'] = 1; + } + + // Filter by month (if we are showing 11 or fewer months). + if ($high_date->diff($low_date)->days < 335) { + $months = []; + + $temp = \DateTime::createFromImmutable($low_date); + + while ($temp->format('Ym') <= $high_date->format('Ym')) { + $months[] = (int) $temp->format('m'); + $temp->modify('+1 month'); + } + + $months = array_unique($months); + + switch (Db::$db->title) { + case POSTGRE_TITLE: + $where[] = 'EXTRACT(MONTH FROM cal.start_date) IN ({array_int:months}) OR cal.rrule ~ {string:bymonth_regex}' . (array_intersect([3, 4], $months) !== [] ? ' OR cal.rrule IN ({array_string:easter})' : ''); + break; + + default: + $where[] = 'MONTH(cal.start_date) IN ({array_int:months}) OR REGEXP_LIKE(cal.rrule, {string:bymonth_regex})' . (array_intersect([3, 4], $months) !== [] ? ' OR cal.rrule IN ({array_string:easter})' : ''); + break; + } + + $params['months'] = $months; + $params['bymonth_regex'] = 'BYMONTH=(?:(?:' . implode('|', array_diff(range(1, 12), $months)) . '),)*(' . implode('|', $months) . ')'; + $params['easter'] = ['EASTER_W', 'EASTER_E']; + } + + foreach(self::queryData($selects, $params, $joins, $where, $order, $group, $limit) as $row) { + $id = (int) $row['id_event']; + $row['use_permissions'] = false; + + $row['view_start'] = $low_date; + $row['view_end'] = $high_date; + + yield (new self($id, $row)); + } + } + + /** + * Returns Holiday instances for the holidays recorded in the database. + * + * @param int $start The item to start with (for pagination purposes) + * @param int $items_per_page How many items to show on each page + * @param string $sort A string indicating how to sort the results + * @return array An array of Holiday instances. + */ + public static function list(int $start, int $items_per_page, string $sort = 'cal.title ASC, cal.start_date ASC'): array + { + $loaded = []; + + $selects = ['cal.*']; + $joins = []; + $where = ['type = {int:type}']; + $order = [$sort]; + $group = []; + $limit = implode(', ', [$start, $items_per_page]); + $params = ['type' => self::TYPE_HOLIDAY]; + + foreach(self::queryData($selects, $params, $joins, $where, $order, $group, $limit) as $row) { + $id = (int) $row['id_event']; + $row['use_permissions'] = false; + + if (!empty($row['rdates'])) { + $max_rdate = max($row['rdates']); + $row['view_end'] = (new \DateTimeImmutable(substr($max_rdate, 0, strcspn($max_rdate, '/'))))->modify('+1 day'); + } + + $loaded[] = new self($id, $row); + } + + return $loaded; + } + + /** + * Gets the total number of holidays recorded in the database. + * + * @return int The total number of known holidays. + */ + public static function count(): int + { + $request = Db::$db->query( + '', + 'SELECT COUNT(*) + FROM {db_prefix}calendar + WHERE type = {int:type}', + [ + 'type' => self::TYPE_HOLIDAY, + ], + ); + list($num_items) = Db::$db->fetch_row($request); + Db::$db->free_result($request); + + return (int) $num_items; + } + + /** + * Gets events within the given date range, and returns a generator that + * yields all occurrences of those events within that range. + * + * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. + * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. + * @param bool $only_enabled If true, only show enabled holidays. + * @param array $query_customizations Customizations to the SQL query. + * @return Generator Iterating over result gives + * EventOccurrence instances. + */ + public static function getOccurrencesInRange(string $low_date, string $high_date, bool $only_enabled = true, array $query_customizations = []): \Generator + { + foreach (self::get($low_date, $high_date, $only_enabled, $query_customizations) as $event) { + foreach ($event->getAllVisibleOccurrences() as $occurrence) { + yield $occurrence; + } + } + } + + /** + * Creates a holiday and saves it to the database. + * + * Does not check permissions. + * + * @param array $eventOptions Event data ('title', 'start_date', etc.) + */ + public static function create(array $eventOptions): void + { + // Sanitize the title and location. + foreach (['title', 'location'] as $key) { + $eventOptions[$key] = Utils::htmlspecialchars($eventOptions[$key] ?? '', ENT_QUOTES); + } + + $eventOptions['type'] = self::TYPE_HOLIDAY; + + // Set the start and end dates. + self::setRequestedStartAndDuration($eventOptions); + + $eventOptions['view_start'] = isset($eventOptions['start']) ? \DateTimeImmutable::createFromInterface($eventOptions['start']) : new \DateTimeImmutable('now'); + $eventOptions['view_end'] = $eventOptions['view_start']->add(new \DateInterval('P1D')); + + self::setRequestedRRule($eventOptions); + + $event = new self(0, $eventOptions); + + self::setRequestedRDatesAndExDates($event); + + $event->save(); + } + + /** + * Modifies a holiday. + * + * Does not check permissions. + * + * @param int $id The ID of the event. + * @param array $eventOptions An array of event information. + */ + public static function modify(int $id, array &$eventOptions): void + { + list($event) = self::load($id); + + // Double check that the loaded event is in fact a holiday. + if (!$event->type === self::TYPE_HOLIDAY) { + return; + } + + // Sanitize the title and location. + foreach (['title', 'location'] as $prop) { + $eventOptions[$prop] = Utils::htmlspecialchars($eventOptions[$prop] ?? '', ENT_QUOTES); + } + + foreach (['allday', 'start_date', 'end_date', 'start_time', 'end_time', 'timezone'] as $prop) { + $eventOptions[$prop] = $eventOptions[$prop] ?? $_POST[$prop] ?? $event->{$prop} ?? null; + } + + // Set the new start date and duration. + self::setRequestedStartAndDuration($eventOptions); + + $eventOptions['view_start'] = isset($eventOptions['start']) ? \DateTimeImmutable::createFromInterface($eventOptions['start']) : new \DateTimeImmutable('now'); + $eventOptions['view_end'] = $eventOptions['view_start']->add(new \DateInterval('P1D')); + + if (!empty($event->special_rrule)) { + unset( + $eventOptions['start_date'], + $eventOptions['end_date'], + $eventOptions['start_time'], + $eventOptions['timezone'], + $eventOptions['duration'], + $eventOptions['rrule'], + $eventOptions['rdates'], + $eventOptions['exdates'], + ); + + if (isset(self::$special_rrules[$_POST['RRULE']])) { + $event->special_rrule = $_POST['RRULE']; + + if (in_array($event->special_rrule, ['EASTER_W', 'EASTER_E'])) { + $eventOptions['start_date'] = implode('-', self::easter((int) $event->start->format('Y'), $event->special_rrule === 'EASTER_E' ? 'Eastern' : 'Western')); + } + } + } else { + self::setRequestedRRule($eventOptions); + } + + $event->set($eventOptions); + + self::setRequestedRDatesAndExDates($event); + + $event->save(); + } + + /** + * Removes a holiday. + * + * Does not check permissions. + * + * @param int $id The event's ID. + */ + public static function remove(int $id): void + { + $event = current(self::load($id)); + + if ($event === false) { + return; + } + + if (!$event->type === self::TYPE_HOLIDAY) { + return; + } + + parent::remove($id); + } + + /** + * Computes the Western and Eastern dates of Easter for the given year. + * + * In the returned array: + * + * - 'Western' gives the Gregorian calendar date of Easter as observed by + * Roman Catholic and Protestant Christians. This is computed using the + * Gregorian algorithm and presented as a Gregorian date. + * + * - 'Eastern' gives the Gregorian calendar date of Easter as observed by + * Eastern Orthodox Christians. This is computed using the Julian + * algorithm and then converted to a Gregorian date. + * + * - 'Julian' gives the same date as 'Eastern', except that it is left as a + * Julian calendar date rather than being converted to a Gregorian date. + * + * For years before 1583, 'Eastern' and 'Western' are the same. + * + * Beware that using 'Julian' to construct a \DateTime object will probably + * have unexpected results. \DateTime only understands Gregorian calendar + * dates. + * + * Instead, use 'Julian' to show dates as they were reckoned in a given + * country prior to that country's adoption of the Gregorian calendar. For + * example, Greece didn't adopt the Gregorian calendar until 1923, so you + * can use the Julian date to show the date of Easter in 1920 the way that + * Greek people would have reckoned it at the time (March 29 rather than + * March 11). + * + * Caveats: + * + * - The computation of the date of Easter was not standardized until A.D. + * 325, and some regional variations persisted until approx. A.D. 664. + * + * - Western Christians did not all adopt the Gregorian method of computing + * the date of Easter at the same time, which means that the observed + * date of Easter varied across regions in Western Europe for several + * centuries. Moreover, the adoption of the Gregorian calendar by a + * country as its civil calendar does not necessarily coincide with the + * adoption of the Gregorian date of Easter in that country. Choosing the + * correct computation for a particular location in a particular year + * therefore requires historical knowledge. + * + * @param ?int $year The year. Must be greater than 30. + * @param ?string $type One of 'Western', 'Eastern', or 'Julian'. If set to + * 'Western', 'Eastern', or 'Julian', only that date will be returned. + * Otherwise all three dates will be returned in an array. + * @return ?array Info about the Western and/or Eastern dates for Easter, + * or null on error. + */ + public static function easter(?int $year = null, ?string $type = null): ?array + { + if (!isset($year)) { + $now = getdate(); + $year = $now['year']; + } + + if ($year <= 30) { + return null; + } + + $type = ucfirst(strtolower($type)); + + $return = [ + 'Western' => ['year' => null, 'month' => null, 'day' => null], + 'Eastern' => ['year' => null, 'month' => null, 'day' => null], + 'Julian' => ['year' => null, 'month' => null, 'day' => null], + ]; + + // Compute according to Julian calendar. + // https://en.wikipedia.org/w/index.php?title=Date_of_Easter&oldid=1124654731#Meeus's_Julian_algorithm + $a = $year % 4; + $b = $year % 7; + $c = $year % 19; + $d = (19 * $c + 15) % 30; + $e = (2 * $a + 4 * $b - $d + 34) % 7; + $f = ($d + $e + 114); + + $return['Julian']['year'] = $year; + $return['Julian']['month'] = intdiv($f, 31); + $return['Julian']['day'] = $f % 31 + 1; + + // Convert Julian date to Gregorian date: + + // 1. Covert Julian calendar date to Julian Day number. + // https://en.wikipedia.org/w/index.php?title=Julian_day&oldid=996614725#Converting_Julian_calendar_date_to_Julian_Day_Number + $jd = 367 * $year - intdiv((7 * ($year + 5001 + intdiv(($return['Julian']['month'] - 9), 7))), 4) + intdiv((275 * $return['Julian']['month']), 9) + $return['Julian']['day'] + 1729777; + + // 2. Convert Julian Day number to Gregorian date. + // https://en.wikipedia.org/w/index.php?title=Julian_day&oldid=996614725#Julian_or_Gregorian_calendar_from_Julian_day_number + $y = 4716; + $j = 1401; + $m = 2; + $n = 12; + $r = 4; + $p = 1461; + $v = 3; + $u = 5; + $s = 153; + $w = 2; + $B = 274277; + + $f = $jd + $j + intdiv(intdiv(4 * $jd + $B, 146097) * 3, 4) - 38; + + $e = $r * $f + $v; + $g = intdiv($e % $p, $r); + $h = $u * $g + $w; + + $return['Eastern']['day'] = intdiv($h % $s, $u) + 1; + $return['Eastern']['month'] = ((intdiv($h, $s) + $m) % $n) + 1; + $return['Eastern']['year'] = intdiv($e, $p) - $y + intdiv($n + $m - $return['Eastern']['month'], $n); + + // Compute according to Gregorian calendar. + // https://en.wikipedia.org/w/index.php?title=Date_of_Easter&oldid=1180071644#Anonymous_Gregorian_algorithm + if ($year > 1582) { + $a = $year % 19; + $b = intdiv($year, 100); + $c = $year % 100; + $d = intdiv($b, 4); + $e = $b % 4; + $g = intdiv((8 * $b + 13), 25); + $h = (19 * $a + $b - $d - $g + 15) % 30; + $i = intdiv($c, 4); + $k = $c % 4; + $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; + $m = intdiv(($a + 11 * $h + 19 * $l), 433); + + $return['Western']['year'] = $year; + $return['Western']['month'] = intdiv(($h + $l - 7 * $m + 90), 25); + $return['Western']['day'] = ($h + $l - 7 * $m + 33 * $return['Western']['month'] + 19) % 32; + } else { + $return['Western'] = $return['Eastern']; + } + + if (isset($return[$type])) { + return $return[$type]; + } + + return $return; + } +} + +?> \ No newline at end of file diff --git a/Sources/ServerSideIncludes.php b/Sources/ServerSideIncludes.php index 25f71b6321..212a75a206 100644 --- a/Sources/ServerSideIncludes.php +++ b/Sources/ServerSideIncludes.php @@ -1937,6 +1937,8 @@ public static function todaysHolidays(string $output_method = 'echo'): ?array ]; $return = CacheApi::quickGet('calendar_index_offset_' . User::$me->time_offset, 'Actions/Calendar.php', 'SMF\\Actions\\Calendar::cache_getRecentEvents', [$eventOptions]); + $return['calendar_holidays'] = array_map(fn ($h) => $h->title, $return['calendar_holidays']); + // The self::todaysCalendar variants all use the same hook and just pass on $eventOptions so the hooked code can distinguish different cases if necessary IntegrationHook::call('integrate_ssi_calendar', [&$return, $eventOptions]); @@ -2023,6 +2025,8 @@ public static function todaysCalendar(string $output_method = 'echo'): array|str ]; $return = CacheApi::quickGet('calendar_index_offset_' . User::$me->time_offset, 'Actions/Calendar.php', 'SMF\\Actions\\Calendar::cache_getRecentEvents', [$eventOptions]); + $return['calendar_holidays'] = array_map(fn ($h) => $h->title, $return['calendar_holidays']); + // The self::todaysCalendar variants all use the same hook and just pass on $eventOptions so the hooked code can distinguish different cases if necessary IntegrationHook::call('integrate_ssi_calendar', [&$return, $eventOptions]); diff --git a/Themes/default/BoardIndex.template.php b/Themes/default/BoardIndex.template.php index 87e11d4232..d93ae0a837 100644 --- a/Themes/default/BoardIndex.template.php +++ b/Themes/default/BoardIndex.template.php @@ -413,11 +413,18 @@ function template_ic_block_calendar()
    '; // Holidays like "Christmas", "Chanukah", and "We Love [Unknown] Day" :P - if (!empty(Utils::$context['calendar_holidays'])) + if (!empty(Utils::$context['calendar_holidays'])) { + $holidays = array(); + + foreach (Utils::$context['calendar_holidays'] as $holiday) { + $holidays[] = $holiday->title . (!empty($holiday->location) ? ' (' . $holiday->location . ')' : ''); + } + echo '

    - ', Lang::$txt['calendar_prompt'], ' ', implode(', ', Utils::$context['calendar_holidays']), ' + ', Lang::$txt['calendar_prompt'], ' ', implode(', ', $holidays), '

    '; + } // People's birthdays. Like mine. And yours, I guess. Kidding. if (!empty(Utils::$context['calendar_birthdays'])) diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index 6170c7992c..f044a38cf9 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -205,18 +205,24 @@ function template_show_upcoming_list($grid_name)

    '; - $holidays = array(); + foreach ($calendar_data['holidays'] as $date) { + echo ' + + ', $date['date_local'], ': '; - foreach ($calendar_data['holidays'] as $date) - { - $date_local = $date['date_local']; unset($date['date_local']); - foreach ($date as $holiday) - $holidays[] = $holiday . ' (' . $date_local . ')'; - } + $holidays = array(); + + foreach ($date as $holiday) { + $holidays[] = $holiday->title . (!empty($holiday->location) ? ' (' . $holiday->location . ')' : ''); + } - echo implode(', ', $holidays); + echo implode(', ', $holidays); + + echo '. + '; + } echo '

    @@ -368,7 +374,29 @@ function template_show_month_grid($grid_name, $is_mini = false) if (!empty($day['holidays'])) echo '
    - ', Lang::$txt['calendar_prompt'], ' ', implode(', ', $day['holidays']), ' + ', Lang::$txt['calendar_prompt'], ' '; + + $holidays = []; + + foreach ($day['holidays'] as $holiday) { + echo ''; + + $holiday_string = $holiday->title; + + if (empty($holiday->allday) && !empty($holiday->start_time_local) && $holiday->start_date == $day['date']) { + $holiday_string .= ' ' . trim(str_replace(':00 ', ' ', $holiday->start_time_local)) . ''; + } + + if (!empty($holiday->location)) { + $holiday_string .= ' ' . $holiday->location . ''; + } + + $holidays[] = $holiday_string; + } + + echo implode(', ', $holidays); + + echo '
    '; // Happy Birthday Dear Member! @@ -699,11 +727,36 @@ function($a, $b) '; // Show any holidays! - if (!empty($day['holidays'])) - echo implode('
    ', $day['holidays']); + if (!empty($day['holidays'])) { + echo ' +
    '; + + $holidays = []; + + foreach ($day['holidays'] as $holiday) { + $holiday_string = $holiday->title; + + if (empty($holiday->allday) && !empty($holiday->start_time_local) && $holiday->start_date == $day['date']) { + $holiday_string .= ' ' . trim(str_replace(':00 ', ' ', $holiday->start_time_local)) . ''; + } + + if (!empty($holiday->location)) { + $holiday_string .= ' ' . $holiday->location . ''; + } + + $holidays[] = $holiday_string; + } + + echo implode(' +
    +
    ', $holidays); + + echo ' +
    '; + } echo ' - '; + '; } if (!empty($calendar_data['show_birthdays'])) diff --git a/Themes/default/EventEditor.template.php b/Themes/default/EventEditor.template.php index 655f501eaa..2f2cf7b797 100644 --- a/Themes/default/EventEditor.template.php +++ b/Themes/default/EventEditor.template.php @@ -11,6 +11,7 @@ * @version 3.0 Alpha 1 */ +use SMF\Calendar\Event; use SMF\Config; use SMF\Lang; use SMF\Utils; @@ -83,7 +84,7 @@ function template_event_options()
    - allday) ? ' checked' : '', '> + allday) ? ' checked' : '', !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', '>
    @@ -91,17 +92,16 @@ function template_event_options()
    - - allday) ? ' disabled' : '', '> + special_rrule) ? ' disabled data-force-disabled' : '', '> + allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '>
    - - allday) ? ' disabled' : '', '> - + special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '> + allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '>
    @@ -112,7 +112,7 @@ function template_event_options() // Setting max-width on selects inside floating elements can be flaky, // so we need to calculate the width value manually. echo ' - allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', ' style="width:min(', max(array_map(fn ($tzname) => Utils::entityStrlen($tzname), Utils::$context['all_timezones'])) * 0.9, 'ch, 100%)">'; foreach (Utils::$context['all_timezones'] as $tz => $tzname) { echo ' @@ -137,15 +137,29 @@ function template_event_options() '; // When to end the recurrence. - echo ' + if (empty(Utils::$context['event']->special_rrule)) { + echo ' recurrence_iterator->getRRule()->until) ? ' value="' . Utils::$context['event']->recurrence_iterator->getRRule()->until->format('Y-m-d') . '"' : ' disabled', '> recurrence_iterator->getRRule()->count ?? 0) > 1 ? ' value="' . Utils::$context['event']->recurrence_iterator->getRRule()->count . '"' : ' value="1" disabled', '> - + '; + } + + echo ' '; - // Custom frequency and interval (e.g. "every 2 weeks") - echo ' + + if (!empty(Utils::$context['event']->special_rrule) || (Utils::$context['event']->new && Utils::$context['event']->type === Event::TYPE_HOLIDAY)) { + echo ' +
    +
    + + +
    +
    + +
    +
    '; + } + + + if (empty(Utils::$context['event']->special_rrule)) { + // Custom frequency and interval (e.g. "every 2 weeks") + echo '
    @@ -168,18 +201,18 @@ function template_event_options()
    '; - // Custom yearly options. - echo ' + // Custom yearly options. + echo '
    @@ -187,31 +220,31 @@ function template_event_options()
    '; - for ($i = 1; $i <= 12; $i++) { - echo ' + for ($i = 1; $i <= 12; $i++) { + echo ' '; - if ($i % 6 === 0) { - echo ' + if ($i % 6 === 0) { + echo '
    '; + } } - } - echo ' + echo '
    '; - // Custom monthly options. - echo ' + // Custom monthly options. + echo '
    '; - // Custom monthly: by day of month. - echo ' + // Custom monthly: by day of month. + echo '
    '; - // Custom monthly: by weekday and offset (e.g. "the second Tuesday") - echo ' + // Custom monthly: by weekday and offset (e.g. "the second Tuesday") + echo '
    '; - foreach (Utils::$context['event']->byday_items as $byday_item_key => $byday_item) { - echo ' + foreach (Utils::$context['event']->byday_items as $byday_item_key => $byday_item) { + echo '
    '; - } + } - echo ' + echo '
    ', Lang::$txt['calendar_repeat_add_condition'], ' @@ -297,29 +330,29 @@ function template_event_options()
    recurrence_iterator->getRRule()->byday ?? []) ? ' checked' : '', ' disabled> ', $weekday['short'], ' '; - } + } - echo ' + echo '
    '; + } // Advanced options. echo ' diff --git a/Themes/default/ManageCalendar.template.php b/Themes/default/ManageCalendar.template.php index e9c61259b9..0c1005dd42 100644 --- a/Themes/default/ManageCalendar.template.php +++ b/Themes/default/ManageCalendar.template.php @@ -25,59 +25,20 @@ function template_edit_holiday()

    ', Utils::$context['page_title'], '

    -
    -
    -
    - ', Lang::$txt['holidays_title_label'], ': -
    -
    - -
    -
    - ', Lang::$txt['calendar_year'], ' -
    -
    - - - - - -
    -
    '; - - if (Utils::$context['is_new']) + if (Utils::$context['is_new']) { echo ' '; - else + } else { echo ' - '; + '; + } + echo ' diff --git a/Themes/default/css/calendar.css b/Themes/default/css/calendar.css index 63d1148af9..5837fbb6f3 100644 --- a/Themes/default/css/calendar.css +++ b/Themes/default/css/calendar.css @@ -173,6 +173,13 @@ td.days:hover { #main_grid tr:not(.days_wrapper) td { min-height: 30px; } +p.inline.holidays { + display: flex; + flex-flow: row wrap; +} +p.inline.holidays > span { + width: 300px; +} #calendar_range, #calendar_navigation { padding: 5px 0; diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index 77e4ef4000..9f9f8fe6e8 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -557,9 +557,18 @@ strong[id^='child_list_']::after { color: #078907; } -.holiday > span { +.holiday > span.label { color: #025dff; } +.holiday_wrapper > span:first-of-type::before { + content: '('; +} +.holiday_wrapper > span:first-of-type::after { + content: ','; +} +.holiday_wrapper > span:last-of-type::after { + content: ')'; +} /* Events that are currently selected on the calendar. Won't see it much, probably. */ .sel_event { font-weight: bold; diff --git a/Themes/default/scripts/event.js b/Themes/default/scripts/event.js index 16b65adacc..9493177246 100644 --- a/Themes/default/scripts/event.js +++ b/Themes/default/scripts/event.js @@ -31,9 +31,10 @@ function updateEventUI() let weekday = getWeekday(start_date); // Disable or enable the time-related fields as necessary. - document.getElementById("start_time").disabled = document.getElementById("allday").checked; - document.getElementById("end_time").disabled = document.getElementById("allday").checked; - document.getElementById("tz").disabled = document.getElementById("allday").checked; + document.getElementById("start_time").disabled = document.getElementById("start_time").dataset.forceDisabled; + document.getElementById("start_time").disabled = document.getElementById("allday").checked || document.getElementById("start_time").dataset.forceDisabled; + document.getElementById("end_time").disabled = document.getElementById("allday").checked || document.getElementById("end_time").dataset.forceDisabled; + document.getElementById("tz").disabled = document.getElementById("allday").checked || document.getElementById("tz").dataset.forceDisabled; // Reset the recurring event options to be hidden and disabled. // We'll turn the appropriate ones back on below. @@ -422,6 +423,21 @@ function updateEventUI() } } + if ( + document.getElementById("rrule") + && document.getElementById("special_rrule_modifier") + && typeof special_rrules !== "undefined" + && Array.isArray(special_rrules) + ) { + if (special_rrules.includes(document.getElementById("rrule").value)) { + document.getElementById("special_rrule_options").style.display = ""; + document.getElementById("special_rrule_modifier").disabled = false; + } else { + document.getElementById("special_rrule_options").style.display = "none"; + document.getElementById("special_rrule_modifier").disabled = true; + } + } + end_date = updateEndDate(start_date, end_date); // Show the basic RRule select menu. @@ -434,7 +450,7 @@ function updateEventUI() } // If necessary, show the options for RRule end. - if (document.getElementById("rrule").value !== "never") { + if (document.getElementById("rrule").value === "custom" || document.getElementById("rrule").value.substring(0, 5) === "FREQ=") { const end_option = document.getElementById("end_option"); const until = document.getElementById("until"); const count = document.getElementById("count"); diff --git a/other/install_3-0_MySQL.sql b/other/install_3-0_MySQL.sql index 875b8ec083..d46dd8f61b 100644 --- a/other/install_3-0_MySQL.sql +++ b/other/install_3-0_MySQL.sql @@ -181,24 +181,13 @@ CREATE TABLE {$db_prefix}calendar ( sequence SMALLINT UNSIGNED NOT NULL DEFAULT '0'; uid VARCHAR(255) NOT NULL DEFAULT '', type TINYINT UNSIGNED NOT NULL DEFAULT '0'; + enabled TINYINT UNSIGNED NOT NULL DEFAULT '1'; PRIMARY KEY (id_event), INDEX idx_start_date (start_date), INDEX idx_end_date (end_date), INDEX idx_topic (id_topic, id_member) ) ENGINE={$engine}; -# -# Table structure for table `calendar_holidays` -# - -CREATE TABLE {$db_prefix}calendar_holidays ( - id_holiday SMALLINT UNSIGNED AUTO_INCREMENT, - event_date date NOT NULL DEFAULT '1004-01-01', - title VARCHAR(255) NOT NULL DEFAULT '', - PRIMARY KEY (id_holiday), - INDEX idx_event_date (event_date) -) ENGINE={$engine}; - # # Table structure for table `categories` # @@ -1575,217 +1564,39 @@ VALUES (-1,1,0), (0,1,0), (2,1,0); # -------------------------------------------------------- # -# Dumping data for table `calendar_holidays` -# - -INSERT INTO {$db_prefix}calendar_holidays - (title, event_date) -VALUES ('New Year''s', '1004-01-01'), - ('Christmas', '1004-12-25'), - ('Valentine''s Day', '1004-02-14'), - ('St. Patrick''s Day', '1004-03-17'), - ('April Fools', '1004-04-01'), - ('Earth Day', '1004-04-22'), - ('United Nations Day', '1004-10-24'), - ('Halloween', '1004-10-31'), - ('Mother''s Day', '2010-05-09'), - ('Mother''s Day', '2011-05-08'), - ('Mother''s Day', '2012-05-13'), - ('Mother''s Day', '2013-05-12'), - ('Mother''s Day', '2014-05-11'), - ('Mother''s Day', '2015-05-10'), - ('Mother''s Day', '2016-05-08'), - ('Mother''s Day', '2017-05-14'), - ('Mother''s Day', '2018-05-13'), - ('Mother''s Day', '2019-05-12'), - ('Mother''s Day', '2020-05-10'), - ('Mother''s Day', '2021-05-09'), - ('Mother''s Day', '2022-05-08'), - ('Mother''s Day', '2023-05-14'), - ('Mother''s Day', '2024-05-12'), - ('Mother''s Day', '2025-05-11'), - ('Mother''s Day', '2026-05-10'), - ('Mother''s Day', '2027-05-09'), - ('Mother''s Day', '2028-05-14'), - ('Mother''s Day', '2029-05-13'), - ('Mother''s Day', '2030-05-12'), - ('Father''s Day', '2010-06-20'), - ('Father''s Day', '2011-06-19'), - ('Father''s Day', '2012-06-17'), - ('Father''s Day', '2013-06-16'), - ('Father''s Day', '2014-06-15'), - ('Father''s Day', '2015-06-21'), - ('Father''s Day', '2016-06-19'), - ('Father''s Day', '2017-06-18'), - ('Father''s Day', '2018-06-17'), - ('Father''s Day', '2019-06-16'), - ('Father''s Day', '2020-06-21'), - ('Father''s Day', '2021-06-20'), - ('Father''s Day', '2022-06-19'), - ('Father''s Day', '2023-06-18'), - ('Father''s Day', '2024-06-16'), - ('Father''s Day', '2025-06-15'), - ('Father''s Day', '2026-06-21'), - ('Father''s Day', '2027-06-20'), - ('Father''s Day', '2028-06-18'), - ('Father''s Day', '2029-06-17'), - ('Father''s Day', '2030-06-16'), - ('Summer Solstice', '2010-06-21'), - ('Summer Solstice', '2011-06-21'), - ('Summer Solstice', '2012-06-20'), - ('Summer Solstice', '2013-06-21'), - ('Summer Solstice', '2014-06-21'), - ('Summer Solstice', '2015-06-21'), - ('Summer Solstice', '2016-06-20'), - ('Summer Solstice', '2017-06-20'), - ('Summer Solstice', '2018-06-21'), - ('Summer Solstice', '2019-06-21'), - ('Summer Solstice', '2020-06-20'), - ('Summer Solstice', '2021-06-21'), - ('Summer Solstice', '2022-06-21'), - ('Summer Solstice', '2023-06-21'), - ('Summer Solstice', '2024-06-20'), - ('Summer Solstice', '2025-06-21'), - ('Summer Solstice', '2026-06-21'), - ('Summer Solstice', '2027-06-21'), - ('Summer Solstice', '2028-06-20'), - ('Summer Solstice', '2029-06-21'), - ('Summer Solstice', '2030-06-21'), - ('Vernal Equinox', '2010-03-20'), - ('Vernal Equinox', '2011-03-20'), - ('Vernal Equinox', '2012-03-20'), - ('Vernal Equinox', '2013-03-20'), - ('Vernal Equinox', '2014-03-20'), - ('Vernal Equinox', '2015-03-20'), - ('Vernal Equinox', '2016-03-20'), - ('Vernal Equinox', '2017-03-20'), - ('Vernal Equinox', '2018-03-20'), - ('Vernal Equinox', '2019-03-20'), - ('Vernal Equinox', '2020-03-20'), - ('Vernal Equinox', '2021-03-20'), - ('Vernal Equinox', '2022-03-20'), - ('Vernal Equinox', '2023-03-20'), - ('Vernal Equinox', '2024-03-20'), - ('Vernal Equinox', '2025-03-20'), - ('Vernal Equinox', '2026-03-20'), - ('Vernal Equinox', '2027-03-20'), - ('Vernal Equinox', '2028-03-20'), - ('Vernal Equinox', '2029-03-20'), - ('Vernal Equinox', '2030-03-20'), - ('Winter Solstice', '2010-12-21'), - ('Winter Solstice', '2011-12-22'), - ('Winter Solstice', '2012-12-21'), - ('Winter Solstice', '2013-12-21'), - ('Winter Solstice', '2014-12-21'), - ('Winter Solstice', '2015-12-22'), - ('Winter Solstice', '2016-12-21'), - ('Winter Solstice', '2017-12-21'), - ('Winter Solstice', '2018-12-21'), - ('Winter Solstice', '2019-12-22'), - ('Winter Solstice', '2020-12-21'), - ('Winter Solstice', '2021-12-21'), - ('Winter Solstice', '2022-12-21'), - ('Winter Solstice', '2023-12-22'), - ('Winter Solstice', '2024-12-21'), - ('Winter Solstice', '2025-12-21'), - ('Winter Solstice', '2026-12-21'), - ('Winter Solstice', '2027-12-22'), - ('Winter Solstice', '2028-12-21'), - ('Winter Solstice', '2029-12-21'), - ('Winter Solstice', '2030-12-21'), - ('Autumnal Equinox', '2010-09-23'), - ('Autumnal Equinox', '2011-09-23'), - ('Autumnal Equinox', '2012-09-22'), - ('Autumnal Equinox', '2013-09-22'), - ('Autumnal Equinox', '2014-09-23'), - ('Autumnal Equinox', '2015-09-23'), - ('Autumnal Equinox', '2016-09-22'), - ('Autumnal Equinox', '2017-09-22'), - ('Autumnal Equinox', '2018-09-23'), - ('Autumnal Equinox', '2019-09-23'), - ('Autumnal Equinox', '2020-09-22'), - ('Autumnal Equinox', '2021-09-22'), - ('Autumnal Equinox', '2022-09-23'), - ('Autumnal Equinox', '2023-09-23'), - ('Autumnal Equinox', '2024-09-22'), - ('Autumnal Equinox', '2025-09-22'), - ('Autumnal Equinox', '2026-09-23'), - ('Autumnal Equinox', '2027-09-23'), - ('Autumnal Equinox', '2028-09-22'), - ('Autumnal Equinox', '2029-09-22'), - ('Autumnal Equinox', '2030-09-22'); - -INSERT INTO {$db_prefix}calendar_holidays - (title, event_date) -VALUES ('Independence Day', '1004-07-04'), - ('Cinco de Mayo', '1004-05-05'), - ('Flag Day', '1004-06-14'), - ('Veterans Day', '1004-11-11'), - ('Groundhog Day', '1004-02-02'), - ('Thanksgiving', '2010-11-25'), - ('Thanksgiving', '2011-11-24'), - ('Thanksgiving', '2012-11-22'), - ('Thanksgiving', '2013-11-28'), - ('Thanksgiving', '2014-11-27'), - ('Thanksgiving', '2015-11-26'), - ('Thanksgiving', '2016-11-24'), - ('Thanksgiving', '2017-11-23'), - ('Thanksgiving', '2018-11-22'), - ('Thanksgiving', '2019-11-28'), - ('Thanksgiving', '2020-11-26'), - ('Thanksgiving', '2021-11-25'), - ('Thanksgiving', '2022-11-24'), - ('Thanksgiving', '2023-11-23'), - ('Thanksgiving', '2024-11-28'), - ('Thanksgiving', '2025-11-27'), - ('Thanksgiving', '2026-11-26'), - ('Thanksgiving', '2027-11-25'), - ('Thanksgiving', '2028-11-23'), - ('Thanksgiving', '2029-11-22'), - ('Thanksgiving', '2030-11-28'), - ('Memorial Day', '2010-05-31'), - ('Memorial Day', '2011-05-30'), - ('Memorial Day', '2012-05-28'), - ('Memorial Day', '2013-05-27'), - ('Memorial Day', '2014-05-26'), - ('Memorial Day', '2015-05-25'), - ('Memorial Day', '2016-05-30'), - ('Memorial Day', '2017-05-29'), - ('Memorial Day', '2018-05-28'), - ('Memorial Day', '2019-05-27'), - ('Memorial Day', '2020-05-25'), - ('Memorial Day', '2021-05-31'), - ('Memorial Day', '2022-05-30'), - ('Memorial Day', '2023-05-29'), - ('Memorial Day', '2024-05-27'), - ('Memorial Day', '2025-05-26'), - ('Memorial Day', '2026-05-25'), - ('Memorial Day', '2027-05-31'), - ('Memorial Day', '2028-05-29'), - ('Memorial Day', '2029-05-28'), - ('Memorial Day', '2030-05-27'), - ('Labor Day', '2010-09-06'), - ('Labor Day', '2011-09-05'), - ('Labor Day', '2012-09-03'), - ('Labor Day', '2013-09-02'), - ('Labor Day', '2014-09-01'), - ('Labor Day', '2015-09-07'), - ('Labor Day', '2016-09-05'), - ('Labor Day', '2017-09-04'), - ('Labor Day', '2018-09-03'), - ('Labor Day', '2019-09-02'), - ('Labor Day', '2020-09-07'), - ('Labor Day', '2021-09-06'), - ('Labor Day', '2022-09-05'), - ('Labor Day', '2023-09-04'), - ('Labor Day', '2024-09-02'), - ('Labor Day', '2025-09-01'), - ('Labor Day', '2026-09-07'), - ('Labor Day', '2027-09-06'), - ('Labor Day', '2028-09-04'), - ('Labor Day', '2029-09-03'), - ('Labor Day', '2030-09-02'), - ('D-Day', '1004-06-06'); +# Dumping data for table `calendar` +# + +INSERT INTO {$db_prefix}calendar + (title, start_date, end_date, start_time, timezone, location, duration, rrule, rdates, type, enabled) +VALUES + ('April Fools\' Day', '2000-04-01', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Christmas', '2000-12-25', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Cinco de Mayo', '2000-05-05', '9999-12-31', NULL, NULL, 'Mexico, USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('D-Day', '2000-06-06', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Easter', '2000-04-23', '9999-12-31', NULL, NULL, '', 'P1D', 'EASTER_W', '', 1, 1), + ('Earth Day', '2000-04-22', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Father\'s Day', '2000-06-19', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY;BYMONTH=6;BYDAY=3SU', '', 1, 1), + ('Flag Day', '2000-06-14', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Good Friday', '2000-04-21', '9999-12-31', NULL, NULL, '', 'P1D', 'EASTER_W-P2D', '', 1, 1), + ('Groundhog Day', '2000-02-02', '9999-12-31', NULL, NULL, 'Canada, USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Halloween', '2000-10-31', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Independence Day', '2000-07-04', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Labor Day', '2000-09-03', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', '', 1, 1), + ('Labour Day', '2000-09-03', '9999-12-31', NULL, NULL, 'Canada', 'P1D', 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', '', 1, 1), + ('Memorial Day', '2000-05-31', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY;BYMONTH=5;BYDAY=-1MO', '', 1, 1), + ('Mother\'s Day', '2000-05-08', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY;BYMONTH=5;BYDAY=2SU', '', 1, 1), + ('New Year\'s Day', '2000-01-01', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Remembrance Day', '2000-11-11', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('St. Patrick\'s Day', '2000-03-17', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Thanksgiving', '2000-11-26', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY;BYMONTH=11;BYDAY=4TH', '', 1, 1), + ('United Nations Day', '2000-10-24', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Valentine\'s Day', '2000-02-14', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Veterans Day', '2000-11-11', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Vernal Equinox', '2000-03-20', '9999-12-31', '07:30:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20000320T073000Z,20010320T131900Z,20020320T190800Z,20030321T005800Z,20040320T064700Z,20050320T123600Z,20060320T182500Z,20070321T001400Z,20080320T060400Z,20090320T115300Z,20100320T174200Z,20110320T233100Z,20120320T052000Z,20130320T111000Z,20140320T165900Z,20150320T224800Z,20160320T043700Z,20170320T102600Z,20180320T161600Z,20190320T220500Z,20200320T035400Z,20210320T094300Z,20220320T153200Z,20230320T212200Z,20240320T031100Z,20250320T090000Z,20260320T144900Z,20270320T203800Z,20280320T022800Z,20290320T081700Z,20300320T140600Z,20310320T195500Z,20320320T014400Z,20330320T073400Z,20340320T132300Z,20350320T191200Z,20360320T010100Z,20370320T065000Z,20380320T124000Z,20390320T182900Z,20400320T001800Z,20410320T060700Z,20420320T115600Z,20430320T174600Z,20440319T233500Z,20450320T052400Z,20460320T111300Z,20470320T170200Z,20480319T225200Z,20490320T044100Z,20500320T103000Z,20510320T161900Z,20520319T220800Z,20530320T035800Z,20540320T094700Z,20550320T153600Z,20560319T212500Z,20570320T031400Z,20580320T090400Z,20590320T145300Z,20600319T204200Z,20610320T023100Z,20620320T082000Z,20630320T141000Z,20640319T195900Z,20650320T014800Z,20660320T073700Z,20670320T132600Z,20680319T191600Z,20690320T010500Z,20700320T065400Z,20710320T124300Z,20720319T183200Z,20730320T002200Z,20740320T061100Z,20750320T120000Z,20760319T174900Z,20770319T233800Z,20780320T052800Z,20790320T111700Z,20800319T170600Z,20810319T225500Z,20820320T044400Z,20830320T103400Z,20840319T162300Z,20850319T221200Z,20860320T040100Z,20870320T095000Z,20880319T154000Z,20890319T212900Z,20900320T031800Z,20910320T090700Z,20920319T145600Z,20930319T204600Z,20940320T023500Z,20950320T082400Z,20960319T141300Z,20970319T200200Z,20980320T015200Z,20990320T074100Z', 1, 1), + ('Summer Solstice', '2000-06-21', '9999-12-31', '01:44:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20000621T014400Z,20010621T073200Z,20020621T132000Z,20030621T190800Z,20040621T005600Z,20050621T064400Z,20060621T123200Z,20070621T182100Z,20080621T000900Z,20090621T055700Z,20100621T114500Z,20110621T173300Z,20120620T232100Z,20130621T050900Z,20140621T105700Z,20150621T164600Z,20160620T223400Z,20170621T042200Z,20180621T101000Z,20190621T155800Z,20200620T214600Z,20210621T033400Z,20220621T092300Z,20230621T151100Z,20240620T205900Z,20250621T024700Z,20260621T083500Z,20270621T142300Z,20280620T201100Z,20290621T015900Z,20300621T074800Z,20310621T133600Z,20320620T192400Z,20330621T011200Z,20340621T070000Z,20350621T124800Z,20360620T183600Z,20370621T002400Z,20380621T061300Z,20390621T120100Z,20400620T174900Z,20410620T233700Z,20420621T052500Z,20430621T111300Z,20440620T170100Z,20450620T224900Z,20460621T043700Z,20470621T102600Z,20480620T161400Z,20490620T220200Z,20500621T035000Z,20510621T093800Z,20520620T152600Z,20530620T211400Z,20540621T030200Z,20550621T085100Z,20560620T143900Z,20570620T202700Z,20580621T021500Z,20590621T080300Z,20600620T135100Z,20610620T193900Z,20620621T012700Z,20630621T071600Z,20640620T130400Z,20650620T185200Z,20660621T004000Z,20670621T062800Z,20680620T121600Z,20690620T180400Z,20700620T235200Z,20710621T054100Z,20720620T112900Z,20730620T171700Z,20740620T230500Z,20750621T045300Z,20760620T104100Z,20770620T162900Z,20780620T221700Z,20790621T040500Z,20800620T095400Z,20810620T154200Z,20820620T213000Z,20830621T031800Z,20840620T090600Z,20850620T145400Z,20860620T204200Z,20870621T023000Z,20880620T081900Z,20890620T140700Z,20900620T195500Z,20910621T014300Z,20920620T073100Z,20930620T131900Z,20940620T190700Z,20950621T005500Z,20960620T064300Z,20970620T123200Z,20980620T182000Z,20990621T000800Z', 1, 1), + ('Autumnal Equinox', '2000-09-22', '9999-12-31', '17:16:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20000922T171600Z,20010922T230500Z,20020923T045400Z,20030923T104200Z,20040922T163100Z,20050922T222000Z,20060923T040800Z,20070923T095700Z,20080922T154600Z,20090922T213400Z,20100923T032300Z,20110923T091200Z,20120922T150100Z,20130922T204900Z,20140923T023800Z,20150923T082700Z,20160922T141500Z,20170922T200400Z,20180923T015300Z,20190923T074100Z,20200922T133000Z,20210922T191900Z,20220923T010700Z,20230923T065600Z,20240922T124500Z,20250922T183300Z,20260923T002200Z,20270923T061100Z,20280922T115900Z,20290922T174800Z,20300922T233700Z,20310923T052600Z,20320922T111400Z,20330922T170300Z,20340922T225200Z,20350923T044000Z,20360922T102900Z,20370922T161800Z,20380922T220600Z,20390923T035500Z,20400922T094400Z,20410922T153200Z,20420922T212100Z,20430923T031000Z,20440922T085800Z,20450922T144700Z,20460922T203600Z,20470923T022400Z,20480922T081300Z,20490922T140200Z,20500922T195000Z,20510923T013900Z,20520922T072800Z,20530922T131600Z,20540922T190500Z,20550923T005400Z,20560922T064200Z,20570922T123100Z,20580922T182000Z,20590923T000800Z,20600922T055700Z,20610922T114600Z,20620922T173400Z,20630922T232300Z,20640922T051200Z,20650922T110000Z,20660922T164900Z,20670922T223800Z,20680922T042600Z,20690922T101500Z,20700922T160400Z,20710922T215200Z,20720922T034100Z,20730922T093000Z,20740922T151800Z,20750922T210700Z,20760922T025600Z,20770922T084400Z,20780922T143300Z,20790922T202200Z,20800922T021000Z,20810922T075900Z,20820922T134800Z,20830922T193600Z,20840922T012500Z,20850922T071400Z,20860922T130200Z,20870922T185100Z,20880922T003900Z,20890922T062800Z,20900922T121700Z,20910922T180500Z,20920921T235400Z,20930922T054300Z,20940922T113100Z,20950922T172000Z,20960921T230900Z,20970922T045700Z,20980922T104600Z,20990922T163500Z', 1, 1), + ('Winter Solstice', '2000-12-21', '9999-12-31', '13:27:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20001221T132700Z,20011221T191600Z,20021222T010600Z,20031222T065600Z,20041221T124600Z,20051221T183500Z,20061222T002500Z,20071222T061500Z,20081221T120400Z,20091221T175400Z,20101221T234400Z,20111222T053400Z,20121221T112300Z,20131221T171300Z,20141221T230300Z,20151222T045300Z,20161221T104200Z,20171221T163200Z,20181221T222200Z,20191222T041100Z,20201221T100100Z,20211221T155100Z,20221221T214100Z,20231222T033000Z,20241221T092000Z,20251221T151000Z,20261221T205900Z,20271222T024900Z,20281221T083900Z,20291221T142900Z,20301221T201800Z,20311222T020800Z,20321221T075800Z,20331221T134800Z,20341221T193700Z,20351222T012700Z,20361221T071700Z,20371221T130600Z,20381221T185600Z,20391222T004600Z,20401221T063600Z,20411221T122500Z,20421221T181500Z,20431222T000500Z,20441221T055400Z,20451221T114400Z,20461221T173400Z,20471221T232400Z,20481221T051300Z,20491221T110300Z,20501221T165300Z,20511221T224200Z,20521221T043200Z,20531221T102200Z,20541221T161200Z,20551221T220100Z,20561221T035100Z,20571221T094100Z,20581221T153000Z,20591221T212000Z,20601221T031000Z,20611221T090000Z,20621221T144900Z,20631221T203900Z,20641221T022900Z,20651221T081800Z,20661221T140800Z,20671221T195800Z,20681221T014700Z,20691221T073700Z,20701221T132700Z,20711221T191700Z,20721221T010600Z,20731221T065600Z,20741221T124600Z,20751221T183500Z,20761221T002500Z,20771221T061500Z,20781221T120500Z,20791221T175400Z,20801220T234400Z,20811221T053400Z,20821221T112300Z,20831221T171300Z,20841220T230300Z,20851221T045200Z,20861221T104200Z,20871221T163200Z,20881220T222200Z,20891221T041100Z,20901221T100100Z,20911221T155100Z,20921220T214000Z,20931221T033000Z,20941221T092000Z,20951221T150900Z,20961220T205900Z,20971221T024900Z,20981221T083900Z,20991221T142800Z', 1, 1) # -------------------------------------------------------- # diff --git a/other/install_3-0_PostgreSQL.sql b/other/install_3-0_PostgreSQL.sql index 3165afe740..a36779d9c5 100644 --- a/other/install_3-0_PostgreSQL.sql +++ b/other/install_3-0_PostgreSQL.sql @@ -342,6 +342,7 @@ CREATE TABLE {$db_prefix}calendar ( sequence smallint NOT NULL DEFAULT '0'; uid VARCHAR(255) NOT NULL DEFAULT '', type smallint NOT NULL DEFAULT '0'; + enabled smallint NOT NULL DEFAULT '1'; PRIMARY KEY (id_event) ); @@ -353,29 +354,6 @@ CREATE INDEX {$db_prefix}calendar_start_date ON {$db_prefix}calendar (start_date CREATE INDEX {$db_prefix}calendar_end_date ON {$db_prefix}calendar (end_date); CREATE INDEX {$db_prefix}calendar_topic ON {$db_prefix}calendar (id_topic, id_member); -# -# Sequence for table `calendar_holidays` -# - -CREATE SEQUENCE {$db_prefix}calendar_holidays_seq; - -# -# Table structure for table `calendar_holidays` -# - -CREATE TABLE {$db_prefix}calendar_holidays ( - id_holiday smallint DEFAULT nextval('{$db_prefix}calendar_holidays_seq'), - event_date date NOT NULL DEFAULT '1004-01-01', - title varchar(255) NOT NULL DEFAULT '', - PRIMARY KEY (id_holiday) -); - -# -# Indexes for table `calendar_holidays` -# - -CREATE INDEX {$db_prefix}calendar_holidays_event_date ON {$db_prefix}calendar_holidays (event_date); - # # Sequence for table `categories` # @@ -2131,217 +2109,39 @@ VALUES (-1,1,0), (0,1,0), (2,1,0); # -------------------------------------------------------- # -# Dumping data for table `calendar_holidays` -# - -INSERT INTO {$db_prefix}calendar_holidays - (title, event_date) -VALUES ('New Year''s', '1004-01-01'), - ('Christmas', '1004-12-25'), - ('Valentine''s Day', '1004-02-14'), - ('St. Patrick''s Day', '1004-03-17'), - ('April Fools', '1004-04-01'), - ('Earth Day', '1004-04-22'), - ('United Nations Day', '1004-10-24'), - ('Halloween', '1004-10-31'), - ('Mother''s Day', '2010-05-09'), - ('Mother''s Day', '2011-05-08'), - ('Mother''s Day', '2012-05-13'), - ('Mother''s Day', '2013-05-12'), - ('Mother''s Day', '2014-05-11'), - ('Mother''s Day', '2015-05-10'), - ('Mother''s Day', '2016-05-08'), - ('Mother''s Day', '2017-05-14'), - ('Mother''s Day', '2018-05-13'), - ('Mother''s Day', '2019-05-12'), - ('Mother''s Day', '2020-05-10'), - ('Mother''s Day', '2021-05-09'), - ('Mother''s Day', '2022-05-08'), - ('Mother''s Day', '2023-05-14'), - ('Mother''s Day', '2024-05-12'), - ('Mother''s Day', '2025-05-11'), - ('Mother''s Day', '2026-05-10'), - ('Mother''s Day', '2027-05-09'), - ('Mother''s Day', '2028-05-14'), - ('Mother''s Day', '2029-05-13'), - ('Mother''s Day', '2030-05-12'), - ('Father''s Day', '2010-06-20'), - ('Father''s Day', '2011-06-19'), - ('Father''s Day', '2012-06-17'), - ('Father''s Day', '2013-06-16'), - ('Father''s Day', '2014-06-15'), - ('Father''s Day', '2015-06-21'), - ('Father''s Day', '2016-06-19'), - ('Father''s Day', '2017-06-18'), - ('Father''s Day', '2018-06-17'), - ('Father''s Day', '2019-06-16'), - ('Father''s Day', '2020-06-21'), - ('Father''s Day', '2021-06-20'), - ('Father''s Day', '2022-06-19'), - ('Father''s Day', '2023-06-18'), - ('Father''s Day', '2024-06-16'), - ('Father''s Day', '2025-06-15'), - ('Father''s Day', '2026-06-21'), - ('Father''s Day', '2027-06-20'), - ('Father''s Day', '2028-06-18'), - ('Father''s Day', '2029-06-17'), - ('Father''s Day', '2030-06-16'), - ('Summer Solstice', '2010-06-21'), - ('Summer Solstice', '2011-06-21'), - ('Summer Solstice', '2012-06-20'), - ('Summer Solstice', '2013-06-21'), - ('Summer Solstice', '2014-06-21'), - ('Summer Solstice', '2015-06-21'), - ('Summer Solstice', '2016-06-20'), - ('Summer Solstice', '2017-06-20'), - ('Summer Solstice', '2018-06-21'), - ('Summer Solstice', '2019-06-21'), - ('Summer Solstice', '2020-06-20'), - ('Summer Solstice', '2021-06-21'), - ('Summer Solstice', '2022-06-21'), - ('Summer Solstice', '2023-06-21'), - ('Summer Solstice', '2024-06-20'), - ('Summer Solstice', '2025-06-21'), - ('Summer Solstice', '2026-06-21'), - ('Summer Solstice', '2027-06-21'), - ('Summer Solstice', '2028-06-20'), - ('Summer Solstice', '2029-06-21'), - ('Summer Solstice', '2030-06-21'), - ('Vernal Equinox', '2010-03-20'), - ('Vernal Equinox', '2011-03-20'), - ('Vernal Equinox', '2012-03-20'), - ('Vernal Equinox', '2013-03-20'), - ('Vernal Equinox', '2014-03-20'), - ('Vernal Equinox', '2015-03-20'), - ('Vernal Equinox', '2016-03-20'), - ('Vernal Equinox', '2017-03-20'), - ('Vernal Equinox', '2018-03-20'), - ('Vernal Equinox', '2019-03-20'), - ('Vernal Equinox', '2020-03-20'), - ('Vernal Equinox', '2021-03-20'), - ('Vernal Equinox', '2022-03-20'), - ('Vernal Equinox', '2023-03-20'), - ('Vernal Equinox', '2024-03-20'), - ('Vernal Equinox', '2025-03-20'), - ('Vernal Equinox', '2026-03-20'), - ('Vernal Equinox', '2027-03-20'), - ('Vernal Equinox', '2028-03-20'), - ('Vernal Equinox', '2029-03-20'), - ('Vernal Equinox', '2030-03-20'), - ('Winter Solstice', '2010-12-21'), - ('Winter Solstice', '2011-12-22'), - ('Winter Solstice', '2012-12-21'), - ('Winter Solstice', '2013-12-21'), - ('Winter Solstice', '2014-12-21'), - ('Winter Solstice', '2015-12-22'), - ('Winter Solstice', '2016-12-21'), - ('Winter Solstice', '2017-12-21'), - ('Winter Solstice', '2018-12-21'), - ('Winter Solstice', '2019-12-22'), - ('Winter Solstice', '2020-12-21'), - ('Winter Solstice', '2021-12-21'), - ('Winter Solstice', '2022-12-21'), - ('Winter Solstice', '2023-12-22'), - ('Winter Solstice', '2024-12-21'), - ('Winter Solstice', '2025-12-21'), - ('Winter Solstice', '2026-12-21'), - ('Winter Solstice', '2027-12-22'), - ('Winter Solstice', '2028-12-21'), - ('Winter Solstice', '2029-12-21'), - ('Winter Solstice', '2030-12-21'), - ('Autumnal Equinox', '2010-09-23'), - ('Autumnal Equinox', '2011-09-23'), - ('Autumnal Equinox', '2012-09-22'), - ('Autumnal Equinox', '2013-09-22'), - ('Autumnal Equinox', '2014-09-23'), - ('Autumnal Equinox', '2015-09-23'), - ('Autumnal Equinox', '2016-09-22'), - ('Autumnal Equinox', '2017-09-22'), - ('Autumnal Equinox', '2018-09-23'), - ('Autumnal Equinox', '2019-09-23'), - ('Autumnal Equinox', '2020-09-22'), - ('Autumnal Equinox', '2021-09-22'), - ('Autumnal Equinox', '2022-09-23'), - ('Autumnal Equinox', '2023-09-23'), - ('Autumnal Equinox', '2024-09-22'), - ('Autumnal Equinox', '2025-09-22'), - ('Autumnal Equinox', '2026-09-23'), - ('Autumnal Equinox', '2027-09-23'), - ('Autumnal Equinox', '2028-09-22'), - ('Autumnal Equinox', '2029-09-22'), - ('Autumnal Equinox', '2030-09-22'); - -INSERT INTO {$db_prefix}calendar_holidays - (title, event_date) -VALUES ('Independence Day', '1004-07-04'), - ('Cinco de Mayo', '1004-05-05'), - ('Flag Day', '1004-06-14'), - ('Veterans Day', '1004-11-11'), - ('Groundhog Day', '1004-02-02'), - ('Thanksgiving', '2010-11-25'), - ('Thanksgiving', '2011-11-24'), - ('Thanksgiving', '2012-11-22'), - ('Thanksgiving', '2013-11-28'), - ('Thanksgiving', '2014-11-27'), - ('Thanksgiving', '2015-11-26'), - ('Thanksgiving', '2016-11-24'), - ('Thanksgiving', '2017-11-23'), - ('Thanksgiving', '2018-11-22'), - ('Thanksgiving', '2019-11-28'), - ('Thanksgiving', '2020-11-26'), - ('Thanksgiving', '2021-11-25'), - ('Thanksgiving', '2022-11-24'), - ('Thanksgiving', '2023-11-23'), - ('Thanksgiving', '2024-11-28'), - ('Thanksgiving', '2025-11-27'), - ('Thanksgiving', '2026-11-26'), - ('Thanksgiving', '2027-11-25'), - ('Thanksgiving', '2028-11-23'), - ('Thanksgiving', '2029-11-22'), - ('Thanksgiving', '2030-11-28'), - ('Memorial Day', '2010-05-31'), - ('Memorial Day', '2011-05-30'), - ('Memorial Day', '2012-05-28'), - ('Memorial Day', '2013-05-27'), - ('Memorial Day', '2014-05-26'), - ('Memorial Day', '2015-05-25'), - ('Memorial Day', '2016-05-30'), - ('Memorial Day', '2017-05-29'), - ('Memorial Day', '2018-05-28'), - ('Memorial Day', '2019-05-27'), - ('Memorial Day', '2020-05-25'), - ('Memorial Day', '2021-05-31'), - ('Memorial Day', '2022-05-30'), - ('Memorial Day', '2023-05-29'), - ('Memorial Day', '2024-05-27'), - ('Memorial Day', '2025-05-26'), - ('Memorial Day', '2026-05-25'), - ('Memorial Day', '2027-05-31'), - ('Memorial Day', '2028-05-29'), - ('Memorial Day', '2029-05-28'), - ('Memorial Day', '2030-05-27'), - ('Labor Day', '2010-09-06'), - ('Labor Day', '2011-09-05'), - ('Labor Day', '2012-09-03'), - ('Labor Day', '2013-09-02'), - ('Labor Day', '2014-09-01'), - ('Labor Day', '2015-09-07'), - ('Labor Day', '2016-09-05'), - ('Labor Day', '2017-09-04'), - ('Labor Day', '2018-09-03'), - ('Labor Day', '2019-09-02'), - ('Labor Day', '2020-09-07'), - ('Labor Day', '2021-09-06'), - ('Labor Day', '2022-09-05'), - ('Labor Day', '2023-09-04'), - ('Labor Day', '2024-09-02'), - ('Labor Day', '2025-09-01'), - ('Labor Day', '2026-09-07'), - ('Labor Day', '2027-09-06'), - ('Labor Day', '2028-09-04'), - ('Labor Day', '2029-09-03'), - ('Labor Day', '2030-09-02'), - ('D-Day', '1004-06-06'); +# Dumping data for table `calendar` +# + +INSERT INTO {$db_prefix}calendar + (title, start_date, end_date, start_time, timezone, location, duration, rrule, rdates, type, enabled) +VALUES + ('April Fools\' Day', '2000-04-01', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Christmas', '2000-12-25', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Cinco de Mayo', '2000-05-05', '9999-12-31', NULL, NULL, 'Mexico, USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('D-Day', '2000-06-06', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Easter', '2000-04-23', '9999-12-31', NULL, NULL, '', 'P1D', 'EASTER_W', '', 1, 1), + ('Earth Day', '2000-04-22', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Father\'s Day', '2000-06-19', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY;BYMONTH=6;BYDAY=3SU', '', 1, 1), + ('Flag Day', '2000-06-14', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Good Friday', '2000-04-21', '9999-12-31', NULL, NULL, '', 'P1D', 'EASTER_W-P2D', '', 1, 1), + ('Groundhog Day', '2000-02-02', '9999-12-31', NULL, NULL, 'Canada, USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Halloween', '2000-10-31', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Independence Day', '2000-07-04', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Labor Day', '2000-09-03', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', '', 1, 1), + ('Labour Day', '2000-09-03', '9999-12-31', NULL, NULL, 'Canada', 'P1D', 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', '', 1, 1), + ('Memorial Day', '2000-05-31', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY;BYMONTH=5;BYDAY=-1MO', '', 1, 1), + ('Mother\'s Day', '2000-05-08', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY;BYMONTH=5;BYDAY=2SU', '', 1, 1), + ('New Year\'s Day', '2000-01-01', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Remembrance Day', '2000-11-11', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('St. Patrick\'s Day', '2000-03-17', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Thanksgiving', '2000-11-26', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY;BYMONTH=11;BYDAY=4TH', '', 1, 1), + ('United Nations Day', '2000-10-24', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Valentine\'s Day', '2000-02-14', '9999-12-31', NULL, NULL, '', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Veterans Day', '2000-11-11', '9999-12-31', NULL, NULL, 'USA', 'P1D', 'FREQ=YEARLY', '', 1, 1), + ('Vernal Equinox', '2000-03-20', '9999-12-31', '07:30:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20000320T073000Z,20010320T131900Z,20020320T190800Z,20030321T005800Z,20040320T064700Z,20050320T123600Z,20060320T182500Z,20070321T001400Z,20080320T060400Z,20090320T115300Z,20100320T174200Z,20110320T233100Z,20120320T052000Z,20130320T111000Z,20140320T165900Z,20150320T224800Z,20160320T043700Z,20170320T102600Z,20180320T161600Z,20190320T220500Z,20200320T035400Z,20210320T094300Z,20220320T153200Z,20230320T212200Z,20240320T031100Z,20250320T090000Z,20260320T144900Z,20270320T203800Z,20280320T022800Z,20290320T081700Z,20300320T140600Z,20310320T195500Z,20320320T014400Z,20330320T073400Z,20340320T132300Z,20350320T191200Z,20360320T010100Z,20370320T065000Z,20380320T124000Z,20390320T182900Z,20400320T001800Z,20410320T060700Z,20420320T115600Z,20430320T174600Z,20440319T233500Z,20450320T052400Z,20460320T111300Z,20470320T170200Z,20480319T225200Z,20490320T044100Z,20500320T103000Z,20510320T161900Z,20520319T220800Z,20530320T035800Z,20540320T094700Z,20550320T153600Z,20560319T212500Z,20570320T031400Z,20580320T090400Z,20590320T145300Z,20600319T204200Z,20610320T023100Z,20620320T082000Z,20630320T141000Z,20640319T195900Z,20650320T014800Z,20660320T073700Z,20670320T132600Z,20680319T191600Z,20690320T010500Z,20700320T065400Z,20710320T124300Z,20720319T183200Z,20730320T002200Z,20740320T061100Z,20750320T120000Z,20760319T174900Z,20770319T233800Z,20780320T052800Z,20790320T111700Z,20800319T170600Z,20810319T225500Z,20820320T044400Z,20830320T103400Z,20840319T162300Z,20850319T221200Z,20860320T040100Z,20870320T095000Z,20880319T154000Z,20890319T212900Z,20900320T031800Z,20910320T090700Z,20920319T145600Z,20930319T204600Z,20940320T023500Z,20950320T082400Z,20960319T141300Z,20970319T200200Z,20980320T015200Z,20990320T074100Z', 1, 1), + ('Summer Solstice', '2000-06-21', '9999-12-31', '01:44:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20000621T014400Z,20010621T073200Z,20020621T132000Z,20030621T190800Z,20040621T005600Z,20050621T064400Z,20060621T123200Z,20070621T182100Z,20080621T000900Z,20090621T055700Z,20100621T114500Z,20110621T173300Z,20120620T232100Z,20130621T050900Z,20140621T105700Z,20150621T164600Z,20160620T223400Z,20170621T042200Z,20180621T101000Z,20190621T155800Z,20200620T214600Z,20210621T033400Z,20220621T092300Z,20230621T151100Z,20240620T205900Z,20250621T024700Z,20260621T083500Z,20270621T142300Z,20280620T201100Z,20290621T015900Z,20300621T074800Z,20310621T133600Z,20320620T192400Z,20330621T011200Z,20340621T070000Z,20350621T124800Z,20360620T183600Z,20370621T002400Z,20380621T061300Z,20390621T120100Z,20400620T174900Z,20410620T233700Z,20420621T052500Z,20430621T111300Z,20440620T170100Z,20450620T224900Z,20460621T043700Z,20470621T102600Z,20480620T161400Z,20490620T220200Z,20500621T035000Z,20510621T093800Z,20520620T152600Z,20530620T211400Z,20540621T030200Z,20550621T085100Z,20560620T143900Z,20570620T202700Z,20580621T021500Z,20590621T080300Z,20600620T135100Z,20610620T193900Z,20620621T012700Z,20630621T071600Z,20640620T130400Z,20650620T185200Z,20660621T004000Z,20670621T062800Z,20680620T121600Z,20690620T180400Z,20700620T235200Z,20710621T054100Z,20720620T112900Z,20730620T171700Z,20740620T230500Z,20750621T045300Z,20760620T104100Z,20770620T162900Z,20780620T221700Z,20790621T040500Z,20800620T095400Z,20810620T154200Z,20820620T213000Z,20830621T031800Z,20840620T090600Z,20850620T145400Z,20860620T204200Z,20870621T023000Z,20880620T081900Z,20890620T140700Z,20900620T195500Z,20910621T014300Z,20920620T073100Z,20930620T131900Z,20940620T190700Z,20950621T005500Z,20960620T064300Z,20970620T123200Z,20980620T182000Z,20990621T000800Z', 1, 1), + ('Autumnal Equinox', '2000-09-22', '9999-12-31', '17:16:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20000922T171600Z,20010922T230500Z,20020923T045400Z,20030923T104200Z,20040922T163100Z,20050922T222000Z,20060923T040800Z,20070923T095700Z,20080922T154600Z,20090922T213400Z,20100923T032300Z,20110923T091200Z,20120922T150100Z,20130922T204900Z,20140923T023800Z,20150923T082700Z,20160922T141500Z,20170922T200400Z,20180923T015300Z,20190923T074100Z,20200922T133000Z,20210922T191900Z,20220923T010700Z,20230923T065600Z,20240922T124500Z,20250922T183300Z,20260923T002200Z,20270923T061100Z,20280922T115900Z,20290922T174800Z,20300922T233700Z,20310923T052600Z,20320922T111400Z,20330922T170300Z,20340922T225200Z,20350923T044000Z,20360922T102900Z,20370922T161800Z,20380922T220600Z,20390923T035500Z,20400922T094400Z,20410922T153200Z,20420922T212100Z,20430923T031000Z,20440922T085800Z,20450922T144700Z,20460922T203600Z,20470923T022400Z,20480922T081300Z,20490922T140200Z,20500922T195000Z,20510923T013900Z,20520922T072800Z,20530922T131600Z,20540922T190500Z,20550923T005400Z,20560922T064200Z,20570922T123100Z,20580922T182000Z,20590923T000800Z,20600922T055700Z,20610922T114600Z,20620922T173400Z,20630922T232300Z,20640922T051200Z,20650922T110000Z,20660922T164900Z,20670922T223800Z,20680922T042600Z,20690922T101500Z,20700922T160400Z,20710922T215200Z,20720922T034100Z,20730922T093000Z,20740922T151800Z,20750922T210700Z,20760922T025600Z,20770922T084400Z,20780922T143300Z,20790922T202200Z,20800922T021000Z,20810922T075900Z,20820922T134800Z,20830922T193600Z,20840922T012500Z,20850922T071400Z,20860922T130200Z,20870922T185100Z,20880922T003900Z,20890922T062800Z,20900922T121700Z,20910922T180500Z,20920921T235400Z,20930922T054300Z,20940922T113100Z,20950922T172000Z,20960921T230900Z,20970922T045700Z,20980922T104600Z,20990922T163500Z', 1, 1), + ('Winter Solstice', '2000-12-21', '9999-12-31', '13:27:00', 'UTC', '', 'PT1M', 'FREQ=YEARLY;COUNT=1', '20001221T132700Z,20011221T191600Z,20021222T010600Z,20031222T065600Z,20041221T124600Z,20051221T183500Z,20061222T002500Z,20071222T061500Z,20081221T120400Z,20091221T175400Z,20101221T234400Z,20111222T053400Z,20121221T112300Z,20131221T171300Z,20141221T230300Z,20151222T045300Z,20161221T104200Z,20171221T163200Z,20181221T222200Z,20191222T041100Z,20201221T100100Z,20211221T155100Z,20221221T214100Z,20231222T033000Z,20241221T092000Z,20251221T151000Z,20261221T205900Z,20271222T024900Z,20281221T083900Z,20291221T142900Z,20301221T201800Z,20311222T020800Z,20321221T075800Z,20331221T134800Z,20341221T193700Z,20351222T012700Z,20361221T071700Z,20371221T130600Z,20381221T185600Z,20391222T004600Z,20401221T063600Z,20411221T122500Z,20421221T181500Z,20431222T000500Z,20441221T055400Z,20451221T114400Z,20461221T173400Z,20471221T232400Z,20481221T051300Z,20491221T110300Z,20501221T165300Z,20511221T224200Z,20521221T043200Z,20531221T102200Z,20541221T161200Z,20551221T220100Z,20561221T035100Z,20571221T094100Z,20581221T153000Z,20591221T212000Z,20601221T031000Z,20611221T090000Z,20621221T144900Z,20631221T203900Z,20641221T022900Z,20651221T081800Z,20661221T140800Z,20671221T195800Z,20681221T014700Z,20691221T073700Z,20701221T132700Z,20711221T191700Z,20721221T010600Z,20731221T065600Z,20741221T124600Z,20751221T183500Z,20761221T002500Z,20771221T061500Z,20781221T120500Z,20791221T175400Z,20801220T234400Z,20811221T053400Z,20821221T112300Z,20831221T171300Z,20841220T230300Z,20851221T045200Z,20861221T104200Z,20871221T163200Z,20881220T222200Z,20891221T041100Z,20901221T100100Z,20911221T155100Z,20921220T214000Z,20931221T033000Z,20941221T092000Z,20951221T150900Z,20961220T205900Z,20971221T024900Z,20981221T083900Z,20991221T142800Z', 1, 1) # -------------------------------------------------------- # diff --git a/other/upgrade_3-0_MySQL.sql b/other/upgrade_3-0_MySQL.sql index 5763d05165..3d4bae8d71 100644 --- a/other/upgrade_3-0_MySQL.sql +++ b/other/upgrade_3-0_MySQL.sql @@ -106,6 +106,7 @@ ADD COLUMN adjustments JSON DEFAULT NULL; ADD COLUMN sequence SMALLINT UNSIGNED NOT NULL DEFAULT '0'; ADD COLUMN uid VARCHAR(255) NOT NULL DEFAULT '', ADD COLUMN type TINYINT UNSIGNED NOT NULL DEFAULT '0'; +ADD COLUMN enabled TINYINT UNSIGNED NOT NULL DEFAULT '1'; ---# ---# Set duration and rrule values and change end_date @@ -169,4 +170,629 @@ ADD COLUMN type TINYINT UNSIGNED NOT NULL DEFAULT '0'; ---# Drop end_time column from calendar table ALTER TABLE {$db_prefix}calendar DROP COLUMN end_time; +---# + +---# Migrate holidays to events +---{ + $known_holidays = [ + 'April Fools' => [ + 'title' => "April Fools' Day", + 'start_date' => '2000-04-01', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Christmas' => [ + 'start_date' => '2000-12-25', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Cinco de Mayo' => [ + 'start_date' => '2000-05-05', + 'recurrence_end' => '9999-12-31', + 'location' => 'Mexico, USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'D-Day' => [ + 'start_date' => '2000-06-06', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Easter' => [ + 'start_date' => '2000-04-23', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'EASTER_W', + ], + 'Earth Day' => [ + 'start_date' => '2000-04-22', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + "Father's Day" => [ + 'start_date' => '2000-06-19', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY;BYMONTH=6;BYDAY=3SU', + ], + 'Flag Day' => [ + 'start_date' => '2000-06-14', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'Good Friday' => [ + 'start_date' => '2000-04-21', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'EASTER_W-P2D', + ], + 'Groundhog Day' => [ + 'start_date' => '2000-02-02', + 'recurrence_end' => '9999-12-31', + 'location' => 'Canada, USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'Halloween' => [ + 'start_date' => '2000-10-31', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Independence Day' => [ + 'start_date' => '2000-07-04', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'Labor Day' => [ + 'start_date' => '2000-09-03', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', + ], + 'Labour Day' => [ + 'start_date' => '2000-09-03', + 'recurrence_end' => '9999-12-31', + 'location' => 'Canada', + 'rrule' => 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', + ], + 'Memorial Day' => [ + 'start_date' => '2000-05-31', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY;BYMONTH=5;BYDAY=-1MO', + ], + "Mother's Day" => [ + 'start_date' => '2000-05-08', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY;BYMONTH=5;BYDAY=2SU', + ], + "New Year's" => [ + 'title' => "New Year's Day", + 'start_date' => '2000-01-01', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Remembrance Day' => [ + 'start_date' => '2000-11-11', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + "St. Patrick's Day" => [ + 'start_date' => '2000-03-17', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Thanksgiving' => [ + 'start_date' => '2000-11-26', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY;BYMONTH=11;BYDAY=4TH', + ], + 'United Nations Day' => [ + 'start_date' => '2000-10-24', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + "Valentine's Day" => [ + 'start_date' => '2000-02-14', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Veterans Day' => [ + 'start_date' => '2000-11-11', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY', + ], + + // Astronomical events + 'Vernal Equinox' => [ + 'start_date' => '2000-03-20', + 'recurrence_end' => '2100-01-01', + 'start_time' => '07:30:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20000320T073000Z', + '20010320T131900Z', + '20020320T190800Z', + '20030321T005800Z', + '20040320T064700Z', + '20050320T123600Z', + '20060320T182500Z', + '20070321T001400Z', + '20080320T060400Z', + '20090320T115300Z', + '20100320T174200Z', + '20110320T233100Z', + '20120320T052000Z', + '20130320T111000Z', + '20140320T165900Z', + '20150320T224800Z', + '20160320T043700Z', + '20170320T102600Z', + '20180320T161600Z', + '20190320T220500Z', + '20200320T035400Z', + '20210320T094300Z', + '20220320T153200Z', + '20230320T212200Z', + '20240320T031100Z', + '20250320T090000Z', + '20260320T144900Z', + '20270320T203800Z', + '20280320T022800Z', + '20290320T081700Z', + '20300320T140600Z', + '20310320T195500Z', + '20320320T014400Z', + '20330320T073400Z', + '20340320T132300Z', + '20350320T191200Z', + '20360320T010100Z', + '20370320T065000Z', + '20380320T124000Z', + '20390320T182900Z', + '20400320T001800Z', + '20410320T060700Z', + '20420320T115600Z', + '20430320T174600Z', + '20440319T233500Z', + '20450320T052400Z', + '20460320T111300Z', + '20470320T170200Z', + '20480319T225200Z', + '20490320T044100Z', + '20500320T103000Z', + '20510320T161900Z', + '20520319T220800Z', + '20530320T035800Z', + '20540320T094700Z', + '20550320T153600Z', + '20560319T212500Z', + '20570320T031400Z', + '20580320T090400Z', + '20590320T145300Z', + '20600319T204200Z', + '20610320T023100Z', + '20620320T082000Z', + '20630320T141000Z', + '20640319T195900Z', + '20650320T014800Z', + '20660320T073700Z', + '20670320T132600Z', + '20680319T191600Z', + '20690320T010500Z', + '20700320T065400Z', + '20710320T124300Z', + '20720319T183200Z', + '20730320T002200Z', + '20740320T061100Z', + '20750320T120000Z', + '20760319T174900Z', + '20770319T233800Z', + '20780320T052800Z', + '20790320T111700Z', + '20800319T170600Z', + '20810319T225500Z', + '20820320T044400Z', + '20830320T103400Z', + '20840319T162300Z', + '20850319T221200Z', + '20860320T040100Z', + '20870320T095000Z', + '20880319T154000Z', + '20890319T212900Z', + '20900320T031800Z', + '20910320T090700Z', + '20920319T145600Z', + '20930319T204600Z', + '20940320T023500Z', + '20950320T082400Z', + '20960319T141300Z', + '20970319T200200Z', + '20980320T015200Z', + '20990320T074100Z', + ], + ], + 'Summer Solstice' => [ + 'start_date' => '2000-06-21', + 'recurrence_end' => '2100-01-01', + 'start_time' => '01:44:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20000621T014400Z', + '20010621T073200Z', + '20020621T132000Z', + '20030621T190800Z', + '20040621T005600Z', + '20050621T064400Z', + '20060621T123200Z', + '20070621T182100Z', + '20080621T000900Z', + '20090621T055700Z', + '20100621T114500Z', + '20110621T173300Z', + '20120620T232100Z', + '20130621T050900Z', + '20140621T105700Z', + '20150621T164600Z', + '20160620T223400Z', + '20170621T042200Z', + '20180621T101000Z', + '20190621T155800Z', + '20200620T214600Z', + '20210621T033400Z', + '20220621T092300Z', + '20230621T151100Z', + '20240620T205900Z', + '20250621T024700Z', + '20260621T083500Z', + '20270621T142300Z', + '20280620T201100Z', + '20290621T015900Z', + '20300621T074800Z', + '20310621T133600Z', + '20320620T192400Z', + '20330621T011200Z', + '20340621T070000Z', + '20350621T124800Z', + '20360620T183600Z', + '20370621T002400Z', + '20380621T061300Z', + '20390621T120100Z', + '20400620T174900Z', + '20410620T233700Z', + '20420621T052500Z', + '20430621T111300Z', + '20440620T170100Z', + '20450620T224900Z', + '20460621T043700Z', + '20470621T102600Z', + '20480620T161400Z', + '20490620T220200Z', + '20500621T035000Z', + '20510621T093800Z', + '20520620T152600Z', + '20530620T211400Z', + '20540621T030200Z', + '20550621T085100Z', + '20560620T143900Z', + '20570620T202700Z', + '20580621T021500Z', + '20590621T080300Z', + '20600620T135100Z', + '20610620T193900Z', + '20620621T012700Z', + '20630621T071600Z', + '20640620T130400Z', + '20650620T185200Z', + '20660621T004000Z', + '20670621T062800Z', + '20680620T121600Z', + '20690620T180400Z', + '20700620T235200Z', + '20710621T054100Z', + '20720620T112900Z', + '20730620T171700Z', + '20740620T230500Z', + '20750621T045300Z', + '20760620T104100Z', + '20770620T162900Z', + '20780620T221700Z', + '20790621T040500Z', + '20800620T095400Z', + '20810620T154200Z', + '20820620T213000Z', + '20830621T031800Z', + '20840620T090600Z', + '20850620T145400Z', + '20860620T204200Z', + '20870621T023000Z', + '20880620T081900Z', + '20890620T140700Z', + '20900620T195500Z', + '20910621T014300Z', + '20920620T073100Z', + '20930620T131900Z', + '20940620T190700Z', + '20950621T005500Z', + '20960620T064300Z', + '20970620T123200Z', + '20980620T182000Z', + '20990621T000800Z', + ], + ], + 'Autumnal Equinox' => [ + 'start_date' => '2000-09-22', + 'recurrence_end' => '2100-01-01', + 'start_time' => '17:16:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20000922T171600Z', + '20010922T230500Z', + '20020923T045400Z', + '20030923T104200Z', + '20040922T163100Z', + '20050922T222000Z', + '20060923T040800Z', + '20070923T095700Z', + '20080922T154600Z', + '20090922T213400Z', + '20100923T032300Z', + '20110923T091200Z', + '20120922T150100Z', + '20130922T204900Z', + '20140923T023800Z', + '20150923T082700Z', + '20160922T141500Z', + '20170922T200400Z', + '20180923T015300Z', + '20190923T074100Z', + '20200922T133000Z', + '20210922T191900Z', + '20220923T010700Z', + '20230923T065600Z', + '20240922T124500Z', + '20250922T183300Z', + '20260923T002200Z', + '20270923T061100Z', + '20280922T115900Z', + '20290922T174800Z', + '20300922T233700Z', + '20310923T052600Z', + '20320922T111400Z', + '20330922T170300Z', + '20340922T225200Z', + '20350923T044000Z', + '20360922T102900Z', + '20370922T161800Z', + '20380922T220600Z', + '20390923T035500Z', + '20400922T094400Z', + '20410922T153200Z', + '20420922T212100Z', + '20430923T031000Z', + '20440922T085800Z', + '20450922T144700Z', + '20460922T203600Z', + '20470923T022400Z', + '20480922T081300Z', + '20490922T140200Z', + '20500922T195000Z', + '20510923T013900Z', + '20520922T072800Z', + '20530922T131600Z', + '20540922T190500Z', + '20550923T005400Z', + '20560922T064200Z', + '20570922T123100Z', + '20580922T182000Z', + '20590923T000800Z', + '20600922T055700Z', + '20610922T114600Z', + '20620922T173400Z', + '20630922T232300Z', + '20640922T051200Z', + '20650922T110000Z', + '20660922T164900Z', + '20670922T223800Z', + '20680922T042600Z', + '20690922T101500Z', + '20700922T160400Z', + '20710922T215200Z', + '20720922T034100Z', + '20730922T093000Z', + '20740922T151800Z', + '20750922T210700Z', + '20760922T025600Z', + '20770922T084400Z', + '20780922T143300Z', + '20790922T202200Z', + '20800922T021000Z', + '20810922T075900Z', + '20820922T134800Z', + '20830922T193600Z', + '20840922T012500Z', + '20850922T071400Z', + '20860922T130200Z', + '20870922T185100Z', + '20880922T003900Z', + '20890922T062800Z', + '20900922T121700Z', + '20910922T180500Z', + '20920921T235400Z', + '20930922T054300Z', + '20940922T113100Z', + '20950922T172000Z', + '20960921T230900Z', + '20970922T045700Z', + '20980922T104600Z', + '20990922T163500Z', + ], + ], + 'Winter Solstice' => [ + 'start_date' => '2000-12-21', + 'recurrence_end' => '2100-01-01', + 'start_time' => '13:27:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20001221T132700Z', + '20011221T191600Z', + '20021222T010600Z', + '20031222T065600Z', + '20041221T124600Z', + '20051221T183500Z', + '20061222T002500Z', + '20071222T061500Z', + '20081221T120400Z', + '20091221T175400Z', + '20101221T234400Z', + '20111222T053400Z', + '20121221T112300Z', + '20131221T171300Z', + '20141221T230300Z', + '20151222T045300Z', + '20161221T104200Z', + '20171221T163200Z', + '20181221T222200Z', + '20191222T041100Z', + '20201221T100100Z', + '20211221T155100Z', + '20221221T214100Z', + '20231222T033000Z', + '20241221T092000Z', + '20251221T151000Z', + '20261221T205900Z', + '20271222T024900Z', + '20281221T083900Z', + '20291221T142900Z', + '20301221T201800Z', + '20311222T020800Z', + '20321221T075800Z', + '20331221T134800Z', + '20341221T193700Z', + '20351222T012700Z', + '20361221T071700Z', + '20371221T130600Z', + '20381221T185600Z', + '20391222T004600Z', + '20401221T063600Z', + '20411221T122500Z', + '20421221T181500Z', + '20431222T000500Z', + '20441221T055400Z', + '20451221T114400Z', + '20461221T173400Z', + '20471221T232400Z', + '20481221T051300Z', + '20491221T110300Z', + '20501221T165300Z', + '20511221T224200Z', + '20521221T043200Z', + '20531221T102200Z', + '20541221T161200Z', + '20551221T220100Z', + '20561221T035100Z', + '20571221T094100Z', + '20581221T153000Z', + '20591221T212000Z', + '20601221T031000Z', + '20611221T090000Z', + '20621221T144900Z', + '20631221T203900Z', + '20641221T022900Z', + '20651221T081800Z', + '20661221T140800Z', + '20671221T195800Z', + '20681221T014700Z', + '20691221T073700Z', + '20701221T132700Z', + '20711221T191700Z', + '20721221T010600Z', + '20731221T065600Z', + '20741221T124600Z', + '20751221T183500Z', + '20761221T002500Z', + '20771221T061500Z', + '20781221T120500Z', + '20791221T175400Z', + '20801220T234400Z', + '20811221T053400Z', + '20821221T112300Z', + '20831221T171300Z', + '20841220T230300Z', + '20851221T045200Z', + '20861221T104200Z', + '20871221T163200Z', + '20881220T222200Z', + '20891221T041100Z', + '20901221T100100Z', + '20911221T155100Z', + '20921220T214000Z', + '20931221T033000Z', + '20941221T092000Z', + '20951221T150900Z', + '20961220T205900Z', + '20971221T024900Z', + '20981221T083900Z', + '20991221T142800Z', + ], + ], + ]; + + $request = Db::$db->query( + '', + 'SELECT title, GROUP_CONCAT(event_date) as rdates + FROM {db_prefix}calendar_holidays + GROUP BY title', + [] + ); + + while ($row = Db::$db->fetch_assoc($request)) { + if (isset($known_holidays[$row['title']])) { + $holiday = &$known_holidays[$row['title']]; + + $holiday['type'] = 1; + $holiday['title'] = $holiday['title'] ?? $row['title']; + $holiday['allday'] = !isset($holiday['start_time']) || !isset($holiday['timezone']) || !in_array($holiday['timezone'], timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC)); + $holiday['start'] = new \SMF\Time($holiday['start_date'] . (!$holiday['allday'] ? ' ' . $holiday['start_time'] . ' ' . $holiday['timezone'] : '')); + $holiday['duration'] = new \DateInterval($holiday['duration'] ?? 'P1D'); + $holiday['recurrence_end'] = new \SMF\Time($holiday['recurrence_end']); + unset($holiday['start_date'], $holiday['start_time'], $holiday['timezone']); + + $event = new \SMF\Calendar\Event(0, $known_holidays[$row['title']]); + } else { + $row['type'] = 1; + $row['allday'] = true; + $row['recurrence_end'] = new \SMF\Time('9999-12-31'); + $row['duration'] = new \DateInterval('P1D'); + $row['rdates'] = explode(',', $row['rdates']); + + $row['start'] = array_shift($row['rdates']); + + if (preg_match('/^100\d-/', $row['start'])) { + $row['start'] = new \SMF\Time(preg_replace('/^100\d-/', '2000-', $row['start'])); + $row['rrule'] = 'FREQ=YEARLY'; + } else { + $row['start'] = new \SMF\Time($row['start']); + $row['rrule'] = 'FREQ=DAILY;COUNT=1'; + } + + $event = new \SMF\Calendar\Event(0, $row); + } + + $event->save(); + } + + Db::$db->free_result($request); +---} +---# + +---# Dropping "calendar_holidays" +DROP TABLE IF EXISTS {$db_prefix}calendar_holidays; ---# \ No newline at end of file diff --git a/other/upgrade_3-0_PostgreSQL.sql b/other/upgrade_3-0_PostgreSQL.sql index 38f6527175..06f5602f3f 100644 --- a/other/upgrade_3-0_PostgreSQL.sql +++ b/other/upgrade_3-0_PostgreSQL.sql @@ -104,7 +104,7 @@ ADD COLUMN IF NOT EXISTS adjustments jsonb DEFAULT NULL; ADD COLUMN IF NOT EXISTS sequence smallint NOT NULL DEFAULT '0'; ADD COLUMN IF NOT EXISTS uid varchar(255) NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS type smallint NOT NULL DEFAULT '0'; - +ADD COLUMN IF NOT EXISTS enabled smallint NOT NULL DEFAULT '1'; ---# ---# Set duration and rrule values and change end_date @@ -168,4 +168,629 @@ ADD COLUMN IF NOT EXISTS type smallint NOT NULL DEFAULT '0'; ---# Drop end_time column from calendar table ALTER TABLE {$db_prefix}calendar DROP COLUMN end_time; +---# + +---# Migrate holidays to events +---{ + $known_holidays = [ + 'April Fools' => [ + 'title' => "April Fools' Day", + 'start_date' => '2000-04-01', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Christmas' => [ + 'start_date' => '2000-12-25', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Cinco de Mayo' => [ + 'start_date' => '2000-05-05', + 'recurrence_end' => '9999-12-31', + 'location' => 'Mexico, USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'D-Day' => [ + 'start_date' => '2000-06-06', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Easter' => [ + 'start_date' => '2000-04-23', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'EASTER_W', + ], + 'Earth Day' => [ + 'start_date' => '2000-04-22', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + "Father's Day" => [ + 'start_date' => '2000-06-19', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY;BYMONTH=6;BYDAY=3SU', + ], + 'Flag Day' => [ + 'start_date' => '2000-06-14', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'Good Friday' => [ + 'start_date' => '2000-04-21', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'EASTER_W-P2D', + ], + 'Groundhog Day' => [ + 'start_date' => '2000-02-02', + 'recurrence_end' => '9999-12-31', + 'location' => 'Canada, USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'Halloween' => [ + 'start_date' => '2000-10-31', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Independence Day' => [ + 'start_date' => '2000-07-04', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY', + ], + 'Labor Day' => [ + 'start_date' => '2000-09-03', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', + ], + 'Labour Day' => [ + 'start_date' => '2000-09-03', + 'recurrence_end' => '9999-12-31', + 'location' => 'Canada', + 'rrule' => 'FREQ=YEARLY;BYMONTH=9;BYDAY=1MO', + ], + 'Memorial Day' => [ + 'start_date' => '2000-05-31', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY;BYMONTH=5;BYDAY=-1MO', + ], + "Mother's Day" => [ + 'start_date' => '2000-05-08', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY;BYMONTH=5;BYDAY=2SU', + ], + "New Year's" => [ + 'title' => "New Year's Day", + 'start_date' => '2000-01-01', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Remembrance Day' => [ + 'start_date' => '2000-11-11', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + "St. Patrick's Day" => [ + 'start_date' => '2000-03-17', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Thanksgiving' => [ + 'start_date' => '2000-11-26', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY;BYMONTH=11;BYDAY=4TH', + ], + 'United Nations Day' => [ + 'start_date' => '2000-10-24', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + "Valentine's Day" => [ + 'start_date' => '2000-02-14', + 'recurrence_end' => '9999-12-31', + 'rrule' => 'FREQ=YEARLY', + ], + 'Veterans Day' => [ + 'start_date' => '2000-11-11', + 'recurrence_end' => '9999-12-31', + 'location' => 'USA', + 'rrule' => 'FREQ=YEARLY', + ], + + // Astronomical events + 'Vernal Equinox' => [ + 'start_date' => '2000-03-20', + 'recurrence_end' => '2100-01-01', + 'start_time' => '07:30:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20000320T073000Z', + '20010320T131900Z', + '20020320T190800Z', + '20030321T005800Z', + '20040320T064700Z', + '20050320T123600Z', + '20060320T182500Z', + '20070321T001400Z', + '20080320T060400Z', + '20090320T115300Z', + '20100320T174200Z', + '20110320T233100Z', + '20120320T052000Z', + '20130320T111000Z', + '20140320T165900Z', + '20150320T224800Z', + '20160320T043700Z', + '20170320T102600Z', + '20180320T161600Z', + '20190320T220500Z', + '20200320T035400Z', + '20210320T094300Z', + '20220320T153200Z', + '20230320T212200Z', + '20240320T031100Z', + '20250320T090000Z', + '20260320T144900Z', + '20270320T203800Z', + '20280320T022800Z', + '20290320T081700Z', + '20300320T140600Z', + '20310320T195500Z', + '20320320T014400Z', + '20330320T073400Z', + '20340320T132300Z', + '20350320T191200Z', + '20360320T010100Z', + '20370320T065000Z', + '20380320T124000Z', + '20390320T182900Z', + '20400320T001800Z', + '20410320T060700Z', + '20420320T115600Z', + '20430320T174600Z', + '20440319T233500Z', + '20450320T052400Z', + '20460320T111300Z', + '20470320T170200Z', + '20480319T225200Z', + '20490320T044100Z', + '20500320T103000Z', + '20510320T161900Z', + '20520319T220800Z', + '20530320T035800Z', + '20540320T094700Z', + '20550320T153600Z', + '20560319T212500Z', + '20570320T031400Z', + '20580320T090400Z', + '20590320T145300Z', + '20600319T204200Z', + '20610320T023100Z', + '20620320T082000Z', + '20630320T141000Z', + '20640319T195900Z', + '20650320T014800Z', + '20660320T073700Z', + '20670320T132600Z', + '20680319T191600Z', + '20690320T010500Z', + '20700320T065400Z', + '20710320T124300Z', + '20720319T183200Z', + '20730320T002200Z', + '20740320T061100Z', + '20750320T120000Z', + '20760319T174900Z', + '20770319T233800Z', + '20780320T052800Z', + '20790320T111700Z', + '20800319T170600Z', + '20810319T225500Z', + '20820320T044400Z', + '20830320T103400Z', + '20840319T162300Z', + '20850319T221200Z', + '20860320T040100Z', + '20870320T095000Z', + '20880319T154000Z', + '20890319T212900Z', + '20900320T031800Z', + '20910320T090700Z', + '20920319T145600Z', + '20930319T204600Z', + '20940320T023500Z', + '20950320T082400Z', + '20960319T141300Z', + '20970319T200200Z', + '20980320T015200Z', + '20990320T074100Z', + ], + ], + 'Summer Solstice' => [ + 'start_date' => '2000-06-21', + 'recurrence_end' => '2100-01-01', + 'start_time' => '01:44:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20000621T014400Z', + '20010621T073200Z', + '20020621T132000Z', + '20030621T190800Z', + '20040621T005600Z', + '20050621T064400Z', + '20060621T123200Z', + '20070621T182100Z', + '20080621T000900Z', + '20090621T055700Z', + '20100621T114500Z', + '20110621T173300Z', + '20120620T232100Z', + '20130621T050900Z', + '20140621T105700Z', + '20150621T164600Z', + '20160620T223400Z', + '20170621T042200Z', + '20180621T101000Z', + '20190621T155800Z', + '20200620T214600Z', + '20210621T033400Z', + '20220621T092300Z', + '20230621T151100Z', + '20240620T205900Z', + '20250621T024700Z', + '20260621T083500Z', + '20270621T142300Z', + '20280620T201100Z', + '20290621T015900Z', + '20300621T074800Z', + '20310621T133600Z', + '20320620T192400Z', + '20330621T011200Z', + '20340621T070000Z', + '20350621T124800Z', + '20360620T183600Z', + '20370621T002400Z', + '20380621T061300Z', + '20390621T120100Z', + '20400620T174900Z', + '20410620T233700Z', + '20420621T052500Z', + '20430621T111300Z', + '20440620T170100Z', + '20450620T224900Z', + '20460621T043700Z', + '20470621T102600Z', + '20480620T161400Z', + '20490620T220200Z', + '20500621T035000Z', + '20510621T093800Z', + '20520620T152600Z', + '20530620T211400Z', + '20540621T030200Z', + '20550621T085100Z', + '20560620T143900Z', + '20570620T202700Z', + '20580621T021500Z', + '20590621T080300Z', + '20600620T135100Z', + '20610620T193900Z', + '20620621T012700Z', + '20630621T071600Z', + '20640620T130400Z', + '20650620T185200Z', + '20660621T004000Z', + '20670621T062800Z', + '20680620T121600Z', + '20690620T180400Z', + '20700620T235200Z', + '20710621T054100Z', + '20720620T112900Z', + '20730620T171700Z', + '20740620T230500Z', + '20750621T045300Z', + '20760620T104100Z', + '20770620T162900Z', + '20780620T221700Z', + '20790621T040500Z', + '20800620T095400Z', + '20810620T154200Z', + '20820620T213000Z', + '20830621T031800Z', + '20840620T090600Z', + '20850620T145400Z', + '20860620T204200Z', + '20870621T023000Z', + '20880620T081900Z', + '20890620T140700Z', + '20900620T195500Z', + '20910621T014300Z', + '20920620T073100Z', + '20930620T131900Z', + '20940620T190700Z', + '20950621T005500Z', + '20960620T064300Z', + '20970620T123200Z', + '20980620T182000Z', + '20990621T000800Z', + ], + ], + 'Autumnal Equinox' => [ + 'start_date' => '2000-09-22', + 'recurrence_end' => '2100-01-01', + 'start_time' => '17:16:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20000922T171600Z', + '20010922T230500Z', + '20020923T045400Z', + '20030923T104200Z', + '20040922T163100Z', + '20050922T222000Z', + '20060923T040800Z', + '20070923T095700Z', + '20080922T154600Z', + '20090922T213400Z', + '20100923T032300Z', + '20110923T091200Z', + '20120922T150100Z', + '20130922T204900Z', + '20140923T023800Z', + '20150923T082700Z', + '20160922T141500Z', + '20170922T200400Z', + '20180923T015300Z', + '20190923T074100Z', + '20200922T133000Z', + '20210922T191900Z', + '20220923T010700Z', + '20230923T065600Z', + '20240922T124500Z', + '20250922T183300Z', + '20260923T002200Z', + '20270923T061100Z', + '20280922T115900Z', + '20290922T174800Z', + '20300922T233700Z', + '20310923T052600Z', + '20320922T111400Z', + '20330922T170300Z', + '20340922T225200Z', + '20350923T044000Z', + '20360922T102900Z', + '20370922T161800Z', + '20380922T220600Z', + '20390923T035500Z', + '20400922T094400Z', + '20410922T153200Z', + '20420922T212100Z', + '20430923T031000Z', + '20440922T085800Z', + '20450922T144700Z', + '20460922T203600Z', + '20470923T022400Z', + '20480922T081300Z', + '20490922T140200Z', + '20500922T195000Z', + '20510923T013900Z', + '20520922T072800Z', + '20530922T131600Z', + '20540922T190500Z', + '20550923T005400Z', + '20560922T064200Z', + '20570922T123100Z', + '20580922T182000Z', + '20590923T000800Z', + '20600922T055700Z', + '20610922T114600Z', + '20620922T173400Z', + '20630922T232300Z', + '20640922T051200Z', + '20650922T110000Z', + '20660922T164900Z', + '20670922T223800Z', + '20680922T042600Z', + '20690922T101500Z', + '20700922T160400Z', + '20710922T215200Z', + '20720922T034100Z', + '20730922T093000Z', + '20740922T151800Z', + '20750922T210700Z', + '20760922T025600Z', + '20770922T084400Z', + '20780922T143300Z', + '20790922T202200Z', + '20800922T021000Z', + '20810922T075900Z', + '20820922T134800Z', + '20830922T193600Z', + '20840922T012500Z', + '20850922T071400Z', + '20860922T130200Z', + '20870922T185100Z', + '20880922T003900Z', + '20890922T062800Z', + '20900922T121700Z', + '20910922T180500Z', + '20920921T235400Z', + '20930922T054300Z', + '20940922T113100Z', + '20950922T172000Z', + '20960921T230900Z', + '20970922T045700Z', + '20980922T104600Z', + '20990922T163500Z', + ], + ], + 'Winter Solstice' => [ + 'start_date' => '2000-12-21', + 'recurrence_end' => '2100-01-01', + 'start_time' => '13:27:00', + 'timezone' => 'UTC', + 'duration' => 'PT1M', + 'rrule' => 'FREQ=YEARLY;COUNT=1', + 'rdates' => [ + '20001221T132700Z', + '20011221T191600Z', + '20021222T010600Z', + '20031222T065600Z', + '20041221T124600Z', + '20051221T183500Z', + '20061222T002500Z', + '20071222T061500Z', + '20081221T120400Z', + '20091221T175400Z', + '20101221T234400Z', + '20111222T053400Z', + '20121221T112300Z', + '20131221T171300Z', + '20141221T230300Z', + '20151222T045300Z', + '20161221T104200Z', + '20171221T163200Z', + '20181221T222200Z', + '20191222T041100Z', + '20201221T100100Z', + '20211221T155100Z', + '20221221T214100Z', + '20231222T033000Z', + '20241221T092000Z', + '20251221T151000Z', + '20261221T205900Z', + '20271222T024900Z', + '20281221T083900Z', + '20291221T142900Z', + '20301221T201800Z', + '20311222T020800Z', + '20321221T075800Z', + '20331221T134800Z', + '20341221T193700Z', + '20351222T012700Z', + '20361221T071700Z', + '20371221T130600Z', + '20381221T185600Z', + '20391222T004600Z', + '20401221T063600Z', + '20411221T122500Z', + '20421221T181500Z', + '20431222T000500Z', + '20441221T055400Z', + '20451221T114400Z', + '20461221T173400Z', + '20471221T232400Z', + '20481221T051300Z', + '20491221T110300Z', + '20501221T165300Z', + '20511221T224200Z', + '20521221T043200Z', + '20531221T102200Z', + '20541221T161200Z', + '20551221T220100Z', + '20561221T035100Z', + '20571221T094100Z', + '20581221T153000Z', + '20591221T212000Z', + '20601221T031000Z', + '20611221T090000Z', + '20621221T144900Z', + '20631221T203900Z', + '20641221T022900Z', + '20651221T081800Z', + '20661221T140800Z', + '20671221T195800Z', + '20681221T014700Z', + '20691221T073700Z', + '20701221T132700Z', + '20711221T191700Z', + '20721221T010600Z', + '20731221T065600Z', + '20741221T124600Z', + '20751221T183500Z', + '20761221T002500Z', + '20771221T061500Z', + '20781221T120500Z', + '20791221T175400Z', + '20801220T234400Z', + '20811221T053400Z', + '20821221T112300Z', + '20831221T171300Z', + '20841220T230300Z', + '20851221T045200Z', + '20861221T104200Z', + '20871221T163200Z', + '20881220T222200Z', + '20891221T041100Z', + '20901221T100100Z', + '20911221T155100Z', + '20921220T214000Z', + '20931221T033000Z', + '20941221T092000Z', + '20951221T150900Z', + '20961220T205900Z', + '20971221T024900Z', + '20981221T083900Z', + '20991221T142800Z', + ], + ], + ]; + + $request = Db::$db->query( + '', + 'SELECT title, GROUP_CONCAT(event_date) as rdates + FROM {db_prefix}calendar_holidays + GROUP BY title', + [] + ); + + while ($row = Db::$db->fetch_assoc($request)) { + if (isset($known_holidays[$row['title']])) { + $holiday = &$known_holidays[$row['title']]; + + $holiday['type'] = 1; + $holiday['title'] = $holiday['title'] ?? $row['title']; + $holiday['allday'] = !isset($holiday['start_time']) || !isset($holiday['timezone']) || !in_array($holiday['timezone'], timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC)); + $holiday['start'] = new \SMF\Time($holiday['start_date'] . (!$holiday['allday'] ? ' ' . $holiday['start_time'] . ' ' . $holiday['timezone'] : '')); + $holiday['duration'] = new \DateInterval($holiday['duration'] ?? 'P1D'); + $holiday['recurrence_end'] = new \SMF\Time($holiday['recurrence_end']); + unset($holiday['start_date'], $holiday['start_time'], $holiday['timezone']); + + $event = new \SMF\Calendar\Event(0, $known_holidays[$row['title']]); + } else { + $row['type'] = 1; + $row['allday'] = true; + $row['recurrence_end'] = new \SMF\Time('9999-12-31'); + $row['duration'] = new \DateInterval('P1D'); + $row['rdates'] = explode(',', $row['rdates']); + + $row['start'] = array_shift($row['rdates']); + + if (preg_match('/^100\d-/', $row['start'])) { + $row['start'] = new \SMF\Time(preg_replace('/^100\d-/', '2000-', $row['start'])); + $row['rrule'] = 'FREQ=YEARLY'; + } else { + $row['start'] = new \SMF\Time($row['start']); + $row['rrule'] = 'FREQ=DAILY;COUNT=1'; + } + + $event = new \SMF\Calendar\Event(0, $row); + } + + $event->save(); + } + + Db::$db->free_result($request); +---} +---# + +---# Dropping "calendar_holidays" +DROP TABLE IF EXISTS {$db_prefix}calendar_holidays; ---# \ No newline at end of file From 88efeefc2f3cd765c7ab3e4e24edc8ba6b76b036 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sat, 16 Dec 2023 15:27:48 -0700 Subject: [PATCH 12/22] Implements SMF\Calendar\Birthday Signed-off-by: Jon Stovell --- Sources/Actions/Calendar.php | 75 +---- Sources/Calendar/Birthday.php | 392 +++++++++++++++++++++++++++ Sources/Calendar/Event.php | 2 +- Sources/Calendar/EventOccurrence.php | 8 + Themes/default/Calendar.template.php | 8 +- 5 files changed, 415 insertions(+), 70 deletions(-) create mode 100644 Sources/Calendar/Birthday.php diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index ac9cb9a29f..dab79be4b6 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -18,6 +18,7 @@ use SMF\Board; use SMF\BrowserDetector; use SMF\Cache\CacheApi; +use SMF\Calendar\Birthday; use SMF\Calendar\Event; use SMF\Calendar\Holiday; use SMF\Config; @@ -675,78 +676,22 @@ public static function call(): void */ public static function getBirthdayRange(string $low_date, string $high_date): array { - // We need to search for any birthday in this range, and whatever year that birthday is on. - $year_low = (int) substr($low_date, 0, 4); - $year_high = (int) substr($high_date, 0, 4); - - if (Db::$db->title !== POSTGRE_TITLE) { - // Collect all of the birthdays for this month. I know, it's a painful query. - $result = Db::$db->query( - '', - 'SELECT id_member, real_name, YEAR(birthdate) AS birth_year, birthdate - FROM {db_prefix}members - WHERE birthdate != {date:no_birthdate} - AND ( - DATE_FORMAT(birthdate, {string:year_low}) BETWEEN {date:low_date} AND {date:high_date}' . ($year_low == $year_high ? '' : ' - OR DATE_FORMAT(birthdate, {string:year_high}) BETWEEN {date:low_date} AND {date:high_date}') . ' - ) - AND is_activated = {int:is_activated}', - [ - 'is_activated' => 1, - 'no_birthdate' => '1004-01-01', - 'year_low' => $year_low . '-%m-%d', - 'year_high' => $year_high . '-%m-%d', - 'low_date' => $low_date, - 'high_date' => $high_date, - ], - ); - } else { - $result = Db::$db->query( - '', - 'SELECT id_member, real_name, YEAR(birthdate) AS birth_year, birthdate - FROM {db_prefix}members - WHERE birthdate != {date:no_birthdate} - AND ( - indexable_month_day(birthdate) BETWEEN indexable_month_day({date:year_low_low_date}) AND indexable_month_day({date:year_low_high_date})' . ($year_low == $year_high ? '' : ' - OR indexable_month_day(birthdate) BETWEEN indexable_month_day({date:year_high_low_date}) AND indexable_month_day({date:year_high_high_date})') . ' - ) - AND is_activated = {int:is_activated}', - [ - 'is_activated' => 1, - 'no_birthdate' => '1004-01-01', - 'year_low_low_date' => $low_date, - 'year_low_high_date' => $year_low == $year_high ? $high_date : $year_low . '-12-31', - 'year_high_low_date' => $year_low == $year_high ? $low_date : $year_high . '-01-01', - 'year_high_high_date' => $high_date, - ], - ); - } - $bday = []; - - while ($row = Db::$db->fetch_assoc($result)) { - if ($year_low != $year_high) { - $age_year = substr($row['birthdate'], 5) <= substr($high_date, 5) ? $year_high : $year_low; - } else { - $age_year = $year_low; - } + $birthdays = []; + $high_date = (new \DateTimeImmutable($high_date . ' +1 day'))->format('Y-m-d'); - $bday[$age_year . substr($row['birthdate'], 4)][] = [ - 'id' => $row['id_member'], - 'name' => $row['real_name'], - 'age' => $row['birth_year'] > 1004 && $row['birth_year'] <= $age_year ? $age_year - $row['birth_year'] : null, - 'is_last' => false, - ]; + foreach(Birthday::getOccurrencesInRange($low_date, $high_date) as $occurrence) { + $birthdays[$occurrence->start->format('Y-m-d')][$occurrence->member] = $occurrence; } - Db::$db->free_result($result); - ksort($bday); + ksort($birthdays); // Set is_last, so the themes know when to stop placing separators. - foreach ($bday as $mday => $array) { - $bday[$mday][count($array) - 1]['is_last'] = true; + foreach ($birthdays as $date => $bdays) { + ksort($birthdays[$date]); + $birthdays[$date][array_key_last($birthdays[$date])]['is_last'] = true; } - return $bday; + return $birthdays; } /** diff --git a/Sources/Calendar/Birthday.php b/Sources/Calendar/Birthday.php new file mode 100644 index 0000000000..e81ef91478 --- /dev/null +++ b/Sources/Calendar/Birthday.php @@ -0,0 +1,392 @@ + $id, + ]; + + foreach(self::queryData($selects, $params, [], $where) as $row) { + // Add a value to the ID in order to avoid conflicts with regular events. + $id = self::ID_MODIFIER + (int) $row['id_member']; + + $row['view_start'] = \DateTimeImmutable::createFromInterface($row['view_start']); + $row['view_end'] = $row['view_start']->add(new \DateInterval('P1D')); + + $loaded[] = new self($id, $row); + } + + return $loaded; + } + + /** + * Generator that yields instances of this class. + * + * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. + * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. + * @param bool $use_permissions Ignored. + * @param array $query_customizations Customizations to the SQL query. + * @return Generator Iterating over result gives Event instances. + */ + public static function get(string $low_date, string $high_date, bool $use_permissions = true, array $query_customizations = []): \Generator + { + $low_date = !empty($low_date) ? $low_date : (new \DateTime('first day of this month, midnight'))->format('Y-m-d'); + $high_date = !empty($high_date) ? $high_date : (new \DateTime('first day of next month, midnight'))->format('Y-m-d'); + + // We need to search for any birthday in this range, and whatever year that birthday is on. + $year_low = (int) substr($low_date, 0, 4); + $year_high = (int) substr($high_date, 0, 4); + + $selects = $query_customizations['selects'] ?? [ + 'm.id_member', + 'm.real_name', + 'm.birthdate', + ]; + $joins = $query_customizations['joins'] ?? []; + $order = $query_customizations['order'] ?? []; + $group = $query_customizations['group'] ?? []; + $limit = $query_customizations['limit'] ?? 0; + + switch (Db::$db->title) { + case POSTGRE_TITLE: + $where = $query_customizations['where'] ?? [ + 'birthdate != {date:no_birthdate}', + 'is_activated = {int:is_activated}', + '( + indexable_month_day(birthdate) BETWEEN indexable_month_day({date:year_low_low_date}) AND indexable_month_day({date:year_low_high_date})' . ($year_low == $year_high ? '' : ' + OR indexable_month_day(birthdate) BETWEEN indexable_month_day({date:year_high_low_date}) AND indexable_month_day({date:year_high_high_date})') . ' + )', + ]; + $params = $query_customizations['params'] ?? [ + 'is_activated' => 1, + 'no_birthdate' => '1004-01-01', + 'year_low_low_date' => $low_date, + 'year_low_high_date' => $year_low == $year_high ? $high_date : $year_low . '-12-31', + 'year_high_low_date' => $year_low == $year_high ? $low_date : $year_high . '-01-01', + 'year_high_high_date' => $high_date, + ]; + break; + + default: + $where = $query_customizations['where'] ?? [ + 'birthdate != {date:no_birthdate}', + 'is_activated = {int:is_activated}', + '( + DATE_FORMAT(birthdate, {string:year_low}) BETWEEN {date:low_date} AND {date:high_date}' . ($year_low == $year_high ? '' : ' + OR DATE_FORMAT(birthdate, {string:year_high}) BETWEEN {date:low_date} AND {date:high_date}') . ' + )', + ]; + $params = $query_customizations['params'] ?? [ + 'is_activated' => 1, + 'no_birthdate' => '1004-01-01', + 'year_low' => $year_low . '-%m-%d', + 'year_high' => $year_high . '-%m-%d', + 'low_date' => $low_date, + 'high_date' => $high_date, + ]; + break; + } + + foreach(self::queryData($selects, $params, $joins, $where, $order, $group, $limit) as $row) { + // Add a value to the ID in order to avoid conflicts with regular events. + $id = self::ID_MODIFIER + (int) $row['id_member']; + + $row['view_start'] = new \DateTimeImmutable($low_date); + $row['view_end'] = new \DateTimeImmutable($high_date); + + yield (new self($id, $row)); + + if (!self::$keep_all) { + unset(self::$loaded[$id]); + } + } + } + + /** + * Gets events within the given date range, and returns a generator that + * yields all occurrences of those events within that range. + * + * @param string $low_date The low end of the range, inclusive, in YYYY-MM-DD format. + * @param string $high_date The high end of the range, inclusive, in YYYY-MM-DD format. + * @param bool $use_permissions Whether to use permissions. Default: true. + * @param array $query_customizations Customizations to the SQL query. + * @return Generator Iterating over result gives + * EventOccurrence instances. + */ + public static function getOccurrencesInRange(string $low_date, string $high_date, bool $use_permissions = true, array $query_customizations = []): \Generator + { + self::$keep_all = true; + + foreach (self::get($low_date, $high_date, $use_permissions, $query_customizations) as $event) { + foreach ($event->getAllVisibleOccurrences() as $occurrence) { + yield $occurrence; + } + } + + self::$keep_all = false; + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param array $eventOptions Event data ('title', 'start_date', etc.) + */ + public static function create(array $eventOptions): void + { + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param int $id The ID of the event + * @param array $eventOptions An array of event information. + */ + public static function modify(int $id, array &$eventOptions): void + { + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param int $id The event's ID. + */ + public static function remove(int $id): void + { + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param array $eventOptions An array of optional time and date parameters + * (span, start_year, end_month, etc., etc.) + */ + public static function setRequestedStartAndDuration(array &$eventOptions): void + { + } + + /************************* + * Internal static methods + *************************/ + + /** + * Generator that runs queries about event data and yields the result rows. + * + * @param array $selects Table columns to select. + * @param array $params Parameters to substitute into query text. + * @param array $joins Zero or more *complete* JOIN clauses. + * E.g.: 'LEFT JOIN {db_prefix}categories AS c ON (c.id_cat = b.id_cat)' + * Note that 'FROM {db_prefix}boards AS b' is always part of the query. + * @param array $where Zero or more conditions for the WHERE clause. + * Conditions will be placed in parentheses and concatenated with AND. + * If this is left empty, no WHERE clause will be used. + * @param array $order Zero or more conditions for the ORDER BY clause. + * If this is left empty, no ORDER BY clause will be used. + * @param array $group Zero or more conditions for the GROUP BY clause. + * If this is left empty, no GROUP BY clause will be used. + * @param int|string $limit Maximum number of results to retrieve. + * If this is left empty, all results will be retrieved. + * + * @return Generator Iterating over the result gives database rows. + */ + protected static function queryData(array $selects, array $params = [], array $joins = [], array $where = [], array $order = [], array $group = [], int|string $limit = 0): \Generator + { + $request = Db::$db->query( + '', + 'SELECT + ' . implode(', ', $selects) . ' + FROM {db_prefix}members AS m' . (empty($joins) ? '' : ' + ' . implode("\n\t\t\t\t", $joins)) . (empty($where) ? '' : ' + WHERE (' . implode(') AND (', $where) . ')') . (empty($group) ? '' : ' + GROUP BY ' . implode(', ', $group)) . (empty($order) ? '' : ' + ORDER BY ' . implode(', ', $order)) . (!empty($limit) ? ' + LIMIT ' . $limit : ''), + $params, + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $row['start'] = new Time($row['birthdate']); + unset($row['birthdate']); + + $row['recurrence_end'] = (clone $row['start'])->modify('+ 130 years'); + + $row['rrule'] = 'FREQ=YEARLY;INTERVAL=1'; + $row['duration'] = new \DateInterval('P1D'); + $row['allday'] = true; + + $row['rdates'] = []; + $row['exdates'] = []; + + yield $row; + } + Db::$db->free_result($request); + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param array $eventOptions An array of optional time and date parameters + * (span, start_year, end_month, etc., etc.) + */ + protected static function setRequestedRRule(array &$eventOptions): void + { + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param Event $event An event that is being created or modified. + */ + protected static function setRequestedRDatesAndExDates(Event $event): void + { + } + + /** + * Not applicable. Birthday info is updated via the user's profile. + * + * @param array $input Array of info about event start and end times. + * @return array Standardized version of $input array. + */ + protected static function standardizeEventOptions(array $input): array + { + return $input; + } +} + +?> \ No newline at end of file diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index 6d9b488c6b..a9194dfbd3 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -45,7 +45,7 @@ class Event implements \ArrayAccess public const TYPE_EVENT = 0; public const TYPE_HOLIDAY = 1; - public const TYPE_BIRTHDAY = 2; // Not yet implemented. + public const TYPE_BIRTHDAY = 2; /******************* * Public properties diff --git a/Sources/Calendar/EventOccurrence.php b/Sources/Calendar/EventOccurrence.php index 2914e26731..0fd8dadc6c 100644 --- a/Sources/Calendar/EventOccurrence.php +++ b/Sources/Calendar/EventOccurrence.php @@ -350,6 +350,13 @@ public function __get(string $prop): mixed case 'end_iso_gmdate': return $end->{substr($prop, 4)}; + case 'age': + if ($this->getParentEvent()->type === Event::TYPE_BIRTHDAY && $this->getParentEvent()->start->format('Y') === 1004) { + return null; + } + + return date_diff($this->start, $this->getParentEvent()->start)->y; + // These inherit from the parent event unless overridden for this occurrence. case 'allday': case 'duration': @@ -431,6 +438,7 @@ public function __isset(string $prop): bool return property_exists($this, 'start'); case 'uid': + case 'age': case 'type': case 'allday': case 'duration': diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index f044a38cf9..63f2387669 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -180,8 +180,8 @@ function template_show_upcoming_list($grid_name) $birthdays = array(); - foreach ($date as $member) - $birthdays[] = '' . $member['name'] . (isset($member['age']) ? ' (' . $member['age'] . ')' : '') . ''; + foreach ($date as $bday) + $birthdays[] = '' . $bday->name . (isset($bday->age) ? ' (' . $bday->age . ')' : '') . ''; echo implode(', ', $birthdays); @@ -410,9 +410,9 @@ function template_show_month_grid($grid_name, $is_mini = false) id, name (person), age (if they have one set?), and is_last. (last in list?) */ $use_js_hide = empty(Utils::$context['show_all_birthdays']) && count($day['birthdays']) > 15; $birthday_count = 0; - foreach ($day['birthdays'] as $member) + foreach ($day['birthdays'] as $bday) { - echo '', $member['name'], '', isset($member['age']) ? ' (' . $member['age'] . ')' : '', '', $member['is_last'] || ($count == 10 && $use_js_hide) ? '' : ', '; + echo '', $bday['name'], '', isset($bday['age']) ? ' (' . $bday['age'] . ')' : '', '', $bday['is_last'] || ($count == 10 && $use_js_hide) ? '' : ', '; // 9...10! Let's stop there. if ($birthday_count == 10 && $use_js_hide) From c5d55cf86140f3ec7f6d2c3d1ef0a6712d1f4e97 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Wed, 27 Dec 2023 16:47:21 -0700 Subject: [PATCH 13/22] Adds support for altering event occurrences Signed-off-by: Jon Stovell --- Languages/en_US/General.php | 7 + Sources/Actions/Calendar.php | 39 ++- Sources/Actions/Post.php | 19 +- Sources/Actions/Post2.php | 23 +- Sources/Calendar/Event.php | 411 +++++++++++++++--------- Sources/Calendar/EventAdjustment.php | 162 ++++++++++ Sources/Calendar/EventOccurrence.php | 209 +++++++++++- Themes/default/Calendar.template.php | 16 +- Themes/default/Display.template.php | 2 +- Themes/default/EventEditor.template.php | 78 ++++- Themes/default/Post.template.php | 10 +- Themes/default/scripts/event.js | 28 +- Themes/default/scripts/script.js | 4 + 13 files changed, 790 insertions(+), 218 deletions(-) create mode 100644 Sources/Calendar/EventAdjustment.php diff --git a/Languages/en_US/General.php b/Languages/en_US/General.php index 0236ddc308..bb97af5bf4 100644 --- a/Languages/en_US/General.php +++ b/Languages/en_US/General.php @@ -759,6 +759,13 @@ $txt['calendar_repeat_special'] = 'Special'; $txt['calendar_repeat_special_rrule_modifier'] = 'Offset'; $txt['calendar_repeat_offset_examples'] = 'e.g.: +P1D, -PT2H'; +$txt['calendar_repeat_adjustment_label'] = 'Apply changes to'; +$txt['calendar_repeat_adjustment_this_only'] = 'Only this occurrence'; +$txt['calendar_repeat_adjustment_this_and_future'] = 'This and future occurrences'; +$txt['calendar_repeat_adjustment_confirm'] = 'Are you sure you want to apply these changes to all future occurrences?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; +$txt['calendar_repeat_delete_label'] = 'Delete'; +$txt['calendar_confirm_occurrence_delete'] = 'Are you sure you want to delete this occurrence of the event?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; +$txt['calendar_repeat_adjustment_edit_first'] = 'Edit original event'; $txt['movetopic_change_subject'] = 'Change the topic’s subject'; $txt['movetopic_new_subject'] = 'New subject'; diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index dab79be4b6..1d4f2221e8 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -20,6 +20,7 @@ use SMF\Cache\CacheApi; use SMF\Calendar\Birthday; use SMF\Calendar\Event; +use SMF\Calendar\EventOccurrence; use SMF\Calendar\Holiday; use SMF\Config; use SMF\Db\DatabaseApi as Db; @@ -123,7 +124,7 @@ public function show(): void Theme::loadJavaScriptFile('calendar.js', ['defer' => true], 'smf_calendar'); // Did they specify an individual event ID? If so, let's splice the year/month in to what we would otherwise be doing. - if (isset($_GET['event'])) { + if (isset($_GET['event']) && !isset($_REQUEST['start_date'])) { $evid = (int) $_GET['event']; if ($evid > 0) { @@ -387,7 +388,11 @@ public function post(): void } // Deleting... elseif (isset($_REQUEST['deleteevent'])) { - Event::remove($_REQUEST['eventid']); + if (isset($_REQUEST['recurrenceid'])) { + EventOccurrence::remove($_REQUEST['eventid'], $_REQUEST['recurrenceid'], !empty($_REQUEST['affects_future'])); + } else { + Event::remove($_REQUEST['eventid']); + } } // ... or just update it? else { @@ -395,6 +400,15 @@ public function post(): void 'title' => Utils::entitySubstr($_REQUEST['evtitle'], 0, 100), 'location' => Utils::entitySubstr($_REQUEST['event_location'], 0, 255), ]; + + if (!empty($_REQUEST['recurrenceid'])) { + $eventOptions['recurrenceid'] = $_REQUEST['recurrenceid']; + } + + if (!empty($_REQUEST['affects_future'])) { + $eventOptions['affects_future'] = $_REQUEST['affects_future']; + } + Event::modify($_REQUEST['eventid'], $eventOptions); } @@ -434,6 +448,7 @@ public function post(): void // New? if (!isset($_REQUEST['eventid'])) { Utils::$context['event'] = new Event(-1); + Utils::$context['event']->selected_occurrence = Utils::$context['event']->getFirstOccurrence(); } else { list(Utils::$context['event']) = Event::load($_REQUEST['eventid']); @@ -454,21 +469,31 @@ public function post(): void } elseif (!User::$me->allowedTo('calendar_edit_any')) { User::$me->isAllowedTo('calendar_edit_own'); } + + if (isset($_REQUEST['recurrenceid'])) { + $selected_occurrence = Utils::$context['event']->getOccurrence($_REQUEST['recurrenceid']); + } + + if (empty($selected_occurrence)) { + $selected_occurrence = Utils::$context['event']->getFirstOccurrence(); + } + + Utils::$context['event']->selected_occurrence = $selected_occurrence; } // An all day event? Set up some nice defaults in case the user wants to change that - if (Utils::$context['event']->allday == true) { + if (Utils::$context['event']->allday) { $now = Time::create('now'); - Utils::$context['event']->tz = User::getTimezone(); - Utils::$context['event']->start->modify(Time::create('now')->format('H:i:s')); - Utils::$context['event']->duration = new TimeInterval('PT' . ($now->format('H') < 23 ? '1H' : (59 - $now->format('i')) . 'M')); + Utils::$context['event']->selected_occurrence->tz = User::getTimezone(); + Utils::$context['event']->selected_occurrence->start->modify($now->format('H:i:s')); + Utils::$context['event']->selected_occurrence->duration = new TimeInterval('PT' . ($now->format('H') < 23 ? '1H' : (59 - $now->format('i')) . 'M')); } // Need this so the user can select a timezone for the event. Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->start_datetime); // If the event's timezone is not in SMF's standard list of time zones, try to fix it. - Utils::$context['event']->fixTimezone(); + Utils::$context['event']->selected_occurrence->fixTimezone(); // Get list of boards that can be posted in. $boards = User::$me->boardsAllowedTo('post_new'); diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index e11942ce33..51ad42b3d8 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -737,6 +737,17 @@ protected function initiateEvent(): void if (!isset(Utils::$context['event']) || !(Utils::$context['event'] instanceof Event)) { Utils::$context['event'] = new Event(-1); + Utils::$context['event']->selected_occurrence = Utils::$context['event']->getFirstOccurrence(); + } else { + if (isset($_REQUEST['recurrenceid'])) { + $selected_occurrence = Utils::$context['event']->getOccurrence($_REQUEST['recurrenceid']); + } + + if (empty($selected_occurrence)) { + $selected_occurrence = Utils::$context['event']->getFirstOccurrence(); + } + + Utils::$context['event']->selected_occurrence = $selected_occurrence; } // Permissions check! @@ -770,16 +781,16 @@ protected function initiateEvent(): void // An all day event? Set up some nice defaults in case the user wants to change that if (Utils::$context['event']->allday == true) { - Utils::$context['event']->tz = User::getTimezone(); - Utils::$context['event']->start->modify(Time::create('now')->format('%H:%M:%S')); - Utils::$context['event']->end->modify(Time::create('now + 1 hour')->format('%H:%M:%S')); + Utils::$context['event']->selected_occurrence->tz = User::getTimezone(); + Utils::$context['event']->selected_occurrence->start->modify(Time::create('now')->format('%H:%M:%S')); + Utils::$context['event']->selected_occurrence->end->modify(Time::create('now + 1 hour')->format('%H:%M:%S')); } // Need this so the user can select a timezone for the event. Utils::$context['all_timezones'] = TimeZone::list(Utils::$context['event']->timestamp); // If the event's timezone is not in SMF's standard list of time zones, try to fix it. - Utils::$context['event']->fixTimezone(); + Utils::$context['event']->selected_occurrence->fixTimezone(); Theme::loadTemplate('EventEditor'); Theme::addJavaScriptVar('monthly_byday_items', (string) (count(Utils::$context['event']->byday_items) - 1)); diff --git a/Sources/Actions/Post2.php b/Sources/Actions/Post2.php index 86eb07f19a..780af2488d 100644 --- a/Sources/Actions/Post2.php +++ b/Sources/Actions/Post2.php @@ -21,6 +21,7 @@ use SMF\BrowserDetector; use SMF\Cache\CacheApi; use SMF\Calendar\Event; +use SMF\Calendar\EventOccurrence; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\Draft; @@ -535,14 +536,11 @@ public function submit(): void // Delete it? if (isset($_REQUEST['deleteevent'])) { - Db::$db->query( - '', - 'DELETE FROM {db_prefix}calendar - WHERE id_event = {int:id_event}', - [ - 'id_event' => $_REQUEST['eventid'], - ], - ); + if (isset($_REQUEST['recurrenceid'])) { + EventOccurrence::remove($_REQUEST['eventid'], $_REQUEST['recurrenceid'], !empty($_REQUEST['affects_future'])); + } else { + Event::remove($_REQUEST['eventid']); + } } // ... or just update it? else { @@ -554,6 +552,15 @@ public function submit(): void 'location' => $_POST['event_location'], 'member' => User::$me->id, ]; + + if (!empty($_REQUEST['recurrenceid'])) { + $eventOptions['recurrenceid'] = $_REQUEST['recurrenceid']; + } + + if (!empty($_REQUEST['affects_future'])) { + $eventOptions['affects_future'] = $_REQUEST['affects_future']; + } + Event::modify($_REQUEST['eventid'], $eventOptions); } } diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index a9194dfbd3..498e2d8df9 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -33,7 +33,16 @@ /** * Represents a (possibly recurring) calendar event. * - * @todo Add support for editing specific instances as exceptions to the RRule. + * Overview the process by which the complete recurrence set is built: + * + * 1. The $start and $duration values are used to determine when the first + * occurrence happens. + * 2. A RecurrenceIterator is constructed using the RRule, RDates, and ExDates. + * 3. The RecurrenceIterator generates the list of occurrences. + * 4. Individual occurrences are instantiated as EventOccurrence objects when + * needed. + * 5. When an EventOccurrence is instantiated, it may have adjustments applied + * to it via an EventAdjustment object. */ class Event implements \ArrayAccess { @@ -242,6 +251,23 @@ class Event implements \ArrayAccess */ public array $special_rrule; + /** + * @var array + * + * Info about adjustments to apply to subsets of occurrences of this event. + * + * Keys are the IDs of EventOccurrence objects. + * Values are EventAdjustment objects. + */ + public array $adjustments = []; + + /** + * @var EventOccurrence + * + * When editing an event, this is the occurrence being edited. + */ + public EventOccurrence $selected_occurrence; + /************************** * Public static properties **************************/ @@ -291,6 +317,13 @@ class Event implements \ArrayAccess * Internal properties *********************/ + /** + * @var int + * + * Increments every time the event is modified. + */ + protected int $sequence = 0; + /** * @var bool * @@ -317,7 +350,6 @@ class Event implements \ArrayAccess 'id_board' => 'board', 'id_topic' => 'topic', 'id_first_msg' => 'msg', - 'sequence' => 'modified_time', 'id_member' => 'member', 'poster' => 'member', 'real_name' => 'name', @@ -489,6 +521,10 @@ public function __construct(int $id = 0, array $props = []) } } + if ((string) ($props['adjustments'] ?? '') !== '') { + $this->adjustments = EventAdjustment::createBatch($props['adjustments']); + } + unset( $props['rrule'], $props['start'], @@ -610,6 +646,49 @@ public function save(): void $rrule = !empty($this->special_rrule) ? implode('', $this->special_rrule) : (string) $this->recurrence_iterator->getRRule(); + $rdates = array_unique($this->recurrence_iterator->getRDates()); + $exdates = array_unique($this->recurrence_iterator->getExDates()); + + $rdates = array_diff($rdates, $exdates); + $exdates = array_intersect($exdates, $this->recurrence_iterator->getRRuleOccurrences()); + + foreach ($exdates as $key => $exdate) { + if (new Time($exdate) > $recurrence_end) { + unset($exdates[$key]); + } + } + + foreach ($this->adjustments as $recurrence_id => $adjustment) { + if (new Time((string) $recurrence_id) > $recurrence_end) { + unset($this->adjustments[$recurrence_id]); + + continue; + } + + if ( + ( + !isset($adjustment->offset) + || (string) $adjustment->offset === 'PT0S' + ) + && ( + !isset($adjustment->duration) + || (string) $adjustment->duration === (string) $this->duration + ) + && ( + !isset($adjustment->location) + || $adjustment->location === $this->location + ) + && ( + !isset($adjustment->title) + || $adjustment->title === $this->title + ) + ) { + unset($this->adjustments[$recurrence_id]); + } + } + + ksort($this->adjustments); + // Saving a new event. if (!$is_edit) { $columns = [ @@ -624,6 +703,8 @@ public function save(): void 'rrule' => 'string', 'rdates' => 'string', 'exdates' => 'string', + 'adjustments' => 'string', + 'sequence' => 'int', 'uid' => 'string-255', 'type' => 'int', 'enabled' => 'int', @@ -639,8 +720,10 @@ public function save(): void Utils::truncate($this->location, 255), (string) $this->duration, $rrule, - implode(',', $this->recurrence_iterator->getRDates()), - implode(',', $this->recurrence_iterator->getExDates()), + implode(',', $rdates), + implode(',', $exdates), + json_encode($this->adjustments), + 0, $this->uid, $this->type, (int) $this->enabled, @@ -684,6 +767,8 @@ public function save(): void 'rrule = {string:rrule}', 'rdates = {string:rdates}', 'exdates = {string:exdates}', + 'adjustments = {string:adjustments}', + 'sequence = {int:sequence}', 'uid = {string:uid}', 'type = {int:type}', 'enabled = {int:enabled}', @@ -699,8 +784,10 @@ public function save(): void 'id_topic' => $this->topic, 'duration' => (string) $this->duration, 'rrule' => $rrule, - 'rdates' => implode(',', $this->recurrence_iterator->getRDates()), - 'exdates' => implode(',', $this->recurrence_iterator->getExDates()), + 'rdates' => implode(',', $rdates), + 'exdates' => implode(',', $exdates), + 'adjustments' => json_encode($this->adjustments), + 'sequence' => ++$this->sequence, 'uid' => $this->uid, 'type' => $this->type, 'enabled' => (int) $this->enabled, @@ -906,6 +993,29 @@ public function getOccurrence(string $id): EventOccurrence|false return $occurrence ?? false; } + /** + * Updates the RRule to set a new UNTIL date. + * + * This is somewhat complicated because it requires updating the recurrence + * iterator and its underlying RRule. + */ + public function changeUntil(\DateTimeInterface $until): void + { + if ($until < $this->start) { + throw new \ValueError(); + } + + $rrule = $this->recurrence_iterator->getRRule(); + + unset($rrule->count); + $rrule->until = Time::createFromInterface($until); + $rrule->until_type = $rrule->until_type ?? ($this->allday ? RecurrenceIterator::TYPE_ALLDAY : RecurrenceIterator::TYPE_ABSOLUTE); + + $this->rrule = (string) $rrule; + + $this->createRecurrenceIterator(); + } + /** * Sets custom properties. * @@ -999,7 +1109,7 @@ public function __set(string $prop, mixed $value): void break; } } - $this->duration = $this->start->diff($value); + $this->duration = TimeInterval::createFromDateInterval($this->start->diff($value)); break; case 'end_datetime': @@ -1370,41 +1480,6 @@ public function __isset(string $prop): bool } } - /** - * - */ - public function fixTimezone(): void - { - $all_timezones = TimeZone::list($this->start->date); - - if (!isset($all_timezones[$this->start->timezone])) { - $later = strtotime('@' . $this->start->timestamp . ' + 1 year'); - $tzinfo = (new \DateTimeZone($this->start->timezone))->getTransitions($this->start->timestamp, $later); - - $found = false; - - foreach ($all_timezones as $possible_tzid => $dummy) { - // Ignore the "-----" option - if (empty($possible_tzid)) { - continue; - } - - $possible_tzinfo = (new \DateTimeZone($possible_tzid))->getTransitions($this->start->timestamp, $later); - - if ($tzinfo === $possible_tzinfo) { - $this->start->timezone = $possible_tzid; - $found = true; - break; - } - } - - // Hm. That's weird. Well, just prepend it to the list and let the user deal with it. - if (!$found) { - $all_timezones = [$this->start->timezone => '[UTC' . $this->start->format('P') . '] - ' . $this->start->timezone] + $all_timezones; - } - } - } - /*********************** * Public static methods ***********************/ @@ -1662,6 +1737,23 @@ public static function create(array $eventOptions): void */ public static function modify(int $id, array &$eventOptions): void { + list($event) = self::load($id); + + // If request was to modify a specific occurrence, do that instead. + if ( + !empty($eventOptions['recurrenceid']) + && $event->getFirstOccurrence()->id != $eventOptions['recurrenceid'] + ) { + $rid = $eventOptions['recurrenceid']; + unset($eventOptions['recurrenceid']); + + EventOccurrence::modify($id, $rid, $eventOptions); + + return; + } + + unset($eventOptions['recurrenceid']); + // Sanitize the title and location. foreach (['title', 'location'] as $key) { $eventOptions[$key] = Utils::htmlspecialchars($eventOptions[$key] ?? '', ENT_QUOTES); @@ -1675,7 +1767,6 @@ public static function modify(int $id, array &$eventOptions): void self::setRequestedRRule($eventOptions); - list($event) = self::load($id); $event->set($eventOptions); self::setRequestedRDatesAndExDates($event); @@ -1710,6 +1801,115 @@ public static function remove(int $id): void unset(self::$loaded[$id]); } + /** + * Set the start and end dates and times for a posted event for insertion + * into the database. + * + * - Validates all date and times given to it. + * - Makes sure events do not exceed the maximum allowed duration (if any). + * - If passed an array that defines any time or date parameters, they will + * be used. Otherwise, gets the values from $_POST. + * + * @param array $eventOptions An array of optional time and date parameters + * (span, start_year, end_month, etc., etc.) + */ + public static function setRequestedStartAndDuration(array &$eventOptions): void + { + // Convert unprefixed time unit parameters to start_* parameters. + foreach (['year', 'month', 'day', 'hour', 'minute', 'second'] as $key) { + foreach ([$eventOptions, $_POST] as &$array) { + if (isset($array[$key])) { + $array['start_' . $key] = $array[$key]; + unset($array[$key]); + } + } + } + + // Try to fill missing values in $eventOptions with values from $_POST. + foreach (['year', 'month', 'day', 'hour', 'minute', 'second', 'date', 'time', 'datetime'] as $key) { + $eventOptions['start_' . $key] = $eventOptions['start_' . $key] ?? ($_POST['start_' . $key] ?? null); + + $eventOptions['end_' . $key] = $eventOptions['end_' . $key] ?? ($_POST['end_' . $key] ?? null); + } + + foreach (['allday', 'timezone'] as $key) { + $eventOptions[$key] = $eventOptions[$key] ?? ($_POST[$key] ?? null); + } + + // Standardize the input. + $eventOptions = self::standardizeEventOptions($eventOptions); + + // Create our two Time objects. + $eventOptions['start'] = new Time(implode(' ', [$eventOptions['start_date'], $eventOptions['start_time'], $eventOptions['timezone']])); + + $eventOptions['end'] = new Time(implode(' ', [$eventOptions['end_date'], $eventOptions['end_time'], $eventOptions['timezone']])); + + // Make sure the two dates have a sane relationship. + if ($eventOptions['end']->getTimestamp() < $eventOptions['start']->getTimestamp()) { + $eventOptions['end']->setTimestamp($eventOptions['start']->getTimestamp()); + } + + // Ensure 'allday' is a boolean. + $eventOptions['allday'] = !empty($eventOptions['allday']); + + // Now replace 'end' with 'duration'. + if ($eventOptions['allday']) { + $eventOptions['end']->modify('+1 day'); + } + $eventOptions['duration'] = TimeInterval::createFromDateInterval($eventOptions['start']->diff($eventOptions['end'])); + unset($eventOptions['end']); + + // Unset all null values and all scalar date/time parameters. + $scalars = [ + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'start_datetime', + 'start_date', + 'start_time', + 'start_year', + 'start_month', + 'start_day', + 'start_hour', + 'start_minute', + 'start_second', + 'start_date_local', + 'start_date_orig', + 'start_time_local', + 'start_time_orig', + 'start_timestamp', + 'start_iso_gmdate', + 'end_datetime', + 'end_date', + 'end_time', + 'end_year', + 'end_month', + 'end_day', + 'end_hour', + 'end_minute', + 'end_second', + 'end_date_local', + 'end_date_orig', + 'end_time_local', + 'end_time_orig', + 'end_timestamp', + 'end_iso_gmdate', + 'timezone', + 'tz', + 'tzid', + 'tz_abbrev', + ]; + + foreach($eventOptions as $key => $value) { + if (is_null($value) || in_array($key, $scalars)) { + unset($eventOptions[$key]); + } + } + } + /****************** * Internal methods ******************/ @@ -1835,6 +2035,7 @@ protected function createRecurrenceIterator(): bool */ protected function createOccurrence(\DateTimeInterface $start, ?TimeInterval $duration = null): EventOccurrence { + // Set up the basic properties for the occurrence. $props = [ 'start' => Time::createFromInterface($start), ]; @@ -1845,6 +2046,19 @@ protected function createOccurrence(\DateTimeInterface $start, ?TimeInterval $du $props['id'] = $this->allday ? $props['start']->format('Ymd') : (clone $props['start'])->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\\THis\\Z'); + // Are their any adjustments to apply? + foreach ($this->adjustments as $adjustment) { + if ($adjustment->id > $props['id']) { + break; + } + + if (!$adjustment->affects_future && $adjustment->id < $props['id']) { + continue; + } + + $props['adjustment'] = $adjustment; + } + return new EventOccurrence($this->id, $props); } @@ -1898,7 +2112,7 @@ protected static function queryData(array $selects, array $params = [], array $j $row['start'] = new Time($row['start_date'] . (!$row['allday'] ? ' ' . $row['start_time'] . ' ' . $row['timezone'] : ' ' . User::getTimezone())); unset($row['start_date'], $row['start_time'], $row['timezone']); - // Replace duration string with a DateInterval object. + // Replace duration string with a TimeInterval object. $row['duration'] = new TimeInterval($row['duration']); // end_date is only used for narrowing the query. @@ -1916,115 +2130,6 @@ protected static function queryData(array $selects, array $params = [], array $j Db::$db->free_result($request); } - /** - * Set the start and end dates and times for a posted event for insertion - * into the database. - * - * - Validates all date and times given to it. - * - Makes sure events do not exceed the maximum allowed duration (if any). - * - If passed an array that defines any time or date parameters, they will - * be used. Otherwise, gets the values from $_POST. - * - * @param array $eventOptions An array of optional time and date parameters - * (span, start_year, end_month, etc., etc.) - */ - protected static function setRequestedStartAndDuration(array &$eventOptions): void - { - // Convert unprefixed time unit parameters to start_* parameters. - foreach (['year', 'month', 'day', 'hour', 'minute', 'second'] as $key) { - foreach ([$eventOptions, $_POST] as &$array) { - if (isset($array[$key])) { - $array['start_' . $key] = $array[$key]; - unset($array[$key]); - } - } - } - - // Try to fill missing values in $eventOptions with values from $_POST. - foreach (['year', 'month', 'day', 'hour', 'minute', 'second', 'date', 'time', 'datetime'] as $key) { - $eventOptions['start_' . $key] = $eventOptions['start_' . $key] ?? ($_POST['start_' . $key] ?? null); - - $eventOptions['end_' . $key] = $eventOptions['end_' . $key] ?? ($_POST['end_' . $key] ?? null); - } - - foreach (['allday', 'timezone'] as $key) { - $eventOptions[$key] = $eventOptions[$key] ?? ($_POST[$key] ?? null); - } - - // Standardize the input. - $eventOptions = self::standardizeEventOptions($eventOptions); - - // Create our two Time objects. - $eventOptions['start'] = new Time(implode(' ', [$eventOptions['start_date'], $eventOptions['start_time'], $eventOptions['timezone']])); - - $eventOptions['end'] = new Time(implode(' ', [$eventOptions['end_date'], $eventOptions['end_time'], $eventOptions['timezone']])); - - // Make sure the two dates have a sane relationship. - if ($eventOptions['end']->getTimestamp() < $eventOptions['start']->getTimestamp()) { - $eventOptions['end']->setTimestamp($eventOptions['start']->getTimestamp()); - } - - // Ensure 'allday' is a boolean. - $eventOptions['allday'] = !empty($eventOptions['allday']); - - // Now replace 'end' with 'duration'. - if ($eventOptions['allday']) { - $eventOptions['end']->modify('+1 day'); - } - $eventOptions['duration'] = $eventOptions['start']->diff($eventOptions['end']); - unset($eventOptions['end']); - - // Unset all null values and all scalar date/time parameters. - $scalars = [ - 'year', - 'month', - 'day', - 'hour', - 'minute', - 'second', - 'start_datetime', - 'start_date', - 'start_time', - 'start_year', - 'start_month', - 'start_day', - 'start_hour', - 'start_minute', - 'start_second', - 'start_date_local', - 'start_date_orig', - 'start_time_local', - 'start_time_orig', - 'start_timestamp', - 'start_iso_gmdate', - 'end_datetime', - 'end_date', - 'end_time', - 'end_year', - 'end_month', - 'end_day', - 'end_hour', - 'end_minute', - 'end_second', - 'end_date_local', - 'end_date_orig', - 'end_time_local', - 'end_time_orig', - 'end_timestamp', - 'end_iso_gmdate', - 'timezone', - 'tz', - 'tzid', - 'tz_abbrev', - ]; - - foreach($eventOptions as $key => $value) { - if (is_null($value) || in_array($key, $scalars)) { - unset($eventOptions[$key]); - } - } - } - /** * Set the RRule for a posted event for insertion into the database. * diff --git a/Sources/Calendar/EventAdjustment.php b/Sources/Calendar/EventAdjustment.php new file mode 100644 index 0000000000..8591a2a0ac --- /dev/null +++ b/Sources/Calendar/EventAdjustment.php @@ -0,0 +1,162 @@ +id = $id; + $this->affects_future = $affects_future; + + foreach (['offset', 'duration'] as $prop) { + if (!isset(${$prop})) { + continue; + } + + if (!empty(${$prop}['f'])) { + ${$prop}['s'] += ${$prop}['f']; + unset(${$prop}['f']); + } + + $string = 'P'; + + foreach (['y', 'm', 'd', 'h', 'i', 's'] as $key) { + if ($key === 'h') { + $string .= 'T'; + } + + if (!empty(${$prop}[$key])) { + $string .= ${$prop}[$key] . ($key === 'i' ? 'M' : strtoupper($key)); + } + } + + $this->{$prop} = new TimeInterval(rtrim($string, 'PT')); + $this->{$prop}->invert = (int) ${$prop}['invert']; + } + + $this->location = $location; + $this->title = $title; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Creates a batch of EventAdjustment instances from JSON data. + * + * @param string $json JSON string containing a batch of adjustment data. + * @return array Instances of this class. + */ + public static function createBatch(string $json): array + { + $adjustments = []; + + foreach (json_decode($json, true) as $key => $value) { + $key = $value['id'] ?? $key; + + $adjustments[$key] = new self( + $key, + $value['affects_future'] ?? false, + $value['offset'] ?? null, + $value['duration'] ?? null, + $value['location'] ?? null, + $value['title'] ?? null, + ); + } + + ksort($adjustments); + + return $adjustments; + } +} + +?> \ No newline at end of file diff --git a/Sources/Calendar/EventOccurrence.php b/Sources/Calendar/EventOccurrence.php index 0fd8dadc6c..af19e980c6 100644 --- a/Sources/Calendar/EventOccurrence.php +++ b/Sources/Calendar/EventOccurrence.php @@ -19,6 +19,9 @@ use SMF\ArrayAccessHelper; use SMF\Config; use SMF\Time; +use SMF\TimeInterval; +use SMF\TimeZone; +use SMF\Utils; /** * Represents a single occurrence of a calendar event. @@ -56,6 +59,14 @@ class EventOccurrence implements \ArrayAccess */ public Time $start; + /** + * @var SMF\Time + * + * An EventAdjustment object representing changes made to this occurrence of + * the event, if any. + */ + public EventAdjustment $adjustment; + /********************* * Internal properties *********************/ @@ -66,11 +77,11 @@ class EventOccurrence implements \ArrayAccess * Alternate names for some object properties. */ protected array $prop_aliases = [ + 'recurrenceid' => 'id', 'eventid' => 'id_event', 'id_board' => 'board', 'id_topic' => 'topic', 'id_first_msg' => 'msg', - 'sequence' => 'modified_time', 'id_member' => 'member', 'poster' => 'member', 'real_name' => 'name', @@ -81,6 +92,13 @@ class EventOccurrence implements \ArrayAccess 'end_object' => 'end', ]; + /** + * @var SMF\Time + * + * A Time object representing the unadjusted start of this occurrence. + */ + protected Time $unadjusted_start; + /**************** * Public methods ****************/ @@ -100,11 +118,79 @@ public function __construct(int $id_event = 0, array $props = []) throw new \ValueError(); } + $this->unadjusted_start = clone $props['start']; + $this->start = clone $props['start']; + unset($props['start']); + + $this->id = $props['id'] ?? ($this->allday ? $this->start->format('Ymd') : (clone $this->start)->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\\THis\\Z')); + unset($props['id']); + + if (isset($props['adjustment'])) { + $this->adjustment = $props['adjustment']; + unset($props['adjustment']); + } + + // Set any other properties we were given. $this->set($props); - if (!isset($this->id)) { - $this->id = $this->allday ? $this->start->format('Ymd') : (clone $this->start)->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\\THis\\Z'); + // Apply any adjustments. + if (isset($this->adjustment)) { + if (isset($this->adjustment->offset)) { + $this->start->add($this->adjustment->offset); + } + + if (isset($this->adjustment->duration)) { + $this->duration = clone $this->adjustment->duration; + } + + if (isset($this->adjustment->location)) { + $this->location = $this->adjustment->location; + } + + if (isset($this->adjustment->title)) { + $this->title = $this->adjustment->title; + } + } + } + + /** + * Saving an individual occurrence means updating the parent event's + * adjustments property and then saving the parent event. + */ + public function save() + { + // Just in case... + ksort($this->getParentEvent()->adjustments); + + foreach ($this->getParentEvent()->adjustments as $adjustment) { + // Adjustment takes effect after this occurrence, so stop. + if ($adjustment->id > $this->id) { + break; + } + + // Adjustment takes effect before this occurrence but doesn't + // affect it, so skip. + if (!$adjustment->affects_future && $adjustment->id < $this->id) { + continue; + } + + // If the found adjustment has all the same values that this + // occurrence's current adjustment also has, then there's nothing + // that needs to change. + if ( + (string) ($this->adjustment->offset ?? '') === (string) ($adjustment->offset ?? '') + && (string) ($this->adjustment->duration ?? '') === (string) ($adjustment->duration ?? '') + && (string) ($this->adjustment->location ?? '') === (string) ($adjustment->location ?? '') + && (string) ($this->adjustment->title ?? '') === (string) ($adjustment->title ?? '') + ) { + return; + } } + + // Add a new entry to the parent event's list of adjustments. + $this->adjustment->id = $this->id; + $this->getParentEvent()->adjustments[$this->id] = clone $this->adjustment; + $this->getParentEvent()->save(); } /** @@ -222,10 +308,12 @@ public function __set(string $prop, $value): void case 'duration': if (!($value instanceof \DateInterval)) { try { - $value = new \DateInterval((string) $value); + $value = new TimeInterval((string) $value); } catch (\Throwable $e) { break; } + } elseif (!($value instanceof TimeInterval)) { + $value = TimeInterval::createFromDateInterval($value); } $this->custom['duration'] = $value; break; @@ -264,6 +352,7 @@ public function __set(string $prop, $value): void case 'age': case 'uid': case 'tz_abbrev': + case 'sequence': case 'new': case 'is_selected': case 'href': @@ -357,6 +446,19 @@ public function __get(string $prop): mixed return date_diff($this->start, $this->getParentEvent()->start)->y; + case 'is_first': + return $this->unadjusted_start == $this->getParentEvent()->start; + + case 'can_affect_future': + return !isset($this->adjustment) || $this->adjustment->affects_future === true; + + // These are set separately for each occurrence. + case 'modify_href': + return Config::$scripturl . '?action=' . ($this->getParentEvent()->board == 0 ? 'calendar;sa=post;' : 'post;msg=' . $this->getParentEvent()->msg . ';topic=' . $this->getParentEvent()->topic . '.0;calendar;') . 'eventid=' . $this->id_event . ';recurrenceid=' . $this->id . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']; + + case 'export_href': + return Config::$scripturl . '?action=calendar;sa=ical;eventid=' . $this->id_event . ';recurrenceid=' . $this->id . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']; + // These inherit from the parent event unless overridden for this occurrence. case 'allday': case 'duration': @@ -374,14 +476,13 @@ public function __get(string $prop): mixed case 'member': case 'name': case 'groups': + case 'sequence': case 'new': case 'is_selected': case 'href': case 'link': case 'can_edit': - case 'modify_href': case 'can_export': - case 'export_href': return $this->getParentEvent()->{$prop}; default: @@ -451,6 +552,7 @@ public function __isset(string $prop): bool case 'member': case 'name': case 'groups': + case 'sequence': case 'new': case 'is_selected': case 'href': @@ -466,6 +568,41 @@ public function __isset(string $prop): bool } } + /** + * + */ + public function fixTimezone(): void + { + $all_timezones = TimeZone::list($this->start->date); + + if (!isset($all_timezones[$this->start->timezone])) { + $later = strtotime('@' . $this->start->timestamp . ' + 1 year'); + $tzinfo = (new \DateTimeZone($this->start->timezone))->getTransitions($this->start->timestamp, $later); + + $found = false; + + foreach ($all_timezones as $possible_tzid => $dummy) { + // Ignore the "-----" option + if (empty($possible_tzid)) { + continue; + } + + $possible_tzinfo = (new \DateTimeZone($possible_tzid))->getTransitions($this->start->timestamp, $later); + + if ($tzinfo === $possible_tzinfo) { + $this->start->timezone = $possible_tzid; + $found = true; + break; + } + } + + // Hm. That's weird. Well, just prepend it to the list and let the user deal with it. + if (!$found) { + $all_timezones = [$this->start->timezone => '[UTC' . $this->start->format('P') . '] - ' . $this->start->timezone] + $all_timezones; + } + } + } + /** * Retrieves the Event that this EventOccurrence is an occurrence of. * @@ -477,7 +614,65 @@ public function getParentEvent(): Event Event::load($this->id_event); } - return Event::$loaded[$this->id_event]; + return Event::$loaded[$this->id_event] ?? new Event(-1); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Modifies an individual occurrence of an event. + * + * @param int $id_event The ID of the parent event. + * @param string $id The recurrence ID of the occurrence. + * @param array $eventOptions An array of event information. + */ + public static function modify(int $id_event, string $id, array &$eventOptions): void + { + // Set the new start date and duration. + Event::setRequestedStartAndDuration($eventOptions); + + $eventOptions['view_start'] = \DateTimeImmutable::createFromInterface($eventOptions['start']); + $eventOptions['view_end'] = $eventOptions['view_start']->add(new TimeInterval('P1D')); + + list($event) = Event::load($id_event); + + $occurrence = $event->getOccurrence($id); + + $offset = TimeInterval::createFromDateInterval(date_diff($occurrence->unadjusted_start, $eventOptions['start'])); + + $occurrence->adjustment = new EventAdjustment( + $id, + isset($occurrence->adjustment) && $occurrence->adjustment->affects_future ? !empty($eventOptions['affects_future']) : false, + (string) $offset !== 'PT0S' ? (array) $offset : null, + (string) $eventOptions['duration'] !== (string) $occurrence->getParentEvent()->duration ? (array) $eventOptions['duration'] : null, + isset($eventOptions['location']) && $eventOptions['location'] !== $occurrence->getParentEvent()->location ? Utils::htmlspecialchars($eventOptions['location'], ENT_QUOTES) : null, + isset($eventOptions['title']) && $eventOptions['title'] !== $occurrence->getParentEvent()->title ? Utils::htmlspecialchars($eventOptions['title'], ENT_QUOTES) : null, + ); + + $occurrence->save(); + } + + /** + * Removes an event occurrence from the recurrence set. + * + * @param int $id_event The parent event's ID. + * @param string $id The recurrence ID. + */ + public static function remove(int $id_event, string $id, bool $affects_future = false): void + { + list($event) = Event::load($id_event); + + if ($event->getFirstOccurrence()->id === $id) { + Event::remove($id_event); + } elseif ($affects_future) { + $event->changeUntil(new Time($id)); + $event->save(); + } else { + $event->removeOccurrence(new Time($id)); + $event->save(); + } } } diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index 63f2387669..5c8de13156 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -831,9 +831,10 @@ function template_event_post() echo ' '; - if (!empty(Utils::$context['event']['new'])) + if (!empty(Utils::$context['event']->new)) echo ' - '; + + '; // Start the main table. echo ' @@ -863,17 +864,8 @@ function template_event_post() template_event_options(); echo ' - '; - - // Delete button? - if (empty(Utils::$context['event']['new'])) - echo ' - '; - - echo ' + - -
    '; diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index 56864dc583..5204e5033d 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -187,7 +187,7 @@ function template_main() { echo '
  • - ', $event['title'], ''; + ', $event['title'], ''; if ($event['can_edit']) echo ' '; diff --git a/Themes/default/EventEditor.template.php b/Themes/default/EventEditor.template.php index 2f2cf7b797..81aa475831 100644 --- a/Themes/default/EventEditor.template.php +++ b/Themes/default/EventEditor.template.php @@ -65,14 +65,14 @@ function template_event_options() ', Lang::$txt['calendar_event_title'], '
    - +
    - +
    '; @@ -84,7 +84,7 @@ function template_event_options()
    - allday) ? ' checked' : '', !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', '> + allday) ? ' checked' : '', !empty(Utils::$context['event']->special_rrule) || (!Utils::$context['event']->new && !Utils::$context['event']->selected_occurrence->is_first) ? ' disabled' : '', '>
    @@ -92,16 +92,16 @@ function template_event_options()
    - special_rrule) ? ' disabled data-force-disabled' : '', '> - allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '> + special_rrule) ? ' disabled data-force-disabled' : '', '> + allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '>
    - special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '> - allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '> + special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '> + allday) || !empty(Utils::$context['event']->special_rrule) ? ' disabled' : '', !empty(Utils::$context['event']->special_rrule) ? ' data-force-disabled' : '', '>
    @@ -116,7 +116,7 @@ function template_event_options() foreach (Utils::$context['all_timezones'] as $tz => $tzname) { echo ' - tz ? ' selected' : '', '>', $tzname, ''; + selected_occurrence->tz ? ' selected' : '', '>', $tzname, ''; } echo ' @@ -124,6 +124,63 @@ function template_event_options() '; + // If this is a new event or the first occurrence of an existing event, show the RRULE stuff. + if (Utils::$context['event']->new || Utils::$context['event']->selected_occurrence->is_first) { + template_rrule(); + } else { + template_occurrence_options(); + } + + echo ' + '; +} + +/** + * Template used when editing a single occurrence of an event. + */ +function template_occurrence_options() +{ + if (Utils::$context['event']->selected_occurrence->can_affect_future) { + echo ' +
    +
    + +
    +
    + +
    + +
    + ', Lang::$txt['calendar_repeat_adjustment_label'], ' +
    +
    + +
    + +
    +
    '; + } + + echo ' +
    +
    +
    + ', Lang::$txt['calendar_repeat_adjustment_edit_first'], ' +
    +
    '; +} + +/** + * Template for the recurrence rule options for events. + */ +function template_rrule() +{ // Recurring event options. echo '
    '; @@ -428,11 +485,13 @@ function template_event_options() foreach (Utils::$context['event']->recurrence_iterator->getExDates() as $key => $exdate) { $exdate = new SMF\Time($exdate); + $exdate->setTimezone(Utils::$context['event']->start->getTimezone()); echo '
    +
    '; } @@ -452,8 +511,7 @@ function template_event_options()
    '; echo ' - - '; + '; } ?> \ No newline at end of file diff --git a/Themes/default/Post.template.php b/Themes/default/Post.template.php index 5cccc71d1e..e5055b4346 100644 --- a/Themes/default/Post.template.php +++ b/Themes/default/Post.template.php @@ -88,9 +88,10 @@ function addPollOption()
    '; - if (Utils::$context['make_event'] && (!Utils::$context['event']['new'] || !empty(Utils::$context['current_board']))) + if (Utils::$context['make_event'] && (!Utils::$context['event']->new || !empty(Utils::$context['current_board']))) echo ' - '; + + '; // Start the main table. echo ' @@ -437,11 +438,6 @@ function addPollOption() ', template_control_richedit_buttons(Utils::$context['post_box_name']); - // Option to delete an event if user is editing one. - if (Utils::$context['make_event'] && !Utils::$context['event']['new']) - echo ' - '; - echo ' diff --git a/Themes/default/scripts/event.js b/Themes/default/scripts/event.js index 9493177246..b3043ffa1a 100644 --- a/Themes/default/scripts/event.js +++ b/Themes/default/scripts/event.js @@ -4,7 +4,9 @@ for (const elem of document.querySelectorAll("#start_date, #start_time, #end_dat elem.addEventListener("change", updateEventUI); } -document.getElementById("event_add_byday").addEventListener("click", addByDayItem); +for (const elem of document.querySelectorAll("#event_add_byday")) { + elem.addEventListener("click", addByDayItem); +} for (const elem of document.querySelectorAll("#event_add_rdate, #event_add_exdate")) { elem.addEventListener("click", addRDateOrExDate); @@ -47,7 +49,7 @@ function updateEventUI() } // If using a custom RRule, show the relevant options. - if (document.getElementById("rrule").value === "custom") { + if (document.getElementById("rrule") && document.getElementById("rrule").value === "custom") { // Show select menu for FREQ options. document.getElementById("freq_interval_options").style.display = ""; @@ -403,8 +405,10 @@ function updateEventUI() } } else { - document.getElementById("freq_interval_options").style.display = "none"; - document.getElementById("freq").value = "DAILY"; + if (document.getElementById("freq_interval_options")) { + document.getElementById("freq_interval_options").style.display = "none"; + document.getElementById("freq").value = "DAILY"; + } for (const elem of document.querySelectorAll(".rrule_input_wrapper")) { elem.style.display = "none"; @@ -450,7 +454,7 @@ function updateEventUI() } // If necessary, show the options for RRule end. - if (document.getElementById("rrule").value === "custom" || document.getElementById("rrule").value.substring(0, 5) === "FREQ=") { + if (document.getElementById("rrule") && (document.getElementById("rrule").value === "custom" || document.getElementById("rrule").value.substring(0, 5) === "FREQ=")) { const end_option = document.getElementById("end_option"); const until = document.getElementById("until"); const count = document.getElementById("count"); @@ -533,12 +537,14 @@ function updateEndDate(start_date, end_date) } // If necessary, also update the UNTIL field. - let until = new Date(document.getElementById("until").value + "T23:59:59.999"); + if (document.getElementById("until")) { + let until = new Date(document.getElementById("until").value + "T23:59:59.999"); - document.getElementById("until").min = document.getElementById("start_date").value; + document.getElementById("until").min = document.getElementById("start_date").value; - if (start_date.getTime() > until.getTime()) { - document.getElementById("until").value = document.getElementById("end_date").value; + if (start_date.getTime() > until.getTime()) { + document.getElementById("until").value = document.getElementById("end_date").value; + } } // Remember any changes to start and end dates. @@ -593,6 +599,10 @@ function getNewStartDateByDay(start_date, end_of_month, byday_num, selected_days // Determine whether the BYDAY_num select menu's "fifth" option should be enabled or not. function enableOrDisableFifth() { + if (!document.getElementById("byday_name_select_0")) { + return; + } + const start_date = new Date(document.getElementById("start_date").value + 'T' + document.getElementById("start_time").value); const end_of_month = new Date( diff --git a/Themes/default/scripts/script.js b/Themes/default/scripts/script.js index 9397aad431..f21d45710b 100644 --- a/Themes/default/scripts/script.js +++ b/Themes/default/scripts/script.js @@ -1732,6 +1732,10 @@ $(function() { // Generic confirmation message. $(document).on('click', '.you_sure', function() { + if (!this.checked) { + return true; + } + var custom_message = $(this).attr('data-confirm'); var timeBefore = new Date(); var result = confirm(custom_message ? custom_message.replace(/-n-/g, "\n") : smf_you_sure); From b5a38255eaf8d97980cbaa615a3f2b6497fd0336 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sun, 7 Jan 2024 03:26:34 -0700 Subject: [PATCH 14/22] Adds support for importing calendar data from iCal files Signed-off-by: Jon Stovell --- Languages/en_US/Admin.php | 1 + Languages/en_US/ManageCalendar.php | 9 ++ Sources/Actions/Admin/ACP.php | 4 + Sources/Actions/Admin/Calendar.php | 42 +++++++ Sources/Calendar/Event.php | 126 +++++++++++++++++++++ Sources/Calendar/Holiday.php | 17 +++ Themes/default/ManageCalendar.template.php | 42 +++++++ 7 files changed, 241 insertions(+) diff --git a/Languages/en_US/Admin.php b/Languages/en_US/Admin.php index 96c42b340d..878bf98b4f 100644 --- a/Languages/en_US/Admin.php +++ b/Languages/en_US/Admin.php @@ -711,6 +711,7 @@ $txt['boards_edit'] = 'Modify Boards'; $txt['mboards_new_cat'] = 'Create new category'; $txt['manage_holidays'] = 'Manage Holidays'; +$txt['calendar_import'] = 'Import Calendar Data'; $txt['calendar_settings'] = 'Calendar Settings'; $txt['search_weights'] = 'Weights'; $txt['search_method'] = 'Search Method'; diff --git a/Languages/en_US/ManageCalendar.php b/Languages/en_US/ManageCalendar.php index 800cc2dee9..59ea04e774 100644 --- a/Languages/en_US/ManageCalendar.php +++ b/Languages/en_US/ManageCalendar.php @@ -55,4 +55,13 @@ $txt['holidays_date_varies'] = 'Date varies'; $txt['every_year'] = 'Every Year'; +// Importing calendar data +$txt['calendar_import_desc'] = 'Here you can import holidays and events from iCalendar files.'; +$txt['calendar_import_url'] = 'URL of iCalendar data'; +$txt['calendar_import_url_desc'] = 'This should be the URL of an .ics file.'; +$txt['calendar_import_type'] = 'Import events as'; +$txt['calendar_import_type_holiday'] = 'Holidays'; +$txt['calendar_import_type_event'] = 'Events'; +$txt['calendar_import_button'] = 'Import'; + ?> \ No newline at end of file diff --git a/Sources/Actions/Admin/ACP.php b/Sources/Actions/Admin/ACP.php index 26d03f8bb9..5e7f94298c 100644 --- a/Sources/Actions/Admin/ACP.php +++ b/Sources/Actions/Admin/ACP.php @@ -305,6 +305,10 @@ class ACP implements ActionInterface 'label' => 'manage_holidays', 'permission' => 'admin_forum', ], + 'import' => [ + 'label' => 'calendar_import', + 'permission' => 'admin_forum', + ], 'settings' => [ 'label' => 'calendar_settings', 'permission' => 'admin_forum', diff --git a/Sources/Actions/Admin/Calendar.php b/Sources/Actions/Admin/Calendar.php index 846c0bc477..6f679e32bd 100644 --- a/Sources/Actions/Admin/Calendar.php +++ b/Sources/Actions/Admin/Calendar.php @@ -32,8 +32,10 @@ use SMF\Time; use SMF\TimeInterval; use SMF\TimeZone; +use SMF\Url; use SMF\User; use SMF\Utils; +use SMF\WebFetch\WebFetchApi; /** * This class allows you to manage the calendar. @@ -66,6 +68,7 @@ class Calendar implements ActionInterface public static array $subactions = [ 'holidays' => 'holidays', 'editholiday' => 'edit', + 'import' => 'import', 'settings' => 'settings', ]; @@ -302,6 +305,42 @@ public function edit(): void Theme::loadJavaScriptFile('event.js', ['defer' => true], 'smf_event'); } + /** + * Handles importing events and holidays from iCalendar files. + */ + public function import(): void + { + Theme::loadTemplate('ManageCalendar'); + Utils::$context['sub_template'] = 'import'; + Utils::$context['page_title'] = Lang::$txt['calendar_import']; + + // Submitting? + if (isset($_POST[Utils::$context['session_var']], $_POST['ics_url'], $_POST['type'])) { + User::$me->checkSession(); + SecurityToken::validate('admin-calendarimport'); + + $ics_url = new Url($_POST['ics_url'], true); + + if ($ics_url->isValid()) { + $ics_data = WebFetchApi::fetch($ics_url); + } + + if (!empty($ics_data)) { + switch ($_POST['type']) { + case 'holiday': + Holiday::import($ics_data); + break; + + case 'event': + Event::import($ics_data); + break; + } + } + } + + SecurityToken::create('admin-calendarimport'); + } + /** * Handles showing and changing calendar settings. */ @@ -474,6 +513,9 @@ protected function __construct() 'holidays' => [ 'description' => Lang::$txt['manage_holidays_desc'], ], + 'import' => [ + 'description' => Lang::$txt['calendar_import_desc'], + ], 'settings' => [ 'description' => Lang::$txt['calendar_settings_desc'], ], diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index 498e2d8df9..e580d02873 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -1801,6 +1801,132 @@ public static function remove(int $id): void unset(self::$loaded[$id]); } + /** + * Imports events from iCalendar data and saves them to the database. + * + * @param string $ics Some iCalendar data (e.g. the content of an ICS file). + * @return array An array of instances of this class. + */ + public static function import(string $ics): array + { + $events = self::constructFromICal($ics, self::TYPE_EVENT); + + foreach ($events as $event) { + $event->save(); + } + + return $events; + } + + /** + * Constructs instances of this class from iCalendar data. + * + * @param string $ics Some iCalendar data (e.g. the content of an ICS file). + * @param ?int $type Forces all events to be imported as the specified type. + * Values can be one of this class's TYPE_* constants, or null for auto. + * @return array An array of instances of this class. + */ + public static function constructFromICal(string $ics, ?int $type = null): array + { + $events = []; + + $ics = preg_replace('/\R\h/', '', $ics); + + $lines = preg_split('/\R/', $ics); + + $in_event = false; + $props = []; + + foreach ($lines as $line) { + if ($line === 'BEGIN:VEVENT') { + $in_event = true; + } + + if ($line === 'END:VEVENT') { + if ( + isset( + $props['start'], + $props['duration'], + $props['title'], + ) + ) { + if (isset($type)) { + $props['type'] = $type; + } + + if (isset($props['type']) && $props['type'] === self::TYPE_HOLIDAY) { + $events[] = new Holiday(0, $props); + } else { + unset($props['type']); + $events[] = new self(0, $props); + } + } + + $in_event = false; + $props = []; + } + + if (!$in_event) { + continue; + } + + if (strpos($line, 'DTSTART') === 0) { + if (preg_match('/;TZID=([^:;]+)[^:]*:(\d+T\d+)/', $line, $matches)) { + $props['start'] = new Time($matches[2] . ' ' . $matches[1]); + $props['allday'] = false; + } elseif (preg_match('/:(\d+T\d+)(Z?)/', $line, $matches)) { + $props['start'] = new Time($matches[1] . ($matches[2] === 'Z' ? ' UTC' : '')); + $props['allday'] = false; + } elseif (preg_match('/:(\d+)/', $line, $matches)) { + $props['start'] = new Time($matches[1] . ' ' . User::getTimezone()); + $props['allday'] = true; + } + } + + if (strpos($line, 'DTEND') === 0) { + if (preg_match('/;TZID=([^:;]+)[^:]*:(\d+T\d+)/', $line, $matches)) { + $end = new Time($matches[2] . ' ' . $matches[1]); + } elseif (preg_match('/:(\d+T\d+)(Z?)/', $line, $matches)) { + $end = new Time($matches[1] . ($matches[2] === 'Z' ? ' UTC' : '')); + } elseif (preg_match('/:(\d+)/', $line, $matches)) { + $end = new Time($matches[1] . ' ' . User::getTimezone()); + } + + $props['duration'] = TimeInterval::createFromDateInterval($props['start']->diff($end)); + } + + if (strpos($line, 'DURATION') === 0) { + $props['duration'] = new TimeInterval(substr($line, strpos($line, ':') + 1)); + } + + if (!isset($type) && strpos($line, 'CATEGORIES') === 0) { + $props['type'] = str_contains(strtolower($line), 'holiday') ? self::TYPE_HOLIDAY : self::TYPE_EVENT; + } + + if (strpos($line, 'SUMMARY') === 0) { + $props['title'] = substr($line, strpos($line, ':') + 1); + } + + if (strpos($line, 'LOCATION') === 0) { + $props['location'] = substr($line, strpos($line, ':') + 1); + } + + if (strpos($line, 'RRULE') === 0) { + $props['rrule'] = substr($line, strpos($line, ':') + 1); + } + + if (strpos($line, 'RDATE') === 0) { + $props['rdates'] = explode(',', substr($line, strpos($line, ':') + 1)); + } + + if (strpos($line, 'EXDATE') === 0) { + $props['exdates'] = explode(',', substr($line, strpos($line, ':') + 1)); + } + } + + return $events; + } + /** * Set the start and end dates and times for a posted event for insertion * into the database. diff --git a/Sources/Calendar/Holiday.php b/Sources/Calendar/Holiday.php index 05fdb2f88a..3148fbe15a 100644 --- a/Sources/Calendar/Holiday.php +++ b/Sources/Calendar/Holiday.php @@ -424,6 +424,23 @@ public static function remove(int $id): void parent::remove($id); } + /** + * Imports holidays from iCalendar data and saves them to the database. + * + * @param string $ics Some iCalendar data (e.g. the content of an ICS file). + * @return array An array of instances of this class. + */ + public static function import(string $ics): array + { + $holidays = self::constructFromICal($ics, self::TYPE_HOLIDAY); + + foreach ($holidays as $holiday) { + $holiday->save(); + } + + return $holidays; + } + /** * Computes the Western and Eastern dates of Easter for the given year. * diff --git a/Themes/default/ManageCalendar.template.php b/Themes/default/ManageCalendar.template.php index 0c1005dd42..7303851c72 100644 --- a/Themes/default/ManageCalendar.template.php +++ b/Themes/default/ManageCalendar.template.php @@ -46,4 +46,46 @@ function template_edit_holiday() '; } +/** + * Importing iCalendar data. + */ +function template_import() +{ + // Show a form for all the holiday information. + echo ' +
    +
    +

    ', Utils::$context['page_title'], '

    +
    +
    +
    +
    + +
    + ', Lang::$txt['calendar_import_url_desc'], ' +
    +
    + +
    +
    + +
    +
    + + +
    +
    + + + +
    +
    '; +} + ?> \ No newline at end of file From 6db7551f4126658d04d622ce4f28174783593918 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sun, 7 Jan 2024 23:58:14 -0700 Subject: [PATCH 15/22] Improves the code to export events in iCal format 1. Fixes problems where we weren't conforming to the spec. 2. Adds support for exporting recurring events. Signed-off-by: Jon Stovell --- Sources/Actions/Calendar.php | 7 ++- Sources/Calendar/Event.php | 90 ++++++++++++++++++++++++---- Sources/Calendar/EventOccurrence.php | 48 ++++++--------- 3 files changed, 101 insertions(+), 44 deletions(-) diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 1d4f2221e8..eb154d6d68 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -546,7 +546,7 @@ public function export(): void } // Load up the event in question and check it is valid. - list($event) = Event::load($_REQUEST['eventid']); + $event = current(Event::load((int) $_REQUEST['eventid'])); if (!($event instanceof Event)) { ErrorHandler::fatalLang('no_access', false); @@ -559,7 +559,7 @@ public function export(): void $filecontents[] = 'PRODID:-//SimpleMachines//' . SMF_FULL_VERSION . '//EN'; $filecontents[] = 'VERSION:2.0'; - $filecontents[] = $event->getVEvent(); + $filecontents[] = $event->export(); $filecontents[] = 'END:VCALENDAR'; @@ -586,7 +586,8 @@ public function export(): void header('connection: close'); header('content-disposition: attachment; filename="' . $event->title . '.ics"'); - $calevent = implode("\n", $filecontents); + // RFC 5545 requires "\r\n", not just "\n". + $calevent = implode("\r\n", $filecontents); if (empty(Config::$modSettings['enableCompressedOutput'])) { // todo: correctly handle $filecontents before passing to string function diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index e580d02873..517ebffb29 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -821,16 +821,60 @@ public function save(): void ]); } - // /** - // * @todo Builds an iCalendar document for the event, including all - // * recurrence info. - // * - // * @return string An iCalendar VEVENT document. - // */ - // public function getVEvent(): string - // { - // return ''; - // } + /** + * Builds iCalendar components for events, including recurrence info. + * + * @return string One or more VEVENT components for an iCalendar document. + */ + public function export(): string + { + $filecontents = []; + $filecontents[] = 'BEGIN:VEVENT'; + + $filecontents[] = 'SUMMARY:' . $this->title; + + if (!empty($this->location)) { + $filecontents[] = 'LOCATION:' . str_replace(',', '\\,', $this->location); + } + + if (!empty($this->topic)) { + $filecontents[] = 'URL:' . Config::$scripturl . '?topic=' . $this->topic . '.0'; + } + + $filecontents[] = 'UID:' . $this->uid; + $filecontents[] = 'SEQUENCE:' . $this->sequence; + + $filecontents[] = 'DTSTAMP:' . date('Ymd\\THis\\Z', $this->modified_time ?? time()); + $filecontents[] = 'DTSTART' . ($this->allday ? ';VALUE=DATE' : (!in_array($this->tz, RRule::UTC_SYNONYMS) ? ';TZID=' . $this->tz : '')) . ':' . $this->start->format('Ymd' . ($this->allday ? '' : '\\THis' . (in_array($this->tz, RRule::UTC_SYNONYMS) ? '\\Z' : ''))); + $filecontents[] = 'DURATION:' . (string) $this->duration; + + if ((string) $this->recurrence_iterator->getRRule() !== 'FREQ=YEARLY;COUNT=1') { + $filecontents[] = 'RRULE:' . (string) $this->recurrence_iterator->getRRule(); + } + + if ($this->recurrence_iterator->getRDates() !== []) { + $filecontents[] = 'RDATE' . ($this->allday ? ';VALUE=DATE' : '') . ':' . implode(",\r\n ", $this->recurrence_iterator->getRDates()); + } + + if ($this->recurrence_iterator->getExDates() !== []) { + $filecontents[] = 'EXDATE' . ($this->allday ? ';VALUE=DATE' : '') . ':' . implode(",\r\n ", $this->recurrence_iterator->getExDates()); + } + + $filecontents[] = 'END:VEVENT'; + + // Fit all lines within iCalendar's line width restraint. + foreach ($filecontents as $line_num => $line) { + $filecontents[$line_num] = self::foldICalLine($line); + } + + // Adjusted occurrences need their own VEVENTs. + foreach ($this->adjustments as $recurrence_id => $adjustment) { + $occurrence = $this->getOccurrence($recurrence_id); + $filecontents[] = $occurrence->export(); + } + + return implode("\r\n", $filecontents); + } /** * Adds an arbitrary date to the recurrence set. @@ -1927,6 +1971,32 @@ public static function constructFromICal(string $ics, ?int $type = null): array return $events; } + /** + * Folds lines of text to fit within the iCalendar line width restraint. + * + * @param string $line The line of text to fold. + * @return string $line The folded version of $line. + */ + public static function foldICalLine(string $line): string + { + $folded = []; + + $temp = ''; + + foreach (mb_str_split($line) as $char) { + if (strlen($temp . $char) > 75) { + $folded[] = $temp; + $temp = ''; + } + + $temp .= $char; + } + + $folded[] = $temp; + + return implode("\r\n ", $folded); + } + /** * Set the start and end dates and times for a posted event for insertion * into the database. diff --git a/Sources/Calendar/EventOccurrence.php b/Sources/Calendar/EventOccurrence.php index af19e980c6..55a233fe05 100644 --- a/Sources/Calendar/EventOccurrence.php +++ b/Sources/Calendar/EventOccurrence.php @@ -22,6 +22,7 @@ use SMF\TimeInterval; use SMF\TimeZone; use SMF\Utils; +use SMF\Uuid; /** * Represents a single occurrence of a calendar event. @@ -194,51 +195,36 @@ public function save() } /** - * Builds an iCalendar document for this occurrence of the event. + * Builds an iCalendar component for this occurrence of the event. * - * @return string An iCalendar VEVENT document. + * @return string A VEVENT component for an iCalendar document. */ - public function getVEvent(): string + public function export(): string { - // Check the title isn't too long - iCal requires some formatting if so. - $title = str_split($this->title, 30); - - foreach ($title as $id => $line) { - if ($id != 0) { - $title[$id] = ' ' . $title[$id]; - } - } - - // This is what we will be sending later. $filecontents = []; $filecontents[] = 'BEGIN:VEVENT'; - $filecontents[] = 'ORGANIZER;CN="' . $this->name . '":MAILTO:' . Config::$webmaster_email; - $filecontents[] = 'DTSTAMP:' . date('Ymd\\THis\\Z', time()); - $filecontents[] = 'DTSTART' . ($this->allday ? ';VALUE=DATE' : (!in_array($this->tz, RRule::UTC_SYNONYMS) ? ';TZID=' . $this->tz : '')) . ':' . $this->start->format('Ymd' . ($this->allday ? '' : '\\THis' . (in_array($this->tz, RRule::UTC_SYNONYMS) ? '\\Z' : ''))); - // Event has a duration/ - if ( - (!$this->allday && $this->start_iso_gmdate != $this->end_iso_gmdate) - || ($this->allday && $this->start_date != $this->end_date) - ) { - $filecontents[] = 'DTEND' . ($this->allday ? ';VALUE=DATE' : (!in_array($this->tz, RRule::UTC_SYNONYMS) ? ';TZID=' . $this->tz : '')) . ':' . $this->end->format('Ymd' . ($this->allday ? '' : '\\THis' . (in_array($this->tz, RRule::UTC_SYNONYMS) ? '\\Z' : ''))); - } - - // Event has changed? Advance the sequence for this UID. - if ($this->sequence > 0) { - $filecontents[] = 'SEQUENCE:' . $this->sequence; - } + $filecontents[] = 'SUMMARY:' . $this->title; if (!empty($this->location)) { $filecontents[] = 'LOCATION:' . str_replace(',', '\\,', $this->location); } - $filecontents[] = 'SUMMARY:' . implode('', $title); $filecontents[] = 'UID:' . $this->uid; - $filecontents[] = 'RECURRENCE-ID' . ($this->allday ? ';VALUE=DATE' : '') . ':' . $this->id; + $filecontents[] = 'SEQUENCE:' . $this->sequence; + $filecontents[] = 'RECURRENCE-ID' . (isset($this->adjustment) && $this->adjustment->affects_future ? ';RANGE=THISANDFUTURE' : '') . ($this->allday ? ';VALUE=DATE' : '') . ':' . $this->id; + + $filecontents[] = 'DTSTAMP:' . date('Ymd\\THis\\Z', $this->modified_time ?? time()); + $filecontents[] = 'DTSTART' . ($this->allday ? ';VALUE=DATE' : (!in_array($this->tz, RRule::UTC_SYNONYMS) ? ';TZID=' . $this->tz : '')) . ':' . $this->start->format('Ymd' . ($this->allday ? '' : '\\THis' . (in_array($this->tz, RRule::UTC_SYNONYMS) ? '\\Z' : ''))); + $filecontents[] = 'DURATION:' . (string) $this->duration; + $filecontents[] = 'END:VEVENT'; - return implode("\n", $filecontents); + foreach ($filecontents as $line_num => $line) { + $filecontents[$line_num] = Event::foldICalLine($line); + } + + return implode("\r\n", $filecontents); } /** From e95676992702e8251847aa392e906b46757bf24e Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sat, 10 Feb 2024 00:57:14 -0700 Subject: [PATCH 16/22] Shows human-readable recurrence rule descriptions for recurring events Signed-off-by: Jon Stovell --- Languages/en_US/General.php | 139 +++++++ Sources/Calendar/RRule.php | 550 +++++++++++++++++++++++++++ Themes/default/Calendar.template.php | 15 + Themes/default/Display.template.php | 7 + Themes/default/css/index.css | 2 +- 5 files changed, 712 insertions(+), 1 deletion(-) diff --git a/Languages/en_US/General.php b/Languages/en_US/General.php index bb97af5bf4..3e4d4f4e70 100644 --- a/Languages/en_US/General.php +++ b/Languages/en_US/General.php @@ -767,6 +767,145 @@ $txt['calendar_confirm_occurrence_delete'] = 'Are you sure you want to delete this occurrence of the event?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; $txt['calendar_repeat_adjustment_edit_first'] = 'Edit original event'; +// Used to show a human-readable explanation of the recurrence rule for a repeating event. +$txt['calendar_rrule_desc'] = 'Repeats {rrule_description}{start_date, select, + false {} + other {, starting {start_date}} +}.'; +// 'freq' will be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, or SECONDLY, or else a named month day (e.g. "Friday the 13th"). +$txt['calendar_rrule_desc_frequency_interval'] = 'every {freq, select, + YEARLY {{interval, plural, + =1 {year} + one {# year} + other {# years} + }} + MONTHLY {{interval, plural, + =1 {month} + one {# month} + other {# months} + }} + WEEKLY {{interval, plural, + =1 {week} + one {# week} + other {# weeks} + }} + DAILY {{interval, plural, + =1 {day} + one {# day} + other {# days} + }} + HOURLY {{interval, plural, + =1 {hour} + one {# hour} + other {# hours} + }} + MINUTELY {{interval, plural, + =1 {minute} + one {# minute} + other {# minutes} + }} + SECONDLY {{interval, plural, + =1 {second} + one {# second} + other {# seconds} + }} + other {{freq}} +}'; +// 'freq' will be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, or SECONDLY, or else a named month day (e.g. "Friday the 13th"). +$txt['calendar_rrule_desc_frequency_interval_ordinal'] = 'every {freq, select, + YEARLY {{interval, selectordinal, + =1 {year} + one {#st year} + two {#nd year} + few {#rd year} + other {#th year} + }} + MONTHLY {{interval, selectordinal, + =1 {month} + one {#st month} + two {#nd month} + few {#rd month} + other {#th month} + }} + WEEKLY {{interval, selectordinal, + =1 {week} + one {#st week} + two {#nd week} + few {#rd week} + other {#th week} + }} + DAILY {{interval, selectordinal, + =1 {day} + one {#st day} + two {#nd day} + few {#rd day} + other {#th day} + }} + HOURLY {{interval, selectordinal, + =1 {hour} + one {#st hour} + two {#nd hour} + few {#rd hour} + other {#th hour} + }} + MINUTELY {{interval, selectordinal, + =1 {minute} + one {#st minute} + two {#nd minute} + few {#rd minute} + other {#th minute} + }} + SECONDLY {{interval, selectordinal, + =1 {second} + one {#st second} + two {#nd second} + few {#rd second} + other {#th second} + }} + other {{freq}} +}'; +// 'months_titles' is a list of month names, using the forms from $txt['months_titles']. +$txt['calendar_rrule_desc_bymonth'] = 'in {months_titles}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_byweekno'] = '{count, plural, + one {in the {ordinal_list} week of the year} + other {in the {ordinal_list} weeks of the year} +}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_byyearday'] = '{count, plural, + one {on the {ordinal_list} day of the year} + other {on the {ordinal_list} days of the year} +}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_bymonthday'] = '{count, plural, + one {on the {ordinal_list} day of the month} + other {on the {ordinal_list} days of the month} +}'; +// Translators can replace 'ordinal_month_day' with 'cardinal_month_day' in this string if the target language prefers cardinal numbers instead of ordinal numbers for this form. For example, '{day_name} the {ordinal_month_day}' will produce 'Friday the 13th', whereas '{cardinal_month_day} {day_name}' will produce '13 Friday'. +$txt['calendar_rrule_desc_named_monthday'] = '{day_name} the {ordinal_month_day}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, or 3rd"). +$txt['calendar_rrule_desc_named_monthdays'] = 'the first {day_name} that is the {ordinal_list} day of the month'; +// E.g. "the 2nd Thursday" +$txt['calendar_rrule_desc_ordinal_day_name'] = 'the {ordinal} {day_name}'; +// E.g. "on Monday", "on Tuesday and Thursday" +$txt['calendar_rrule_desc_byday'] = 'on {day_names}'; +// E.g. "on every Monday", "on every Tuesday and Thursday" +$txt['calendar_rrule_desc_byday_every'] = 'on every {day_names}'; +$txt['calendar_rrule_desc_byminute'] = '{minute_list} past the hour'; +$txt['calendar_rrule_desc_bytime'] = 'at {times_list}'; +$txt['calendar_rrule_desc_bygeneric'] = 'of {list}'; +$txt['calendar_rrule_desc_between'] = 'between {min} and {max}'; +$txt['calendar_rrule_desc_until'] = 'until {date}'; +$txt['calendar_rrule_desc_count'] = '{count, plural, + one {for # occurrence} + other {for # occurrences} +}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_bysetpos'] = '{count, plural, + one {on each {ordinal_list} occurrence} + other {on each {ordinal_list} occurrences} +} of {rrule_description}'; + $txt['movetopic_change_subject'] = 'Change the topic’s subject'; $txt['movetopic_new_subject'] = 'New subject'; $txt['movetopic_change_all_subjects'] = 'Change every message’s subject'; diff --git a/Sources/Calendar/RRule.php b/Sources/Calendar/RRule.php index 79ccdcc68f..1b64c30123 100644 --- a/Sources/Calendar/RRule.php +++ b/Sources/Calendar/RRule.php @@ -15,6 +15,11 @@ namespace SMF\Calendar; +use SMF\Config; +use SMF\Lang; +use SMF\Time; +use SMF\Utils; + /** * Represents a recurrence rule from RFC 5545. */ @@ -515,6 +520,551 @@ public function __toString(): string return implode(';', $rrule); } + + /** + * Builds a human-readable description of this RRule. + * + * @param EventOccurrence $occurrence The event occurrence that is currently + * being viewed. + * @param ?bool $show_start Whether to show the start date in the description. + * If not set, will be determined automatically. + */ + public function getDescription(EventOccurrence $occurrence, ?bool $show_start = null): string + { + if (($this->count ?? 0) === 1) { + return ''; + } + + // Just in case... + Lang::load('General+Modifications'); + + if (!empty($this->bysetpos)) { + $description = $this->getDescriptionBySetPos($occurrence->getParentEvent()->start); + } else { + $description = $this->getDescriptionNormal($occurrence->getParentEvent()->start); + } + + // When the repetition ends. + if (isset($this->until)) { + if ( + !in_array($this->freq, ['HOURLY', 'MINUTELY', 'SECONDLY']) + && empty($this->byhour) + && empty($this->byminute) + && empty($this->bysecond) + ) { + $until = Time::createFromInterface($this->until)->format(Time::getDateFormat()); + } else { + $until = Time::createFromInterface($this->until)->format(); + } + + $description .= ' ' . Lang::getTxt('calendar_rrule_desc_until', ['date' => $until]); + } elseif (!empty($this->count)) { + $description .= ' ' . Lang::getTxt('calendar_rrule_desc_count', ['count' => $this->count]); + } + + $description = Lang::getTxt( + 'calendar_rrule_desc', + [ + 'rrule_description' => $description, + 'start_date' => ($show_start ?? !$occurrence->is_first) ? '' . Time::createFromInterface($occurrence->getParentEvent()->start)->format($occurrence->allday ? Time::getDateFormat() : null, false) . '' : 'false', + ], + ); + + return Utils::normalizeSpaces( + $description, + true, + true, + [ + 'no_breaks' => true, + 'collapse_hspace' => true, + ], + ); + } + + /****************** + * Internal methods + ******************/ + + /** + * Gets the description without any BYSETPOS considerations. + * + * @param \DateTimeInterface $start + */ + protected function getDescriptionNormal(\DateTimeInterface $start): string + { + $description = []; + + // Basic frequency interval (e.g. "Every year", "Every 3 weeks", etc.) + $description['frequency_interval'] = Lang::getTxt('calendar_rrule_desc_frequency_interval', ['freq' => $this->freq, 'interval' => $this->interval]); + + $this->getDescriptionByDay($start, $description); + $this->getDescriptionByMonth($start, $description); + $this->getDescriptionByWeekNo($start, $description); + $this->getDescriptionByYearDay($start, $description); + $this->getDescriptionByMonthDay($start, $description); + $this->getDescriptionByTime($start, $description); + + return implode(' ', $description); + } + + /** + * Gets the description with BYSETPOS considerations. + * + * @param \DateTimeInterface $start + */ + protected function getDescriptionBySetPos(\DateTimeInterface $start): string + { + $description = []; + + // BYSETPOS can theoretically be used with any RRule, but it only really + // makes much sense to use it with BYDAY and BYMONTH (and that is the + // only way it will ever be used in events created by SMF). For that + // reason, it is really awkward to build a custom BYSETPOS description + // for RRules that contain any BY* values besides BYDAY and BYMONTH. + // So in the rare case that we are given such an RRule, we just use + // 'the nth instance of ""' and call it good enough. + if ( + empty($byweekno) + && empty($byyearday) + && empty($bymonthday) + && empty($byhour) + && empty($byminute) + && empty($bysecond) + ) { + if (!empty($this->byday)) { + $days_long_or_short = count($this->byday) > 3 ? 'days_short' : 'days'; + + $day_names = []; + + foreach ($this->byday as $day) { + if (in_array($day, self::WEEKDAYS)) { + $day_names[] = Lang::$txt[$days_long_or_short][(array_search($day, self::WEEKDAYS) + 1) % 7]; + } else { + $desc_byday = 'calendar_rrule_desc_byday'; + + list($num, $name) = preg_split('/(?=MO|TU|WE|TH|FR|SA|SU)/', $day); + $num = empty($num) ? 1 : (int) $num; + + $nth = Lang::getTxt($num < 0 ? 'ordinal_spellout_last' : 'ordinal_spellout', [abs($num)]); + $day_name = Lang::$txt[$days_long_or_short][(array_search($name, self::WEEKDAYS) + 1) % 7]; + + $day_names[] = Lang::getTxt('calendar_rrule_desc_ordinal_day_name', ['ordinal' => $nth, 'day_name' => $day_name]); + } + } + + $description['byday'] = Lang::sentenceList($day_names, 'xor'); + } + + if (!empty($this->bymonth)) { + if (($this->freq === 'MONTHLY' || $this->freq === 'YEARLY') && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + $months_titles = []; + + foreach ($this->bymonth as $month_num) { + $months_titles[] = Lang::$txt['month_titles'][$month_num]; + } + + $description['bymonth'] = Lang::getTxt('calendar_rrule_desc_bymonth', ['months_titles' => Lang::sentenceList($months_titles)]); + } + + // Basic frequency interval (e.g. "Every year", "Every 3 weeks", etc.) + $frequency_interval = Lang::getTxt('calendar_rrule_desc_frequency_interval', ['freq' => $this->freq, 'interval' => $this->interval]); + } else { + $description[] = '' . $this->getDescriptionNormal($start) . ''; + } + + $ordinals = array_map(fn ($n) => Lang::getTxt($n < 0 ? 'ordinal_spellout_last' : 'ordinal_spellout', [abs($n)]), $this->bysetpos); + + return (isset($frequency_interval) ? $frequency_interval . ' ' : '') . Lang::getTxt( + 'calendar_rrule_desc_bysetpos', + [ + 'ordinal_list' => Lang::sentenceList($ordinals), + 'count' => count($this->bysetpos), + 'rrule_description' => implode(' ', $description), + ], + ); + } + + /** + * Day of week (e.g. "on Monday", "on Monday and Tuesday", "on the 3rd Monday") + * + * @param \DateTimeInterface $start + * @param array &$description + */ + protected function getDescriptionByDay(\DateTimeInterface $start, array &$description): void + { + if (empty($this->byday)) { + return; + } + + if (($this->freq === 'DAILY' || $this->freq === 'WEEKLY') && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + $desc_byday = ($this->freq === 'YEARLY' && empty($this->byweekno)) || $this->freq === 'MONTHLY' ? 'calendar_rrule_desc_byday_every' : 'calendar_rrule_desc_byday'; + + $days_long_or_short = count($this->byday) > 3 ? 'days_short' : 'days'; + + $day_names = []; + + foreach ($this->byday as $day) { + if (in_array($day, self::WEEKDAYS)) { + $day_names[] = Lang::$txt[$days_long_or_short][(array_search($day, self::WEEKDAYS) + 1) % 7]; + } else { + $desc_byday = 'calendar_rrule_desc_byday'; + + list($num, $name) = preg_split('/(?=MO|TU|WE|TH|FR|SA|SU)/', $day); + $num = empty($num) ? 1 : (int) $num; + + $nth = Lang::getTxt($num < 0 ? 'ordinal_spellout_last' : 'ordinal_spellout', [abs($num)]); + $day_name = Lang::$txt[$days_long_or_short][(array_search($name, self::WEEKDAYS) + 1) % 7]; + + $day_names[] = Lang::getTxt('calendar_rrule_desc_ordinal_day_name', ['ordinal' => $nth, 'day_name' => $day_name]); + } + } + + $description['byday'] = Lang::getTxt($desc_byday, ['day_names' => Lang::sentenceList($day_names)]); + + if ( + $desc_byday === 'calendar_rrule_desc_byday_every' + && ($this->interval ?? 1) > 1 + && ( + $this->freq === 'MONTHLY' + || ( + $this->freq === 'YEARLY' + && empty($this->bymonth) + && empty($this->byweekno) + && empty($this->byyearday) + && empty($this->bymonthday) + ) + ) + ) { + $description['bymonth'] = Lang::getTxt( + 'calendar_rrule_desc_bygeneric', + [ + 'list' => Lang::getTxt( + 'calendar_rrule_desc_frequency_interval_ordinal', + [ + 'freq' => $this->freq, + 'interval' => $this->interval, + ], + ), + ], + ); + + unset($description['frequency_interval']); + } + } + + /** + * Months (e.g. "in January", "in March and April") + * + * @param \DateTimeInterface $start + * @param array &$description + */ + protected function getDescriptionByMonth(\DateTimeInterface $start, array &$description): void + { + if (empty($this->bymonth)) { + return; + } + + if (($this->freq === 'MONTHLY' || $this->freq === 'YEARLY') && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + $months_titles = []; + + foreach ($this->bymonth as $month_num) { + $months_titles[] = Lang::$txt['months_titles'][$month_num]; + } + + $description['bymonth'] = Lang::getTxt('calendar_rrule_desc_bymonth', ['months_titles' => Lang::sentenceList($months_titles)]); + } + + /** + * Week number (e.g. "in the 3rd week of the year") + * + * @param \DateTimeInterface $start + * @param array &$description + */ + protected function getDescriptionByWeekNo(\DateTimeInterface $start, array &$description): void + { + if (empty($this->byweekno)) { + return; + } + + if ($this->freq === 'YEARLY' && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + $ordinals = array_map(fn ($n) => Lang::getTxt('ordinal_spellout', [$n]), $this->byweekno); + $description['byweekno'] = Lang::getTxt('calendar_rrule_desc_byweekno', ['ordinal_list' => Lang::sentenceList($ordinals), 'count' => count($ordinals)]); + } + + /** + * Day of year (e.g. "on the 3rd day of the year") + * + * @param \DateTimeInterface $start + * @param array &$description + */ + protected function getDescriptionByYearDay(\DateTimeInterface $start, array &$description): void + { + if (empty($this->byyearday)) { + return; + } + + if ($this->freq === 'YEARLY' && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + $ordinals = array_map(fn ($n) => Lang::getTxt('ordinal_spellout', [$n]), $this->byyearday); + $description['byeyarday'] = Lang::getTxt('calendar_rrule_desc_byyearday', ['ordinal_list' => Lang::sentenceList($ordinals), 'count' => count($ordinals)]); + } + + /** + * Day of month (e.g. "on the 3rd day of the month") + * + * @param \DateTimeInterface $start + * @param array &$description + */ + protected function getDescriptionByMonthDay(\DateTimeInterface $start, array &$description): void + { + if (empty($this->bymonthday)) { + return; + } + + if ($this->freq === 'MONTHLY' && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + // Special cases for when we have both day names and month days. + if ( + count($this->byday ?? []) >= 1 + && array_intersect($this->byday, self::WEEKDAYS) === $this->byday + ) { + $days_long_or_short = count($this->byday) > 3 ? 'days_short' : 'days'; + + // "Friday the 13th" + if (count($this->bymonthday) === 1 && count($this->byday) === 1) { + $named_monthday = Lang::getTxt( + 'calendar_rrule_desc_named_monthday', + [ + 'day_name' => Lang::$txt[$days_long_or_short][(array_search($this->byday[0], self::WEEKDAYS) + 1) % 7], + 'ordinal_month_day' => Lang::getTxt('ordinal', $this->bymonthday), + 'cardinal_month_day' => $this->bymonthday[0], + ], + ); + + if ($this->freq === 'MONTHLY') { + $description['frequency_interval'] = Lang::getTxt( + 'calendar_rrule_desc_frequency_interval', + [ + 'freq' => $named_monthday, + ], + ); + + unset($description['byday']); + } else { + $description['byday'] = Lang::getTxt( + 'calendar_rrule_desc_byday', + [ + 'day_names' => $named_monthday, + ], + ); + } + } + // "the first Tuesday or Thursday that is the second, third, or fourth day of the month" + else { + foreach ($this->byday as $day_abbrev) { + $day_names[] = Lang::$txt[$days_long_or_short][(array_search($day_abbrev, self::WEEKDAYS) + 1) % 7]; + } + + $ordinal_form = max(array_map('abs', $this->bymonthday)) < 10 && count($this->bymonthday) <= 3 ? 'ordinal_spellout' : 'ordinal'; + + $ordinals = array_map(fn ($n) => Lang::getTxt($n < 0 ? $ordinal_form . '_last' : $ordinal_form, [abs($n)]), $this->bymonthday); + + $description['byday'] = Lang::getTxt( + 'calendar_rrule_desc_byday', + [ + 'day_names' => Lang::getTxt( + 'calendar_rrule_desc_named_monthdays', + [ + 'ordinal_list' => Lang::sentenceList($ordinals, 'or'), + 'day_name' => Lang::sentenceList($day_names, 'or'), + ], + ), + ], + ); + } + } + // Normal case. + else { + $ordinals = array_map(fn ($n) => Lang::getTxt($n < 0 ? 'ordinal_spellout_last' : 'ordinal_spellout', [abs($n)]), $this->bymonthday); + $description['bymonthday'] = Lang::getTxt('calendar_rrule_desc_bymonthday', ['ordinal_list' => Lang::sentenceList($ordinals), 'count' => count($ordinals)]); + } + } + + /** + * Hour, minute, and second. + * + * @param \DateTimeInterface $start + * @param array &$description + */ + protected function getDescriptionByTime(\DateTimeInterface $start, array &$description): void + { + // Hour, minute, and second. + $time_format = Time::getTimeFormat(); + + // Do we need to show seconds? + if (!empty($this->bysecond) || $this->freq === 'SECONDLY') { + if (Time::isStrftimeFormat($time_format)) { + if (!str_contains($time_format, '%S')) { + if (str_contains($time_format, '%M')) { + $time_format = str_replace('%M', '%M:%S', $time_format); + } else { + if (str_contains($time_format, '%I')) { + $time_format = str_replace('%I', '%I:%M:%S', $time_format); + } elseif (str_contains($time_format, '%l')) { + $time_format = str_replace('%l', '%l:%M:%S', $time_format); + } elseif (str_contains($time_format, '%H')) { + $time_format = str_replace('%H', '%H:%M:%S', $time_format); + } elseif (str_contains($time_format, '%k')) { + $time_format = str_replace('%k', '%k:%M:%S', $time_format); + } else { + $time_format = '%H:%M:%S'; + } + } + } + } else { + if (!str_contains($time_format, 's')) { + if (str_contains($time_format, 'i')) { + $time_format = str_replace('i', 'i:s', $time_format); + } else { + if (str_contains($time_format, 'h')) { + $time_format = str_replace('h', 'h:i:s', $time_format); + } elseif (str_contains($time_format, 'g')) { + $time_format = str_replace('g', 'g:i:s', $time_format); + } elseif (str_contains($time_format, 'H')) { + $time_format = str_replace('H', 'H:i:s', $time_format); + } elseif (str_contains($time_format, 'G')) { + $time_format = str_replace('G', 'G:i:s', $time_format); + } else { + $time_format = 'H:i:s'; + } + } + } + } + } + + $min = Time::createFromInterface($start); + + $max = Time::createFromInterface($start)->setTime( + (int) (isset($this->byhour) ? max($this->byhour) : $start->format('H')), + (int) (isset($this->byminute) ? max($this->byminute) : $start->format('i')), + (int) (isset($this->bysecond) ? max($this->bysecond) : $start->format('s')), + ); + + // Seconds. + if (!empty($this->bysecond)) { + if ($this->freq === 'SECONDLY' && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + if (range(min($this->bysecond), max($this->bysecond)) === $this->bysecond) { + $list = Lang::getTxt( + 'calendar_rrule_desc_between', + [ + 'min' => $min->format('s'), + 'max' => Lang::getTxt('number_of_seconds', [$max->format('s')]), + ], + ); + } else { + $list = $this->bysecond; + + sort($list); + $list[array_key_last($list)] = Lang::getTxt('number_of_seconds', [$list[array_key_last($list)]]); + $list = Lang::sentenceList($list); + } + + $description['bytime'] = Lang::getTxt('calendar_rrule_desc_bytime', ['times_list' => $list]); + } + + // Minutes. + if (!empty($this->byminute)) { + if ($this->freq === 'MINUTELY' && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + if (range(min($this->byminute), max($this->byminute)) === $this->byminute) { + $list = Lang::getTxt( + 'calendar_rrule_desc_between', + [ + 'min' => $min->format('i'), + 'max' => Lang::getTxt('number_of_minutes', [$max->format('i')]), + ], + ); + + if (!isset($description['bytime'])) { + $description['bytime'] = Lang::getTxt('calendar_rrule_desc_byminute', ['minute_list' => $list]); + } else { + $description['bytime'] .= ' ' . Lang::getTxt('calendar_rrule_desc_byminute', ['minute_list' => $list]); + } + } else { + $list = $this->byminute; + + sort($list); + $list[array_key_last($list)] = Lang::getTxt('number_of_minutes', [$list[array_key_last($list)]]); + $list = Lang::sentenceList($list); + + if (!isset($description['bytime'])) { + $description['bytime'] = Lang::getTxt('calendar_rrule_desc_bytime', ['times_list' => Lang::getTxt('calendar_rrule_desc_byminute', ['minute_list' => $list])]); + } else { + $description['bytime'] .= ' ' . Lang::getTxt('calendar_rrule_desc_bygeneric', ['list' => Lang::getTxt('calendar_rrule_desc_byminute', ['minute_list' => $list])]); + } + } + } elseif (!empty($this->bysecond)) { + $description['bytime'] .= ' ' . Lang::getTxt('calendar_rrule_desc_bygeneric', ['list' => Lang::getTxt('calendar_rrule_desc_frequency_interval', ['freq' => 'MINUTELY', 'interval' => 1])]); + } + + // Hours. + if (!empty($this->byhour)) { + if ($this->freq === 'HOURLY' && ($this->interval ?? 1) === 1) { + unset($description['frequency_interval']); + } + + if (range(min($this->byhour), max($this->byhour)) === $this->byhour) { + $list = Lang::getTxt( + 'calendar_rrule_desc_between', + [ + 'min' => $min->format($time_format), + 'max' => $max->format($time_format), + ], + ); + + if (!isset($description['bytime'])) { + $description['bytime'] = $list; + } else { + $description['bytime'] .= ' ' . $list; + } + } else { + $list = $this->byhour; + + sort($list); + $list[array_key_last($list)] = Lang::getTxt('number_of_hours', [$list[array_key_last($list)]]); + $list = Lang::sentenceList($list); + + if (!isset($description['bytime'])) { + $description['bytime'] = Lang::getTxt('calendar_rrule_desc_bytime', ['times_list' => $list]); + } else { + $description['bytime'] .= ' ' . Lang::getTxt('calendar_rrule_desc_bygeneric', ['list' => $list]); + } + } + } elseif (!empty($this->byminute)) { + $description['bytime'] .= ' ' . Lang::getTxt('calendar_rrule_desc_bygeneric', ['list' => Lang::getTxt('calendar_rrule_desc_frequency_interval', ['freq' => 'HOURLY', 'interval' => 1])]); + } + } } ?> \ No newline at end of file diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index 5c8de13156..f8801742c8 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -96,6 +96,8 @@ function template_show_upcoming_list($grid_name)
      '; + $first_shown = []; + foreach ($calendar_data['events'] as $date => $date_events) { foreach ($date_events as $event) @@ -150,6 +152,19 @@ function template_show_upcoming_list($grid_name) if (!empty($event['location'])) echo '
      ', $event['location']; + // If the first occurrence is not visible on the current page, + // we mention it in the RRULE description. + if ($event->is_first) { + $first_shown[] = $event->id_event; + } + + $rrule_description = $event->getParentEvent()->recurrence_iterator->getRRule()->getDescription($event, !in_array($event->id_event, $first_shown)); + + if (!empty($rrule_description)) { + echo ' +
      ', $rrule_description; + } + echo ' '; } diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index 5204e5033d..f06452c3e6 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -236,6 +236,13 @@ function template_main() echo '
      ', $event['location']; + $rrule_description = $event->getParentEvent()->recurrence_iterator->getRRule()->getDescription($event); + + if (!empty($rrule_description)) { + echo ' +
      ', $rrule_description; + } + echo ' '; } diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index 9f9f8fe6e8..4567ed603b 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -3826,7 +3826,7 @@ h3.titlebg, h4.titlebg, .titlebg, h3.subbg, h4.subbg, .subbg { .generic_list_wrapper .information div { background: none; } -.information a:not(.button) { +.information a:not(.button):not(.bbc_link) { font-weight: bold; } p.information img { From eaff03b04cda506fcfda17639cde124530d2bb61 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 7 Mar 2024 23:46:16 -0700 Subject: [PATCH 17/22] Moves calendar strings to their own file Signed-off-by: Jon Stovell --- Languages/en_US/Calendar.php | 211 +++++++++++++++++++++++++++++ Languages/en_US/General.php | 210 +--------------------------- Sources/Actions/Admin/Calendar.php | 2 +- Sources/Actions/BoardIndex.php | 2 + Sources/Actions/Calendar.php | 2 + Sources/Actions/Display.php | 3 + Sources/Actions/Post.php | 2 +- Sources/Calendar/Event.php | 2 + Sources/Calendar/RRule.php | 2 +- Sources/ServerSideIncludes.php | 5 +- Sources/Theme.php | 2 + 11 files changed, 232 insertions(+), 211 deletions(-) create mode 100644 Languages/en_US/Calendar.php diff --git a/Languages/en_US/Calendar.php b/Languages/en_US/Calendar.php new file mode 100644 index 0000000000..c57d5bcb01 --- /dev/null +++ b/Languages/en_US/Calendar.php @@ -0,0 +1,211 @@ + 'Never', 'FREQ=DAILY' => 'Every day', 'FREQ=WEEKLY' => 'Every week', 'FREQ=MONTHLY' => 'Every month', 'FREQ=YEARLY' => 'Every year', 'custom' => 'Custom...']; +$txt['calendar_repeat_frequency_units'] = ['YEARLY' => 'year(s)', 'MONTHLY' => 'month(s)', 'WEEKLY' => 'week(s)', 'DAILY' => 'day(s)', 'HOURLY' => 'hour(s)', 'MINUTELY' => 'minute(s)', 'SECONDLY' => 'second(s)']; +$txt['calendar_repeat_until_options'] = ['forever' => 'Forever', 'until' => 'Until', 'count' => 'Number of times']; +$txt['calendar_repeat_byday_num_options'] = [1 => 'the first', 2 => 'the second', 3 => 'the third', 4 => 'the fourth', 5 => 'the fifth', -1 => 'the last', -2 => 'the second last']; +$txt['calendar_repeat_weekday'] = 'weekday'; +$txt['calendar_repeat_weekend_day'] = 'weekend day'; +$txt['calendar_repeat_add_condition'] = 'Add Another'; +$txt['calendar_repeat_advanced_options_label'] = 'More options...'; +$txt['calendar_repeat_rdates_label'] = 'Additional dates'; +$txt['calendar_repeat_exdates_label'] = 'Skipped dates'; +$txt['calendar_repeat_easter_w'] = 'Easter (Western)'; +$txt['calendar_repeat_easter_e'] = 'Easter (Eastern)'; +$txt['calendar_repeat_vernal_equinox'] = 'Vernal Equinox'; +$txt['calendar_repeat_summer_solstice'] = 'Summer Solstice'; +$txt['calendar_repeat_autumnal_equinox'] = 'Autumnal Equinox'; +$txt['calendar_repeat_winter_solstice'] = 'Winter Solstice'; +$txt['calendar_repeat_special'] = 'Special'; +$txt['calendar_repeat_special_rrule_modifier'] = 'Offset'; +$txt['calendar_repeat_offset_examples'] = 'e.g.: +P1D, -PT2H'; +$txt['calendar_repeat_adjustment_label'] = 'Apply changes to'; +$txt['calendar_repeat_adjustment_this_only'] = 'Only this occurrence'; +$txt['calendar_repeat_adjustment_this_and_future'] = 'This and future occurrences'; +$txt['calendar_repeat_adjustment_confirm'] = 'Are you sure you want to apply these changes to all future occurrences?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; +$txt['calendar_repeat_delete_label'] = 'Delete'; +$txt['calendar_confirm_occurrence_delete'] = 'Are you sure you want to delete this occurrence of the event?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; +$txt['calendar_repeat_adjustment_edit_first'] = 'Edit original event'; + +// Used to show a human-readable explanation of the recurrence rule for a repeating event. +$txt['calendar_rrule_desc'] = 'Repeats {rrule_description}{start_date, select, + false {} + other {, starting {start_date}} +}.'; +// 'freq' will be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, or SECONDLY, or else a named month day (e.g. "Friday the 13th"). +$txt['calendar_rrule_desc_frequency_interval'] = 'every {freq, select, + YEARLY {{interval, plural, + =1 {year} + one {# year} + other {# years} + }} + MONTHLY {{interval, plural, + =1 {month} + one {# month} + other {# months} + }} + WEEKLY {{interval, plural, + =1 {week} + one {# week} + other {# weeks} + }} + DAILY {{interval, plural, + =1 {day} + one {# day} + other {# days} + }} + HOURLY {{interval, plural, + =1 {hour} + one {# hour} + other {# hours} + }} + MINUTELY {{interval, plural, + =1 {minute} + one {# minute} + other {# minutes} + }} + SECONDLY {{interval, plural, + =1 {second} + one {# second} + other {# seconds} + }} + other {{freq}} +}'; +// 'freq' will be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, or SECONDLY, or else a named month day (e.g. "Friday the 13th"). +$txt['calendar_rrule_desc_frequency_interval_ordinal'] = 'every {freq, select, + YEARLY {{interval, selectordinal, + =1 {year} + one {#st year} + two {#nd year} + few {#rd year} + other {#th year} + }} + MONTHLY {{interval, selectordinal, + =1 {month} + one {#st month} + two {#nd month} + few {#rd month} + other {#th month} + }} + WEEKLY {{interval, selectordinal, + =1 {week} + one {#st week} + two {#nd week} + few {#rd week} + other {#th week} + }} + DAILY {{interval, selectordinal, + =1 {day} + one {#st day} + two {#nd day} + few {#rd day} + other {#th day} + }} + HOURLY {{interval, selectordinal, + =1 {hour} + one {#st hour} + two {#nd hour} + few {#rd hour} + other {#th hour} + }} + MINUTELY {{interval, selectordinal, + =1 {minute} + one {#st minute} + two {#nd minute} + few {#rd minute} + other {#th minute} + }} + SECONDLY {{interval, selectordinal, + =1 {second} + one {#st second} + two {#nd second} + few {#rd second} + other {#th second} + }} + other {{freq}} +}'; +// 'months_titles' is a list of month names, using the forms from $txt['months_titles']. +$txt['calendar_rrule_desc_bymonth'] = 'in {months_titles}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_byweekno'] = '{count, plural, + one {in the {ordinal_list} week of the year} + other {in the {ordinal_list} weeks of the year} +}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_byyearday'] = '{count, plural, + one {on the {ordinal_list} day of the year} + other {on the {ordinal_list} days of the year} +}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_bymonthday'] = '{count, plural, + one {on the {ordinal_list} day of the month} + other {on the {ordinal_list} days of the month} +}'; +// Translators can replace 'ordinal_month_day' with 'cardinal_month_day' in this string if the target language prefers cardinal numbers instead of ordinal numbers for this form. For example, '{day_name} the {ordinal_month_day}' will produce 'Friday the 13th', whereas '{cardinal_month_day} {day_name}' will produce '13 Friday'. +$txt['calendar_rrule_desc_named_monthday'] = '{day_name} the {ordinal_month_day}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, or 3rd"). +$txt['calendar_rrule_desc_named_monthdays'] = 'the first {day_name} that is the {ordinal_list} day of the month'; +// E.g. "the 2nd Thursday" +$txt['calendar_rrule_desc_ordinal_day_name'] = 'the {ordinal} {day_name}'; +// E.g. "on Monday", "on Tuesday and Thursday" +$txt['calendar_rrule_desc_byday'] = 'on {day_names}'; +// E.g. "on every Monday", "on every Tuesday and Thursday" +$txt['calendar_rrule_desc_byday_every'] = 'on every {day_names}'; +$txt['calendar_rrule_desc_byminute'] = '{minute_list} past the hour'; +$txt['calendar_rrule_desc_bytime'] = 'at {times_list}'; +$txt['calendar_rrule_desc_bygeneric'] = 'of {list}'; +$txt['calendar_rrule_desc_between'] = 'between {min} and {max}'; +$txt['calendar_rrule_desc_until'] = 'until {date}'; +$txt['calendar_rrule_desc_count'] = '{count, plural, + one {for # occurrence} + other {for # occurrences} +}'; +// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. +$txt['calendar_rrule_desc_bysetpos'] = '{count, plural, + one {on each {ordinal_list} occurrence} + other {on each {ordinal_list} occurrences} +} of {rrule_description}'; + +?> \ No newline at end of file diff --git a/Languages/en_US/General.php b/Languages/en_US/General.php index 3e4d4f4e70..bb617310c3 100644 --- a/Languages/en_US/General.php +++ b/Languages/en_US/General.php @@ -109,10 +109,12 @@ $txt['days_title'] = 'Days'; $txt['days'] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; $txt['days_short'] = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -// Months must start with 1 => 'January'. (or translated, of course.) $txt['months_title'] = 'Months'; +// Months must start with 1 => 'January' (or translated, of course). $txt['months'] = [1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December']; +// Months must start with 1 => 'January' (or translated, of course). $txt['months_titles'] = [1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December']; +// Months must start with 1 => 'Jan' (or translated, of course). $txt['months_short'] = [1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'May', 6 => 'Jun', 7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Oct', 11 => 'Nov', 12 => 'Dec']; $txt['prev_month'] = 'Previous month'; $txt['next_month'] = 'Next month'; @@ -700,212 +702,6 @@ // argument(s): SMF_FULL_VERSION, SMF_SOFTWARE_YEAR, Config::$scripturl $forum_copyright = '{version} © {year}, Simple Machines'; -$txt['birthdays'] = 'Birthdays:'; -$txt['events'] = 'Events:'; -$txt['birthdays_upcoming'] = 'Upcoming Birthdays:'; -$txt['events_upcoming'] = 'Upcoming Events:'; -// Prompt for holidays in the calendar, leave blank to just display the holiday's name. -$txt['calendar_prompt'] = 'Holidays:'; -$txt['calendar_month'] = 'Month'; -$txt['calendar_year'] = 'Year'; -$txt['calendar_day'] = 'Day'; -$txt['calendar_event_title'] = 'Title'; -$txt['calendar_event_options'] = 'Event Options'; -$txt['calendar_post_in'] = 'Post in'; -$txt['calendar_edit'] = 'Edit Event'; -$txt['calendar_export'] = 'Export Event'; -$txt['calendar_view_week'] = 'View Week'; -$txt['event_delete_confirm'] = 'Delete this event?'; -$txt['event_delete'] = 'Delete Event'; -$txt['calendar_post_event'] = 'Post Event'; -$txt['calendar'] = 'Calendar'; -$txt['calendar_link'] = 'Link to Calendar'; -$txt['calendar_upcoming'] = 'Upcoming Calendar'; -$txt['calendar_today'] = 'Today’s Calendar'; -$txt['calendar_week'] = 'Week'; -$txt['calendar_week_beginning'] = 'Week beginning {date}'; -$txt['calendar_numb_days'] = 'Number of Days'; -$txt['calendar_how_edit'] = 'how do you edit these events?'; -$txt['calendar_link_event'] = 'Link Event To Post'; -$txt['calendar_confirm_delete'] = 'Are you sure you want to delete this event?'; -$txt['calendar_linked_events'] = 'Linked Events'; -$txt['calendar_click_all'] = 'Show all'; -$txt['calendar_allday'] = 'All day'; -$txt['calendar_timezone'] = 'Time zone'; -$txt['calendar_list'] = 'List'; -$txt['calendar_empty'] = 'There are no events to display.'; - -$txt['calendar_repeat_recurrence_label'] = 'Repeats'; -$txt['calendar_repeat_interval_label'] = 'Every'; -$txt['calendar_repeat_bymonthday_label'] = 'On'; -$txt['calendar_repeat_byday_label'] = 'On'; -$txt['calendar_repeat_bymonth_label'] = 'In'; -$txt['calendar_repeat_rrule_presets'] = ['never' => 'Never', 'FREQ=DAILY' => 'Every day', 'FREQ=WEEKLY' => 'Every week', 'FREQ=MONTHLY' => 'Every month', 'FREQ=YEARLY' => 'Every year', 'custom' => 'Custom...']; -$txt['calendar_repeat_frequency_units'] = ['YEARLY' => 'year(s)', 'MONTHLY' => 'month(s)', 'WEEKLY' => 'week(s)', 'DAILY' => 'day(s)', 'HOURLY' => 'hour(s)', 'MINUTELY' => 'minute(s)', 'SECONDLY' => 'second(s)']; -$txt['calendar_repeat_until_options'] = ['forever' => 'Forever', 'until' => 'Until', 'count' => 'Number of times']; -$txt['calendar_repeat_byday_num_options'] = [1 => 'the first', 2 => 'the second', 3 => 'the third', 4 => 'the fourth', 5 => 'the fifth', -1 => 'the last', -2 => 'the second last']; -$txt['calendar_repeat_weekday'] = 'weekday'; -$txt['calendar_repeat_weekend_day'] = 'weekend day'; -$txt['calendar_repeat_add_condition'] = 'Add Another'; -$txt['calendar_repeat_advanced_options_label'] = 'More options...'; -$txt['calendar_repeat_rdates_label'] = 'Additional dates'; -$txt['calendar_repeat_exdates_label'] = 'Skipped dates'; -$txt['calendar_repeat_easter_w'] = 'Easter (Western)'; -$txt['calendar_repeat_easter_e'] = 'Easter (Eastern)'; -$txt['calendar_repeat_vernal_equinox'] = 'Vernal Equinox'; -$txt['calendar_repeat_summer_solstice'] = 'Summer Solstice'; -$txt['calendar_repeat_autumnal_equinox'] = 'Autumnal Equinox'; -$txt['calendar_repeat_winter_solstice'] = 'Winter Solstice'; -$txt['calendar_repeat_special'] = 'Special'; -$txt['calendar_repeat_special_rrule_modifier'] = 'Offset'; -$txt['calendar_repeat_offset_examples'] = 'e.g.: +P1D, -PT2H'; -$txt['calendar_repeat_adjustment_label'] = 'Apply changes to'; -$txt['calendar_repeat_adjustment_this_only'] = 'Only this occurrence'; -$txt['calendar_repeat_adjustment_this_and_future'] = 'This and future occurrences'; -$txt['calendar_repeat_adjustment_confirm'] = 'Are you sure you want to apply these changes to all future occurrences?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; -$txt['calendar_repeat_delete_label'] = 'Delete'; -$txt['calendar_confirm_occurrence_delete'] = 'Are you sure you want to delete this occurrence of the event?-n--n-WARNING: if you select both "Delete" and "This and future occurrences", you will delete this and all future occurrences.'; -$txt['calendar_repeat_adjustment_edit_first'] = 'Edit original event'; - -// Used to show a human-readable explanation of the recurrence rule for a repeating event. -$txt['calendar_rrule_desc'] = 'Repeats {rrule_description}{start_date, select, - false {} - other {, starting {start_date}} -}.'; -// 'freq' will be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, or SECONDLY, or else a named month day (e.g. "Friday the 13th"). -$txt['calendar_rrule_desc_frequency_interval'] = 'every {freq, select, - YEARLY {{interval, plural, - =1 {year} - one {# year} - other {# years} - }} - MONTHLY {{interval, plural, - =1 {month} - one {# month} - other {# months} - }} - WEEKLY {{interval, plural, - =1 {week} - one {# week} - other {# weeks} - }} - DAILY {{interval, plural, - =1 {day} - one {# day} - other {# days} - }} - HOURLY {{interval, plural, - =1 {hour} - one {# hour} - other {# hours} - }} - MINUTELY {{interval, plural, - =1 {minute} - one {# minute} - other {# minutes} - }} - SECONDLY {{interval, plural, - =1 {second} - one {# second} - other {# seconds} - }} - other {{freq}} -}'; -// 'freq' will be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, or SECONDLY, or else a named month day (e.g. "Friday the 13th"). -$txt['calendar_rrule_desc_frequency_interval_ordinal'] = 'every {freq, select, - YEARLY {{interval, selectordinal, - =1 {year} - one {#st year} - two {#nd year} - few {#rd year} - other {#th year} - }} - MONTHLY {{interval, selectordinal, - =1 {month} - one {#st month} - two {#nd month} - few {#rd month} - other {#th month} - }} - WEEKLY {{interval, selectordinal, - =1 {week} - one {#st week} - two {#nd week} - few {#rd week} - other {#th week} - }} - DAILY {{interval, selectordinal, - =1 {day} - one {#st day} - two {#nd day} - few {#rd day} - other {#th day} - }} - HOURLY {{interval, selectordinal, - =1 {hour} - one {#st hour} - two {#nd hour} - few {#rd hour} - other {#th hour} - }} - MINUTELY {{interval, selectordinal, - =1 {minute} - one {#st minute} - two {#nd minute} - few {#rd minute} - other {#th minute} - }} - SECONDLY {{interval, selectordinal, - =1 {second} - one {#st second} - two {#nd second} - few {#rd second} - other {#th second} - }} - other {{freq}} -}'; -// 'months_titles' is a list of month names, using the forms from $txt['months_titles']. -$txt['calendar_rrule_desc_bymonth'] = 'in {months_titles}'; -// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. -$txt['calendar_rrule_desc_byweekno'] = '{count, plural, - one {in the {ordinal_list} week of the year} - other {in the {ordinal_list} weeks of the year} -}'; -// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. -$txt['calendar_rrule_desc_byyearday'] = '{count, plural, - one {on the {ordinal_list} day of the year} - other {on the {ordinal_list} days of the year} -}'; -// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. -$txt['calendar_rrule_desc_bymonthday'] = '{count, plural, - one {on the {ordinal_list} day of the month} - other {on the {ordinal_list} days of the month} -}'; -// Translators can replace 'ordinal_month_day' with 'cardinal_month_day' in this string if the target language prefers cardinal numbers instead of ordinal numbers for this form. For example, '{day_name} the {ordinal_month_day}' will produce 'Friday the 13th', whereas '{cardinal_month_day} {day_name}' will produce '13 Friday'. -$txt['calendar_rrule_desc_named_monthday'] = '{day_name} the {ordinal_month_day}'; -// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, or 3rd"). -$txt['calendar_rrule_desc_named_monthdays'] = 'the first {day_name} that is the {ordinal_list} day of the month'; -// E.g. "the 2nd Thursday" -$txt['calendar_rrule_desc_ordinal_day_name'] = 'the {ordinal} {day_name}'; -// E.g. "on Monday", "on Tuesday and Thursday" -$txt['calendar_rrule_desc_byday'] = 'on {day_names}'; -// E.g. "on every Monday", "on every Tuesday and Thursday" -$txt['calendar_rrule_desc_byday_every'] = 'on every {day_names}'; -$txt['calendar_rrule_desc_byminute'] = '{minute_list} past the hour'; -$txt['calendar_rrule_desc_bytime'] = 'at {times_list}'; -$txt['calendar_rrule_desc_bygeneric'] = 'of {list}'; -$txt['calendar_rrule_desc_between'] = 'between {min} and {max}'; -$txt['calendar_rrule_desc_until'] = 'until {date}'; -$txt['calendar_rrule_desc_count'] = '{count, plural, - one {for # occurrence} - other {for # occurrences} -}'; -// 'ordinal_list' is a list of ordinal numbers (e.g. "1st, 2nd, and 3rd"). 'count' is the number of items in 'ordinal_list'. -$txt['calendar_rrule_desc_bysetpos'] = '{count, plural, - one {on each {ordinal_list} occurrence} - other {on each {ordinal_list} occurrences} -} of {rrule_description}'; - $txt['movetopic_change_subject'] = 'Change the topic’s subject'; $txt['movetopic_new_subject'] = 'New subject'; $txt['movetopic_change_all_subjects'] = 'Change every message’s subject'; diff --git a/Sources/Actions/Admin/Calendar.php b/Sources/Actions/Admin/Calendar.php index 6f679e32bd..bdbbdf20f1 100644 --- a/Sources/Actions/Admin/Calendar.php +++ b/Sources/Actions/Admin/Calendar.php @@ -494,7 +494,7 @@ public static function getConfigVars(): array protected function __construct() { // Everything's gonna need this. - Lang::load('ManageCalendar'); + Lang::load('Calendar+ManageCalendar'); if (empty(Config::$modSettings['cal_enabled'])) { unset(self::$subactions['holidays'], self::$subactions['editholiday']); diff --git a/Sources/Actions/BoardIndex.php b/Sources/Actions/BoardIndex.php index 01afe3e07e..3ef7eef4fd 100644 --- a/Sources/Actions/BoardIndex.php +++ b/Sources/Actions/BoardIndex.php @@ -605,6 +605,8 @@ public static function get(array $board_index_options): array */ protected function __construct() { + Lang::load('Calendar'); + Theme::loadTemplate('BoardIndex'); Utils::$context['template_layers'][] = 'boardindex_outer'; diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index eb154d6d68..3c2dfab477 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -1540,6 +1540,8 @@ public static function convertDateToEnglish(string $date): string */ protected function __construct() { + Lang::load('Calendar'); + if ($_GET['action'] === 'clock') { $this->subaction = 'clock'; } elseif (!empty($_GET['sa']) && isset(self::$subactions[$_GET['sa']])) { diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index a2f840a9d6..a63851f27a 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -1064,6 +1064,8 @@ protected function loadEvents(): void && !empty(Config::$modSettings['cal_showInTopic']) && !empty(Config::$modSettings['cal_enabled']) ) { + Lang::load('Calendar'); + foreach(Event::load(Topic::$info->id, true) as $event) { if (($occurrence = $event->getUpcomingOccurrence()) === false) { $occurrence = $event->getLastOccurrence(); @@ -1348,6 +1350,7 @@ protected function buildButtons(): void } if (Topic::$info->permissions['calendar_post']) { + Lang::load('Calendar'); Utils::$context['mod_buttons']['calendar'] = ['text' => 'calendar_link', 'url' => Config::$scripturl . '?action=post;calendar;msg=' . Topic::$info->id_first_msg . ';topic=' . Utils::$context['current_topic'] . '.0']; } diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index 51ad42b3d8..451ed1f165 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -234,7 +234,7 @@ public function execute(): void */ public function show(): void { - Lang::load('Post'); + Lang::load('Post+Calendar'); if (!empty(Config::$modSettings['drafts_post_enabled'])) { Lang::load('Drafts'); diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index 517ebffb29..a4d57eb26c 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -407,6 +407,8 @@ class Event implements \ArrayAccess */ public function __construct(int $id = 0, array $props = []) { + Lang::load('Calendar'); + // Just in case someone passes -2 or something. $id = max(-1, $id); diff --git a/Sources/Calendar/RRule.php b/Sources/Calendar/RRule.php index 1b64c30123..2e48c23164 100644 --- a/Sources/Calendar/RRule.php +++ b/Sources/Calendar/RRule.php @@ -536,7 +536,7 @@ public function getDescription(EventOccurrence $occurrence, ?bool $show_start = } // Just in case... - Lang::load('General+Modifications'); + Lang::load('General+Calendar'); if (!empty($this->bysetpos)) { $description = $this->getDescriptionBySetPos($occurrence->getParentEvent()->start); diff --git a/Sources/ServerSideIncludes.php b/Sources/ServerSideIncludes.php index 212a75a206..2a4c7d5b52 100644 --- a/Sources/ServerSideIncludes.php +++ b/Sources/ServerSideIncludes.php @@ -1883,7 +1883,6 @@ public static function news(string $output_method = 'echo'): ?string */ public static function todaysBirthdays(string $output_method = 'echo'): ?array { - if (!self::$setup_done) { new self(); } @@ -2034,6 +2033,8 @@ public static function todaysCalendar(string $output_method = 'echo'): array|str return $return; } + Lang::load('Calendar'); + if (!empty($return['calendar_holidays'])) { echo ' ' . Lang::$txt['calendar_prompt'] . ' ' . implode(', ', (array) $return['calendar_holidays']) . '
      '; @@ -2435,6 +2436,8 @@ public static function recentEvents(int $max_events = 7, string $output_method = return $return; } + Lang::load('Calendar'); + // Well the output method is echo. echo ' ' . Lang::$txt['events'] . ' '; diff --git a/Sources/Theme.php b/Sources/Theme.php index dbfb623ec9..cd0f430d85 100644 --- a/Sources/Theme.php +++ b/Sources/Theme.php @@ -931,6 +931,8 @@ public static function setupMenuContext(): void $cacheTime = (int) Config::$modSettings['lastActive'] * 60; + Lang::load('Calendar'); + // Initial "can you post an event in the calendar" option - but this might have been set in the calendar already. if (!isset(Utils::$context['allow_calendar_event'])) { Utils::$context['allow_calendar_event'] = Utils::$context['allow_calendar'] && User::$me->allowedTo('calendar_post'); From f06c38ad90b1a0567e7135faacc27537df5dfeff Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Sun, 10 Mar 2024 00:01:44 -0700 Subject: [PATCH 18/22] Allow linking existing topics and events Signed-off-by: Jon Stovell --- Languages/en_US/Calendar.php | 10 +- Languages/en_US/Errors.php | 1 + Sources/Actions/Calendar.php | 114 ++++++---- Sources/Actions/Display.php | 14 +- Sources/Actions/Post.php | 17 +- Sources/Actions/Post2.php | 125 +++++++---- Sources/Topic.php | 22 ++ Themes/default/Calendar.template.php | 4 +- Themes/default/Display.template.php | 74 +------ Themes/default/EventEditor.template.php | 277 ++++++++++++++++++++++-- Themes/default/Post.template.php | 5 +- Themes/default/css/index.css | 19 +- Themes/default/css/responsive.css | 29 +-- Themes/default/scripts/event.js | 63 +++++- Themes/default/scripts/script.js | 5 - 15 files changed, 568 insertions(+), 211 deletions(-) diff --git a/Languages/en_US/Calendar.php b/Languages/en_US/Calendar.php index c57d5bcb01..e9ced65673 100644 --- a/Languages/en_US/Calendar.php +++ b/Languages/en_US/Calendar.php @@ -21,7 +21,15 @@ $txt['event_delete'] = 'Delete Event'; $txt['calendar_post_event'] = 'Post Event'; $txt['calendar'] = 'Calendar'; -$txt['calendar_link'] = 'Link to Calendar'; +$txt['calendar_link'] = 'Add Event'; +$txt['calendar_unlink'] = 'Unlink event'; +$txt['calendar_link_to'] = 'Link to'; +$txt['calendar_only'] = 'Calendar only'; +$txt['calendar_event_new'] = 'New event'; +$txt['calendar_event_existing'] = 'Existing event'; +$txt['calendar_topic_existing'] = 'Existing topic'; +$txt['calendar_link_event_id'] = 'Event ID'; +$txt['calendar_link_topic_id'] = 'Topic ID'; $txt['calendar_upcoming'] = 'Upcoming Calendar'; $txt['calendar_today'] = 'Today’s Calendar'; $txt['calendar_week'] = 'Week'; diff --git a/Languages/en_US/Errors.php b/Languages/en_US/Errors.php index ac3e38c935..047846f817 100644 --- a/Languages/en_US/Errors.php +++ b/Languages/en_US/Errors.php @@ -161,6 +161,7 @@ $txt['invalid_date'] = 'Invalid date.'; $txt['missing_event_id'] = 'Missing event ID.'; $txt['cant_edit_event'] = 'You do not have permission to edit this event.'; +$txt['event_already_linked'] = 'This event is already linked to another topic.'; $txt['missing_board_id'] = 'Board ID is missing.'; $txt['missing_topic_id'] = 'Topic ID is missing.'; $txt['topic_doesnt_exist'] = 'Topic does not exist.'; diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 3c2dfab477..7d504e599c 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -359,7 +359,7 @@ public function post(): void User::$me->checkSession(); // Validate the post... - if (!isset($_POST['link_to_board'])) { + if (!in_array($_POST['link_to'] ?? '', ['board', 'topic'])) { self::validateEventPost(); } @@ -369,8 +369,15 @@ public function post(): void } // New - and directing? - if (isset($_POST['link_to_board']) || empty(Config::$modSettings['cal_allow_unlinked'])) { + if (in_array($_POST['link_to'] ?? '', ['board', 'topic']) || empty(Config::$modSettings['cal_allow_unlinked'])) { $_REQUEST['calendar'] = 1; + + if (empty($_POST['topic'])) { + unset($_POST['topic']); + } elseif (isset($_POST['link_to']) && $_POST['link_to'] === 'topic') { + $_REQUEST['msg'] = Topic::load((int) $_POST['topic'])->id_first_msg; + } + Post::call(); return; @@ -784,53 +791,82 @@ public static function getHolidayRange(string $low_date, string $high_date): arr } /** - * Does permission checks to see if an event can be linked to a board/topic. + * Does permission checks to see if the current user can link the current + * topic to a calendar event. + * + * To succeed, the following conditions must be met: + * + * 1. The calendar must be enabled. * - * Checks if the current user can link the current topic to the calendar, permissions et al. - * This requires the calendar_post permission, a forum moderator, or a topic starter. - * Expects the Topic::$topic_id and Board::$info->id variables to be set. - * If the user doesn't have proper permissions, an error will be shown. + * 2. If an event is passed to the $event parameter, the current user must + * be able to edit that event. Otherwise, the current user must be able + * to create new events. + * + * 3. There must be a current topic (i.e. Topic::$topic_id must be set). + * + * 4. The current user must be able to edit the first post in the current + * topic. + * + * @param bool $trigger_error Whether to trigger an error if the user cannot + * link an event to this topic. Default: true. + * @param ?Event $event The event that the user wants to link to the current + * topic, or null if the user wants to create a new event. + * @return bool Whether the user can link an event to the current topic. */ - public static function canLinkEvent(): void + public static function canLinkEvent(bool $trigger_error = true, ?Event $event = null): bool { - // If you can't post, you can't link. - User::$me->isAllowedTo('calendar_post'); + // Is the calendar enabled? + if (empty(Config::$modSettings['cal_enabled'])) { + if ($trigger_error) { + ErrorHandler::fatalLang('calendar_off', false); + } + + return false; + } + + // Can the user create or edit the event? + $perm = !isset($event) ? 'calendar_post' : ($event->member === User::$me->id ? 'calendar_edit_own' : 'calendar_edit_any'); + + if (!User::$me->allowedTo($perm)) { + if ($trigger_error) { + User::$me->isAllowedTo($perm); + } - // No board? No topic?!? - if (empty(Board::$info->id)) { - ErrorHandler::fatalLang('missing_board_id', false); + return false; } + // Are we in a topic? if (empty(Topic::$topic_id)) { - ErrorHandler::fatalLang('missing_topic_id', false); - } - - // Administrator, Moderator, or owner. Period. - if (!User::$me->allowedTo('admin_forum') && !User::$me->allowedTo('moderate_board')) { - // Not admin or a moderator of this board. You better be the owner - or else. - $result = Db::$db->query( - '', - 'SELECT id_member_started - FROM {db_prefix}topics - WHERE id_topic = {int:current_topic} - LIMIT 1', - [ - 'current_topic' => Topic::$topic_id, - ], - ); - - if ($row = Db::$db->fetch_assoc($result)) { - // Not the owner of the topic. - if ($row['id_member_started'] != User::$me->id) { - ErrorHandler::fatalLang('not_your_topic', 'user'); - } + if ($trigger_error) { + ErrorHandler::fatalLang('missing_topic_id', false); } - // Topic/Board doesn't exist..... - else { - ErrorHandler::fatalLang('calendar_no_topic', 'general'); + + return false; + } + + // Don't let guests edit the posts of other guests. + if (User::$me->is_guest) { + if ($trigger_error) { + ErrorHandler::fatalLang('not_your_topic', false); } - Db::$db->free_result($result); + + return false; } + + // Linking an event counts as modifying the first post. + Topic::load(); + + $perm = User::$me->started ? ['modify_own', 'modify_any'] : 'modify_any'; + + if (!User::$me->allowedTo($perm, Topic::$info->id_board, true)) { + if ($trigger_error) { + User::$me->isAllowedTo($perm, Topic::$info->id_board, true); + } + + return false; + } + + return true; } /** diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index a63851f27a..7458aa55f4 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -1066,7 +1066,7 @@ protected function loadEvents(): void ) { Lang::load('Calendar'); - foreach(Event::load(Topic::$info->id, true) as $event) { + foreach (Topic::$info->getLinkedEvents() as $event) { if (($occurrence = $event->getUpcomingOccurrence()) === false) { $occurrence = $event->getLastOccurrence(); } @@ -1075,6 +1075,8 @@ protected function loadEvents(): void } if (!empty(Utils::$context['linked_calendar_events'])) { + Theme::loadTemplate('EventEditor'); + Utils::$context['linked_calendar_events'][count(Utils::$context['linked_calendar_events']) - 1]['is_last'] = true; } } @@ -1293,6 +1295,11 @@ protected function buildButtons(): void Utils::$context['normal_buttons']['add_poll'] = ['text' => 'add_poll', 'url' => Config::$scripturl . '?action=editpoll;add;topic=' . Utils::$context['current_topic'] . '.' . Utils::$context['start']]; } + if (Calendar::canLinkEvent(false)) { + Lang::load('Calendar'); + Utils::$context['normal_buttons']['calendar'] = ['text' => 'calendar_link', 'url' => Config::$scripturl . '?action=post;calendar;msg=' . Topic::$info->id_first_msg . ';topic=' . Utils::$context['current_topic'] . '.0']; + } + if (Topic::$info->permissions['can_mark_unread']) { Utils::$context['normal_buttons']['mark_unread'] = ['text' => 'mark_unread', 'url' => Config::$scripturl . '?action=markasread;sa=topic;t=' . Utils::$context['mark_unread_time'] . ';topic=' . Utils::$context['current_topic'] . '.' . Utils::$context['start'] . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']]; } @@ -1349,11 +1356,6 @@ protected function buildButtons(): void Utils::$context['mod_buttons']['merge'] = ['text' => 'merge', 'url' => Config::$scripturl . '?action=mergetopics;board=' . Utils::$context['current_board'] . '.0;from=' . Utils::$context['current_topic']]; } - if (Topic::$info->permissions['calendar_post']) { - Lang::load('Calendar'); - Utils::$context['mod_buttons']['calendar'] = ['text' => 'calendar_link', 'url' => Config::$scripturl . '?action=post;calendar;msg=' . Topic::$info->id_first_msg . ';topic=' . Utils::$context['current_topic'] . '.0']; - } - // Restore topic. eh? No monkey business. if (Topic::$info->permissions['can_restore_topic']) { Utils::$context['mod_buttons']['restore_topic'] = ['text' => 'restore_topic', 'url' => Config::$scripturl . '?action=restoretopic;topics=' . Utils::$context['current_topic'] . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']]; diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index 451ed1f165..d82d082e7e 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -753,6 +753,19 @@ protected function initiateEvent(): void // Permissions check! User::$me->isAllowedTo('calendar_post'); + // Are there any other linked events besides this one? + foreach (Event::load(Topic::$topic_id, true) as $event) { + if ($event->id === Utils::$context['event']->id) { + continue; + } + + if (($occurrence = $event->getUpcomingOccurrence()) === false) { + $occurrence = $event->getLastOccurrence(); + } + + Utils::$context['linked_calendar_events'][] = $occurrence; + } + // We want a fairly compact version of the time, but as close as possible to the user's settings. $time_string = Time::getShortTimeFormat(); @@ -796,8 +809,8 @@ protected function initiateEvent(): void Theme::addJavaScriptVar('monthly_byday_items', (string) (count(Utils::$context['event']->byday_items) - 1)); Theme::loadJavaScriptFile('event.js', ['defer' => true], 'smf_event'); - Utils::$context['event']->board = !empty(Board::$info->id) ? Board::$info->id : (int) Config::$modSettings['cal_defaultboard']; - Utils::$context['event']->topic = !empty(Topic::$info->id) ? Topic::$info->id : 0; + Utils::$context['event']->board = !empty(Utils::$context['event']->board) ? Utils::$context['event']->board : (Board::$info->id ?? (int) Config::$modSettings['cal_defaultboard']); + Utils::$context['event']->topic = !empty(Utils::$context['event']->topic) ? Utils::$context['event']->topic : -1; } /** diff --git a/Sources/Actions/Post2.php b/Sources/Actions/Post2.php index 780af2488d..89c048db40 100644 --- a/Sources/Actions/Post2.php +++ b/Sources/Actions/Post2.php @@ -276,7 +276,7 @@ public function submit(): void } } - if (isset($_POST['calendar']) && !isset($_REQUEST['deleteevent']) && Utils::htmlTrim($_POST['evtitle']) === '') { + if (isset($_POST['calendar']) && !isset($_REQUEST['deleteevent']) && !isset($_REQUEST['event_id_to_link']) && Utils::htmlTrim($_POST['evtitle']) === '') { $this->errors[] = 'no_event'; } @@ -510,58 +510,91 @@ public function submit(): void } // Editing or posting an event? - if (isset($_POST['calendar']) && (!isset($_REQUEST['eventid']) || $_REQUEST['eventid'] == -1)) { - // Make sure they can link an event to this post. - Calendar::canLinkEvent(); - - // Insert the event. - $eventOptions = [ - 'board' => Board::$info->id, - 'topic' => Topic::$topic_id, - 'title' => $_POST['evtitle'], - 'location' => $_POST['event_location'], - 'member' => User::$me->id, - ]; - Event::create($eventOptions); - } elseif (isset($_POST['calendar'])) { - $_REQUEST['eventid'] = (int) $_REQUEST['eventid']; - - // Validate the post... - Calendar::validateEventPost(); - - // If you're not allowed to edit any and all events, you have to be the poster. - if (!User::$me->allowedTo('calendar_edit_any')) { - User::$me->isAllowedTo('calendar_edit_' . (!empty(User::$me->id) && Calendar::getEventPoster($_REQUEST['eventid']) == User::$me->id ? 'own' : 'any')); - } + if (isset($_POST['calendar'])) { + if (!isset($_REQUEST['eventid']) || $_REQUEST['eventid'] == -1) { + // Linking an existing event. + if ( + isset($_REQUEST['event_id_to_link'], $_REQUEST['event_link_to']) + && $_REQUEST['event_link_to'] === 'existing' + && Event::load((int) $_REQUEST['event_id_to_link']) !== [] + ) { + $event = Event::$loaded[(int) $_REQUEST['event_id_to_link']]; + + Calendar::canLinkEvent(true, $event); + + if (!empty($event->topic)) { + ErrorHandler::fatalLang('event_already_linked', 'user'); + } - // Delete it? - if (isset($_REQUEST['deleteevent'])) { - if (isset($_REQUEST['recurrenceid'])) { - EventOccurrence::remove($_REQUEST['eventid'], $_REQUEST['recurrenceid'], !empty($_REQUEST['affects_future'])); - } else { - Event::remove($_REQUEST['eventid']); + $event->board = Board::$info->id; + $event->topic = Topic::$topic_id; + $event->save(); } - } - // ... or just update it? - else { - // Set up our options - $eventOptions = [ - 'board' => Board::$info->id, - 'topic' => Topic::$topic_id, - 'title' => $_POST['evtitle'], - 'location' => $_POST['event_location'], - 'member' => User::$me->id, - ]; + // Creating a new event. + else { + // Make sure they can link an event to this post. + Calendar::canLinkEvent(true); + + $eventOptions = [ + 'board' => Board::$info->id, + 'topic' => Topic::$topic_id, + 'title' => $_POST['evtitle'], + 'location' => $_POST['event_location'], + 'member' => User::$me->id, + ]; + Event::create($eventOptions); + } + } else { + $_REQUEST['eventid'] = (int) $_REQUEST['eventid']; - if (!empty($_REQUEST['recurrenceid'])) { - $eventOptions['recurrenceid'] = $_REQUEST['recurrenceid']; + // Validate the post... + Calendar::validateEventPost(); + + // If you're not allowed to edit any and all events, you have to be the poster. + if (!User::$me->allowedTo('calendar_edit_any')) { + User::$me->isAllowedTo('calendar_edit_' . (!empty(User::$me->id) && Calendar::getEventPoster($_REQUEST['eventid']) == User::$me->id ? 'own' : 'any')); } - if (!empty($_REQUEST['affects_future'])) { - $eventOptions['affects_future'] = $_REQUEST['affects_future']; + // Delete it? + if (isset($_REQUEST['deleteevent'])) { + if (isset($_REQUEST['recurrenceid'])) { + EventOccurrence::remove($_REQUEST['eventid'], $_REQUEST['recurrenceid'], !empty($_REQUEST['affects_future'])); + } else { + Event::remove($_REQUEST['eventid']); + } } + // Unlink it from the topic? + elseif (isset($_REQUEST['unlink'])) { + $eventOptions = [ + 'board' => 0, + 'topic' => 0, + 'title' => $_POST['evtitle'], + 'location' => $_POST['event_location'], + ]; + + Event::modify($_REQUEST['eventid'], $eventOptions); + } + // ... or just update it? + else { + // Set up our options + $eventOptions = [ + 'board' => Board::$info->id, + 'topic' => Topic::$topic_id, + 'title' => $_POST['evtitle'], + 'location' => $_POST['event_location'], + 'member' => User::$me->id, + ]; + + if (!empty($_REQUEST['recurrenceid'])) { + $eventOptions['recurrenceid'] = $_REQUEST['recurrenceid']; + } + + if (!empty($_REQUEST['affects_future'])) { + $eventOptions['affects_future'] = $_REQUEST['affects_future']; + } - Event::modify($_REQUEST['eventid'], $eventOptions); + Event::modify($_REQUEST['eventid'], $eventOptions); + } } } diff --git a/Sources/Topic.php b/Sources/Topic.php index cdcc492f6d..ecb9ccf112 100644 --- a/Sources/Topic.php +++ b/Sources/Topic.php @@ -18,6 +18,7 @@ use SMF\Actions\Moderation\ReportedContent; use SMF\Actions\Notify; use SMF\Cache\CacheApi; +use SMF\Calendar\Event; use SMF\Db\DatabaseApi as Db; use SMF\Search\SearchApi; @@ -312,6 +313,13 @@ class Topic implements \ArrayAccess 'topic_started_time' => 'started_time', ]; + /** + * @var array + * + * IDs of any events that are linked to this topic. + */ + protected array $events; + /**************************** * Internal static properties ****************************/ @@ -529,6 +537,20 @@ public function getLikedMsgs(): array return $liked_messages; } + /** + * Returns any calendar events that are linked to this topic. + */ + public function getLinkedEvents(): array + { + if (!isset($this->events)) { + foreach(Event::load($this->id, true) as $event) { + $this->events[] = $event->id; + } + } + + return array_intersect_key(Event::$loaded, array_flip($this->events ?? [])); + } + /*********************** * Public static methods ***********************/ diff --git a/Themes/default/Calendar.template.php b/Themes/default/Calendar.template.php index f8801742c8..058892e4d5 100644 --- a/Themes/default/Calendar.template.php +++ b/Themes/default/Calendar.template.php @@ -879,7 +879,9 @@ function template_event_post() template_event_options(); echo ' - +
      + +
      diff --git a/Themes/default/Display.template.php b/Themes/default/Display.template.php index f06452c3e6..2d95d94a41 100644 --- a/Themes/default/Display.template.php +++ b/Themes/default/Display.template.php @@ -174,81 +174,13 @@ function template_main() } // Does this topic have some events linked to it? - if (!empty(Utils::$context['linked_calendar_events'])) - { + if (!empty(Utils::$context['linked_calendar_events'])) { echo '

      ', Lang::$txt['calendar_linked_events'], '

      -
      -
      -
        '; - - foreach (Utils::$context['linked_calendar_events'] as $event) - { - echo ' -
      • - ', $event['title'], ''; - - if ($event['can_edit']) - echo ' '; - - if ($event['can_export']) - echo ' '; - - echo ' -
        '; - - if (!empty($event['allday'])) - { - echo '', ($event['start_date'] != $event['end_date']) ? ' – ' : ''; - } - else - { - // Display event info relative to user's local timezone - echo ' ()'; - } - // Event is scheduled in the user's own timezone? Let 'em know, just to avoid confusion - else - echo ' ', $event['tz_abbrev'], ''; - } - - if (!empty($event['location'])) - echo ' -
        ', $event['location']; - - $rrule_description = $event->getParentEvent()->recurrence_iterator->getRRule()->getDescription($event); - - if (!empty($rrule_description)) { - echo ' -
        ', $rrule_description; - } +
      '; - echo ' - '; - } - echo ' -
    - '; + template_linked_events(); } // Show the page index... "Pages: [1]". diff --git a/Themes/default/EventEditor.template.php b/Themes/default/EventEditor.template.php index 81aa475831..0cfad78ac6 100644 --- a/Themes/default/EventEditor.template.php +++ b/Themes/default/EventEditor.template.php @@ -14,6 +14,8 @@ use SMF\Calendar\Event; use SMF\Config; use SMF\Lang; +use SMF\Topic; +use SMF\User; use SMF\Utils; /** @@ -24,42 +26,194 @@ function template_event_options() { echo ' -
    - ', Lang::$txt['calendar_event_options'], ' - '; +
    + ', Lang::$txt['calendar_event_options'], ' + + + '; + + // Allow the user to link events to topics. + if ( + Utils::$context['event']->type === Event::TYPE_EVENT + && ( + Utils::$context['event']->new + || Utils::$context['event']->selected_occurrence->is_first + ) + ) { + if (empty(Utils::$context['event']->topic)) { + template_event_board(); + } elseif (Utils::$context['event']->topic === (Topic::$topic_id ?? NAN)) { + template_event_unlink(); + } elseif (Utils::$context['event']->new) { + template_event_link_to(); + } + } - // If this is a new event let the user specify which board they want the linked post to be put into. - if (!empty(Utils::$context['event']->new) && !empty(Utils::$context['event']->categories)) { + template_event_new(); + + echo ' +
    '; +} + +/** + * Template for linking an existing topic to an event. + */ +function template_event_link_to() +{ + // If user cannot edit existing events, we don't need to bother showing these options at all. + if (!User::$me->allowedTo('calendar_edit_any') && !User::$me->allowedTo('calendar_edit_own')) { + return; + } + + // If user can both create and edit events, show both options. + if (User::$me->allowedTo('calendar_post')) { echo ' -
    -
    - -
    -
    - board) ? ' checked' : ''), ' onclick="toggleLinked(this.form);"> - + ' . Lang::$txt['calendar_event_new'] . ' + + +
    +
    '; + } + + // Let the user specify an existing event to link to. + echo ' + '; +} + +/** + * Template for unlinking a topic and an event. + */ +function template_event_unlink() +{ + if (empty(Utils::$context['event']->topic) || empty(Config::$modSettings['cal_allow_unlinked'])) { + return; + } + + echo ' +
    +
    + ' . Lang::$txt['calendar_unlink'] . ' +
    +
    + +
    +
    +
    '; +} + +/** + * Template for choosing a board to create a linked topic in. + */ +function template_event_board() +{ + // If user cannot create new events, skip this. + if (!User::$me->allowedTo('calendar_post')) { + return; + } + + echo ' + '; + + + if (!empty(Utils::$context['event']->categories)) { + echo ' +
    +
    + +
    +
    '; + echo ' + -
    -
    '; + + + '; + } + + echo ' +
    +
    + +
    +
    + +
    +
    +
    '; +} + +/** + * Template for entering info about a new event. + */ +function template_event_new() +{ + // If user cannot create new events, skip this. + if (!User::$me->allowedTo('calendar_post')) { + return; } // Basic event info echo ' +
    ', Lang::$txt['calendar_event_title'], ' @@ -132,7 +286,8 @@ function template_event_options() } echo ' -
    '; + '; + } /** @@ -514,4 +669,88 @@ function template_rrule() '; } +/** + * Template to show linked events. + */ +function template_linked_events() +{ + if (empty(Utils::$context['linked_calendar_events'])) { + return; + } + + echo ' +
    +
      '; + + foreach (Utils::$context['linked_calendar_events'] as $event) { + echo ' +
    • + ', $event['title'], ''; + + if ($event['can_edit']) { + echo ' '; + } + + if ($event['can_export']) { + echo ' '; + } + + echo ' +
      '; + + if (!empty($event['allday'])) { + echo '', ($event['start_date'] != $event['end_date']) ? ' – ' : ''; + } else { + // Display event info relative to user's local timezone + echo ' ()'; + } + // Event is scheduled in the user's own timezone? Let 'em know, just to avoid confusion + else { + echo ' ', $event['tz_abbrev'], ''; + } + } + + if (!empty($event['location'])) { + echo ' +
      ', $event['location']; + } + + $rrule_description = $event->getParentEvent()->recurrence_iterator->getRRule()->getDescription($event); + + if (!empty($rrule_description)) { + echo ' +
      ', $rrule_description; + } + + echo ' +
    • '; + } + + echo ' +
    +
    '; +} + ?> \ No newline at end of file diff --git a/Themes/default/Post.template.php b/Themes/default/Post.template.php index e5055b4346..6d10eb6206 100644 --- a/Themes/default/Post.template.php +++ b/Themes/default/Post.template.php @@ -144,11 +144,14 @@ function addPollOption() if (Utils::$context['make_event']) { echo ' -
    '; template_event_options(); + if (!empty(Utils::$context['linked_calendar_events'])) { + template_linked_events(); + } + echo '
    '; } diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index 4567ed603b..0f5926a122 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -143,6 +143,9 @@ fieldset legend { box-shadow: none; border: none; } +fieldset legend a { + font-weight: normal; +} summary { margin: 5px 0; } @@ -1464,13 +1467,22 @@ ul li.greeting { /* Styles for edit event section ---------------------------------------------------- */ +#post_header + #post_event { + margin: 1em 0 2em; +} #post_event .roundframe { padding: 12px 12%; overflow: auto; } #post_event fieldset { - padding: 6px; + padding: 6px 12px; clear: both; + margin-bottom: 12px; +} +#post_event fieldset:not(:last-child) { + margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } #post_event span.label { margin: 0 0.5em 0 2px; @@ -1558,6 +1570,11 @@ ul li.greeting { .bymonthday_label input:disabled + span { opacity: 0.5; } +#event_link_to label, +#topic_link_to label { + margin-right: 2ch; + display: inline-block; +} /* Styles for the recent messages section. ---------------------------------------------------- */ diff --git a/Themes/default/css/responsive.css b/Themes/default/css/responsive.css index abe23147e3..d35d27724f 100644 --- a/Themes/default/css/responsive.css +++ b/Themes/default/css/responsive.css @@ -42,21 +42,7 @@ #member_list .ip, #member_list .last_active, #member_list .user_name { display: none !important; } -} - -@media (min-width: 720px) and (max-width: 799px) { - #top_info .welcome { - display: none; - } -} -/* We have shared things... */ -@media screen and (max-width: 720px) { - /* Remove some content from the hooks table */ - #list_integration_hooks th#header_list_integration_hooks_function_name, #list_integration_hooks td.function_name, - #list_integration_hooks th#header_list_integration_hooks_remove, #list_integration_hooks td.remove { - display: none; - } /* New Mobile Action/Mod Pop (Test) */ .moderationbuttons_check { display: none; @@ -106,6 +92,21 @@ .mobile_buttons { display: block; } +} + +@media (min-width: 720px) and (max-width: 799px) { + #top_info .welcome { + display: none; + } +} + +/* We have shared things... */ +@media screen and (max-width: 720px) { + /* Remove some content from the hooks table */ + #list_integration_hooks th#header_list_integration_hooks_function_name, #list_integration_hooks td.function_name, + #list_integration_hooks th#header_list_integration_hooks_remove, #list_integration_hooks td.remove { + display: none; + } /* Stuff */ #top_info { diff --git a/Themes/default/scripts/event.js b/Themes/default/scripts/event.js index b3043ffa1a..7b8c1e9950 100644 --- a/Themes/default/scripts/event.js +++ b/Themes/default/scripts/event.js @@ -1,6 +1,6 @@ window.addEventListener("DOMContentLoaded", updateEventUI); -for (const elem of document.querySelectorAll("#start_date, #start_time, #end_date, #end_time, #allday, #tz, #rrule, #freq, #end_option, #monthly_option_type_bymonthday, #monthly_option_type_byday, #byday_num_select_0, #byday_name_select_0, #weekly_options .rrule_input[name=\'BYDAY\[\]\'], #monthly_options .rrule_input[name=\'BYDAY_num\[\]\'], #monthly_options .rrule_input[name=\'BYDAY_name\[\]\'], #monthly_options .rrule_input[name=\'BYMONTHDAY\[\]\']")) { +for (const elem of document.querySelectorAll("#start_date, #start_time, #end_date, #end_time, #allday, #tz, #rrule, #freq, #end_option, #monthly_option_type_bymonthday, #monthly_option_type_byday, #byday_num_select_0, #byday_name_select_0, #weekly_options .rrule_input[name=\'BYDAY\[\]\'], #monthly_options .rrule_input[name=\'BYDAY_num\[\]\'], #monthly_options .rrule_input[name=\'BYDAY_name\[\]\'], #monthly_options .rrule_input[name=\'BYMONTHDAY\[\]\'], #event_link_to label, #event_link_to input, #topic_link_to label, #topic_link_to input")) { elem.addEventListener("change", updateEventUI); } @@ -19,16 +19,19 @@ for (const elem of document.querySelectorAll("#rdate_list a, #exdate_list a")) { let rdates_count = document.querySelectorAll("#rdate_list input[type='date']").length; let exdates_count = document.querySelectorAll("#exdate_list input[type='date']").length; -let current_start_date = new Date(document.getElementById("start_date").value + 'T' + document.getElementById("start_time").value); -let current_end_date = new Date(document.getElementById("end_date").value + 'T' + document.getElementById("end_time").value); +let current_start_date = new Date(document.getElementById("start_date").value + 'T' + (document.getElementById("allday").checked ? '12:00:00' : document.getElementById("start_time").value)); +let current_end_date = new Date(document.getElementById("end_date").value + 'T' + (document.getElementById("allday").checked ? '12:00:00' : document.getElementById("end_time").value)); const weekday_abbrevs = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; // Update all the parts of the event editing UI. function updateEventUI() { - let start_date = new Date(document.getElementById("start_date").value + 'T' + document.getElementById("start_time").value); - let end_date = new Date(document.getElementById("end_date").value + 'T' + document.getElementById("end_time").value); + toggleNewOrExistingEvent(); + toggleNewOrExistingTopic(); + + let start_date = new Date(document.getElementById("start_date").value + 'T' + (document.getElementById("allday").checked ? '12:00:00' : document.getElementById("start_time").value)); + let end_date = new Date(document.getElementById("end_date").value + 'T' + (document.getElementById("allday").checked ? '12:00:00' : document.getElementById("end_time").value)); let weekday = getWeekday(start_date); @@ -705,4 +708,54 @@ function removeRDateOrExDate() } this.parentElement.remove(); +} + +function toggleNewOrExistingEvent() +{ + if (!document.getElementById("event_link_to")) { + return; + } + + if (document.getElementById("event_link_to_new").checked === true) { + document.getElementById("event_new").style.display = ''; + document.getElementById("event_id_to_link").style.display = 'none'; + document.querySelector("#event_id_to_link input").disabled = true; + } else { + document.getElementById("event_new").style.display = 'none'; + document.getElementById("event_id_to_link").style.display = ''; + document.querySelector("#event_id_to_link input").disabled = false; + } +} + +function toggleNewOrExistingTopic() +{ + if (!document.getElementById("topic_link_to")) { + return; + } + + if (document.getElementById("event_board")) { + document.getElementById("event_board").style.display = 'none'; + document.querySelector("#event_board select").disabled = true; + } + + if (document.getElementById("event_topic")) { + document.getElementById("event_topic").style.display = 'none'; + document.querySelector("#event_topic input").disabled = true; + } + + if ( + document.getElementById("event_board") + && document.getElementById("link_to_board") + && document.getElementById("link_to_board").checked === true + ) { + document.getElementById("event_board").style.display = ''; + document.querySelector("#event_board select").disabled = false; + } else if ( + document.getElementById("event_topic") + && document.getElementById("link_to_topic") + && document.getElementById("link_to_topic").checked === true + ) { + document.getElementById("event_topic").style.display = ''; + document.querySelector("#event_topic input").disabled = false; + } } \ No newline at end of file diff --git a/Themes/default/scripts/script.js b/Themes/default/scripts/script.js index f21d45710b..9601a920e6 100644 --- a/Themes/default/scripts/script.js +++ b/Themes/default/scripts/script.js @@ -1591,11 +1591,6 @@ function generateDays(offset) dayElement.selectedIndex = selected; } -function toggleLinked(form) -{ - form.board.disabled = !form.link_to_board.checked; -} - function initSearch() { if (document.forms.searchform.search.value.indexOf("%u") != -1) From 1c0c52ee6dca98aa88747713c4e407304097c080 Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Thu, 25 Apr 2024 19:21:41 -0600 Subject: [PATCH 19/22] Adds support for subscribing to external calendars Signed-off-by: Jon Stovell --- Languages/en_US/ManageCalendar.php | 4 + Languages/en_US/ManageScheduledTasks.php | 2 + Sources/Actions/Admin/Calendar.php | 194 +++++++++++++++++-- Sources/TaskRunner.php | 3 + Sources/Tasks/FetchCalendarSubscriptions.php | 62 ++++++ Themes/default/ManageCalendar.template.php | 12 ++ 6 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 Sources/Tasks/FetchCalendarSubscriptions.php diff --git a/Languages/en_US/ManageCalendar.php b/Languages/en_US/ManageCalendar.php index 59ea04e774..5003131d1f 100644 --- a/Languages/en_US/ManageCalendar.php +++ b/Languages/en_US/ManageCalendar.php @@ -63,5 +63,9 @@ $txt['calendar_import_type_holiday'] = 'Holidays'; $txt['calendar_import_type_event'] = 'Events'; $txt['calendar_import_button'] = 'Import'; +$txt['calendar_import_subscribe'] = 'Subscribe to this calendar'; +$txt['calendar_import_subscribe_desc'] = 'Calendars you subscribe to will be reimported regularly.'; +$txt['calendar_import_manage_subscriptions'] = 'Subscribed calendars'; +$txt['calendar_import_unsubscribe'] = 'Unsubscribe'; ?> \ No newline at end of file diff --git a/Languages/en_US/ManageScheduledTasks.php b/Languages/en_US/ManageScheduledTasks.php index 9e41742ea6..c2f586a5e3 100644 --- a/Languages/en_US/ManageScheduledTasks.php +++ b/Languages/en_US/ManageScheduledTasks.php @@ -25,6 +25,8 @@ $txt['scheduled_task_desc_weekly_digest'] = 'Emails out the weekly digest for notification subscribers.'; $txt['scheduled_task_fetchSMfiles'] = 'Fetch Simple Machines files'; $txt['scheduled_task_desc_fetchSMfiles'] = 'Retrieves javascript files containing notifications of updates and other information.'; +$txt['scheduled_task_fetch_calendar_subs'] = 'Fetch updates from subscribed calendars'; +$txt['scheduled_task_desc_fetch_calendar_subs'] = 'Updates SMF’s calendar with data from external calendars that you have subscribed to.'; $txt['scheduled_task_birthdayemails'] = 'Send Birthday emails'; $txt['scheduled_task_desc_birthdayemails'] = 'Sends out emails wishing members a happy birthday.'; $txt['scheduled_task_weekly_maintenance'] = 'Weekly Maintenance'; diff --git a/Sources/Actions/Admin/Calendar.php b/Sources/Actions/Admin/Calendar.php index bdbbdf20f1..81e29c2f22 100644 --- a/Sources/Actions/Admin/Calendar.php +++ b/Sources/Actions/Admin/Calendar.php @@ -20,6 +20,7 @@ use SMF\Actions\ActionInterface; use SMF\Actions\BackwardCompatibility; use SMF\Board; +use SMF\Calendar\Event; use SMF\Calendar\Holiday; use SMF\Config; use SMF\Db\DatabaseApi as Db; @@ -28,6 +29,7 @@ use SMF\Lang; use SMF\Menu; use SMF\SecurityToken; +use SMF\TaskRunner; use SMF\Theme; use SMF\Time; use SMF\TimeInterval; @@ -315,30 +317,198 @@ public function import(): void Utils::$context['page_title'] = Lang::$txt['calendar_import']; // Submitting? - if (isset($_POST[Utils::$context['session_var']], $_POST['ics_url'], $_POST['type'])) { + if (isset($_POST[Utils::$context['session_var']])) { User::$me->checkSession(); SecurityToken::validate('admin-calendarimport'); - $ics_url = new Url($_POST['ics_url'], true); + if (isset($_POST['ics_url'], $_POST['type'])) { + $ics_url = new Url($_POST['ics_url'], true); - if ($ics_url->isValid()) { - $ics_data = WebFetchApi::fetch($ics_url); + if ($ics_url->isValid()) { + $ics_data = WebFetchApi::fetch($ics_url); + } + + if (!empty($ics_data)) { + switch ($_POST['type']) { + case 'holiday': + Holiday::import($ics_data); + break; + + case 'event': + Event::import($ics_data); + break; + } + } + + // Subscribing to this calendar? + if (isset($_POST['subscribe'])) { + $subscribed = Utils::jsonDecode(Config::$modSettings['calendar_subscriptions'] ?? '[]', true); + + $subscribed[(string) $ics_url] = $_POST['type'] === 'holiday' ? Event::TYPE_HOLIDAY : Event::TYPE_EVENT; + + Config::updateModSettings(['calendar_subscriptions' => Utils::jsonEncode($subscribed)]); + + $request = Db::$db->query( + '', + 'SELECT id_task + FROM {db_prefix}scheduled_tasks + WHERE task = {string:task}', + [ + 'task' => 'fetch_calendar_subs', + ], + ); + + $exists = Db::$db->num_rows($request) > 0; + Db::$db->free_result($request); + + if (!$exists) { + $id_task = Db::$db->insert( + '', + '{db_prefix}scheduled_tasks', + [ + 'next_time' => 'int', + 'time_offset' => 'int', + 'time_regularity' => 'int', + 'time_unit' => 'string-1', + 'disabled' => 'int', + 'task' => 'string-24', + ], + [ + 'next_time' => 0, + 'time_offset' => 0, + 'time_regularity' => 1, + 'time_unit' => 'd', + 'disabled' => 0, + 'task' => 'fetch_calendar_subs', + ], + ['id_task'], + ); + + TaskRunner::calculateNextTrigger((string) $id_task, true); + } + } } - if (!empty($ics_data)) { - switch ($_POST['type']) { - case 'holiday': - Holiday::import($ics_data); - break; + // Unsubscribing from some calendars? + if (isset($_POST['unsubscribe'], $_POST['subscribed'])) { + $subscribed = Utils::jsonDecode(Config::$modSettings['calendar_subscriptions'] ?? '[]', true); + + foreach ($subscribed as $url => $type) { + $hashes[md5($url)] = $url; + } - case 'event': - Event::import($ics_data); - break; + foreach ($_POST['subscribed'] as $hash => $value) { + unset($subscribed[$hashes[$hash]]); } + + Config::updateModSettings(['calendar_subscriptions' => Utils::jsonEncode($subscribed)]); } } SecurityToken::create('admin-calendarimport'); + + // List the current calendar subscriptions. + $subscribed = Utils::jsonDecode(Config::$modSettings['calendar_subscriptions'] ?? '[]', true); + + if (!empty($subscribed)) { + foreach ($subscribed as $url => $type) { + Utils::$context['calendar_subscriptions'][] = [ + 'hash' => md5($url), + 'url' => $url, + 'type' => $type === Event::TYPE_HOLIDAY ? Lang::$txt['calendar_import_type_holiday'] : Lang::$txt['calendar_import_type_event'], + ]; + } + + $listOptions = [ + 'id' => 'calendar_subscriptions', + 'title' => Lang::$txt['calendar_import_manage_subscriptions'], + 'items_per_page' => Config::$modSettings['defaultMaxListItems'], + 'base_href' => Config::$scripturl . '?action=admin;area=managecalendar;sa=import', + // 'default_sort_col' => 'url', + 'get_items' => [ + 'value' => Utils::$context['calendar_subscriptions'], + ], + 'get_count' => [ + 'value' => count(Utils::$context['calendar_subscriptions']), + ], + 'no_items_label' => Lang::$txt['none'], + 'columns' => [ + 'url' => [ + 'header' => [ + 'value' => Lang::$txt['url'], + ], + 'data' => [ + 'sprintf' => [ + 'format' => '%1$s', + 'params' => [ + 'url' => true, + ], + ], + ], + 'sort' => [ + 'default' => '', + 'reverse' => '', + ], + ], + 'type' => [ + 'header' => [ + 'value' => Lang::sentenceList( + [ + Lang::$txt['calendar_import_type_event'], + Lang::$txt['calendar_import_type_holiday'], + ], + 'or', + ), + ], + 'data' => [ + 'sprintf' => [ + 'format' => '%1$s', + 'params' => [ + 'type' => false, + ], + ], + ], + 'sort' => [ + 'default' => 'cal.start_date', + 'reverse' => 'cal.start_date DESC', + ], + ], + 'check' => [ + 'header' => [ + 'value' => '', + 'class' => 'centercol', + ], + 'data' => [ + 'sprintf' => [ + 'format' => '', + 'params' => [ + 'hash' => false, + ], + ], + 'class' => 'centercol', + ], + ], + ], + 'form' => [ + 'href' => Config::$scripturl . '?action=admin;area=managecalendar;sa=import', + 'token' => 'admin-calendarimport', + ], + 'additional_rows' => [ + // [ + // 'position' => 'above_column_headers', + // 'value' => '', + // ], + [ + 'position' => 'below_table_data', + 'value' => '', + ], + ], + ]; + + new ItemList($listOptions); + + Theme::loadTemplate('GenericList'); + } } /** diff --git a/Sources/TaskRunner.php b/Sources/TaskRunner.php index b50d604715..1f622bd4d4 100644 --- a/Sources/TaskRunner.php +++ b/Sources/TaskRunner.php @@ -86,6 +86,9 @@ class TaskRunner 'fetchSMfiles' => [ 'class' => 'SMF\\Tasks\\FetchSMFiles', ], + 'fetch_calendar_subs' => [ + 'class' => 'SMF\\Tasks\\FetchCalendarSubscriptions', + ], 'birthdayemails' => [ 'class' => 'SMF\\Tasks\\Birthday_Notify', ], diff --git a/Sources/Tasks/FetchCalendarSubscriptions.php b/Sources/Tasks/FetchCalendarSubscriptions.php new file mode 100644 index 0000000000..f046dffb9f --- /dev/null +++ b/Sources/Tasks/FetchCalendarSubscriptions.php @@ -0,0 +1,62 @@ + $type) { + $url = new Url($url, true); + + if ($url->isValid()) { + $ics_data = WebFetchApi::fetch($url); + } + + if (!empty($ics_data)) { + switch ($type) { + case 'holiday': + Holiday::import($ics_data); + break; + + case 'event': + Event::import($ics_data); + break; + } + } + } + + return true; + } +} + +?> \ No newline at end of file diff --git a/Themes/default/ManageCalendar.template.php b/Themes/default/ManageCalendar.template.php index 7303851c72..cd6eeb6347 100644 --- a/Themes/default/ManageCalendar.template.php +++ b/Themes/default/ManageCalendar.template.php @@ -80,12 +80,24 @@ function template_import() ', Lang::$txt['calendar_import_type_event'], ' +
    + +
    + ', Lang::$txt['calendar_import_subscribe_desc'], ' +
    +
    + +
    '; + + if (!empty(Utils::$context['calendar_subscriptions'])) { + template_show_list('calendar_subscriptions'); + } } ?> \ No newline at end of file From 253e41571d8a378d2e3c20ca10b648e50e7478bb Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Mon, 29 Apr 2024 16:05:14 -0600 Subject: [PATCH 20/22] Further improves the code for exporting events Signed-off-by: Jon Stovell --- Languages/en_US/Calendar.php | 5 + Sources/Actions/Calendar.php | 244 +++++++++++++++++++++------ Sources/Calendar/Event.php | 82 +++++---- Themes/default/css/index.css | 6 + Themes/default/images/icons/feed.svg | 4 + 5 files changed, 264 insertions(+), 77 deletions(-) create mode 100644 Themes/default/images/icons/feed.svg diff --git a/Languages/en_US/Calendar.php b/Languages/en_US/Calendar.php index e9ced65673..e3d4e09785 100644 --- a/Languages/en_US/Calendar.php +++ b/Languages/en_US/Calendar.php @@ -16,6 +16,11 @@ $txt['calendar_post_in'] = 'Post in'; $txt['calendar_edit'] = 'Edit Event'; $txt['calendar_export'] = 'Export Event'; +$txt['calendar_subscribe'] = 'Subscribe'; +$txt['calendar_subscribe_desc'] = 'Shows upcoming events in your calendar app.'; +$txt['calendar_subscribe_url_copied'] = 'The calendar subscription URL has been copied to the clipboard. Paste it into your calendar app to subscribe.'; +$txt['calendar_download'] = 'Download'; +$txt['calendar_download_desc'] = 'Exports a copy of the currently visible events.'; $txt['calendar_view_week'] = 'View Week'; $txt['event_delete_confirm'] = 'Delete this event?'; $txt['event_delete'] = 'Delete Event'; diff --git a/Sources/Actions/Calendar.php b/Sources/Actions/Calendar.php index 9ba444e240..ac6c15701c 100644 --- a/Sources/Actions/Calendar.php +++ b/Sources/Actions/Calendar.php @@ -320,7 +320,64 @@ public function show(): void Utils::$context['calendar_buttons'] = []; if (Utils::$context['can_post']) { - Utils::$context['calendar_buttons']['post_event'] = ['text' => 'calendar_post_event', 'image' => 'calendarpe.png', 'url' => Config::$scripturl . '?action=calendar;sa=post;month=' . Utils::$context['current_month'] . ';year=' . Utils::$context['current_year'] . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']]; + Utils::$context['calendar_buttons']['post_event'] = [ + 'text' => 'calendar_post_event', + 'url' => Config::$scripturl . '?action=calendar;sa=post;month=' . Utils::$context['current_month'] . ';year=' . Utils::$context['current_year'] . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id'], + ]; + } + + if (!empty(Config::$modSettings['cal_export']) && !User::$me->possibly_robot) { + $webcal_url = Config::$scripturl . '?action=calendar;sa=ical' . (!User::$me->is_guest ? ';u=' . User::$me->id . ';token=' . $this->createToken(User::$me) : ''); + + if (BrowserDetector::isBrowser('safari') || BrowserDetector::isBrowser('iphone')) { + $webcal_url = preg_replace('/^https?/', 'webcal', $webcal_url); + } else { + $webcal_url = 'javascript:navigator.clipboard.writeText(' . Utils::escapeJavaScript($webcal_url) . ');alert(' . Utils::escapeJavaScript(Lang::$txt['calendar_subscribe_url_copied']) . ')'; + } + + $ics_url = Config::$scripturl . '?action=calendar;sa=ical'; + + switch (Utils::$context['calendar_view']) { + case 'viewmonth': + $ics_url .= ';start_date=' . $start_object->format('Y-m-01'); + $ics_url .= ';duration=P1M'; + break; + + case 'viewweek': + $s = clone $start_object; + + while (($s->format('N') % 7) > $calendarOptions['start_day']) { + $s->modify('-1 day'); + } + + $ics_url .= ';start_date=' . $s->format('Y-m-d'); + $ics_url .= ';duration=P7D'; + break; + + default: + $ics_url .= ';start_date=' . $start_object->format('Y-m-d'); + $ics_url .= ';duration=' . (string) TimeInterval::createFromDateInterval($start_object->diff($end_object)); + break; + } + + Lang::$txt[''] = ''; + + Utils::$context['calendar_buttons']['cal_export'] = [ + 'text' => '', + 'class' => 'main_icons feed', + 'custom' => 'title="' . Lang::getTxt('calendar_subscribe') . '"', + 'url' => $ics_url, + 'sub_buttons' => [ + 'subscribe' => [ + 'text' => 'calendar_subscribe', + 'url' => $webcal_url, + ], + 'download' => [ + 'text' => 'calendar_download', + 'url' => $ics_url, + ], + ], + ]; } // Allow mods to add additional buttons here @@ -539,68 +596,107 @@ public function export(): void ErrorHandler::fatalLang('calendar_export_off', false); } - // Goes without saying that this is required. - if (!isset($_REQUEST['eventid'])) { - ErrorHandler::fatalLang('no_access', false); - } + $file = [ + 'mime_type' => 'text/calendar', + 'expires' => time() + 3600, + // Will be changed below. + 'mtime' => 0, + // Will be changed below. + 'filename' => 'event.ics', + // More will be added below. + 'content' => [ + 'BEGIN:VCALENDAR', + 'METHOD:PUBLISH', + 'PRODID:-//SimpleMachines//' . SMF_FULL_VERSION . '//EN', + 'VERSION:2.0', + ], + ]; - // Load up the event in question and check it is valid. - $event = current(Event::load((int) $_REQUEST['eventid'])); + if (isset($_REQUEST['eventid'])) { + // Load up the event in question and check it is valid. + $event = current(Event::load((int) $_REQUEST['eventid'])); - if (!($event instanceof Event)) { - ErrorHandler::fatalLang('no_access', false); - } + if (!($event instanceof Event)) { + ErrorHandler::fatalLang('no_access', false); + } - // This is what we will be sending later. - $filecontents = []; - $filecontents[] = 'BEGIN:VCALENDAR'; - $filecontents[] = 'METHOD:PUBLISH'; - $filecontents[] = 'PRODID:-//SimpleMachines//' . SMF_FULL_VERSION . '//EN'; - $filecontents[] = 'VERSION:2.0'; + // Was a specific occurrence requested, or the event in general? + if ( + isset($_REQUEST['recurrenceid']) + && ($occurrence = $event->getOccurrence($_REQUEST['recurrenceid'])) !== false + ) { + $file['content'][] = $occurrence->export(); + } else { + $file['content'][] = $event->export(); + } - $filecontents[] = $event->export(); + $file['filename'] = $event->title . '.ics'; + $file['mtime'] = $event->modified_time; + } else { + $this->authenticateForExport(); - $filecontents[] = 'END:VCALENDAR'; + // Get all the visible events within a date range. + if (isset($_REQUEST['start_date'])) { + $low_date = @(new Time($_REQUEST['start_date'])); + } - // Send some standard headers. - ob_end_clean(); + if (!isset($low_date)) { + $low_date = new Time('now'); + $low_date->setDate((int) $low_date->format('Y'), (int) $low_date->format('m'), 1); + } - if (!empty(Config::$modSettings['enableCompressedOutput'])) { - @ob_start('ob_gzhandler'); - } else { - ob_start(); - } + if (isset($_REQUEST['duration'])) { + $duration = @(new TimeInterval($_REQUEST['duration'])); + } - // Send the file headers - header('pragma: '); - header('cache-control: no-cache'); + if (!isset($duration)) { + $duration = new TimeInterval('P3M'); + } - if (!BrowserDetector::isBrowser('gecko')) { - header('content-transfer-encoding: binary'); - } + $high_date = (clone $low_date)->add($duration); - header('expires: ' . gmdate('D, d M Y H:i:s', time() + 3600) . ' GMT'); - header('last-modified: ' . gmdate('D, d M Y H:i:s', $event->modified_time) . ' GMT'); - header('accept-ranges: bytes'); - header('connection: close'); - header('content-disposition: attachment; filename="' . $event->title . '.ics"'); + $full_event_uids = []; - // RFC 5545 requires "\r\n", not just "\n". - $calevent = implode("\r\n", $filecontents); + foreach (Event::getOccurrencesInRange($low_date->format('Y-m-d'), $high_date->format('Y-m-d'), true) as $occurrence) { + $event = $occurrence->getParentEvent(); + + // Skip if we already exported the full event. + if (in_array($event->uid, $full_event_uids)) { + continue; + } + + if ( + // If there was no requested start date, export the full event. + !isset($_REQUEST['start_date']) + // Or if all occurrences are visible, export the full event. + || ( + $event->start >= $low_date + && $event->getRecurrenceEnd() <= $high_date + ) + ) { + $file['content'][] = $event->export(); + $full_event_ids[] = $event->uid; + } + // Otherwise, export just this occurrence. + else { + $file['content'][] = $occurrence->export(); + } + + $file['mtime'] = max($file['mtime'], $event->modified_time); + } - if (empty(Config::$modSettings['enableCompressedOutput'])) { - // todo: correctly handle $filecontents before passing to string function - header('content-length: ' . Utils::entityStrlen($calevent)); + $file['filename'] = implode(' ', [Utils::$context['forum_name'], Lang::$txt['events'], $low_date->format('Y-m-d'), $high_date->format('Y-m-d')]) . '.ics'; } - // This is a calendar item! - header('content-type: text/calendar'); + $file['content'][] = 'END:VCALENDAR'; + + // RFC 5545 requires "\r\n", not just "\n". + $file['content'] = implode("\r\n", $file['content']); - // Chuck out the card. - echo $calevent; + $file['size'] = strlen($file['content']); - // Off we pop - lovely! - Utils::obExit(false); + // Send it. + Utils::emitFile($file); } /** @@ -1558,8 +1654,26 @@ protected function __construct() return; } + // Special case for handling calendar subscriptions. + if ( + User::$me->is_guest + && $this->subaction === 'ical' + && isset($_REQUEST['u'], $_REQUEST['token']) + ) { + $user = current(User::load((int) $_REQUEST['u'])); + + if ( + !($user instanceof User) + || !$user->allowedTo('calendar_view') + || $_REQUEST['token'] !== $this->createToken($user) + ) { + exit; + } + } // Permissions, permissions, permissions. - User::$me->isAllowedTo('calendar_view'); + else { + User::$me->isAllowedTo('calendar_view'); + } // Some global template resources. Utils::$context['calendar_resources'] = [ @@ -1567,6 +1681,40 @@ protected function __construct() 'max_year' => Config::$modSettings['cal_maxyear'], ]; } + + /** + * Generates an calendar subscription authentication token. + * + * @param User $user The member that this token is for. + * @return string The authentication token. + */ + protected function createToken(User $user): string + { + $token = hash_hmac('sha3-224', (string) $user->id, Config::getAuthSecret(), true); + + return strtr(base64_encode($token), ['+' => '_', '/' => '-', '=' => '']); + } + + /** + * Validates the guest-supplided user ID and token combination, and loads + * the requested user if the token is valid. + * + * Does nothing if the user is already logged in. + */ + protected function authenticateForExport(): void + { + if (!User::$me->is_guest) { + return; + } + + if (!empty($_REQUEST['u']) && isset($_REQUEST['token'])) { + $user = current(User::load((int) $_REQUEST['u'])); + + if (($user instanceof User) && $_REQUEST['token'] === $this->createToken($user)) { + User::setMe($user->id); + } + } + } } ?> \ No newline at end of file diff --git a/Sources/Calendar/Event.php b/Sources/Calendar/Event.php index a4d57eb26c..6665fc04bb 100644 --- a/Sources/Calendar/Event.php +++ b/Sources/Calendar/Event.php @@ -616,35 +616,7 @@ public function save(): void { $is_edit = ($this->id ?? 0) > 0; - if (!empty($this->recurrence_iterator->getRRule()->until)) { - // When we have an until value, life is easy. - $recurrence_end = Time::createFromInterface($this->recurrence_iterator->getRRule()->until)->modify('-1 second'); - } elseif (!empty($this->recurrence_iterator->getRRule()->count)) { - // Save current values. - $view_start = clone $this->view_start; - $view_end = clone $this->view_end; - $recurrence_iterator = clone $this->recurrence_iterator; - - // Make new recurrence iterator that gets all occurrences. - $this->rrule = (string) $recurrence_iterator->getRRule(); - $this->view_start = clone $this->start; - $this->view_end = new Time('9999-12-31'); - - unset($this->recurrence_iterator); - $this->createRecurrenceIterator(); - - // Get last occurrence. - $this->recurrence_iterator->end(); - $recurrence_end = Time::createFromInterface($this->recurrence_iterator->current()); - - // Put everything back. - $this->view_start = $view_start; - $this->view_end = $view_end; - $this->recurrence_iterator = $recurrence_iterator; - } else { - // Forever. - $recurrence_end = new Time('9999-12-31'); - } + $recurrence_end = $this->getRecurrenceEnd(); $rrule = !empty($this->special_rrule) ? implode('', $this->special_rrule) : (string) $this->recurrence_iterator->getRRule(); @@ -1061,6 +1033,58 @@ public function changeUntil(\DateTimeInterface $until): void $this->createRecurrenceIterator(); } + /** + * Gets the date after which no more occurrences happen. + * + * @return Time When the recurrence ends. + */ + public function getRecurrenceEnd(): Time + { + // If there's no recurrence, there's nothing to do. + if (!isset($this->recurrence_iterator)) { + return $this->start; + } + + // When we have an until value, life is easy. + if (!empty($this->recurrence_iterator->getRRule()->until)) { + return Time::createFromInterface($this->recurrence_iterator->getRRule()->until)->modify('-1 second'); + } + + // A count value takes more work. + if (!empty($this->recurrence_iterator->getRRule()->count)) { + // If the count is 1, then the start is the end. + if ($this->recurrence_iterator->getRRule()->count == 1) { + return $this->start; + } + + // Save current values. + $view_start = clone $this->view_start; + $view_end = clone $this->view_end; + $recurrence_iterator = clone $this->recurrence_iterator; + + // Make new recurrence iterator that gets all occurrences. + $this->rrule = (string) $recurrence_iterator->getRRule(); + $this->view_start = clone $this->start; + $this->view_end = new Time('9999-12-31'); + + unset($this->recurrence_iterator); + $this->createRecurrenceIterator(); + + // Get last occurrence. + $this->recurrence_iterator->end(); + $value = Time::createFromInterface($this->recurrence_iterator->current()); + + // Put everything back. + $this->view_start = $view_start; + $this->view_end = $view_end; + $this->recurrence_iterator = $recurrence_iterator; + + return $value; + } + + // Forever. + return new Time('9999-12-31'); + } /** * Sets custom properties. diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index 0f5926a122..bcfe1918fe 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -1789,6 +1789,12 @@ tr.windowbg td, tr.bg td, .table_grid tr td { background: url(../images/icons/bell.png); background-size: 16px; } +.main_icons.feed::before { + background: url(../images/icons/feed.svg); + background-size: 16px;} +a.main_icons.feed::before { + margin: -0.2em -0.4em 0; +} /* Load better icons for higher resolution screens */ @media screen and (-webkit-min-device-pixel-ratio: 1.66), diff --git a/Themes/default/images/icons/feed.svg b/Themes/default/images/icons/feed.svg new file mode 100644 index 0000000000..c19b379974 --- /dev/null +++ b/Themes/default/images/icons/feed.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 40c90d66dce222d35c6b670b270321bbad3104cf Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Mon, 29 Apr 2024 16:06:07 -0600 Subject: [PATCH 21/22] Fixes CSS for dropmenu buttons created in template_button_strip() Signed-off-by: Jon Stovell --- Themes/default/css/index.css | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Themes/default/css/index.css b/Themes/default/css/index.css index bcfe1918fe..da345483bb 100644 --- a/Themes/default/css/index.css +++ b/Themes/default/css/index.css @@ -4005,7 +4005,7 @@ h3.profile_hd::before, #top_section, .quickbuttons > li, .quickbuttons li ul, .quickbuttons li ul li a:hover, .quickbuttons ul li a:focus, .inline_mod_check, .popup_window, #inner_section, .post_options ul, -.post_options ul a:hover, .post_options ul a:focus, .notify_dropdown a:hover, .notify_dropdown a:focus { +.post_options ul a:hover, .post_options ul a:focus, .dropmenu .viewport .overview a:hover, .dropmenu .viewport .overview a:focus { background: #fff; /* fallback for some browsers */ background-image: linear-gradient(#e2e9f3 0%, #fff 70%); } @@ -4021,20 +4021,21 @@ h3.profile_hd::before, } /* Topic/Board follow-alert menu */ -.notify_dropdown strong { +.dropmenu .viewport .overview strong { font-size: 1.1em; } -.notify_dropdown a { +.dropmenu .viewport .overview a { display: block; padding: 0.5em; text-decoration: none; border: 1px solid transparent; border-radius: 3px; } -.notify_dropdown a:hover, .notify_dropdown a:focus { +.dropmenu .viewport .overview a:hover, +.dropmenu .viewport .overview a:focus { border-color: #ddd; } -.notify_dropdown span { +.dropmenu .viewport .overview span { font-size: 0.9em; } From fc92e92b86bdac83e38db58f3c8515dfd3cab1ae Mon Sep 17 00:00:00 2001 From: Jon Stovell Date: Mon, 29 Apr 2024 18:46:37 -0600 Subject: [PATCH 22/22] Fixes a bug in SMF\Tasks\FetchCalendarSubscriptions Signed-off-by: Jon Stovell --- Sources/Tasks/FetchCalendarSubscriptions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Tasks/FetchCalendarSubscriptions.php b/Sources/Tasks/FetchCalendarSubscriptions.php index f046dffb9f..4dc080be37 100644 --- a/Sources/Tasks/FetchCalendarSubscriptions.php +++ b/Sources/Tasks/FetchCalendarSubscriptions.php @@ -44,11 +44,11 @@ public function execute(): bool if (!empty($ics_data)) { switch ($type) { - case 'holiday': + case Event::TYPE_HOLIDAY: Holiday::import($ics_data); break; - case 'event': + case Event::TYPE_EVENT: Event::import($ics_data); break; }