Skip to content

Commit 2d65ff9

Browse files
committed
ICS export option
1 parent 1345af1 commit 2d65ff9

7 files changed

Lines changed: 353 additions & 1 deletion

File tree

assets/css/default-calendar-grid.css

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,21 @@
542542
.simcal-event-bubble .simcal-event-details .simcal-event-description {
543543
margin: 0 0 1em;
544544
}
545-
545+
.ics-export-button {
546+
margin-top: 20px;
547+
margin-right: 10px;
548+
display: inline-block;
549+
padding: 10px 20px;
550+
color: #ffffff;
551+
font-family: sans-serif;
552+
font-size: 16px;
553+
text-decoration: none;
554+
text-align: center;
555+
background-color: #0009;
556+
border-radius: 4px;
557+
cursor: pointer;
558+
transition: background-color 0.2s ease;
559+
}
546560
.print-calendar-button {
547561
margin-top: 20px;
548562
}
@@ -557,6 +571,8 @@
557571
.simcal-print-calendar .site-content,
558572
.simcal-print-calendar .print-calendar-button,
559573
.print-calendar-button,
574+
.simcal-print-calendar .ics-export-button,
575+
.ics-export-button,
560576
.simcal-print-calendar .simcal-nav .simcal-prev,
561577
.simcal-print-calendar .simcal-nav .simcal-next,
562578
.simcal-print-calendar .fc-button-group,

includes/admin/metaboxes/settings.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,25 @@ private static function calendar_settings_panel($post)
591591
'value' => 'yes' == $display_print_calendar_value ? 'yes' : 'no',
592592
'text' => __('Yes (Display Print Calendar Button)', 'google-calendar-events'),
593593
]);
594+
?>
595+
</td>
596+
</tr>
597+
<tr class="simcal-panel-field">
598+
<th>
599+
<label for="_display_ics_export"><?php _e('ICS Export', 'google-calendar-events'); ?></label>
600+
</th>
601+
<td>
602+
<?php
603+
$display_ics_export_value = get_post_meta($post->ID, '_display_ics_export', true);
604+
605+
simcal_print_field([
606+
'type' => 'checkbox',
607+
'name' => '_display_ics_export',
608+
'id' => '_display_ics_export',
609+
'tooltip' => __('Check this to display ICS export button on frontend.', 'google-calendar-events'),
610+
'value' => 'yes' == $display_ics_export_value ? 'yes' : 'no',
611+
'text' => __('Yes (Display ICS Export Button)', 'google-calendar-events'),
612+
]);
594613
?>
595614
</td>
596615
</tr>
@@ -1184,6 +1203,10 @@ public static function save($post_id, $post)
11841203
$static = isset($_POST['_display_print_calendar']) ? 'yes' : 'no';
11851204
update_post_meta($post_id, '_display_print_calendar', $static);
11861205

1206+
// ICS export.
1207+
$ics_export = isset($_POST['_display_ics_export']) ? 'yes' : 'no';
1208+
update_post_meta($post_id, '_display_ics_export', $ics_export);
1209+
11871210
// Static calendar.
11881211
$static = isset($_POST['_calendar_is_static']) ? 'yes' : 'no';
11891212
update_post_meta($post_id, '_calendar_is_static', $static);

includes/calendars/views/default-calendar-grid.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ class="simcal-nav simcal-current"
297297
if (isset($is_print_calendar[0]) && !empty($is_print_calendar[0]) && $is_print_calendar[0] === 'yes') {
298298
echo '<button id="print-calendar-button" class="print-calendar-button demo_sc_primary_btn">Print Calendar</button>';
299299
}
300+
\SimpleCalendar\Ics_Export::print_button($calendar->id);
300301
}
301302
}
302303

includes/calendars/views/default-calendar-list.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ public function html()
258258
if (isset($is_print_calendar[0]) && !empty($is_print_calendar[0]) && $is_print_calendar[0] === 'yes') {
259259
echo '<button id="print-calendar-button" class="print-calendar-button demo_sc_primary_btn">Print Calendar</button>';
260260
}
261+
\SimpleCalendar\Ics_Export::print_button($calendar->id);
261262
}
262263
}
263264

includes/ics-export.php

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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

Comments
 (0)