diff --git a/assets/css/public.css b/assets/css/public.css index c7142b1..343405a 100644 --- a/assets/css/public.css +++ b/assets/css/public.css @@ -2110,7 +2110,8 @@ .mayo-expand-all-button, .mayo-print-button, -.mayo-shortcode-button { +.mayo-shortcode-button, +.mayo-cal-subscribe-button { background: none; border: none; cursor: pointer; @@ -2124,14 +2125,16 @@ .mayo-expand-all-button:hover, .mayo-print-button:hover, -.mayo-shortcode-button:hover { +.mayo-shortcode-button:hover, +.mayo-cal-subscribe-button:hover { color: #333; } .mayo-expand-all-button .dashicons, .mayo-print-button .dashicons, .mayo-rss-link .dashicons, -.mayo-shortcode-button .dashicons { +.mayo-shortcode-button .dashicons, +.mayo-cal-subscribe-button .dashicons { font-size: 1.25rem; width: 1.25rem; height: 1.25rem; @@ -2270,6 +2273,47 @@ padding: 0; } +.mayo-subscribe-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.mayo-subscribe-action { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.4rem 0.75rem; + background: #f0f0f1; + color: #2271b1; + border: 1px solid #c3c4c7; + border-radius: 3px; + text-decoration: none; + font-size: 0.9em; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.mayo-subscribe-action:hover { + background: #e0e0e1; + border-color: #8c8f94; + color: #135e96; + text-decoration: none; +} + +.mayo-subscribe-action .dashicons { + font-size: 1rem; + width: 1rem; + height: 1rem; +} + +.mayo-subscribe-help { + margin: 0.75rem 0 0; + font-size: 0.85em; + color: #555; + line-height: 1.4; +} + /* ============================================= Announcement Banner Styles ============================================= */ diff --git a/assets/js/src/components/public/EventList.js b/assets/js/src/components/public/EventList.js index fcad1ae..4f2916d 100644 --- a/assets/js/src/components/public/EventList.js +++ b/assets/js/src/components/public/EventList.js @@ -30,6 +30,8 @@ const EventList = ({ widget = false, settings = {} }) => { const [isInitialLoad, setIsInitialLoad] = useState(true); const [autoexpand, setAutoexpand] = useState(false); const [showShortcode, setShowShortcode] = useState(false); + const [showSubscribe, setShowSubscribe] = useState(false); + const [subscribeCopyState, setSubscribeCopyState] = useState('idle'); const [viewMode, setViewMode] = useState(settings?.defaultView || 'list'); // 'list' or 'calendar' const [calendarDate, setCalendarDate] = useState(new Date()); // Current month for calendar view const [calendarEvents, setCalendarEvents] = useState([]); // Events for calendar view @@ -248,6 +250,31 @@ const EventList = ({ widget = false, settings = {} }) => { } }; + const getWebcalUrl = () => getIcsUrl().replace(/^https?:\/\//i, 'webcal://'); + + const handleCopySubscribeUrl = async () => { + const url = getIcsUrl(); + try { + await navigator.clipboard.writeText(url); + setSubscribeCopyState('copied'); + setTimeout(() => setSubscribeCopyState('idle'), 2000); + } catch (err) { + const textArea = document.createElement('textarea'); + textArea.value = url; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + setSubscribeCopyState('copied'); + setTimeout(() => setSubscribeCopyState('idle'), 2000); + } catch (fallbackErr) { + console.error('Failed to copy subscribe URL:', fallbackErr); + } + document.body.removeChild(textArea); + } + }; + // Process events to add validation flags and move invalid dates to the end // Trust the order from the REST API - don't re-sort valid dates const processEvents = (eventList) => { @@ -704,15 +731,14 @@ const EventList = ({ widget = false, settings = {} }) => { > - setShowSubscribe(!showSubscribe)} + title={showSubscribe ? __('Hide Calendar Subscription', 'mayo-events-manager') : __('Subscribe to Calendar', 'mayo-events-manager')} + aria-expanded={showSubscribe} > - + { )} + {showSubscribe && ( +
+
+ {__('Subscribe to this calendar:', 'mayo-events-manager')} + +
+
+ {getIcsUrl()} +
+
+ + + {__('Subscribe in default calendar app', 'mayo-events-manager')} + + + + {__('Download .ics file', 'mayo-events-manager')} + +
+

+ {__('To subscribe in Google Calendar: open Other calendars → From URL, then paste the link above. New events will sync automatically.', 'mayo-events-manager')} +

+
+ )} {viewMode === 'calendar' ? ( post_modified_gmt); + if ($ts && $ts > $last_modified_ts) { + $last_modified_ts = $ts; + } + } + if ($last_modified_ts === 0) { + $last_modified_ts = time(); + } + $last_modified_str = gmdate('D, d M Y H:i:s', $last_modified_ts) . ' GMT'; + + if (!empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { + $since = strtotime(wp_unslash($_SERVER['HTTP_IF_MODIFIED_SINCE'])); + if ($since !== false && $since >= $last_modified_ts) { + status_header(304); + header('Last-Modified: ' . $last_modified_str); + header('Cache-Control: public, max-age=' . self::CACHE_MAX_AGE); + exit; + } + } header('Content-Type: text/calendar; charset=utf-8'); header('Content-Disposition: inline; filename=mayo_events.ics'); + header('Cache-Control: public, max-age=' . self::CACHE_MAX_AGE); + header('Last-Modified: ' . $last_modified_str); + + $host = parse_url(home_url(), PHP_URL_HOST) ?: 'localhost'; + $vevents = []; + $timezones_used = []; - $events = self::get_ics_items($eventType, $serviceBody, $relation, $categories, $tags); + foreach ($posts as $post) { + if (!is_object($post) || !isset($post->ID)) { + continue; + } + try { + $built = self::build_vevent($post, $host); + if ($built === null) continue; + $vevents[] = $built['ics']; + if (!empty($built['tzid'])) { + $timezones_used[$built['tzid']] = true; + } + } catch (\Exception $e) { + error_log('Mayo ICS Feed Event Processing Error: ' . $e->getMessage()); + continue; + } + } echo "BEGIN:VCALENDAR\r\n"; echo "VERSION:2.0\r\n"; @@ -32,154 +80,456 @@ public static function generate_ics_feed() { echo "CALSCALE:GREGORIAN\r\n"; echo "METHOD:PUBLISH\r\n"; echo "X-WR-CALNAME:" . self::escape_ical_text(get_bloginfo('name') . " - Events") . "\r\n"; - echo "X-WR-TIMEZONE:UTC\r\n"; - - foreach ($events as $event) { - echo "BEGIN:VEVENT\r\n"; - echo "UID:" . self::escape_ical_text($event['uid']) . "\r\n"; - echo "DTSTAMP:" . self::escape_ical_text($event['dtstamp']) . "\r\n"; - echo "DTSTART:" . self::escape_ical_text($event['dtstart']) . "\r\n"; - echo "DTEND:" . self::escape_ical_text($event['dtend']) . "\r\n"; - echo "SUMMARY:" . self::escape_ical_text($event['summary']) . "\r\n"; - if (!empty($event['location'])) { - echo "LOCATION:" . self::escape_ical_text($event['location']) . "\r\n"; - } - echo "DESCRIPTION:" . self::escape_ical_text($event['description']) . "\r\n"; - echo "URL:" . self::escape_ical_text($event['url']) . "\r\n"; - echo "END:VEVENT\r\n"; + if (count($timezones_used) === 1) { + $only_tz = array_keys($timezones_used)[0]; + echo "X-WR-TIMEZONE:" . self::escape_ical_text($only_tz) . "\r\n"; + } + + foreach (array_keys($timezones_used) as $tzid) { + echo self::build_vtimezone($tzid); + } + + foreach ($vevents as $vevent) { + echo $vevent; } echo "END:VCALENDAR\r\n"; exit; } - private static function get_ics_items($eventType = '', $serviceBody = '', $relation = 'AND', $categories = '', $tags = '') { - $meta_query = []; - - // Only add meta queries for non-empty values + private static function query_events($eventType, $serviceBody, $relation, $categories, $tags) { + $facet_query = []; if (!empty($eventType)) { - $meta_query[] = [ - 'key' => 'event_type', - 'value' => $eventType, - 'compare' => '=' + $facet_query[] = [ + 'key' => 'event_type', + 'value' => $eventType, + 'compare' => '=', ]; } - if (!empty($serviceBody)) { - $meta_query[] = [ - 'key' => 'service_body', - 'value' => $serviceBody, - 'compare' => '=' + $facet_query[] = [ + 'key' => 'service_body', + 'value' => $serviceBody, + 'compare' => '=', ]; } + if (count($facet_query) > 1) { + $facet_query['relation'] = $relation; + } - $args = [ - 'post_type' => 'mayo_event', - 'posts_per_page' => 100, - 'post_status' => 'publish', - 'suppress_filters' => false // Add this to ensure all filters are applied + // Include non-recurring events whose start date is today or later, plus any event + // with a recurring_pattern meta (so series with past start dates still appear and + // calendar clients can render RRULE-projected future instances). + $now = current_time('Y-m-d'); + $date_or_recurring = [ + 'relation' => 'OR', + [ + 'key' => 'event_start_date', + 'value' => $now, + 'compare' => '>=', + 'type' => 'DATE', + ], + [ + 'key' => 'recurring_pattern', + 'compare' => 'EXISTS', + ], ]; - // Only add meta_query if we have conditions - if (!empty($meta_query)) { - if (count($meta_query) > 1) { - $meta_query['relation'] = $relation; - } - $args['meta_query'] = $meta_query; + if (!empty($facet_query)) { + $combined_meta_query = [ + 'relation' => 'AND', + $facet_query, + $date_or_recurring, + ]; + } else { + $combined_meta_query = $date_or_recurring; } - // Add taxonomy queries only if they exist + $args = [ + 'post_type' => 'mayo_event', + 'posts_per_page' => self::MAX_EVENTS, + 'post_status' => 'publish', + 'suppress_filters' => false, + 'meta_query' => $combined_meta_query, + ]; + if (!empty($categories)) { $args['category_name'] = $categories; } - if (!empty($tags)) { $args['tag'] = $tags; } - // Get current date for filtering - $now = current_time('Y-m-d'); + $events = get_posts($args); + if (is_wp_error($events)) { + error_log('Mayo ICS Feed Error: ' . $events->get_error_message()); + return []; + } + return $events; + } + + /** + * Build VEVENT(s) for a single post. + * @return array{ics:string,tzid:string}|null + */ + private static function build_vevent($post, $host) { + $event_type = get_post_meta($post->ID, 'event_type', true); + $start_date = get_post_meta($post->ID, 'event_start_date', true); + if (!$start_date) return null; + $end_date = get_post_meta($post->ID, 'event_end_date', true) ?: $start_date; + $start_time = get_post_meta($post->ID, 'event_start_time', true) ?: '00:00:00'; + $end_time = get_post_meta($post->ID, 'event_end_time', true) ?: '23:59:59'; + $location = get_post_meta($post->ID, 'location_name', true); + $location_addr = get_post_meta($post->ID, 'location_address', true); + $tzid = get_post_meta($post->ID, 'timezone', true) ?: 'UTC'; + + try { + $tz = new \DateTimeZone($tzid); + } catch (\Exception $e) { + $tzid = 'UTC'; + $tz = new \DateTimeZone('UTC'); + } + + $start_dt = new \DateTime($start_date . ' ' . $start_time, $tz); + $end_dt = new \DateTime($end_date . ' ' . $end_time, $tz); + + $pattern = get_post_meta($post->ID, 'recurring_pattern', true); + $skipped_occurrences = get_post_meta($post->ID, 'skipped_occurrences', true) ?: []; + + $rrule = null; + $exdates = []; + $expand_fallback = false; + if (is_array($pattern) && isset($pattern['type']) && $pattern['type'] !== 'none') { + $rrule = self::build_rrule($pattern, $tz); + if ($rrule !== null) { + foreach ($skipped_occurrences as $skip) { + try { + $skip_dt = new \DateTime($skip . ' ' . $start_time, $tz); + $exdates[] = $skip_dt->format('Ymd\THis'); + } catch (\Exception $e) { + continue; + } + } + } else { + $expand_fallback = true; + } + } + + $description = ''; + if ($event_type) { + $description .= "Event Type: " . $event_type . "\n"; + } + if ($location) { + $description .= "Location: " . $location . "\n"; + } + $description .= wp_strip_all_tags($post->post_content); + + $summary = html_entity_decode($post->post_title, ENT_QUOTES, 'UTF-8'); + $url = get_permalink($post->ID); + $created = gmdate('Ymd\THis\Z', strtotime($post->post_date_gmt)); + $last_modified = gmdate('Ymd\THis\Z', strtotime($post->post_modified_gmt)); + $dtstamp = gmdate('Ymd\THis\Z'); + $location_full = trim($location . ($location_addr ? (', ' . $location_addr) : '')); - // Add meta query for future events - $date_query = [ - 'key' => 'event_start_date', - 'value' => $now, - 'compare' => '>=', - 'type' => 'DATE' + $common = [ + 'dtstamp' => $dtstamp, + 'created' => $created, + 'last_modified' => $last_modified, + 'tzid' => $tzid, + 'summary' => $summary, + 'description' => $description, + 'location' => $location_full, + 'url' => $url, ]; - // Add date query to existing meta query - if (!empty($meta_query)) { - $meta_query[] = $date_query; - } else { - $meta_query = [$date_query]; + if ($expand_fallback) { + $occurrences = self::expand_occurrences($pattern, $start_dt, $end_dt, $skipped_occurrences, $tz); + $out = ''; + foreach ($occurrences as $occ) { + $out .= self::render_vevent($common + [ + 'uid' => 'mayo-' . $post->ID . '-' . $occ['start']->format('Ymd') . '@' . $host, + 'dtstart' => $occ['start']->format('Ymd\THis'), + 'dtend' => $occ['end']->format('Ymd\THis'), + 'rrule' => null, + 'exdates' => [], + ]); + } + return ['ics' => $out, 'tzid' => $tzid]; } - $args['meta_query'] = $meta_query; + $ics = self::render_vevent($common + [ + 'uid' => 'mayo-' . $post->ID . '@' . $host, + 'dtstart' => $start_dt->format('Ymd\THis'), + 'dtend' => $end_dt->format('Ymd\THis'), + 'rrule' => $rrule, + 'exdates' => $exdates, + ]); + return ['ics' => $ics, 'tzid' => $tzid]; + } - // Get posts with error handling - $events = get_posts($args); - if (is_wp_error($events)) { - error_log('ICS Feed Error: ' . $events->get_error_message()); - return []; + private static function render_vevent($d) { + $tzid = $d['tzid']; + $out = "BEGIN:VEVENT\r\n"; + $out .= "UID:" . self::escape_ical_text($d['uid']) . "\r\n"; + $out .= "DTSTAMP:" . $d['dtstamp'] . "\r\n"; + $out .= "CREATED:" . $d['created'] . "\r\n"; + $out .= "LAST-MODIFIED:" . $d['last_modified'] . "\r\n"; + $out .= "DTSTART;TZID=" . $tzid . ":" . $d['dtstart'] . "\r\n"; + $out .= "DTEND;TZID=" . $tzid . ":" . $d['dtend'] . "\r\n"; + if (!empty($d['rrule'])) { + $out .= "RRULE:" . $d['rrule'] . "\r\n"; } + if (!empty($d['exdates'])) { + foreach ($d['exdates'] as $exd) { + $out .= "EXDATE;TZID=" . $tzid . ":" . $exd . "\r\n"; + } + } + $out .= "SUMMARY:" . self::escape_ical_text($d['summary']) . "\r\n"; + if (!empty($d['location'])) { + $out .= "LOCATION:" . self::escape_ical_text($d['location']) . "\r\n"; + } + $out .= "DESCRIPTION:" . self::escape_ical_text($d['description']) . "\r\n"; + if (!empty($d['url'])) { + $out .= "URL:" . self::escape_ical_text($d['url']) . "\r\n"; + } + $out .= "END:VEVENT\r\n"; + return $out; + } - $ics_items = []; - foreach ($events as $event) { - if (!is_object($event) || !isset($event->ID)) { - continue; + /** + * Build an RFC 5545 RRULE for the recurring pattern, or null if not expressible. + */ + private static function build_rrule($pattern, $tz) { + $type = $pattern['type'] ?? ''; + $interval = max(1, intval($pattern['interval'] ?? 1)); + $parts = []; + + if ($type === 'daily') { + $parts[] = 'FREQ=DAILY'; + if ($interval > 1) $parts[] = 'INTERVAL=' . $interval; + } elseif ($type === 'weekly') { + $parts[] = 'FREQ=WEEKLY'; + if ($interval > 1) $parts[] = 'INTERVAL=' . $interval; + if (!empty($pattern['weekdays']) && is_array($pattern['weekdays'])) { + $map = ['SU','MO','TU','WE','TH','FR','SA']; + $days = []; + foreach ($pattern['weekdays'] as $d) { + $i = intval($d); + if (isset($map[$i])) $days[] = $map[$i]; + } + if (!empty($days)) { + $parts[] = 'BYDAY=' . implode(',', $days); + } + } + } elseif ($type === 'monthly') { + $parts[] = 'FREQ=MONTHLY'; + if ($interval > 1) $parts[] = 'INTERVAL=' . $interval; + $monthlyType = $pattern['monthlyType'] ?? 'date'; + if ($monthlyType === 'date' && isset($pattern['monthlyDate'])) { + $parts[] = 'BYMONTHDAY=' . intval($pattern['monthlyDate']); + } elseif ($monthlyType === 'weekday' && isset($pattern['monthlyWeekday'])) { + $pieces = array_pad(explode(',', $pattern['monthlyWeekday']), 2, null); + $week = intval($pieces[0]); + $weekday = intval($pieces[1]); + $map = ['SU','MO','TU','WE','TH','FR','SA']; + if (!isset($map[$weekday])) return null; + // week=0 means "last" per EventsController:1809-1810 + $prefix = ($week === 0) ? '-1' : (string)$week; + $parts[] = 'BYDAY=' . $prefix . $map[$weekday]; + } else { + return null; } + } else { + return null; + } + if (!empty($pattern['endDate'])) { try { - // Get event meta - $event_type = get_post_meta($event->ID, 'event_type', true); - $start_date = get_post_meta($event->ID, 'event_start_date', true); - $end_date = get_post_meta($event->ID, 'event_end_date', true) ?: $start_date; - $start_time = get_post_meta($event->ID, 'event_start_time', true) ?: '00:00:00'; - $end_time = get_post_meta($event->ID, 'event_end_time', true) ?: '23:59:59'; - $location = get_post_meta($event->ID, 'location_name', true); - $timezone = get_post_meta($event->ID, 'timezone', true) ?: 'UTC'; - - // Format dates for iCal - $start_datetime = new \DateTime($start_date . ' ' . $start_time, new \DateTimeZone($timezone)); - $end_datetime = new \DateTime($end_date . ' ' . $end_time, new \DateTimeZone($timezone)); - - // Convert to UTC - $start_datetime->setTimezone(new \DateTimeZone('UTC')); - $end_datetime->setTimezone(new \DateTimeZone('UTC')); - $now = new \DateTime('now', new \DateTimeZone('UTC')); - - // Build description - $description = "Event Type: " . $event_type . "\n"; - if ($location) { - $description .= "Location: " . $location . "\n"; - } - $description .= strip_tags($event->post_content); - - $ics_items[] = [ - 'uid' => $event->ID . '@' . parse_url(home_url(), PHP_URL_HOST), - 'dtstamp' => $now->format('Ymd\THis\Z'), - 'dtstart' => $start_datetime->format('Ymd\THis\Z'), - 'dtend' => $end_datetime->format('Ymd\THis\Z'), - 'summary' => html_entity_decode($event->post_title, ENT_QUOTES, 'UTF-8'), - 'description' => $description, - 'location' => $location, - 'url' => get_permalink($event->ID) - ]; + $until = new \DateTime($pattern['endDate'] . ' 23:59:59', $tz); + $until->setTimezone(new \DateTimeZone('UTC')); + $parts[] = 'UNTIL=' . $until->format('Ymd\THis\Z'); } catch (\Exception $e) { - error_log('ICS Feed Event Processing Error: ' . $e->getMessage()); - continue; + // omit UNTIL on malformed endDate + } + } + + return implode(';', $parts); + } + + /** + * Expansion fallback for recurring patterns that can't be encoded as RRULE. + * Mirrors the algorithm in EventsController::generate_recurring_events(). + */ + private static function expand_occurrences($pattern, $start_dt, $end_dt, $skipped, $tz) { + $weekdays = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + $duration = $start_dt->diff($end_dt); + $occurrences = [['start' => clone $start_dt, 'end' => clone $end_dt]]; + $end_pattern = !empty($pattern['endDate']) ? new \DateTime($pattern['endDate'], $tz) : null; + + $max = 0; + switch ($pattern['type'] ?? '') { + case 'daily': $max = 365 * 5; break; + case 'weekly': $max = 52 * 5; break; + case 'monthly': $max = 12 * 5; break; + default: return $occurrences; + } + + if ($pattern['type'] === 'monthly') { + $current = clone $start_dt; + $interval = max(1, intval($pattern['interval'] ?? 1)); + $current->modify('first day of +' . $interval . ' month'); + while (($end_pattern === null || $current <= $end_pattern) && count($occurrences) < $max) { + $year = (int)$current->format('Y'); + $month = (int)$current->format('m'); + if (isset($pattern['monthlyType']) && $pattern['monthlyType'] === 'date') { + $day = (int)$pattern['monthlyDate']; + $days_in_month = (int)$current->format('t'); + if ($day > $days_in_month) { + $current->modify('first day of +' . $interval . ' month'); + continue; + } + $current->setDate($year, $month, $day); + } else { + if (!isset($pattern['monthlyWeekday'])) { + $current->modify('first day of +' . $interval . ' month'); + continue; + } + list($week, $weekday) = explode(',', $pattern['monthlyWeekday']); + $week = (int)$week; $weekday = (int)$weekday; + $current->setDate($year, $month, 1); + if ($week > 0) { + $current->modify('first ' . $weekdays[$weekday] . ' of this month'); + if ($week > 1) { + $current->modify('+' . ($week - 1) . ' weeks'); + } + } else { + $current->modify('last ' . $weekdays[$weekday] . ' of this month'); + } + } + if ($end_pattern === null || $current <= $end_pattern) { + $date_str = $current->format('Y-m-d'); + if (!in_array($date_str, $skipped, true)) { + $occ_start = (clone $current)->setTime( + (int)$start_dt->format('H'), + (int)$start_dt->format('i'), + (int)$start_dt->format('s') + ); + $occ_end = clone $occ_start; + $occ_end->add($duration); + $occurrences[] = ['start' => $occ_start, 'end' => $occ_end]; + } + } + $current->setDate($year, $month, 1); + $current->modify('+' . $interval . ' month'); } + } else { + $interval = max(1, intval($pattern['interval'] ?? 1)); + $spec = new \DateInterval('P' . $interval . ($pattern['type'] === 'daily' ? 'D' : ($pattern['type'] === 'weekly' ? 'W' : 'M'))); + $current = clone $start_dt; + $current->add($spec); + while (($end_pattern === null || $current <= $end_pattern) && count($occurrences) < $max) { + if ($pattern['type'] === 'weekly' && !empty($pattern['weekdays'])) { + $is = clone $current; + $ie = clone $current; + $ie->add($spec); + while ($is < $ie && count($occurrences) < $max) { + if (in_array($is->format('w'), $pattern['weekdays'])) { + $date_str = $is->format('Y-m-d'); + if (!in_array($date_str, $skipped, true)) { + $occ_start = clone $is; + $occ_end = clone $occ_start; + $occ_end->add($duration); + $occurrences[] = ['start' => $occ_start, 'end' => $occ_end]; + } + } + $is->modify('+1 day'); + } + } else { + $date_str = $current->format('Y-m-d'); + if (!in_array($date_str, $skipped, true)) { + $occ_start = clone $current; + $occ_end = clone $occ_start; + $occ_end->add($duration); + $occurrences[] = ['start' => $occ_start, 'end' => $occ_end]; + } + } + $current->add($spec); + } + } + + return $occurrences; + } + + /** + * Emit a VTIMEZONE block with current STANDARD/DAYLIGHT transitions. + * One historical pair is sufficient for the vast majority of calendar clients. + */ + private static function build_vtimezone($tzid) { + try { + $tz = new \DateTimeZone($tzid); + } catch (\Exception $e) { + return ''; } - return $ics_items; + $now = time(); + $transitions = $tz->getTransitions(strtotime('-1 year', $now), strtotime('+2 year', $now)); + if (empty($transitions)) { + return ''; + } + + $std = null; + $dst = null; + foreach ($transitions as $t) { + if (!empty($t['isdst'])) { + $dst = $t; + } else { + $std = $t; + } + } + + $out = "BEGIN:VTIMEZONE\r\n"; + $out .= "TZID:" . $tzid . "\r\n"; + if ($std) { + $out .= self::build_vtimezone_component('STANDARD', $std, $dst ?: $std); + } + if ($dst) { + $out .= self::build_vtimezone_component('DAYLIGHT', $dst, $std ?: $dst); + } + $out .= "END:VTIMEZONE\r\n"; + return $out; + } + + private static function build_vtimezone_component($type, $trans, $other) { + $offset_from = self::format_offset($other['offset']); + $offset_to = self::format_offset($trans['offset']); + // VTIMEZONE DTSTART is local time of the transition (clock face just after the change). + $local = gmdate('Ymd\THis', $trans['ts'] + $trans['offset']); + $abbr = isset($trans['abbr']) ? $trans['abbr'] : ''; + + $out = "BEGIN:" . $type . "\r\n"; + $out .= "DTSTART:" . $local . "\r\n"; + $out .= "TZOFFSETFROM:" . $offset_from . "\r\n"; + $out .= "TZOFFSETTO:" . $offset_to . "\r\n"; + if ($abbr !== '') { + $out .= "TZNAME:" . $abbr . "\r\n"; + } + $out .= "END:" . $type . "\r\n"; + return $out; + } + + private static function format_offset($seconds) { + $sign = $seconds < 0 ? '-' : '+'; + $abs = abs($seconds); + return $sign . sprintf('%02d%02d', floor($abs / 3600), floor(($abs % 3600) / 60)); } private static function escape_ical_text($text) { - $text = str_replace(["\r\n", "\n", "\r"], "\\n", $text); - $text = str_replace([",", ";", "\\"], ["\\,", "\\;", "\\\\"], $text); + // Order matters: backslashes first, then commas/semicolons, then newlines. + $text = str_replace('\\', '\\\\', $text); + $text = str_replace([',', ';'], ['\\,', '\\;'], $text); + $text = str_replace(["\r\n", "\n", "\r"], '\\n', $text); return $text; } } -CalendarFeed::init(); \ No newline at end of file +CalendarFeed::init(); diff --git a/languages/mayo-events-manager.pot b/languages/mayo-events-manager.pot index 46484f9..dad0d7f 100644 --- a/languages/mayo-events-manager.pot +++ b/languages/mayo-events-manager.pot @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: Mayo Events Manager\n" "Report-Msgid-Bugs-To: https://github.com/bmlt-enabled/mayo/issues\n" -"POT-Creation-Date: 2026-05-17 18:24+0000\n" +"POT-Creation-Date: 2026-05-20 03:28+0000\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -293,10 +293,6 @@ msgid "Calendar" msgstr "" #: assets/js/src/components/public/EventList.js:712 -msgid "Calendar Feed (ICS)" -msgstr "" - -#: assets/js/src/components/public/EventList.js:685 msgid "Calendar View" msgstr "" @@ -374,7 +370,7 @@ msgstr "" msgid "Close %s filter" msgstr "" -#: assets/js/src/components/public/EventList.js:695 +#: assets/js/src/components/public/EventList.js:722 msgid "Collapse All" msgstr "" @@ -423,8 +419,13 @@ msgstr "" msgid "Control when this announcement is visible on the site." msgstr "" +#: assets/js/src/components/public/EventList.js:789 +msgid "Copied" +msgstr "" + #: includes/Admin.php:602 -#: assets/js/src/components/public/EventList.js:744 +#: assets/js/src/components/public/EventList.js:770 +#: assets/js/src/components/public/EventList.js:790 msgid "Copy" msgstr "" @@ -436,7 +437,8 @@ msgstr "" msgid "Copy ID to clipboard" msgstr "" -#: assets/js/src/components/public/EventList.js:741 +#: assets/js/src/components/public/EventList.js:767 +#: assets/js/src/components/public/EventList.js:785 msgid "Copy to Clipboard" msgstr "" @@ -476,7 +478,7 @@ msgid "Date to skip" msgstr "" #: includes/Subscriber.php:900 -#: assets/js/src/components/public/EventList.js:576 +#: assets/js/src/components/public/EventList.js:603 msgid "Date:" msgstr "" @@ -556,12 +558,20 @@ msgstr "" msgid "Display event-based announcements as a banner or modal." msgstr "" +#: assets/js/src/components/public/EventList.js:814 +msgid "Download .ics file" +msgstr "" + #: assets/js/src/components/public/EventModal.js:140 #: assets/js/src/components/public/cards/EventCard.js:181 #: assets/js/src/components/public/EventDetails.js:134 msgid "Download Flyer" msgstr "" +#: assets/js/src/components/public/EventList.js:811 +msgid "Download a one-time snapshot (.ics file)" +msgstr "" + #: assets/js/src/components/admin/CssClassesDocs.js:37 msgid "Dynamic CSS Classes" msgstr "" @@ -772,7 +782,7 @@ msgstr "" msgid "Events will show as announcements when today's date is between the event's start and end dates." msgstr "" -#: assets/js/src/components/public/EventList.js:695 +#: assets/js/src/components/public/EventList.js:722 msgid "Expand All" msgstr "" @@ -926,7 +936,11 @@ msgstr "" msgid "Go to Today" msgstr "" -#: assets/js/src/components/public/EventList.js:728 +#: assets/js/src/components/public/EventList.js:737 +msgid "Hide Calendar Subscription" +msgstr "" + +#: assets/js/src/components/public/EventList.js:754 msgid "Hide Shortcode" msgstr "" @@ -1053,7 +1067,7 @@ msgstr "" msgid "Links & Events" msgstr "" -#: assets/js/src/components/public/EventList.js:678 +#: assets/js/src/components/public/EventList.js:705 msgid "List View" msgstr "" @@ -1071,13 +1085,13 @@ msgstr "" #: assets/js/src/components/admin/AnnouncementEditor.js:208 #: assets/js/src/components/public/CalendarView.js:339 -#: assets/js/src/components/public/EventList.js:627 +#: assets/js/src/components/public/EventList.js:654 #: assets/js/src/components/public/EventArchive.js:45 msgid "Loading events..." msgstr "" -#: assets/js/src/components/public/EventList.js:774 -#: assets/js/src/components/public/EventList.js:779 +#: assets/js/src/components/public/EventList.js:844 +#: assets/js/src/components/public/EventList.js:849 msgid "Loading more events..." msgstr "" @@ -1121,7 +1135,7 @@ msgstr "" #: includes/Subscriber.php:917 #: includes/Rest/EventsController.php:914 -#: assets/js/src/components/public/EventList.js:578 +#: assets/js/src/components/public/EventList.js:605 msgid "Location:" msgstr "" @@ -1259,11 +1273,11 @@ msgstr "" msgid "No events found" msgstr "" -#: assets/js/src/components/public/EventList.js:643 +#: assets/js/src/components/public/EventList.js:670 msgid "No events found in the archive." msgstr "" -#: assets/js/src/components/public/EventList.js:641 +#: assets/js/src/components/public/EventList.js:668 msgid "No events match the selected filters." msgstr "" @@ -1284,7 +1298,7 @@ msgstr "" msgid "No subscription options configured. Configure them in the Settings page." msgstr "" -#: assets/js/src/components/public/EventList.js:645 +#: assets/js/src/components/public/EventList.js:672 msgid "No upcoming events found." msgstr "" @@ -1347,6 +1361,10 @@ msgstr "" msgid "Open Link" msgstr "" +#: assets/js/src/components/public/EventList.js:800 +msgid "Open in your default calendar app (Apple Calendar, Outlook, etc.)" +msgstr "" + #: assets/js/src/components/admin/Settings.js:651 msgid "Opt-in: Existing subscribers must manually add new options" msgstr "" @@ -1432,11 +1450,11 @@ msgstr "" msgid "Previous Month" msgstr "" -#: assets/js/src/components/public/EventList.js:703 +#: assets/js/src/components/public/EventList.js:730 msgid "Print Events" msgstr "" -#: assets/js/src/components/public/EventList.js:570 +#: assets/js/src/components/public/EventList.js:597 msgid "Printed on" msgstr "" @@ -1456,7 +1474,7 @@ msgid_plural "RELATED EVENTS" msgstr[0] "" msgstr[1] "" -#: assets/js/src/components/public/EventList.js:721 +#: assets/js/src/components/public/EventList.js:747 msgid "RSS Feed" msgstr "" @@ -1689,7 +1707,7 @@ msgstr "" msgid "Settings saved successfully!" msgstr "" -#: assets/js/src/components/public/EventList.js:737 +#: assets/js/src/components/public/EventList.js:763 msgid "Shortcode for this event list:" msgstr "" @@ -1699,7 +1717,7 @@ msgstr "" msgid "Shortcodes" msgstr "" -#: assets/js/src/components/public/EventList.js:728 +#: assets/js/src/components/public/EventList.js:754 msgid "Show Shortcode" msgstr "" @@ -1787,6 +1805,18 @@ msgstr "" msgid "Subscribe" msgstr "" +#: assets/js/src/components/public/EventList.js:803 +msgid "Subscribe in default calendar app" +msgstr "" + +#: assets/js/src/components/public/EventList.js:737 +msgid "Subscribe to Calendar" +msgstr "" + +#: assets/js/src/components/public/EventList.js:781 +msgid "Subscribe to this calendar:" +msgstr "" + #: assets/js/src/components/admin/Subscribers.js:390 #: assets/js/src/components/admin/Subscribers.js:474 msgid "Subscribed" @@ -1971,6 +2001,10 @@ msgstr "" msgid "Title: %s" msgstr "" +#: assets/js/src/components/public/EventList.js:818 +msgid "To subscribe in Google Calendar: open Other calendars → From URL, then paste the link above. New events will sync automatically." +msgstr "" + #: assets/js/src/components/public/CalendarView.js:318 msgid "Today" msgstr "" @@ -1991,7 +2025,7 @@ msgid "Type" msgstr "" #: assets/js/src/components/admin/Settings.js:468 -#: assets/js/src/components/public/EventList.js:577 +#: assets/js/src/components/public/EventList.js:604 #: assets/js/src/components/public/EventArchive.js:77 msgid "Type:" msgstr "" @@ -2102,7 +2136,7 @@ msgstr "" msgid "View online:" msgstr "" -#: assets/js/src/components/public/EventList.js:647 +#: assets/js/src/components/public/EventList.js:674 msgid "View past events" msgstr "" @@ -2212,7 +2246,7 @@ msgstr "" #: assets/js/src/components/public/cards/EventCard.js:147 #: assets/js/src/components/public/cards/EventCard.js:152 -#: assets/js/src/components/public/EventList.js:576 +#: assets/js/src/components/public/EventList.js:603 #: assets/js/src/components/public/EventDetails.js:183 #: assets/js/src/components/public/EventDetails.js:188 #: assets/js/src/components/public/AnnouncementDetails.js:98 diff --git a/mayo-events-manager.php b/mayo-events-manager.php index ed1e4c8..700f6ec 100644 --- a/mayo-events-manager.php +++ b/mayo-events-manager.php @@ -3,7 +3,7 @@ /** * Plugin Name: Mayo Events Manager * Description: A plugin for managing and displaying events. - * Version: 1.9.0 + * Version: 1.9.1 * Author: bmlt-enabled * License: GPLv2 or later * Author URI: https://bmlt.app @@ -22,7 +22,7 @@ exit; // Exit if accessed directly } -define('MAYO_VERSION', '1.9.0'); +define('MAYO_VERSION', '1.9.1'); require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/includes/Admin.php'; diff --git a/package.json b/package.json index aa08f61..1d1e3ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mayo", - "version": "1.9.0", + "version": "1.9.1", "description": "", "main": "index.js", "scripts": { diff --git a/readme.txt b/readme.txt index 3c4359c..26f57aa 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: events, bmlt, narcotics anonymous, na Requires PHP: 8.2 Requires at least: 6.7 Tested up to: 6.9 -Stable tag: 1.9.0 +Stable tag: 1.9.1 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -187,6 +187,9 @@ This project is licensed under the GPL v2 or later. == Changelog == += 1.9.1 = +* Added a subscribable ICS calendar feed so Google Calendar, Apple Calendar, and Outlook keep syncing newly approved events automatically. The calendar icon on the event list now opens a Subscribe panel with the feed URL, a webcal:// quick-subscribe link, a one-time .ics download, and Google Calendar instructions. Recurring events now emit proper RRULE/EXDATE so series display as a single repeating event in subscribers' calendars instead of one-shot duplicates, and each event's original timezone is preserved via VTIMEZONE blocks. [#277] + = 1.9.0 = * Added a filter bar above the event list and calendar. Visitors pick filters from compact dropdowns (event type, service body, category, tag); selected values appear as removable chips and combine with OR semantics within a facet and AND across facets. Options are populated from the actual events in scope, including service bodies surfaced by external feeds, and any filter pinned by a shortcode attribute stays locked. [#251] * Fixed issue with long caching mechanisms like Cloudflare to avoid using nonces for readonly API calls [#271] diff --git a/tests/Unit/CalendarFeedTest.php b/tests/Unit/CalendarFeedTest.php index 5235169..d2b80ff 100644 --- a/tests/Unit/CalendarFeedTest.php +++ b/tests/Unit/CalendarFeedTest.php @@ -12,15 +12,14 @@ protected function setUp(): void { parent::setUp(); $this->mockPostMeta(); Functions\when('get_bloginfo')->justReturn('Test Site'); + Functions\when('wp_strip_all_tags')->alias(function ($text) { + return trim(strip_tags($text)); + }); } - /** - * Test init registers feed action - */ public function testInitRegistersFeedAction(): void { $actionsAdded = []; - - Functions\when('add_action')->alias(function($tag, $callback) use (&$actionsAdded) { + Functions\when('add_action')->alias(function ($tag) use (&$actionsAdded) { $actionsAdded[] = $tag; }); @@ -29,13 +28,9 @@ public function testInitRegistersFeedAction(): void { $this->assertContains('init', $actionsAdded); } - /** - * Test register_feed adds the feed - */ public function testRegisterFeedAddsFeed(): void { $feedsAdded = []; - - Functions\when('add_feed')->alias(function($feedname, $callback) use (&$feedsAdded) { + Functions\when('add_feed')->alias(function ($feedname) use (&$feedsAdded) { $feedsAdded[] = $feedname; }); @@ -44,502 +39,318 @@ public function testRegisterFeedAddsFeed(): void { $this->assertContains('mayo_events', $feedsAdded); } - /** - * Test escape_ical_text escapes newlines - */ public function testEscapeIcalTextEscapesNewlines(): void { $method = $this->getPrivateMethod('escape_ical_text'); - $result = $method->invoke(null, "Line 1\nLine 2"); - // The escape produces a literal backslash-n - $this->assertStringContainsString('n', $result); + $this->assertStringContainsString('\\n', $result); $this->assertStringNotContainsString("\n", $result); } - /** - * Test escape_ical_text escapes commas - */ public function testEscapeIcalTextEscapesCommas(): void { $method = $this->getPrivateMethod('escape_ical_text'); - $result = $method->invoke(null, "Test, event"); - // The result will have an escaped comma (backslash before comma) $this->assertStringContainsString('\\,', $result); } - /** - * Test escape_ical_text escapes semicolons - */ public function testEscapeIcalTextEscapesSemicolons(): void { $method = $this->getPrivateMethod('escape_ical_text'); - $result = $method->invoke(null, "Test; event"); - // The result will have an escaped semicolon $this->assertStringContainsString('\\;', $result); } - /** - * Test escape_ical_text escapes backslashes - */ public function testEscapeIcalTextEscapesBackslashes(): void { $method = $this->getPrivateMethod('escape_ical_text'); - - $input = "Test" . chr(92) . "event"; // Single backslash + $input = 'Test' . chr(92) . 'event'; $result = $method->invoke(null, $input); - // Should have doubled the backslash - $this->assertGreaterThan(strlen($input), strlen($result)); + // Single backslash should be escaped to two backslashes + $this->assertStringContainsString('\\\\', $result); } - /** - * Test escape_ical_text handles combined special characters - */ public function testEscapeIcalTextHandlesCombined(): void { $method = $this->getPrivateMethod('escape_ical_text'); - $result = $method->invoke(null, "Event: Test, with; special\nchars"); - // Verify special characters are escaped with backslashes $this->assertStringContainsString('\\,', $result); $this->assertStringContainsString('\\;', $result); + $this->assertStringContainsString('\\n', $result); $this->assertStringNotContainsString("\n", $result); } - /** - * Test escape_ical_text handles carriage returns - */ public function testEscapeIcalTextHandlesCarriageReturns(): void { $method = $this->getPrivateMethod('escape_ical_text'); - $result = $method->invoke(null, "Line 1\r\nLine 2"); - // Should not contain actual CRLF $this->assertStringNotContainsString("\r\n", $result); + $this->assertStringContainsString('\\n', $result); } - /** - * Test get_ics_items returns empty array when no events - */ - public function testGetIcsItemsReturnsEmptyWhenNoEvents(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - /** - * Test get_ics_items returns formatted events - */ - public function testGetIcsItemsReturnsFormattedEvents(): void { - $post = $this->createMockPost([ - 'ID' => 100, - 'post_title' => 'Test Event', - 'post_content' => 'Test description', - 'post_type' => 'mayo_event' - ]); - - $this->setPostMeta(100, [ - 'event_type' => 'Meeting', - 'event_start_date' => date('Y-m-d', strtotime('+7 days')), - 'event_end_date' => date('Y-m-d', strtotime('+7 days')), - 'event_start_time' => '10:00:00', - 'event_end_time' => '12:00:00', - 'timezone' => 'America/New_York', - 'location_name' => 'Test Location' - ]); - - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); - - $this->assertCount(1, $result); - $this->assertArrayHasKey('uid', $result[0]); - $this->assertArrayHasKey('dtstamp', $result[0]); - $this->assertArrayHasKey('dtstart', $result[0]); - $this->assertArrayHasKey('dtend', $result[0]); - $this->assertArrayHasKey('summary', $result[0]); - $this->assertArrayHasKey('description', $result[0]); - $this->assertArrayHasKey('location', $result[0]); - $this->assertArrayHasKey('url', $result[0]); - - $this->assertEquals('Test Event', $result[0]['summary']); - $this->assertEquals('Test Location', $result[0]['location']); - } - - /** - * Test get_ics_items with event_type filter - */ - public function testGetIcsItemsWithEventTypeFilter(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null, 'Service'); - - // Just verify it runs without error - $this->assertIsArray($result); - } - - /** - * Test get_ics_items with service_body filter - */ - public function testGetIcsItemsWithServiceBodyFilter(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null, '', '10'); - - $this->assertIsArray($result); - } - - /** - * Test get_ics_items with categories filter - */ - public function testGetIcsItemsWithCategoriesFilter(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null, '', '', 'AND', 'news,events'); - - $this->assertIsArray($result); - } - - /** - * Test get_ics_items with tags filter - */ - public function testGetIcsItemsWithTagsFilter(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null, '', '', 'AND', '', 'featured'); - - $this->assertIsArray($result); + public function testEscapeIcalTextHandlesEmptyString(): void { + $method = $this->getPrivateMethod('escape_ical_text'); + $this->assertEquals('', $method->invoke(null, '')); } - /** - * Test get_ics_items handles get_posts error - */ - public function testGetIcsItemsHandlesGetPostsError(): void { - Functions\when('get_posts')->justReturn(new \WP_Error('error', 'Database error')); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); + public function testEscapeIcalTextDoesNotDoubleEscapeNewlines(): void { + // Regression guard: ordering must be backslash-first so the literal "\n" + // we emit for newlines isn't subsequently re-escaped into "\\n". + $method = $this->getPrivateMethod('escape_ical_text'); + $result = $method->invoke(null, "Line 1\nLine 2"); - $this->assertIsArray($result); - $this->assertEmpty($result); + $this->assertStringContainsString('\\n', $result); + $this->assertStringNotContainsString('\\\\n', $result); } - /** - * Test get_ics_items skips invalid post objects - */ - public function testGetIcsItemsSkipsInvalidPosts(): void { - $validPost = $this->createMockPost([ - 'ID' => 100, - 'post_title' => 'Valid Event', - 'post_content' => 'Test', - 'post_type' => 'mayo_event' - ]); - - $this->setPostMeta(100, [ - 'event_type' => 'Meeting', - 'event_start_date' => date('Y-m-d', strtotime('+7 days')), - 'event_end_date' => date('Y-m-d', strtotime('+7 days')), - 'event_start_time' => '10:00:00', - 'event_end_time' => '12:00:00', - 'timezone' => 'UTC' - ]); - - // Include an invalid item (string instead of object) - Functions\when('get_posts')->justReturn([$validPost, 'invalid']); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); + public function testBuildVeventReturnsNullWithoutStartDate(): void { + $post = $this->createMockPost(['ID' => 100]); + $this->setPostMeta(100, []); - // Should only have 1 event (the valid one) - $this->assertCount(1, $result); + $result = $this->buildVevent($post); + $this->assertNull($result); } - /** - * Test get_ics_items formats UID correctly - */ - public function testGetIcsItemsFormatsUidCorrectly(): void { + public function testBuildVeventEmitsTzidAnchoredDtstart(): void { $post = $this->createMockPost([ - 'ID' => 123, - 'post_title' => 'Test Event', - 'post_content' => 'Test', - 'post_type' => 'mayo_event' + 'ID' => 101, + 'post_title' => 'Sample Event', + 'post_content' => 'Body', + 'post_date_gmt' => '2024-01-01 12:00:00', + 'post_modified_gmt' => '2024-01-02 13:00:00', ]); - - $this->setPostMeta(123, [ - 'event_type' => 'Meeting', - 'event_start_date' => date('Y-m-d', strtotime('+7 days')), - 'event_end_date' => date('Y-m-d', strtotime('+7 days')), + $this->setPostMeta(101, [ + 'event_type' => 'Meeting', + 'event_start_date' => '2030-06-15', + 'event_end_date' => '2030-06-15', 'event_start_time' => '10:00:00', - 'event_end_time' => '12:00:00', - 'timezone' => 'UTC' + 'event_end_time' => '12:00:00', + 'timezone' => 'America/New_York', + 'location_name' => 'Main Hall', ]); - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); + $result = $this->buildVevent($post); - $this->assertStringContainsString('123@', $result[0]['uid']); - $this->assertStringContainsString('example.com', $result[0]['uid']); + $this->assertIsArray($result); + $this->assertEquals('America/New_York', $result['tzid']); + $this->assertStringContainsString('DTSTART;TZID=America/New_York:20300615T100000', $result['ics']); + $this->assertStringContainsString('DTEND;TZID=America/New_York:20300615T120000', $result['ics']); + $this->assertStringContainsString('SUMMARY:Sample Event', $result['ics']); + $this->assertStringContainsString('LOCATION:Main Hall', $result['ics']); + $this->assertStringContainsString('UID:mayo-101@example.com', $result['ics']); + $this->assertStringContainsString('CREATED:', $result['ics']); + $this->assertStringContainsString('LAST-MODIFIED:', $result['ics']); + $this->assertStringNotContainsString('RRULE:', $result['ics']); } - /** - * Test get_ics_items uses default timezone when not specified - */ - public function testGetIcsItemsUsesDefaultTimezone(): void { + public function testBuildVeventEmitsRruleForWeeklyPattern(): void { $post = $this->createMockPost([ - 'ID' => 124, - 'post_title' => 'Event No Timezone', - 'post_content' => 'Test', - 'post_type' => 'mayo_event' + 'ID' => 102, + 'post_title' => 'Weekly Meeting', + 'post_content' => '', ]); - - // Note: no timezone in meta - $this->setPostMeta(124, [ - 'event_type' => 'Meeting', - 'event_start_date' => date('Y-m-d', strtotime('+7 days')), - 'event_end_date' => date('Y-m-d', strtotime('+7 days')), - 'event_start_time' => '10:00:00', - 'event_end_time' => '12:00:00' + $this->setPostMeta(102, [ + 'event_start_date' => '2030-01-06', + 'event_end_date' => '2030-01-06', + 'event_start_time' => '19:00:00', + 'event_end_time' => '20:00:00', + 'timezone' => 'UTC', + 'recurring_pattern' => [ + 'type' => 'weekly', + 'interval' => 1, + 'weekdays' => [1, 3], // Mon, Wed + ], ]); - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); + $result = $this->buildVevent($post); - // Should not throw an error - $this->assertCount(1, $result); + $this->assertStringContainsString('RRULE:FREQ=WEEKLY;BYDAY=MO,WE', $result['ics']); } - /** - * Test get_ics_items with both categories and tags filters - */ - public function testGetIcsItemsWithCategoriesAndTags(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null, '', '', 'AND', 'news,events', 'featured,important'); + public function testBuildVeventEmitsRruleForMonthlyByDate(): void { + $post = $this->createMockPost(['ID' => 103]); + $this->setPostMeta(103, [ + 'event_start_date' => '2030-01-15', + 'event_end_date' => '2030-01-15', + 'event_start_time' => '09:00:00', + 'event_end_time' => '10:00:00', + 'timezone' => 'UTC', + 'recurring_pattern' => [ + 'type' => 'monthly', + 'interval' => 1, + 'monthlyType' => 'date', + 'monthlyDate' => 15, + ], + ]); - $this->assertIsArray($result); + $result = $this->buildVevent($post); + $this->assertStringContainsString('RRULE:FREQ=MONTHLY;BYMONTHDAY=15', $result['ics']); } - /** - * Test get_ics_items with OR relation - */ - public function testGetIcsItemsWithOrRelation(): void { - Functions\when('get_posts')->justReturn([]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null, 'Service', '5', 'OR'); + public function testBuildVeventEmitsRruleForMonthlyByWeekday(): void { + $post = $this->createMockPost(['ID' => 104]); + $this->setPostMeta(104, [ + 'event_start_date' => '2030-01-08', + 'event_end_date' => '2030-01-08', + 'event_start_time' => '18:00:00', + 'event_end_time' => '19:30:00', + 'timezone' => 'UTC', + 'recurring_pattern' => [ + 'type' => 'monthly', + 'interval' => 1, + 'monthlyType' => 'weekday', + 'monthlyWeekday' => '2,3', // 2nd Wednesday + ], + ]); - $this->assertIsArray($result); + $result = $this->buildVevent($post); + $this->assertStringContainsString('RRULE:FREQ=MONTHLY;BYDAY=2WE', $result['ics']); } - /** - * Test get_ics_items handles DateTime exception gracefully - */ - public function testGetIcsItemsHandlesDateTimeException(): void { - $post = $this->createMockPost([ - 'ID' => 200, - 'post_title' => 'Event With Bad Date', - 'post_content' => 'Test', - 'post_type' => 'mayo_event' + public function testBuildVeventEmitsRruleForMonthlyLastWeekday(): void { + $post = $this->createMockPost(['ID' => 105]); + $this->setPostMeta(105, [ + 'event_start_date' => '2030-01-26', + 'event_end_date' => '2030-01-26', + 'event_start_time' => '14:00:00', + 'event_end_time' => '15:00:00', + 'timezone' => 'UTC', + 'recurring_pattern' => [ + 'type' => 'monthly', + 'interval' => 1, + 'monthlyType' => 'weekday', + 'monthlyWeekday' => '0,6', // last Saturday (week=0 means last) + ], ]); - // Set invalid date that will cause DateTime exception - $this->setPostMeta(200, [ - 'event_type' => 'Meeting', - 'event_start_date' => 'invalid-date-format', - 'event_end_date' => 'also-invalid', - 'event_start_time' => 'not-a-time', - 'event_end_time' => 'also-not-time', - 'timezone' => 'Invalid/Timezone' - ]); - - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); - - // Should handle the exception gracefully and return empty or skip the event - $this->assertIsArray($result); + $result = $this->buildVevent($post); + $this->assertStringContainsString('BYDAY=-1SA', $result['ics']); } - /** - * Test get_ics_items handles events with missing meta - */ - public function testGetIcsItemsHandlesMissingMeta(): void { - $post = $this->createMockPost([ - 'ID' => 201, - 'post_title' => 'Event Without Meta', - 'post_content' => '', - 'post_type' => 'mayo_event' + public function testBuildVeventEmitsExdateForSkippedOccurrences(): void { + $post = $this->createMockPost(['ID' => 106]); + $this->setPostMeta(106, [ + 'event_start_date' => '2030-01-06', + 'event_end_date' => '2030-01-06', + 'event_start_time' => '10:00:00', + 'event_end_time' => '11:00:00', + 'timezone' => 'America/New_York', + 'recurring_pattern' => [ + 'type' => 'weekly', + 'interval' => 1, + 'weekdays' => [0], // Sunday + ], + 'skipped_occurrences' => ['2030-01-20', '2030-02-10'], ]); - // Empty meta - all defaults should kick in - $this->setPostMeta(201, []); - - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); - - // Should handle gracefully - $this->assertIsArray($result); + $result = $this->buildVevent($post); + $this->assertStringContainsString('EXDATE;TZID=America/New_York:20300120T100000', $result['ics']); + $this->assertStringContainsString('EXDATE;TZID=America/New_York:20300210T100000', $result['ics']); } - /** - * Test get_ics_items builds correct description with location - */ - public function testGetIcsItemsBuildsCorrectDescriptionWithLocation(): void { - $post = $this->createMockPost([ - 'ID' => 202, - 'post_title' => 'Event With Location', - 'post_content' => 'This is the event description.', - 'post_type' => 'mayo_event' + public function testBuildVeventEmitsUntilWhenEndDateSet(): void { + $post = $this->createMockPost(['ID' => 107]); + $this->setPostMeta(107, [ + 'event_start_date' => '2030-01-01', + 'event_end_date' => '2030-01-01', + 'event_start_time' => '08:00:00', + 'event_end_time' => '09:00:00', + 'timezone' => 'UTC', + 'recurring_pattern' => [ + 'type' => 'daily', + 'interval' => 1, + 'endDate' => '2030-01-31', + ], ]); - $this->setPostMeta(202, [ - 'event_type' => 'Convention', - 'event_start_date' => date('Y-m-d', strtotime('+10 days')), - 'event_end_date' => date('Y-m-d', strtotime('+12 days')), - 'event_start_time' => '09:00:00', - 'event_end_time' => '17:00:00', - 'timezone' => 'America/Los_Angeles', - 'location_name' => 'Grand Convention Center' - ]); - - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); - - $this->assertCount(1, $result); - $this->assertStringContainsString('Convention', $result[0]['description']); - $this->assertStringContainsString('Grand Convention Center', $result[0]['description']); - $this->assertStringContainsString('This is the event description', $result[0]['description']); + $result = $this->buildVevent($post); + $this->assertMatchesRegularExpression('/RRULE:[^\r\n]*UNTIL=20300131T\d{6}Z/', $result['ics']); } - /** - * Test get_ics_items handles multiple events - */ - public function testGetIcsItemsHandlesMultipleEvents(): void { - $post1 = $this->createMockPost([ - 'ID' => 203, - 'post_title' => 'First Event', - 'post_content' => 'Description 1', - 'post_type' => 'mayo_event' + public function testBuildVeventFallsBackToUtcWhenTimezoneInvalid(): void { + $post = $this->createMockPost(['ID' => 108]); + $this->setPostMeta(108, [ + 'event_start_date' => '2030-01-01', + 'event_end_date' => '2030-01-01', + 'event_start_time' => '08:00:00', + 'event_end_time' => '09:00:00', + 'timezone' => 'Not/AReal_Zone', ]); - $post2 = $this->createMockPost([ - 'ID' => 204, - 'post_title' => 'Second Event', - 'post_content' => 'Description 2', - 'post_type' => 'mayo_event' - ]); + $result = $this->buildVevent($post); + $this->assertEquals('UTC', $result['tzid']); + $this->assertStringContainsString('DTSTART;TZID=UTC:', $result['ics']); + } - $this->setPostMeta(203, [ - 'event_type' => 'Meeting', - 'event_start_date' => date('Y-m-d', strtotime('+5 days')), - 'event_end_date' => date('Y-m-d', strtotime('+5 days')), - 'event_start_time' => '18:00:00', - 'event_end_time' => '20:00:00', - 'timezone' => 'UTC' + public function testBuildVeventDescriptionStripsHtmlAndIncludesContext(): void { + $post = $this->createMockPost([ + 'ID' => 109, + 'post_title' => 'HTML Event', + 'post_content' => '

This is bold text.

', ]); - - $this->setPostMeta(204, [ - 'event_type' => 'Service', - 'event_start_date' => date('Y-m-d', strtotime('+14 days')), - 'event_end_date' => date('Y-m-d', strtotime('+14 days')), + $this->setPostMeta(109, [ + 'event_type' => 'Convention', + 'event_start_date' => '2030-01-01', + 'event_end_date' => '2030-01-01', 'event_start_time' => '10:00:00', - 'event_end_time' => '14:00:00', - 'timezone' => 'UTC' + 'event_end_time' => '12:00:00', + 'timezone' => 'UTC', + 'location_name' => 'Big Hall', ]); - Functions\when('get_posts')->justReturn([$post1, $post2]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); + $result = $this->buildVevent($post); + $this->assertStringContainsString('Event Type: Convention', $result['ics']); + $this->assertStringContainsString('Location: Big Hall', $result['ics']); + $this->assertStringContainsString('This is bold text', $result['ics']); + $this->assertStringNotContainsString('', $result['ics']); + } - $this->assertCount(2, $result); - $this->assertEquals('First Event', $result[0]['summary']); - $this->assertEquals('Second Event', $result[1]['summary']); + public function testBuildRruleReturnsNullForUnknownType(): void { + $method = $this->getPrivateMethod('build_rrule'); + $tz = new \DateTimeZone('UTC'); + $this->assertNull($method->invoke(null, ['type' => 'mystery'], $tz)); + $this->assertNull($method->invoke(null, ['type' => 'none'], $tz)); } - /** - * Test escape_ical_text handles empty string - */ - public function testEscapeIcalTextHandlesEmptyString(): void { - $method = $this->getPrivateMethod('escape_ical_text'); + public function testBuildVtimezoneIncludesTzidLine(): void { + $method = $this->getPrivateMethod('build_vtimezone'); + $out = $method->invoke(null, 'America/New_York'); - $result = $method->invoke(null, ''); + $this->assertStringContainsString('BEGIN:VTIMEZONE', $out); + $this->assertStringContainsString('TZID:America/New_York', $out); + $this->assertStringContainsString('END:VTIMEZONE', $out); + // America/New_York observes DST, so we should get both components. + $this->assertStringContainsString('BEGIN:STANDARD', $out); + $this->assertStringContainsString('BEGIN:DAYLIGHT', $out); + } - $this->assertEquals('', $result); + public function testFormatOffsetProducesIcsOffset(): void { + $method = $this->getPrivateMethod('format_offset'); + $this->assertEquals('+0530', $method->invoke(null, 5 * 3600 + 30 * 60)); + $this->assertEquals('-0500', $method->invoke(null, -5 * 3600)); + $this->assertEquals('+0000', $method->invoke(null, 0)); } - /** - * Test escape_ical_text handles multiple line breaks - */ - public function testEscapeIcalTextHandlesMultipleLineBreaks(): void { - $method = $this->getPrivateMethod('escape_ical_text'); + public function testQueryEventsHandlesGetPostsError(): void { + Functions\when('get_posts')->justReturn(new \WP_Error('error', 'Database error')); - $result = $method->invoke(null, "Line 1\n\nLine 2\n\n\nLine 3"); + $method = $this->getPrivateMethod('query_events'); + $result = $method->invoke(null, '', '', 'AND', '', ''); - // All newlines should be escaped - $this->assertStringNotContainsString("\n", $result); + $this->assertIsArray($result); + $this->assertEmpty($result); } /** - * Test get_ics_items strips HTML from content + * Helper: invoke build_vevent with the standard host. */ - public function testGetIcsItemsStripsHtmlFromContent(): void { - $post = $this->createMockPost([ - 'ID' => 205, - 'post_title' => 'Event With HTML', - 'post_content' => '

This is bold text with links.

', - 'post_type' => 'mayo_event' - ]); - - $this->setPostMeta(205, [ - 'event_type' => 'Meeting', - 'event_start_date' => date('Y-m-d', strtotime('+8 days')), - 'event_end_date' => date('Y-m-d', strtotime('+8 days')), - 'event_start_time' => '19:00:00', - 'event_end_time' => '21:00:00', - 'timezone' => 'UTC' - ]); - - Functions\when('get_posts')->justReturn([$post]); - - $method = $this->getPrivateMethod('get_ics_items'); - $result = $method->invoke(null); - - $this->assertCount(1, $result); - // HTML should be stripped - $this->assertStringNotContainsString('

', $result[0]['description']); - $this->assertStringNotContainsString('', $result[0]['description']); - $this->assertStringContainsString('bold', $result[0]['description']); + private function buildVevent($post): ?array { + $method = $this->getPrivateMethod('build_vevent'); + return $method->invoke(null, $post, 'example.com'); } - /** - * Helper to get private method - */ private function getPrivateMethod(string $methodName): \ReflectionMethod { $reflection = new ReflectionClass(CalendarFeed::class); - $method = $reflection->getMethod($methodName); - $method->setAccessible(true); - return $method; + return $reflection->getMethod($methodName); } }