|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * ICS Export |
| 4 | + * |
| 5 | + * @package SimpleCalendar |
| 6 | + */ |
| 7 | +namespace SimpleCalendar; |
| 8 | + |
| 9 | +use SimpleCalendar\Abstracts\Calendar; |
| 10 | +use SimpleCalendar\Events\Event; |
| 11 | +use SimpleCalendar\plugin_deps\Carbon\Carbon; |
| 12 | + |
| 13 | +if (!defined('ABSPATH')) { |
| 14 | + exit(); |
| 15 | +} |
| 16 | + |
| 17 | +/** |
| 18 | + * Handles ICS file generation and download. |
| 19 | + * |
| 20 | + * @since 4.1.0 |
| 21 | + */ |
| 22 | +class Ics_Export |
| 23 | +{ |
| 24 | + /** |
| 25 | + * Hook into WordPress. |
| 26 | + * |
| 27 | + * @since 4.1.0 |
| 28 | + */ |
| 29 | + public function __construct() |
| 30 | + { |
| 31 | + // Run after Plugin::init() creates the Objects factory (simcal_init). |
| 32 | + add_action('simcal_init', [$this, 'maybe_export']); |
| 33 | + } |
| 34 | + |
| 35 | + /** |
| 36 | + * Handle ICS export download request. |
| 37 | + * |
| 38 | + * @since 4.1.0 |
| 39 | + */ |
| 40 | + public function maybe_export() |
| 41 | + { |
| 42 | + if (!isset($_GET['simcal_export_ics'])) { |
| 43 | + return; |
| 44 | + } |
| 45 | + |
| 46 | + $calendar_id = absint($_GET['simcal_export_ics']); |
| 47 | + if ($calendar_id < 1) { |
| 48 | + wp_die(esc_html__('Invalid calendar.', 'google-calendar-events'), 400); |
| 49 | + } |
| 50 | + |
| 51 | + $post = get_post($calendar_id); |
| 52 | + if (!$post || 'calendar' !== $post->post_type) { |
| 53 | + wp_die(esc_html__('Calendar not found.', 'google-calendar-events'), 404); |
| 54 | + } |
| 55 | + |
| 56 | + $enabled = get_post_meta($calendar_id, '_display_ics_export', true); |
| 57 | + if ('yes' !== $enabled) { |
| 58 | + wp_die(esc_html__('ICS export is not enabled for this calendar.', 'google-calendar-events'), 403); |
| 59 | + } |
| 60 | + |
| 61 | + $calendar = simcal_get_calendar($calendar_id); |
| 62 | + if (!($calendar instanceof Calendar)) { |
| 63 | + wp_die(esc_html__('Unable to load calendar.', 'google-calendar-events'), 500); |
| 64 | + } |
| 65 | + |
| 66 | + $ics = $this->build_ics($calendar); |
| 67 | + $filename = $this->get_filename($post); |
| 68 | + |
| 69 | + nocache_headers(); |
| 70 | + header('Content-Type: text/calendar; charset=utf-8'); |
| 71 | + header('Content-Disposition: attachment; filename="' . $filename . '"'); |
| 72 | + header('Content-Length: ' . strlen($ics)); |
| 73 | + |
| 74 | + echo $ics; |
| 75 | + exit(); |
| 76 | + } |
| 77 | + |
| 78 | + /** |
| 79 | + * Build ICS export URL for a calendar. |
| 80 | + * |
| 81 | + * @since 4.1.0 |
| 82 | + * |
| 83 | + * @param int $calendar_id Calendar post ID. |
| 84 | + * @return string |
| 85 | + */ |
| 86 | + public static function get_export_url($calendar_id) |
| 87 | + { |
| 88 | + return add_query_arg( |
| 89 | + [ |
| 90 | + 'simcal_export_ics' => absint($calendar_id), |
| 91 | + ], |
| 92 | + home_url('/'), |
| 93 | + ); |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * Print the ICS export button when enabled. |
| 98 | + * |
| 99 | + * @since 4.1.0 |
| 100 | + * |
| 101 | + * @param int $calendar_id Calendar post ID. |
| 102 | + */ |
| 103 | + public static function print_button($calendar_id) |
| 104 | + { |
| 105 | + $is_ics_export = get_post_meta($calendar_id, '_display_ics_export'); |
| 106 | + if (!isset($is_ics_export[0]) || empty($is_ics_export[0]) || $is_ics_export[0] !== 'yes') { |
| 107 | + return; |
| 108 | + } |
| 109 | + |
| 110 | + $url = esc_url(self::get_export_url($calendar_id)); |
| 111 | + $label = esc_html__('Export ICS', 'google-calendar-events'); |
| 112 | + |
| 113 | + echo '<a href="' . |
| 114 | + $url . |
| 115 | + '" class="button ics-export-button" id="ics-export-button-' . |
| 116 | + absint($calendar_id) . |
| 117 | + '">' . |
| 118 | + $label . |
| 119 | + '</a>'; |
| 120 | + } |
| 121 | + |
| 122 | + /** |
| 123 | + * Build ICS content for a calendar. |
| 124 | + * |
| 125 | + * @since 4.1.0 |
| 126 | + * |
| 127 | + * @param Calendar $calendar Calendar instance. |
| 128 | + * @return string |
| 129 | + */ |
| 130 | + public function build_ics(Calendar $calendar) |
| 131 | + { |
| 132 | + $calendar_name = get_the_title($calendar->id); |
| 133 | + $lines = [ |
| 134 | + 'BEGIN:VCALENDAR', |
| 135 | + 'VERSION:2.0', |
| 136 | + 'PRODID:-//Simple Calendar//Google Calendar Events//' . SIMPLE_CALENDAR_VERSION, |
| 137 | + 'CALSCALE:GREGORIAN', |
| 138 | + 'METHOD:PUBLISH', |
| 139 | + 'X-WR-CALNAME:' . $this->escape_text($calendar_name), |
| 140 | + ]; |
| 141 | + |
| 142 | + $seen = []; |
| 143 | + if (!empty($calendar->events) && is_array($calendar->events)) { |
| 144 | + foreach ($calendar->events as $event_group) { |
| 145 | + if (!is_array($event_group)) { |
| 146 | + continue; |
| 147 | + } |
| 148 | + foreach ($event_group as $event) { |
| 149 | + if (!($event instanceof Event)) { |
| 150 | + continue; |
| 151 | + } |
| 152 | + |
| 153 | + $dedupe_key = !empty($event->uid) ? $event->uid : $event->ical_id . '-' . $event->start; |
| 154 | + if (isset($seen[$dedupe_key])) { |
| 155 | + continue; |
| 156 | + } |
| 157 | + $seen[$dedupe_key] = true; |
| 158 | + |
| 159 | + $lines = array_merge($lines, $this->build_vevent($event)); |
| 160 | + } |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + $lines[] = 'END:VCALENDAR'; |
| 165 | + |
| 166 | + $output = ''; |
| 167 | + foreach ($lines as $line) { |
| 168 | + $output .= $this->fold_line($line) . "\r\n"; |
| 169 | + } |
| 170 | + |
| 171 | + return $output; |
| 172 | + } |
| 173 | + |
| 174 | + /** |
| 175 | + * Build a VEVENT block for an event. |
| 176 | + * |
| 177 | + * @since 4.1.0 |
| 178 | + * |
| 179 | + * @param Event $event Event instance. |
| 180 | + * @return array |
| 181 | + */ |
| 182 | + private function build_vevent(Event $event) |
| 183 | + { |
| 184 | + $uid = !empty($event->uid) ? $event->uid : $event->ical_id; |
| 185 | + if (empty($uid)) { |
| 186 | + $uid = md5($event->title . $event->start . $event->end); |
| 187 | + } |
| 188 | + $uid .= '@simple-calendar'; |
| 189 | + |
| 190 | + $lines = ['BEGIN:VEVENT', 'UID:' . $this->escape_text($uid), 'DTSTAMP:' . gmdate('Ymd\THis\Z')]; |
| 191 | + |
| 192 | + if ($event->whole_day) { |
| 193 | + $start_dt = $event->start_dt ?: Carbon::createFromTimestamp($event->start); |
| 194 | + $start_date = $start_dt->format('Ymd'); |
| 195 | + if ($event->end_dt) { |
| 196 | + // Stored all-day end is last inclusive moment; ICS DTEND is exclusive next date. |
| 197 | + $end_date = $event->end_dt->copy()->addSeconds(59)->format('Ymd'); |
| 198 | + } else { |
| 199 | + $end_date = $start_dt->copy()->startOfDay()->addDay()->format('Ymd'); |
| 200 | + } |
| 201 | + $lines[] = 'DTSTART;VALUE=DATE:' . $start_date; |
| 202 | + $lines[] = 'DTEND;VALUE=DATE:' . $end_date; |
| 203 | + } else { |
| 204 | + $start_utc = !empty($event->start_utc) ? $event->start_utc : $event->start; |
| 205 | + $end_utc = !empty($event->end_utc) ? $event->end_utc : $event->end; |
| 206 | + if (empty($end_utc)) { |
| 207 | + $end_utc = $start_utc; |
| 208 | + } |
| 209 | + $lines[] = 'DTSTART:' . gmdate('Ymd\THis\Z', $start_utc); |
| 210 | + $lines[] = 'DTEND:' . gmdate('Ymd\THis\Z', $end_utc); |
| 211 | + } |
| 212 | + |
| 213 | + if (!empty($event->title)) { |
| 214 | + $lines[] = 'SUMMARY:' . $this->escape_text($event->title); |
| 215 | + } |
| 216 | + |
| 217 | + if (!empty($event->description)) { |
| 218 | + $lines[] = 'DESCRIPTION:' . $this->escape_text($event->description); |
| 219 | + } |
| 220 | + |
| 221 | + $location = ''; |
| 222 | + if (!empty($event->start_location['name'])) { |
| 223 | + $location = $event->start_location['name']; |
| 224 | + } elseif (!empty($event->start_location['address'])) { |
| 225 | + $location = $event->start_location['address']; |
| 226 | + } |
| 227 | + if (!empty($location)) { |
| 228 | + $lines[] = 'LOCATION:' . $this->escape_text($location); |
| 229 | + } |
| 230 | + |
| 231 | + if (!empty($event->link)) { |
| 232 | + $lines[] = 'URL:' . $this->escape_text($event->link); |
| 233 | + } |
| 234 | + |
| 235 | + $lines[] = 'END:VEVENT'; |
| 236 | + |
| 237 | + return $lines; |
| 238 | + } |
| 239 | + |
| 240 | + /** |
| 241 | + * Escape text for ICS content lines. |
| 242 | + * |
| 243 | + * @since 4.1.0 |
| 244 | + * |
| 245 | + * @param string $text Raw text. |
| 246 | + * @return string |
| 247 | + */ |
| 248 | + private function escape_text($text) |
| 249 | + { |
| 250 | + $text = wp_strip_all_tags((string) $text); |
| 251 | + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 252 | + $text = str_replace('\\', '\\\\', $text); |
| 253 | + $text = str_replace("\r\n", "\n", $text); |
| 254 | + $text = str_replace("\r", "\n", $text); |
| 255 | + $text = str_replace("\n", '\\n', $text); |
| 256 | + $text = str_replace([';', ','], ['\\;', '\\,'], $text); |
| 257 | + |
| 258 | + return $text; |
| 259 | + } |
| 260 | + |
| 261 | + /** |
| 262 | + * Fold long ICS lines per RFC 5545. |
| 263 | + * |
| 264 | + * @since 4.1.0 |
| 265 | + * |
| 266 | + * @param string $line Content line. |
| 267 | + * @return string |
| 268 | + */ |
| 269 | + private function fold_line($line) |
| 270 | + { |
| 271 | + if (strlen($line) <= 75) { |
| 272 | + return $line; |
| 273 | + } |
| 274 | + |
| 275 | + $folded = ''; |
| 276 | + while (strlen($line) > 75) { |
| 277 | + $folded .= substr($line, 0, 75) . "\r\n "; |
| 278 | + $line = substr($line, 75); |
| 279 | + } |
| 280 | + |
| 281 | + return $folded . $line; |
| 282 | + } |
| 283 | + |
| 284 | + /** |
| 285 | + * Build a safe ICS filename from the calendar post title. |
| 286 | + * |
| 287 | + * @since 4.1.0 |
| 288 | + * |
| 289 | + * @param \WP_Post $post Calendar post. |
| 290 | + * @return string |
| 291 | + */ |
| 292 | + private function get_filename($post) |
| 293 | + { |
| 294 | + $name = sanitize_file_name($post->post_title); |
| 295 | + if (empty($name)) { |
| 296 | + $name = 'calendar-' . $post->ID; |
| 297 | + } |
| 298 | + |
| 299 | + if (!preg_match('/\.ics$/i', $name)) { |
| 300 | + $name .= '.ics'; |
| 301 | + } |
| 302 | + |
| 303 | + return $name; |
| 304 | + } |
| 305 | +} |
0 commit comments