Commit | Line | Data |
---|---|---|
89cccc2f G |
1 | ;;; gnus-icalendar.el --- reply to iCalendar meeting requests |
2 | ||
3 | ;; Copyright (C) 2013 Free Software Foundation, Inc. | |
4 | ||
5 | ;; Author: Jan Tatarik <Jan.Tatarik@gmail.com> | |
6 | ;; Keywords: mail, icalendar, org | |
7 | ||
8 | ;; This program is free software; you can redistribute it and/or modify | |
9 | ;; it under the terms of the GNU General Public License as published by | |
10 | ;; the Free Software Foundation, either version 3 of the License, or | |
11 | ;; (at your option) any later version. | |
12 | ||
13 | ;; This program is distributed in the hope that it will be useful, | |
14 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of | |
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
16 | ;; GNU General Public License for more details. | |
17 | ||
18 | ;; You should have received a copy of the GNU General Public License | |
19 | ;; along with this program. If not, see <http://www.gnu.org/licenses/>. | |
20 | ||
21 | ;;; Commentary: | |
22 | ||
23 | ;; To install: | |
24 | ;; (require 'gnus-icalendar) | |
25 | ;; (gnus-icalendar-setup) | |
26 | ||
27 | ;; to enable optional iCalendar->Org sync functionality | |
28 | ;; NOTE: both the capture file and the headline(s) inside must already exist | |
29 | ;; (setq gnus-icalendar-org-capture-file "~/org/notes.org") | |
30 | ;; (setq gnus-icalendar-org-capture-headline '("Calendar")) | |
31 | ;; (gnus-icalendar-org-setup) | |
32 | ||
33 | ||
34 | ;;; Code: | |
35 | ||
36 | (require 'icalendar) | |
37 | (require 'eieio) | |
9ab16aab | 38 | (require 'gmm-utils) |
89cccc2f G |
39 | (require 'mm-decode) |
40 | (require 'gnus-sum) | |
41 | ||
42 | (eval-when-compile (require 'cl)) | |
43 | ||
44 | (defun gnus-icalendar-find-if (pred seq) | |
45 | (catch 'found | |
46 | (while seq | |
47 | (when (funcall pred (car seq)) | |
48 | (throw 'found (car seq))) | |
49 | (pop seq)))) | |
50 | ||
51 | ;;; | |
52 | ;;; ical-event | |
53 | ;;; | |
54 | ||
55 | (defclass gnus-icalendar-event () | |
56 | ((organizer :initarg :organizer | |
57 | :accessor gnus-icalendar-event:organizer | |
58 | :initform "" | |
59 | :type (or null string)) | |
60 | (summary :initarg :summary | |
61 | :accessor gnus-icalendar-event:summary | |
62 | :initform "" | |
63 | :type (or null string)) | |
64 | (description :initarg :description | |
65 | :accessor gnus-icalendar-event:description | |
66 | :initform "" | |
67 | :type (or null string)) | |
68 | (location :initarg :location | |
69 | :accessor gnus-icalendar-event:location | |
70 | :initform "" | |
71 | :type (or null string)) | |
62dfefa0 JT |
72 | (start-time :initarg :start-time |
73 | :accessor gnus-icalendar-event:start-time | |
89cccc2f | 74 | :initform "" |
62dfefa0 JT |
75 | :type (or null t)) |
76 | (end-time :initarg :end-time | |
77 | :accessor gnus-icalendar-event:end-time | |
89cccc2f | 78 | :initform "" |
62dfefa0 | 79 | :type (or null t)) |
89cccc2f G |
80 | (recur :initarg :recur |
81 | :accessor gnus-icalendar-event:recur | |
82 | :initform "" | |
83 | :type (or null string)) | |
84 | (uid :initarg :uid | |
85 | :accessor gnus-icalendar-event:uid | |
86 | :type string) | |
87 | (method :initarg :method | |
88 | :accessor gnus-icalendar-event:method | |
89 | :initform "PUBLISH" | |
90 | :type (or null string)) | |
91 | (rsvp :initarg :rsvp | |
92 | :accessor gnus-icalendar-event:rsvp | |
93 | :initform nil | |
8ef7141b | 94 | :type (or null boolean)) |
42e51060 JT |
95 | (participation-type :initarg :participation-type |
96 | :accessor gnus-icalendar-event:participation-type | |
97 | :initform 'non-participant | |
98 | :type (or null t)) | |
8ef7141b JT |
99 | (req-participants :initarg :req-participants |
100 | :accessor gnus-icalendar-event:req-participants | |
101 | :initform nil | |
102 | :type (or null t)) | |
103 | (opt-participants :initarg :opt-participants | |
104 | :accessor gnus-icalendar-event:opt-participants | |
105 | :initform nil | |
106 | :type (or null t))) | |
89cccc2f G |
107 | "generic iCalendar Event class") |
108 | ||
109 | (defclass gnus-icalendar-event-request (gnus-icalendar-event) | |
110 | nil | |
111 | "iCalendar class for REQUEST events") | |
112 | ||
113 | (defclass gnus-icalendar-event-cancel (gnus-icalendar-event) | |
114 | nil | |
115 | "iCalendar class for CANCEL events") | |
116 | ||
117 | (defclass gnus-icalendar-event-reply (gnus-icalendar-event) | |
118 | nil | |
119 | "iCalendar class for REPLY events") | |
120 | ||
121 | (defmethod gnus-icalendar-event:recurring-p ((event gnus-icalendar-event)) | |
122 | "Return t if EVENT is recurring." | |
123 | (not (null (gnus-icalendar-event:recur event)))) | |
124 | ||
125 | (defmethod gnus-icalendar-event:recurring-freq ((event gnus-icalendar-event)) | |
126 | "Return recurring frequency of EVENT." | |
127 | (let ((rrule (gnus-icalendar-event:recur event))) | |
128 | (string-match "FREQ=\\([[:alpha:]]+\\)" rrule) | |
129 | (match-string 1 rrule))) | |
130 | ||
131 | (defmethod gnus-icalendar-event:recurring-interval ((event gnus-icalendar-event)) | |
132 | "Return recurring interval of EVENT." | |
133 | (let ((rrule (gnus-icalendar-event:recur event)) | |
134 | (default-interval 1)) | |
135 | ||
136 | (string-match "INTERVAL=\\([[:digit:]]+\\)" rrule) | |
137 | (or (match-string 1 rrule) | |
138 | default-interval))) | |
139 | ||
62dfefa0 JT |
140 | (defmethod gnus-icalendar-event:start ((event gnus-icalendar-event)) |
141 | (format-time-string "%Y-%m-%d %H:%M" (gnus-icalendar-event:start-time event))) | |
89cccc2f | 142 | |
62dfefa0 JT |
143 | (defun gnus-icalendar-event--decode-datefield (ical field) |
144 | (let* ((date (icalendar--get-event-property ical field)) | |
145 | (date-props (icalendar--get-event-property-attributes ical field)) | |
146 | (tz (plist-get date-props 'TZID))) | |
89cccc2f | 147 | |
62dfefa0 | 148 | (date-to-time (timezone-make-date-arpa-standard date nil tz)))) |
89cccc2f G |
149 | |
150 | (defun gnus-icalendar-event--find-attendee (ical name-or-email) | |
151 | (let* ((event (car (icalendar--all-events ical))) | |
152 | (event-props (caddr event))) | |
9ab16aab | 153 | (gmm-labels ((attendee-name (att) (plist-get (cadr att) 'CN)) |
89cccc2f G |
154 | (attendee-email (att) |
155 | (replace-regexp-in-string "^.*MAILTO:" "" (caddr att))) | |
156 | (attendee-prop-matches-p (prop) | |
157 | (and (eq (car prop) 'ATTENDEE) | |
158 | (or (member (attendee-name prop) name-or-email) | |
159 | (let ((att-email (attendee-email prop))) | |
160 | (gnus-icalendar-find-if (lambda (email) | |
161 | (string-match email att-email)) | |
162 | name-or-email)))))) | |
163 | ||
164 | (gnus-icalendar-find-if #'attendee-prop-matches-p event-props)))) | |
165 | ||
8ef7141b JT |
166 | (defun gnus-icalendar-event--get-attendee-names (ical) |
167 | (let* ((event (car (icalendar--all-events ical))) | |
168 | (attendee-props (gnus-remove-if-not | |
169 | (lambda (p) (eq (car p) 'ATTENDEE)) | |
170 | (caddr event)))) | |
171 | ||
172 | (gmm-labels ((attendee-role (prop) (plist-get (cadr prop) 'ROLE)) | |
173 | (attendee-name (prop) (plist-get (cadr prop) 'CN)) | |
174 | (attendees-by-type (type) | |
175 | (gnus-remove-if-not | |
176 | (lambda (p) (string= (attendee-role p) type)) | |
177 | attendee-props)) | |
178 | (attendee-names-by-type (type) | |
179 | (mapcar #'attendee-name (attendees-by-type type)))) | |
180 | ||
181 | (list | |
182 | (attendee-names-by-type "REQ-PARTICIPANT") | |
183 | (attendee-names-by-type "OPT-PARTICIPANT"))))) | |
89cccc2f G |
184 | |
185 | (defun gnus-icalendar-event-from-ical (ical &optional attendee-name-or-email) | |
186 | (let* ((event (car (icalendar--all-events ical))) | |
89cccc2f G |
187 | (organizer (replace-regexp-in-string |
188 | "^.*MAILTO:" "" | |
189 | (or (icalendar--get-event-property event 'ORGANIZER) ""))) | |
190 | (prop-map '((summary . SUMMARY) | |
191 | (description . DESCRIPTION) | |
192 | (location . LOCATION) | |
193 | (recur . RRULE) | |
194 | (uid . UID))) | |
195 | (method (caddr (assoc 'METHOD (caddr (car (nreverse ical)))))) | |
196 | (attendee (when attendee-name-or-email | |
197 | (gnus-icalendar-event--find-attendee ical attendee-name-or-email))) | |
8ef7141b | 198 | (attendee-names (gnus-icalendar-event--get-attendee-names ical)) |
42e51060 JT |
199 | (role (plist-get (cadr attendee) 'ROLE)) |
200 | (participation-type (pcase role | |
201 | ("REQ-PARTICIPANT" 'required) | |
202 | ("OPT-PARTICIPANT" 'optional) | |
203 | (_ 'non-participant))) | |
89cccc2f G |
204 | (args (list :method method |
205 | :organizer organizer | |
62dfefa0 JT |
206 | :start-time (gnus-icalendar-event--decode-datefield event 'DTSTART) |
207 | :end-time (gnus-icalendar-event--decode-datefield event 'DTEND) | |
42e51060 JT |
208 | :rsvp (string= (plist-get (cadr attendee) 'RSVP) "TRUE") |
209 | :participation-type participation-type | |
210 | :req-participants (car attendee-names) | |
8ef7141b | 211 | :opt-participants (cadr attendee-names))) |
ec956438 JT |
212 | (event-class (cond |
213 | ((string= method "REQUEST") 'gnus-icalendar-event-request) | |
214 | ((string= method "CANCEL") 'gnus-icalendar-event-cancel) | |
215 | ((string= method "REPLY") 'gnus-icalendar-event-reply) | |
216 | (t 'gnus-icalendar-event)))) | |
89cccc2f | 217 | |
9ab16aab | 218 | (gmm-labels ((map-property (prop) |
89cccc2f G |
219 | (let ((value (icalendar--get-event-property event prop))) |
220 | (when value | |
221 | ;; ugly, but cannot get | |
222 | ;;replace-regexp-in-string work with "\\" as | |
223 | ;;REP, plus we should also handle "\\;" | |
224 | (replace-regexp-in-string | |
225 | "\\\\," "," | |
226 | (replace-regexp-in-string | |
227 | "\\\\n" "\n" (substring-no-properties value)))))) | |
228 | (accumulate-args (mapping) | |
229 | (destructuring-bind (slot . ical-property) mapping | |
230 | (setq args (append (list | |
231 | (intern (concat ":" (symbol-name slot))) | |
232 | (map-property ical-property)) | |
233 | args))))) | |
234 | ||
235 | (mapc #'accumulate-args prop-map) | |
236 | (apply 'make-instance event-class args)))) | |
237 | ||
238 | (defun gnus-icalendar-event-from-buffer (buf &optional attendee-name-or-email) | |
239 | "Parse RFC5545 iCalendar in buffer BUF and return an event object. | |
240 | ||
241 | Return a gnus-icalendar-event object representing the first event | |
242 | contained in the invitation. Return nil for calendars without an event entry. | |
243 | ||
244 | ATTENDEE-NAME-OR-EMAIL is a list of strings that will be matched | |
245 | against the event's attendee names and emails. Invitation rsvp | |
246 | status will be retrieved from the first matching attendee record." | |
247 | (let ((ical (with-current-buffer (icalendar--get-unfolded-buffer (get-buffer buf)) | |
248 | (goto-char (point-min)) | |
249 | (icalendar--read-element nil nil)))) | |
250 | ||
251 | (when ical | |
252 | (gnus-icalendar-event-from-ical ical attendee-name-or-email)))) | |
253 | ||
254 | ;;; | |
255 | ;;; gnus-icalendar-event-reply | |
256 | ;;; | |
257 | ||
258 | (defun gnus-icalendar-event--build-reply-event-body (ical-request status identities) | |
259 | (let ((summary-status (capitalize (symbol-name status))) | |
260 | (attendee-status (upcase (symbol-name status))) | |
261 | reply-event-lines) | |
9ab16aab | 262 | (gmm-labels ((update-summary (line) |
89cccc2f G |
263 | (if (string-match "^[^:]+:" line) |
264 | (replace-match (format "\\&%s: " summary-status) t nil line) | |
265 | line)) | |
266 | (update-dtstamp () | |
267 | (format-time-string "DTSTAMP:%Y%m%dT%H%M%SZ" nil t)) | |
268 | (attendee-matches-identity (line) | |
269 | (gnus-icalendar-find-if (lambda (name) (string-match-p name line)) | |
270 | identities)) | |
271 | (update-attendee-status (line) | |
272 | (when (and (attendee-matches-identity line) | |
273 | (string-match "\\(PARTSTAT=\\)[^;]+" line)) | |
274 | (replace-match (format "\\1%s" attendee-status) t nil line))) | |
275 | (process-event-line (line) | |
276 | (when (string-match "^\\([^;:]+\\)" line) | |
277 | (let* ((key (match-string 0 line)) | |
278 | ;; NOTE: not all of the below fields are mandatory, | |
279 | ;; but they are often present in other clients' | |
280 | ;; replies. Can be helpful for debugging, too. | |
ec956438 JT |
281 | (new-line |
282 | (cond | |
283 | ((string= key "ATTENDEE") (update-attendee-status line)) | |
284 | ((string= key "SUMMARY") (update-summary line)) | |
285 | ((string= key "DTSTAMP") (update-dtstamp)) | |
a99f655b GM |
286 | ((member key '("ORGANIZER" "DTSTART" "DTEND" |
287 | "LOCATION" "DURATION" "SEQUENCE" | |
288 | "RECURRENCE-ID" "UID")) line) | |
ec956438 | 289 | (t nil)))) |
89cccc2f G |
290 | (when new-line |
291 | (push new-line reply-event-lines)))))) | |
292 | ||
293 | (mapc #'process-event-line (split-string ical-request "\n")) | |
294 | ||
295 | (unless (gnus-icalendar-find-if (lambda (x) (string-match "^ATTENDEE" x)) | |
296 | reply-event-lines) | |
297 | (error "Could not find an event attendee matching given identity")) | |
298 | ||
299 | (mapconcat #'identity `("BEGIN:VEVENT" | |
300 | ,@(nreverse reply-event-lines) | |
301 | "END:VEVENT") | |
302 | "\n")))) | |
303 | ||
304 | (defun gnus-icalendar-event-reply-from-buffer (buf status identities) | |
305 | "Build a calendar event reply for request contained in BUF. | |
306 | The reply will have STATUS (`accepted', `tentative' or `declined'). | |
307 | The reply will be composed for attendees matching any entry | |
308 | on the IDENTITIES list." | |
9ab16aab | 309 | (gmm-labels ((extract-block (blockname) |
89cccc2f G |
310 | (save-excursion |
311 | (let ((block-start-re (format "^BEGIN:%s" blockname)) | |
312 | (block-end-re (format "^END:%s" blockname)) | |
313 | start) | |
314 | (when (re-search-forward block-start-re nil t) | |
315 | (setq start (line-beginning-position)) | |
316 | (re-search-forward block-end-re) | |
317 | (buffer-substring-no-properties start (line-end-position))))))) | |
318 | ||
319 | (let (zone event) | |
320 | (with-current-buffer (icalendar--get-unfolded-buffer (get-buffer buf)) | |
321 | (goto-char (point-min)) | |
322 | (setq zone (extract-block "VTIMEZONE") | |
323 | event (extract-block "VEVENT"))) | |
324 | ||
325 | (when event | |
326 | (let ((contents (list "BEGIN:VCALENDAR" | |
327 | "METHOD:REPLY" | |
328 | "PRODID:Gnus" | |
329 | "VERSION:2.0" | |
330 | zone | |
331 | (gnus-icalendar-event--build-reply-event-body event status identities) | |
332 | "END:VCALENDAR"))) | |
333 | ||
334 | (mapconcat #'identity (delq nil contents) "\n")))))) | |
335 | ||
336 | ;;; | |
337 | ;;; gnus-icalendar-org | |
338 | ;;; | |
339 | ;;; TODO: this is an optional feature, and it's only available with org-mode | |
340 | ;;; 7+, so will need to properly handle emacsen with no/outdated org-mode | |
341 | ||
342 | (require 'org) | |
343 | (require 'org-capture) | |
344 | ||
345 | (defgroup gnus-icalendar-org nil | |
346 | "Settings for Calendar Event gnus/org integration." | |
347 | :group 'gnus-icalendar | |
348 | :prefix "gnus-icalendar-org-") | |
349 | ||
350 | (defcustom gnus-icalendar-org-capture-file nil | |
351 | "Target Org file for storing captured calendar events." | |
ae3f0661 | 352 | :type '(choice (const nil) file) |
89cccc2f G |
353 | :group 'gnus-icalendar-org) |
354 | ||
355 | (defcustom gnus-icalendar-org-capture-headline nil | |
356 | "Target outline in `gnus-icalendar-org-capture-file' for storing captured events." | |
357 | :type '(repeat string) | |
358 | :group 'gnus-icalendar-org) | |
359 | ||
360 | (defcustom gnus-icalendar-org-template-name "used by gnus-icalendar-org" | |
361 | "Org-mode template name." | |
362 | :type '(string) | |
363 | :group 'gnus-icalendar-org) | |
364 | ||
365 | (defcustom gnus-icalendar-org-template-key "#" | |
366 | "Org-mode template hotkey." | |
367 | :type '(string) | |
368 | :group 'gnus-icalendar-org) | |
369 | ||
370 | (defvar gnus-icalendar-org-enabled-p nil) | |
371 | ||
372 | ||
373 | (defmethod gnus-icalendar-event:org-repeat ((event gnus-icalendar-event)) | |
374 | "Return `org-mode' timestamp repeater string for recurring EVENT. | |
375 | Return nil for non-recurring EVENT." | |
376 | (when (gnus-icalendar-event:recurring-p event) | |
377 | (let* ((freq-map '(("HOURLY" . "h") | |
378 | ("DAILY" . "d") | |
379 | ("WEEKLY" . "w") | |
380 | ("MONTHLY" . "m") | |
381 | ("YEARLY" . "y"))) | |
382 | (org-freq (cdr (assoc (gnus-icalendar-event:recurring-freq event) freq-map)))) | |
383 | ||
384 | (when org-freq | |
385 | (format "+%s%s" (gnus-icalendar-event:recurring-interval event) org-freq))))) | |
386 | ||
387 | (defmethod gnus-icalendar-event:org-timestamp ((event gnus-icalendar-event)) | |
388 | "Build `org-mode' timestamp from EVENT start/end dates and recurrence info." | |
389 | (let* ((start (gnus-icalendar-event:start-time event)) | |
390 | (end (gnus-icalendar-event:end-time event)) | |
62dfefa0 JT |
391 | (start-date (format-time-string "%Y-%m-%d %a" start)) |
392 | (start-time (format-time-string "%H:%M" start)) | |
680f4ae6 | 393 | (start-at-midnight (string= start-time "00:00")) |
62dfefa0 JT |
394 | (end-date (format-time-string "%Y-%m-%d %a" end)) |
395 | (end-time (format-time-string "%H:%M" end)) | |
680f4ae6 JT |
396 | (end-at-midnight (string= end-time "00:00")) |
397 | (start-end-date-diff (/ (float-time (time-subtract | |
398 | (date-to-time end-date) | |
399 | (date-to-time start-date))) | |
400 | 86400)) | |
89cccc2f | 401 | (org-repeat (gnus-icalendar-event:org-repeat event)) |
680f4ae6 JT |
402 | (repeat (if org-repeat (concat " " org-repeat) "")) |
403 | (time-1-day '(0 86400))) | |
404 | ||
405 | ;; NOTE: special care is needed with appointments ending at midnight | |
406 | ;; (typically all-day events): the end time has to be changed to 23:59 to | |
407 | ;; prevent org agenda showing the event on one additional day | |
408 | (cond | |
409 | ;; start/end midnight | |
410 | ;; A 0:0 - A+1 0:0 -> A | |
411 | ;; A 0:0 - A+n 0:0 -> A - A+n-1 | |
412 | ((and start-at-midnight end-at-midnight) (if (> start-end-date-diff 1) | |
413 | (let ((end-ts (format-time-string "%Y-%m-%d %a" (time-subtract end time-1-day)))) | |
414 | (format "<%s>--<%s>" start-date end-ts)) | |
415 | (format "<%s%s>" start-date repeat))) | |
416 | ;; end midnight | |
417 | ;; A .:. - A+1 0:0 -> A .:.-23:59 | |
418 | ;; A .:. - A+n 0:0 -> A .:. - A_n-1 | |
419 | (end-at-midnight (if (= start-end-date-diff 1) | |
420 | (format "<%s %s-23:59%s>" start-date start-time repeat) | |
421 | (let ((end-ts (format-time-string "%Y-%m-%d %a" (time-subtract end time-1-day)))) | |
422 | (format "<%s %s>--<%s>" start-date start-time end-ts)))) | |
423 | ;; start midnight | |
424 | ;; A 0:0 - A .:. -> A 0:0-.:. (default 1) | |
425 | ;; A 0:0 - A+n .:. -> A - A+n .:. | |
426 | ((and start-at-midnight | |
427 | (plusp start-end-date-diff)) (format "<%s>--<%s %s>" start-date end-date end-time)) | |
428 | ;; default | |
429 | ;; A .:. - A .:. -> A .:.-.:. | |
430 | ;; A .:. - B .:. | |
431 | ((zerop start-end-date-diff) (format "<%s %s-%s%s>" start-date start-time end-time repeat)) | |
432 | (t (format "<%s %s>--<%s %s>" start-date start-time end-date end-time))))) | |
89cccc2f | 433 | |
0f755e30 JT |
434 | (defun gnus-icalendar--format-summary-line (summary &optional location) |
435 | (if location | |
436 | (format "%s (%s)" summary location) | |
437 | (format "%s" summary))) | |
438 | ||
8ef7141b JT |
439 | |
440 | (defun gnus-icalendar--format-participant-list (participants) | |
441 | (mapconcat #'identity participants ", ")) | |
442 | ||
89cccc2f G |
443 | ;; TODO: make the template customizable |
444 | (defmethod gnus-icalendar-event->org-entry ((event gnus-icalendar-event) reply-status) | |
445 | "Return string with new `org-mode' entry describing EVENT." | |
446 | (with-temp-buffer | |
447 | (org-mode) | |
448 | (with-slots (organizer summary description location | |
449 | recur uid) event | |
450 | (let* ((reply (if reply-status (capitalize (symbol-name reply-status)) | |
451 | "Not replied yet")) | |
452 | (props `(("ICAL_EVENT" . "t") | |
453 | ("ID" . ,uid) | |
454 | ("DT" . ,(gnus-icalendar-event:org-timestamp event)) | |
455 | ("ORGANIZER" . ,(gnus-icalendar-event:organizer event)) | |
456 | ("LOCATION" . ,(gnus-icalendar-event:location event)) | |
42e51060 | 457 | ("PARTICIPATION_TYPE" . ,(symbol-name (gnus-icalendar-event:participation-type event))) |
8ef7141b JT |
458 | ("REQ_PARTICIPANTS" . ,(gnus-icalendar--format-participant-list (gnus-icalendar-event:req-participants event))) |
459 | ("OPT_PARTICIPANTS" . ,(gnus-icalendar--format-participant-list (gnus-icalendar-event:opt-participants event))) | |
89cccc2f G |
460 | ("RRULE" . ,(gnus-icalendar-event:recur event)) |
461 | ("REPLY" . ,reply)))) | |
462 | ||
0f755e30 JT |
463 | (insert (format "* %s\n\n" |
464 | (gnus-icalendar--format-summary-line summary location))) | |
89cccc2f G |
465 | (mapc (lambda (prop) |
466 | (org-entry-put (point) (car prop) (cdr prop))) | |
467 | props)) | |
468 | ||
469 | (when description | |
470 | (save-restriction | |
471 | (narrow-to-region (point) (point)) | |
472 | (insert description) | |
473 | (indent-region (point-min) (point-max) 2) | |
474 | (fill-region (point-min) (point-max)))) | |
475 | ||
476 | (buffer-string)))) | |
477 | ||
478 | (defun gnus-icalendar--deactivate-org-timestamp (ts) | |
479 | (replace-regexp-in-string "[<>]" | |
ec956438 JT |
480 | (lambda (m) (cond ((string= m "<") "[") |
481 | ((string= m ">") "]"))) | |
89cccc2f G |
482 | ts)) |
483 | ||
484 | (defun gnus-icalendar-find-org-event-file (event &optional org-file) | |
485 | "Return the name of the file containing EVENT org entry. | |
486 | Return nil when not found. | |
487 | ||
488 | All org agenda files are searched for the EVENT entry. When | |
489 | the optional ORG-FILE argument is specified, only that one file | |
490 | is searched." | |
491 | (let ((uid (gnus-icalendar-event:uid event)) | |
492 | (files (or org-file (org-agenda-files t 'ifmode)))) | |
9ab16aab | 493 | (gmm-labels |
89cccc2f G |
494 | ((find-event-in (file) |
495 | (org-check-agenda-file file) | |
496 | (with-current-buffer (find-file-noselect file) | |
497 | (let ((event-pos (org-find-entry-with-id uid))) | |
498 | (when (and event-pos | |
499 | (string= (cdr (assoc "ICAL_EVENT" (org-entry-properties event-pos))) | |
500 | "t")) | |
501 | (throw 'found file)))))) | |
502 | ||
503 | (gnus-icalendar-find-if #'find-event-in files)))) | |
504 | ||
505 | ||
506 | (defun gnus-icalendar--show-org-event (event &optional org-file) | |
507 | (let ((file (gnus-icalendar-find-org-event-file event org-file))) | |
508 | (when file | |
509 | (switch-to-buffer (find-file file)) | |
510 | (goto-char (org-find-entry-with-id (gnus-icalendar-event:uid event))) | |
511 | (org-show-entry)))) | |
512 | ||
513 | ||
514 | (defun gnus-icalendar--update-org-event (event reply-status &optional org-file) | |
515 | (let ((file (gnus-icalendar-find-org-event-file event org-file))) | |
516 | (when file | |
517 | (with-current-buffer (find-file-noselect file) | |
8ef7141b | 518 | (with-slots (uid summary description organizer location recur |
42e51060 | 519 | participation-type req-participants opt-participants) event |
89cccc2f G |
520 | (let ((event-pos (org-find-entry-with-id uid))) |
521 | (when event-pos | |
522 | (goto-char event-pos) | |
523 | ||
524 | ;; update the headline, keep todo, priority and tags, if any | |
525 | (save-excursion | |
526 | (let* ((priority (org-entry-get (point) "PRIORITY")) | |
527 | (headline (delq nil (list | |
528 | (org-entry-get (point) "TODO") | |
529 | (when priority (format "[#%s]" priority)) | |
0f755e30 | 530 | (gnus-icalendar--format-summary-line summary location) |
89cccc2f G |
531 | (org-entry-get (point) "TAGS"))))) |
532 | ||
533 | (re-search-forward "^\\*+ " (line-end-position)) | |
534 | (delete-region (point) (line-end-position)) | |
535 | (insert (mapconcat #'identity headline " ")))) | |
536 | ||
537 | ;; update props and description | |
538 | (let ((entry-end (org-entry-end-position)) | |
539 | (entry-outline-level (org-outline-level))) | |
540 | ||
541 | ;; delete body of the entry, leave org drawers intact | |
542 | (save-restriction | |
543 | (org-narrow-to-element) | |
544 | (goto-char entry-end) | |
545 | (re-search-backward "^[\t ]*:END:") | |
546 | (forward-line) | |
547 | (delete-region (point) entry-end)) | |
548 | ||
549 | ;; put new event description in the entry body | |
550 | (when description | |
551 | (save-restriction | |
552 | (narrow-to-region (point) (point)) | |
553 | (insert "\n" (replace-regexp-in-string "[\n]+$" "\n" description) "\n") | |
554 | (indent-region (point-min) (point-max) (1+ entry-outline-level)) | |
555 | (fill-region (point-min) (point-max)))) | |
556 | ||
557 | ;; update entry properties | |
558 | (org-entry-put event-pos "DT" (gnus-icalendar-event:org-timestamp event)) | |
559 | (org-entry-put event-pos "ORGANIZER" organizer) | |
560 | (org-entry-put event-pos "LOCATION" location) | |
42e51060 | 561 | (org-entry-put event-pos "PARTICIPATION_TYPE" (symbol-name participation-type)) |
8ef7141b JT |
562 | (org-entry-put event-pos "REQ_PARTICIPANTS" (gnus-icalendar--format-participant-list req-participants)) |
563 | (org-entry-put event-pos "OPT_PARTICIPANTS" (gnus-icalendar--format-participant-list opt-participants)) | |
89cccc2f G |
564 | (org-entry-put event-pos "RRULE" recur) |
565 | (when reply-status (org-entry-put event-pos "REPLY" | |
566 | (capitalize (symbol-name reply-status)))) | |
567 | (save-buffer))))))))) | |
568 | ||
569 | ||
570 | (defun gnus-icalendar--cancel-org-event (event &optional org-file) | |
571 | (let ((file (gnus-icalendar-find-org-event-file event org-file))) | |
572 | (when file | |
573 | (with-current-buffer (find-file-noselect file) | |
574 | (let ((event-pos (org-find-entry-with-id (gnus-icalendar-event:uid event)))) | |
575 | (when event-pos | |
576 | (let ((ts (org-entry-get event-pos "DT"))) | |
577 | (when ts | |
578 | (org-entry-put event-pos "DT" (gnus-icalendar--deactivate-org-timestamp ts)) | |
579 | (save-buffer))))))))) | |
580 | ||
581 | ||
582 | (defun gnus-icalendar--get-org-event-reply-status (event &optional org-file) | |
583 | (let ((file (gnus-icalendar-find-org-event-file event org-file))) | |
584 | (when file | |
585 | (save-excursion | |
586 | (with-current-buffer (find-file-noselect file) | |
587 | (let ((event-pos (org-find-entry-with-id (gnus-icalendar-event:uid event)))) | |
588 | (org-entry-get event-pos "REPLY"))))))) | |
589 | ||
590 | ||
591 | (defun gnus-icalendar-insinuate-org-templates () | |
592 | (unless (gnus-icalendar-find-if (lambda (x) (string= (cadr x) gnus-icalendar-org-template-name)) | |
593 | org-capture-templates) | |
594 | (setq org-capture-templates | |
595 | (append `((,gnus-icalendar-org-template-key | |
596 | ,gnus-icalendar-org-template-name | |
597 | entry | |
598 | (file+olp ,gnus-icalendar-org-capture-file ,@gnus-icalendar-org-capture-headline) | |
599 | "%i" | |
600 | :immediate-finish t)) | |
601 | org-capture-templates)) | |
602 | ||
603 | ;; hide the template from interactive template selection list | |
604 | ;; (org-capture) | |
605 | ;; NOTE: doesn't work when capturing from string | |
606 | ;; (when (boundp 'org-capture-templates-contexts) | |
607 | ;; (push `(,gnus-icalendar-org-template-key "" ((in-mode . "gnus-article-mode"))) | |
608 | ;; org-capture-templates-contexts)) | |
609 | )) | |
610 | ||
611 | (defun gnus-icalendar:org-event-save (event reply-status) | |
612 | (with-temp-buffer | |
613 | (org-capture-string (gnus-icalendar-event->org-entry event reply-status) | |
614 | gnus-icalendar-org-template-key))) | |
615 | ||
616 | (defun gnus-icalendar-show-org-agenda (event) | |
617 | (let* ((time-delta (time-subtract (gnus-icalendar-event:end-time event) | |
618 | (gnus-icalendar-event:start-time event))) | |
619 | (duration-days (1+ (/ (+ (* (car time-delta) (expt 2 16)) | |
620 | (cadr time-delta)) | |
621 | 86400)))) | |
622 | ||
623 | (org-agenda-list nil (gnus-icalendar-event:start event) duration-days))) | |
624 | ||
625 | (defmethod gnus-icalendar-event:sync-to-org ((event gnus-icalendar-event-request) reply-status) | |
626 | (if (gnus-icalendar-find-org-event-file event) | |
627 | (gnus-icalendar--update-org-event event reply-status) | |
628 | (gnus-icalendar:org-event-save event reply-status))) | |
629 | ||
0f755e30 | 630 | (defmethod gnus-icalendar-event:sync-to-org ((event gnus-icalendar-event-cancel) reply-status) |
89cccc2f G |
631 | (when (gnus-icalendar-find-org-event-file event) |
632 | (gnus-icalendar--cancel-org-event event))) | |
633 | ||
634 | (defun gnus-icalendar-org-setup () | |
635 | (if (and gnus-icalendar-org-capture-file gnus-icalendar-org-capture-headline) | |
636 | (progn | |
637 | (gnus-icalendar-insinuate-org-templates) | |
638 | (setq gnus-icalendar-org-enabled-p t)) | |
639 | (message "Cannot enable Calendar->Org: missing capture file, headline"))) | |
640 | ||
641 | ;;; | |
642 | ;;; gnus-icalendar | |
643 | ;;; | |
644 | ||
645 | (defgroup gnus-icalendar nil | |
646 | "Settings for inline display of iCalendar invitations." | |
647 | :group 'gnus-article | |
648 | :prefix "gnus-icalendar-") | |
649 | ||
650 | (defcustom gnus-icalendar-reply-bufname "*CAL*" | |
651 | "Buffer used for building iCalendar invitation reply." | |
652 | :type '(string) | |
653 | :group 'gnus-icalendar) | |
654 | ||
680f4ae6 JT |
655 | (defcustom gnus-icalendar-additional-identities nil |
656 | "We need to know your identity to make replies to calendar requests work. | |
657 | ||
658 | Gnus will only offer you the Accept/Tentative/Decline buttons for | |
659 | calendar events if any of your identities matches at least one | |
660 | RSVP participant. | |
661 | ||
662 | Your identity is guessed automatically from the variables `user-full-name', | |
663 | `user-mail-address', and `gnus-ignored-from-addresses'. | |
664 | ||
665 | If you need even more aliases you can define them here. It really | |
666 | only makes sense to define names or email addresses." | |
667 | ||
668 | :type '(repeat string) | |
669 | :group 'gnus-icalendar) | |
670 | ||
89cccc2f G |
671 | (make-variable-buffer-local |
672 | (defvar gnus-icalendar-reply-status nil)) | |
673 | ||
674 | (make-variable-buffer-local | |
675 | (defvar gnus-icalendar-event nil)) | |
676 | ||
677 | (make-variable-buffer-local | |
678 | (defvar gnus-icalendar-handle nil)) | |
679 | ||
88312cfc JT |
680 | (defun gnus-icalendar-identities () |
681 | "Return list of regexp-quoted names and email addresses belonging to the user. | |
682 | ||
683 | These will be used to retrieve the RSVP information from ical events." | |
89cccc2f G |
684 | (apply #'append |
685 | (mapcar (lambda (x) (if (listp x) x (list x))) | |
686 | (list user-full-name (regexp-quote user-mail-address) | |
680f4ae6 JT |
687 | ; NOTE: these can be lists |
688 | gnus-ignored-from-addresses ; already regexp-quoted | |
689 | (mapcar #'regexp-quote gnus-icalendar-additional-identities))))) | |
89cccc2f G |
690 | |
691 | ;; TODO: make the template customizable | |
692 | (defmethod gnus-icalendar-event->gnus-calendar ((event gnus-icalendar-event) &optional reply-status) | |
693 | "Format an overview of EVENT details." | |
9ab16aab | 694 | (gmm-labels ((format-header (x) |
89cccc2f G |
695 | (format "%-12s%s" |
696 | (propertize (concat (car x) ":") 'face 'bold) | |
697 | (cadr x)))) | |
698 | ||
8ef7141b | 699 | (with-slots (organizer summary description location recur uid |
42e51060 | 700 | method rsvp participation-type) event |
89cccc2f | 701 | (let ((headers `(("Summary" ,summary) |
0f755e30 | 702 | ("Location" ,(or location "")) |
89cccc2f G |
703 | ("Time" ,(gnus-icalendar-event:org-timestamp event)) |
704 | ("Organizer" ,organizer) | |
42e51060 JT |
705 | ("Attendance" ,(if (eq participation-type 'non-participant) |
706 | "You are not listed as an attendee" | |
707 | (capitalize (symbol-name participation-type)))) | |
89cccc2f G |
708 | ("Method" ,method)))) |
709 | ||
710 | (when (and (not (gnus-icalendar-event-reply-p event)) rsvp) | |
711 | (setq headers (append headers | |
712 | `(("Status" ,(or reply-status "Not replied yet")))))) | |
713 | ||
714 | (concat | |
715 | (mapconcat #'format-header headers "\n") | |
716 | "\n\n" | |
717 | description))))) | |
718 | ||
719 | (defmacro gnus-icalendar-with-decoded-handle (handle &rest body) | |
720 | "Execute BODY in buffer containing the decoded contents of HANDLE." | |
721 | (let ((charset (make-symbol "charset"))) | |
722 | `(let ((,charset (cdr (assoc 'charset (mm-handle-type ,handle))))) | |
723 | (with-temp-buffer | |
724 | (mm-insert-part ,handle) | |
725 | (when (string= ,charset "utf-8") | |
726 | (mm-decode-coding-region (point-min) (point-max) 'utf-8)) | |
727 | ||
728 | ,@body)))) | |
729 | ||
730 | ||
731 | (defun gnus-icalendar-event-from-handle (handle &optional attendee-name-or-email) | |
732 | (gnus-icalendar-with-decoded-handle handle | |
733 | (gnus-icalendar-event-from-buffer (current-buffer) attendee-name-or-email))) | |
734 | ||
735 | (defun gnus-icalendar-insert-button (text callback data) | |
736 | ;; FIXME: the gnus-mime-button-map keymap does not make sense for this kind | |
737 | ;; of button. | |
738 | (let ((start (point))) | |
739 | (gnus-add-text-properties | |
740 | start | |
741 | (progn | |
742 | (insert "[ " text " ]") | |
743 | (point)) | |
744 | `(gnus-callback | |
745 | ,callback | |
746 | keymap ,gnus-mime-button-map | |
747 | face ,gnus-article-button-face | |
748 | gnus-data ,data)) | |
749 | (widget-convert-button 'link start (point) | |
750 | :action 'gnus-widget-press-button | |
751 | :button-keymap gnus-widget-button-keymap))) | |
752 | ||
753 | (defun gnus-icalendar-send-buffer-by-mail (buffer-name subject) | |
754 | (let ((message-signature nil)) | |
755 | (with-current-buffer gnus-summary-buffer | |
756 | (gnus-summary-reply) | |
757 | (message-goto-body) | |
758 | (mml-insert-multipart "alternative") | |
759 | (mml-insert-empty-tag 'part 'type "text/plain") | |
760 | (mml-attach-buffer buffer-name "text/calendar; method=REPLY; charset=UTF-8") | |
761 | (message-goto-subject) | |
762 | (delete-region (line-beginning-position) (line-end-position)) | |
763 | (insert "Subject: " subject) | |
764 | (message-send-and-exit)))) | |
765 | ||
766 | (defun gnus-icalendar-reply (data) | |
767 | (let* ((handle (car data)) | |
768 | (status (cadr data)) | |
769 | (event (caddr data)) | |
770 | (reply (gnus-icalendar-with-decoded-handle handle | |
771 | (gnus-icalendar-event-reply-from-buffer | |
88312cfc | 772 | (current-buffer) status (gnus-icalendar-identities))))) |
89cccc2f G |
773 | |
774 | (when reply | |
9ab16aab | 775 | (gmm-labels ((fold-icalendar-buffer () |
89cccc2f G |
776 | (goto-char (point-min)) |
777 | (while (re-search-forward "^\\(.\\{72\\}\\)\\(.+\\)$" nil t) | |
778 | (replace-match "\\1\n \\2") | |
779 | (goto-char (line-beginning-position))))) | |
780 | (let ((subject (concat (capitalize (symbol-name status)) | |
781 | ": " (gnus-icalendar-event:summary event)))) | |
782 | ||
783 | (with-current-buffer (get-buffer-create gnus-icalendar-reply-bufname) | |
784 | (delete-region (point-min) (point-max)) | |
785 | (insert reply) | |
786 | (fold-icalendar-buffer) | |
787 | (gnus-icalendar-send-buffer-by-mail (buffer-name) subject)) | |
788 | ||
789 | ;; Back in article buffer | |
790 | (setq-local gnus-icalendar-reply-status status) | |
791 | (when gnus-icalendar-org-enabled-p | |
792 | (gnus-icalendar--update-org-event event status) | |
793 | ;; refresh article buffer to update the reply status | |
794 | (with-current-buffer gnus-summary-buffer | |
795 | (gnus-summary-show-article)))))))) | |
796 | ||
797 | (defun gnus-icalendar-sync-event-to-org (event) | |
798 | (gnus-icalendar-event:sync-to-org event gnus-icalendar-reply-status)) | |
799 | ||
800 | (defmethod gnus-icalendar-event:inline-reply-buttons ((event gnus-icalendar-event) handle) | |
801 | (when (gnus-icalendar-event:rsvp event) | |
802 | `(("Accept" gnus-icalendar-reply (,handle accepted ,event)) | |
803 | ("Tentative" gnus-icalendar-reply (,handle tentative ,event)) | |
804 | ("Decline" gnus-icalendar-reply (,handle declined ,event))))) | |
805 | ||
806 | (defmethod gnus-icalendar-event:inline-reply-buttons ((event gnus-icalendar-event-reply) handle) | |
807 | "No buttons for REPLY events." | |
808 | nil) | |
809 | ||
810 | (defmethod gnus-icalendar-event:inline-reply-status ((event gnus-icalendar-event)) | |
811 | (or (when gnus-icalendar-org-enabled-p | |
812 | (gnus-icalendar--get-org-event-reply-status event)) | |
813 | "Not replied yet")) | |
814 | ||
815 | (defmethod gnus-icalendar-event:inline-reply-status ((event gnus-icalendar-event-reply)) | |
816 | "No reply status for REPLY events." | |
817 | nil) | |
818 | ||
819 | ||
820 | (defmethod gnus-icalendar-event:inline-org-buttons ((event gnus-icalendar-event)) | |
821 | (let* ((org-entry-exists-p (gnus-icalendar-find-org-event-file event)) | |
822 | (export-button-text (if org-entry-exists-p "Update Org Entry" "Export to Org"))) | |
823 | ||
824 | (delq nil (list | |
825 | `("Show Agenda" gnus-icalendar-show-org-agenda ,event) | |
826 | (when (gnus-icalendar-event-request-p event) | |
827 | `(,export-button-text gnus-icalendar-sync-event-to-org ,event)) | |
828 | (when org-entry-exists-p | |
829 | `("Show Org Entry" gnus-icalendar--show-org-event ,event)))))) | |
830 | ||
0f755e30 JT |
831 | |
832 | (defmethod gnus-icalendar-event:inline-org-buttons ((event gnus-icalendar-event-cancel)) | |
833 | (let ((org-entry-exists-p (gnus-icalendar-find-org-event-file event))) | |
834 | ||
835 | (delq nil (list | |
836 | `("Show Agenda" gnus-icalendar-show-org-agenda ,event) | |
837 | (when org-entry-exists-p | |
838 | `("Update Org Entry" gnus-icalendar-sync-event-to-org ,event)) | |
839 | (when org-entry-exists-p | |
840 | `("Show Org Entry" gnus-icalendar--show-org-event ,event)))))) | |
841 | ||
842 | ||
89cccc2f | 843 | (defun gnus-icalendar-mm-inline (handle) |
88312cfc | 844 | (let ((event (gnus-icalendar-event-from-handle handle (gnus-icalendar-identities)))) |
89cccc2f G |
845 | |
846 | (setq gnus-icalendar-reply-status nil) | |
847 | ||
848 | (when event | |
9ab16aab | 849 | (gmm-labels ((insert-button-group (buttons) |
89cccc2f G |
850 | (when buttons |
851 | (mapc (lambda (x) | |
852 | (apply 'gnus-icalendar-insert-button x) | |
853 | (insert " ")) | |
854 | buttons) | |
855 | (insert "\n\n")))) | |
856 | ||
857 | (insert-button-group | |
858 | (gnus-icalendar-event:inline-reply-buttons event handle)) | |
859 | ||
860 | (when gnus-icalendar-org-enabled-p | |
861 | (insert-button-group (gnus-icalendar-event:inline-org-buttons event))) | |
862 | ||
863 | (setq gnus-icalendar-event event | |
864 | gnus-icalendar-handle handle) | |
865 | ||
866 | (insert (gnus-icalendar-event->gnus-calendar | |
867 | event | |
868 | (gnus-icalendar-event:inline-reply-status event))))))) | |
869 | ||
870 | (defun gnus-icalendar-save-part (handle) | |
871 | (let (event) | |
872 | (when (and (equal (car (mm-handle-type handle)) "text/calendar") | |
88312cfc | 873 | (setq event (gnus-icalendar-event-from-handle handle (gnus-icalendar-identities)))) |
89cccc2f G |
874 | |
875 | (gnus-icalendar-event:sync-to-org event)))) | |
876 | ||
877 | ||
878 | (defun gnus-icalendar-save-event () | |
879 | "Save the Calendar event in the text/calendar part under point." | |
880 | (interactive) | |
881 | (gnus-article-check-buffer) | |
882 | (let ((data (get-text-property (point) 'gnus-data))) | |
883 | (when data | |
884 | (gnus-icalendar-save-part data)))) | |
885 | ||
886 | (defun gnus-icalendar-reply-accept () | |
887 | "Accept invitation in the current article." | |
888 | (interactive) | |
889 | (with-current-buffer gnus-article-buffer | |
890 | (gnus-icalendar-reply (list gnus-icalendar-handle 'accepted gnus-icalendar-event)) | |
891 | (setq-local gnus-icalendar-reply-status 'accepted))) | |
892 | ||
893 | (defun gnus-icalendar-reply-tentative () | |
894 | "Send tentative response to invitation in the current article." | |
895 | (interactive) | |
896 | (with-current-buffer gnus-article-buffer | |
897 | (gnus-icalendar-reply (list gnus-icalendar-handle 'tentative gnus-icalendar-event)) | |
898 | (setq-local gnus-icalendar-reply-status 'tentative))) | |
899 | ||
900 | (defun gnus-icalendar-reply-decline () | |
901 | "Decline invitation in the current article." | |
902 | (interactive) | |
903 | (with-current-buffer gnus-article-buffer | |
904 | (gnus-icalendar-reply (list gnus-icalendar-handle 'declined gnus-icalendar-event)) | |
905 | (setq-local gnus-icalendar-reply-status 'declined))) | |
906 | ||
907 | (defun gnus-icalendar-event-export () | |
908 | "Export calendar event to `org-mode', or update existing agenda entry." | |
909 | (interactive) | |
910 | (with-current-buffer gnus-article-buffer | |
911 | (gnus-icalendar-sync-event-to-org gnus-icalendar-event)) | |
912 | ;; refresh article buffer in case the reply had been sent before initial org | |
913 | ;; export | |
914 | (with-current-buffer gnus-summary-buffer | |
915 | (gnus-summary-show-article))) | |
916 | ||
917 | (defun gnus-icalendar-event-show () | |
918 | "Display `org-mode' agenda entry related to the calendar event." | |
919 | (interactive) | |
920 | (gnus-icalendar--show-org-event | |
921 | (with-current-buffer gnus-article-buffer | |
922 | gnus-icalendar-event))) | |
923 | ||
924 | (defun gnus-icalendar-event-check-agenda () | |
925 | "Display `org-mode' agenda for days between event start and end dates." | |
926 | (interactive) | |
927 | (gnus-icalendar-show-org-agenda | |
928 | (with-current-buffer gnus-article-buffer gnus-icalendar-event))) | |
929 | ||
a99f655b GM |
930 | (defvar gnus-mime-action-alist) ; gnus-art |
931 | ||
89cccc2f G |
932 | (defun gnus-icalendar-setup () |
933 | (add-to-list 'mm-inlined-types "text/calendar") | |
934 | (add-to-list 'mm-automatic-display "text/calendar") | |
935 | (add-to-list 'mm-inline-media-tests '("text/calendar" gnus-icalendar-mm-inline identity)) | |
936 | ||
937 | (gnus-define-keys (gnus-summary-calendar-map "i" gnus-summary-mode-map) | |
938 | "a" gnus-icalendar-reply-accept | |
939 | "t" gnus-icalendar-reply-tentative | |
940 | "d" gnus-icalendar-reply-decline | |
941 | "c" gnus-icalendar-event-check-agenda | |
942 | "e" gnus-icalendar-event-export | |
943 | "s" gnus-icalendar-event-show) | |
944 | ||
945 | (require 'gnus-art) | |
946 | (add-to-list 'gnus-mime-action-alist | |
947 | (cons "save calendar event" 'gnus-icalendar-save-event) | |
948 | t)) | |
949 | ||
950 | (provide 'gnus-icalendar) | |
951 | ||
952 | ;;; gnus-icalendar.el ends here |