Ihor Radchenko <[email protected]> writes:

> #+ICALENDAR_VCALENDAR: X-MICROSOFT-CDO-INTENDEDSTATUS:FREE
> or an equivalent export snippet, but ox-icalendar does not support such
> thing. I believe that it should be considered a bug - we should provide
> some means to produce text to be exported verbatim from inside Org files.

The following patch provides initial support for #+ICALENDAR keywords
and for icalendar export blocks.

Delightfully, syntax highlighting for icalendar blocks worked out of the
box with no extra effort, when icalendar-mode.el (Emacs 31) is available.

I think this should also be useful for the recently discussed iCal->Org
importer -- it will allow us to include obscure iCal properties without
information loss, by putting them into icalendar blocks.

>From 199c115ebd57c44ed32bdb82c2d12cffb35f11ed Mon Sep 17 00:00:00 2001
From: Jack Kamm <[email protected]>
Date: Thu, 12 Mar 2026 09:02:26 -0700
Subject: [PATCH] ox-icalendar: Add export blocks and keywords

* lisp/ox-icalendar.el (org-export-define-derived-backend): Add
export-block and keyword to `:translate-alist'
(org-icalendar-entry): Limit DESCRIPTION to paragraph elements
only.  Extract literal iCalendar text from export-block and
keyword elements to pass to `org-icalendar--vevent' and
`org-icalendar--vtodo'.
(org-icalendar--export-block): New function for export-block.
(org-icalendar--keyword): New function for keyword export.
(org-icalendar--vevent): Add argument `literal-ical' to append
literal iCalendar test to the VEVENT.
(org-icalendar--vtodo): Add argument `literal-ical' to append
literal iCalendar test to the VTODO.

* testing/lisp/test-ox-icalendar.el
(test-ox-icalendar/export-block): New test for icalendar export-block.
(test-ox-icalendar/keyword): New test for icalendar keywords.
---
 etc/ORG-NEWS                      | 32 +++++++++++++++
 lisp/ox-icalendar.el              | 65 ++++++++++++++++++++++++-------
 testing/lisp/test-ox-icalendar.el | 42 ++++++++++++++++++++
 3 files changed, 124 insertions(+), 15 deletions(-)

diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 230a88396..13c982a0c 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -24,6 +24,38 @@ Please send Org bug reports to mailto:[email protected].
 # We list the most important features, and the features that may
 # require user action to be used.
 
+*** iCalendar export blocks and keywords
+
+The iCalendar exporter now allows using export blocks and keywords
+to insert literal text into the exported document. For example:
+
+#+begin_src org
+  ,* An event
+  :PROPERTIES:
+  :ID:       abc123
+  :END:
+  <2026-03-12 Thu>
+
+  ,#+ICALENDAR: CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234
+
+  ,#+begin_export icalendar
+  ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Henry
+   Cabot:mailto:[email protected]
+  ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="mailto:bob@
+   example.com";PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@
+   example.com
+  ,#+end_export
+#+end_src
+
+Note that text from export blocks and keywords is inserted literally
+into the exported iCalendar without any syntax checking.  When
+icalendar-mode.el (from Emacs 31) is available, text in icalendar
+export blocks will have syntax highlighting.
+
+A current limitation is that export blocks and keywords are only
+implemented for events and todos, and not yet for calendar-wide
+properties.
+
 ** New and changed options
 
 # Changes dealing with changing default values of customizations,
diff --git a/lisp/ox-icalendar.el b/lisp/ox-icalendar.el
index 9fc1e54cb..21142e943 100644
--- a/lisp/ox-icalendar.el
+++ b/lisp/ox-icalendar.el
@@ -376,8 +376,10 @@ (defvar org-icalendar-after-save-hook nil
 
 (org-export-define-derived-backend 'icalendar 'ascii
   :translate-alist '((clock . nil)
+                     (export-block . org-icalendar--export-block)
 		     (footnote-definition . nil)
 		     (footnote-reference . nil)
+		     (keyword . org-icalendar--keyword)
 		     (headline . org-icalendar-entry)
                      (inner-template . org-icalendar-inner-template)
 		     (inlinetask . nil)
@@ -568,6 +570,8 @@ (defun org-icalendar-get-categories (entry info)
 			 categories)))))))
    ","))
 
+;; TODO: this should also support other fields such as DESCRIPTION,
+;; CATEGORIES, LOCATION, LITERAL-ICAL, etc
 (defun org-icalendar-transcode-diary-sexp (sexp uid summary)
   "Transcode a diary sexp into iCalendar format.
 SEXP is the diary sexp being transcoded, as a string.  UID is the
@@ -696,7 +700,11 @@ (defun org-icalendar-entry (entry contents info)
 	      (org-icalendar-cleanup-string
 	       (or (let ((org-property-separators '(("DESCRIPTION" . "\n"))))
                      (org-entry-get entry "DESCRIPTION" 'selective))
-		   (let ((contents (org-export-data inside info)))
+		   (let ((contents (string-join (org-element-map
+                                               (org-element-contents inside)
+                                               'paragraph
+                                             (lambda (pg) (org-export-data pg info))
+                                             info))))
 		     (cond
 		      ((not (org-string-nw-p contents)) nil)
 		      ((wholenump org-icalendar-include-body)
@@ -708,7 +716,13 @@ (defun org-icalendar-entry (entry contents info)
 	     (cat (org-icalendar-get-categories entry info))
 	     (tz (org-export-get-node-property
 		  :TIMEZONE entry
-		  (org-property-inherit-p "TIMEZONE"))))
+		  (org-property-inherit-p "TIMEZONE")))
+             (literal-ical (string-join (org-element-map
+                                            (org-element-contents inside)
+                                            '(export-block keyword)
+                                          (lambda (pg) (org-export-data pg info))
+                                          info)
+                                        "\n")))
 	 (concat
 	  ;; Events: Delegate to `org-icalendar--vevent' to generate
 	  ;; "VEVENT" component from scheduled, deadline, or any
@@ -726,7 +740,7 @@ (defun org-icalendar-entry (entry contents info)
 		 (org-icalendar--vevent
 		  entry deadline (concat "DL-" uid)
 		  (concat deadline-summary-prefix summary)
-                  loc desc cat tz class)))
+                  loc desc cat tz class literal-ical)))
 	  (let ((scheduled (org-element-property :scheduled entry))
 		(use-scheduled (plist-get info :icalendar-use-scheduled))
                 (scheduled-summary-prefix (org-icalendar-cleanup-string
@@ -740,7 +754,7 @@ (defun org-icalendar-entry (entry contents info)
 		 (org-icalendar--vevent
 		  entry scheduled (concat "SC-" uid)
 		  (concat scheduled-summary-prefix summary)
-                  loc desc cat tz class)))
+                  loc desc cat tz class literal-ical)))
 	  ;; When collecting plain timestamps from a headline and its
 	  ;; title, skip inlinetasks since collection will happen once
 	  ;; ENTRY is one of them.
@@ -756,7 +770,7 @@ (defun org-icalendar-entry (entry contents info)
                           (org-element-property :type ts))
 		   (let ((uid (format "TS%d-%s" (cl-incf counter) uid)))
 		     (org-icalendar--vevent
-		      entry ts uid summary loc desc cat tz class))))
+		      entry ts uid summary loc desc cat tz class literal-ical))))
 	       info nil (and (eq type 'headline) 'inlinetask))
 	     ""))
 	  ;; Task: First check if it is appropriate to export it.  If
@@ -773,7 +787,7 @@ (defun org-icalendar-entry (entry contents info)
 		       (`t (eq todo-type 'todo))
                        ((and (pred listp) kwd-list)
                         (member (org-element-property :todo-keyword entry) kwd-list))))
-	    (org-icalendar--vtodo entry uid summary loc desc cat tz class))
+	    (org-icalendar--vtodo entry uid summary loc desc cat tz class literal-ical))
 	  ;; Diary-sexp: Collect every diary-sexp element within ENTRY
 	  ;; and its title, and transcode them.  If ENTRY is
 	  ;; a headline, skip inlinetasks: they will be handled
@@ -803,6 +817,22 @@ (defun org-icalendar-entry (entry contents info)
        ;; Don't forget components from inner entries.
        contents))))
 
+(defun org-icalendar--export-block (export-block _contents _info)
+  "Transcode a EXPORT-BLOCK element from Org to iCalendar.
+CONTENTS is nil.  INFO is a plist holding contextual information."
+  (when (equal (org-element-property :type export-block) "ICALENDAR")
+    (org-remove-indentation (org-element-property :value export-block))))
+
+(defun org-icalendar--keyword (keyword contents info)
+  "Transcode a KEYWORD element into Beamer code.
+CONTENTS is nil.  INFO is a plist used as a communication
+channel."
+  (let ((key (org-element-property :key keyword))
+	(value (org-element-property :value keyword)))
+    ;; Handle specifically BEAMER and TOC (headlines only) keywords.
+    ;; Otherwise, fallback to `latex' backend.
+    (when (equal key "ICALENDAR") value)))
+
 (defun org-icalendar--rrule (unit value)
   "Format RRULE icalendar entry for UNIT frequency and VALUE interval.
 UNIT is a symbol `hour', `day', `week', `month', or `year'."
@@ -813,7 +843,7 @@ (defun org-icalendar--rrule (unit value)
 	  value))
 
 (defun org-icalendar--vevent
-    (entry timestamp uid summary location description categories timezone class)
+    (entry timestamp uid summary location description categories timezone class literal-ical)
   "Create a VEVENT component.
 
 ENTRY is either a headline or an inlinetask element.  TIMESTAMP
@@ -826,6 +856,7 @@ (defun org-icalendar--vevent
 only.  CLASS contains the visibility attribute.  Three of them
 \\(\"PUBLIC\", \"CONFIDENTIAL\", and \"PRIVATE\") are predefined, others
 should be treated as \"PRIVATE\" if they are unknown to the iCalendar server.
+LITERAL-ICAL is additional iCalendar text to be added to the entry.
 
 Return VEVENT component as a string."
   (if (eq (org-element-property :type timestamp) 'diary)
@@ -850,6 +881,8 @@ (defun org-icalendar--vevent
 	    "CATEGORIES:" categories "\n"
 	    ;; VALARM.
 	    (org-icalendar--valarm entry timestamp summary)
+            ;; additional literal iCalendar text
+            literal-ical (unless (equal literal-ical "") "\n")
 	    "END:VEVENT\n")))
 
 (defun org-icalendar--repeater-type (elem)
@@ -870,16 +903,16 @@ (defun org-icalendar--repeater-type (elem)
      (repeater-type))))
 
 (defun org-icalendar--vtodo
-    (entry uid summary location description categories timezone class)
+    (entry uid summary location description categories timezone class literal-ical)
   "Create a VTODO component.
 
-ENTRY is either a headline or an inlinetask element.  UID is the
-unique identifier for the task.  SUMMARY defines a short summary
-or subject for the task.  LOCATION defines the intended venue for
-the task.  CLASS sets the task class (e.g. confidential).  DESCRIPTION
-provides the complete description of the task.  CATEGORIES defines the
-categories the task belongs to.  TIMEZONE specifies a time zone for
-this TODO only.
+ENTRY is either a headline or an inlinetask element.  UID is the unique
+identifier for the task.  SUMMARY defines a short summary or subject for
+the task.  LOCATION defines the intended venue for the task.  CLASS sets
+the task class (e.g. confidential).  DESCRIPTION provides the complete
+description of the task.  CATEGORIES defines the categories the task
+belongs to.  TIMEZONE specifies a time zone for this TODO only.
+LITERAL-ICAL is additional iCalendar text to be added to the entry.
 
 Return VTODO component as a string."
   (let* ((sc (and (memq 'todo-start org-icalendar-use-scheduled)
@@ -994,6 +1027,8 @@ (defun org-icalendar--vtodo
 		    (if (eq (org-element-property :todo-type entry) 'todo)
 			"NEEDS-ACTION"
 		      "COMPLETED"))
+            ;; additional literal iCalendar text
+            literal-ical (unless (equal literal-ical "") "\n")
 	    "END:VTODO\n")))
 
 (defun org-icalendar--valarm (entry timestamp summary)
diff --git a/testing/lisp/test-ox-icalendar.el b/testing/lisp/test-ox-icalendar.el
index 8c0ab6377..be5a2b891 100644
--- a/testing/lisp/test-ox-icalendar.el
+++ b/testing/lisp/test-ox-icalendar.el
@@ -158,5 +158,47 @@ (ert-deftest test-ox-icalendar/exclude-diary-timestamp ()
             (should (not (search-forward "RRULE:FREQ=MONTHLY;BYDAY=1SU" nil t)))))
       (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
 
+(ert-deftest test-ox-icalendar/export-block ()
+  "Test every line of iCalendar export has CRLF ending."
+  (let ((tmp-ics (org-test-with-temp-text-in-file
+                  "* Test event
+:PROPERTIES:
+:ID:       b17d8f92-1beb-442e-be4d-d2060fa3c7ff
+:END:
+<2023-03-30 Thu>
+
+#+begin_export icalendar
+CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234
+#+end_export"
+                  (expand-file-name (org-icalendar-export-to-ics)))))
+    (unwind-protect
+        (with-temp-buffer
+          (insert-file-contents tmp-ics)
+          (save-excursion
+            (should (search-forward "SUMMARY:Test event")))
+          (save-excursion
+            (should (search-forward "CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234"))))
+      (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
+
+(ert-deftest test-ox-icalendar/keyword ()
+  "Test every line of iCalendar export has CRLF ending."
+  (let ((tmp-ics (org-test-with-temp-text-in-file
+                  "* Test event
+:PROPERTIES:
+:ID:       b17d8f92-1beb-442e-be4d-d2060fa3c7ff
+:END:
+<2023-03-30 Thu>
+
+#+ICALENDAR: CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234"
+                  (expand-file-name (org-icalendar-export-to-ics)))))
+    (unwind-protect
+        (with-temp-buffer
+          (insert-file-contents tmp-ics)
+          (save-excursion
+            (should (search-forward "SUMMARY:Test event")))
+          (save-excursion
+            (should (search-forward "CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234"))))
+      (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
+
 (provide 'test-ox-icalendar)
 ;;; test-ox-icalendar.el ends here
-- 
2.53.0

Reply via email to