Thanks as always for the review. I attach the updated patch.

>> * lisp/ox-icalendar.el (org-export-define-derived-backend): Add
>> export-block and keyword to `:translate-alist'
>
> FYI, we have recently added git hook for automatic checks of the commit
> messages for common pitfalls. See 
> https://orgmode.org/worg/org-contribute.html#git-hooks

I think the error here is the missing period? I've added it. I've also
installed the commit hooks now but they actually didn't catch this one.

>> @@ -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))))
>
> Why only paragraphs?
> On main, things like
> :fixed width
> are included into DESCRIPTION

Good point. I'm not sure if there are other element types that are also worth
including. I therefore switched to just using mapconcat over the
top-level contents instead of org-element-map, and skipping over the
elements that are export-blocks/keywords.

Also, I added a couple tests to ensure lists and :fixed width are included.

>> -              (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")))
>
> This will go into child headings and inlinetasks.
> Consider the following example
>
> * This is test
> <2026-03-15 Sun>
> foo
> - item
> : fixed
> *************** this is test
> <2026-03-17 Tue>
> #+ICALENDAR: CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234
> *************** END
> ** More test
> <2026-03-16 Mon>

Good catch. I've added 'inlinetasks to the NO-RECURSION argument of
org-element-map, and also added a test for this.

>> +(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.
>
> Be careful with copy-paste :)

D'oh!

>From 5027ec45790f005c22d4a28f0d1779af0c266b92 Mon Sep 17 00:00:00 2001
From: Jack Kamm <[email protected]>
Date: Sat, 21 Mar 2026 19:56:07 -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): Exclude export blocks and keywords from
DESCRIPTION.  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.
(test-ox-icalendar/list): New test for lists.
(test-ox-icalendar/fixed-width): New test for fixed-width lines.
(test-ox-icalendar/inline-task-keyword): Test to ensure keywords from
inline tasks not included twice.
---
 etc/ORG-NEWS                      |  32 +++++++++
 lisp/ox-icalendar.el              |  64 +++++++++++++----
 testing/lisp/test-ox-icalendar.el | 110 ++++++++++++++++++++++++++++++
 3 files changed, 191 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..ae6ee06f1 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,13 @@ (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 (mapconcat
+                                    (lambda (elem)
+                                      (unless (memq (org-element-type elem)
+                                                    '(export-block keyword))
+                                        (org-export-data elem info)))
+                                    (org-element-contents inside)
+                                    "")))
 		     (cond
 		      ((not (org-string-nw-p contents)) nil)
 		      ((wholenump org-icalendar-include-body)
@@ -708,7 +718,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 nil 'inlinetask)
+                                        "\n")))
 	 (concat
 	  ;; Events: Delegate to `org-icalendar--vevent' to generate
 	  ;; "VEVENT" component from scheduled, deadline, or any
@@ -726,7 +742,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 +756,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 +772,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 +789,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 +819,19 @@ (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 ignored.  INFO is a plist used as a communication channel."
+  (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 iCalendar text.
+CONTENTS is ignored.  INFO is a plist used as a communication channel."
+  (let ((key (org-element-property :key keyword))
+	(value (org-element-property :value keyword)))
+    (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 +842,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 +855,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 +880,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 +902,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 +1026,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..2ca6eed3e 100644
--- a/testing/lisp/test-ox-icalendar.el
+++ b/testing/lisp/test-ox-icalendar.el
@@ -158,5 +158,115 @@ (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 export blocks are exported verbatim."
+  (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 keywords are exported verbatim."
+  (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)))))
+
+(ert-deftest test-ox-icalendar/list ()
+  "Test lists are exported in DESCRIPTION."
+  (let ((tmp-ics (org-test-with-temp-text-in-file
+                  "* Test event
+:PROPERTIES:
+:ID:       b17d8f92-1beb-442e-be4d-d2060fa3c7ff
+:END:
+<2023-03-30 Thu>
+
+- Item 1
+- Item 2
+- Item 3"
+                  (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 "• Item 1"))))
+      (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
+
+(ert-deftest test-ox-icalendar/fixed-width ()
+  "Test that fixed width lines are exported in DESCRIPTION."
+  (let ((tmp-ics (org-test-with-temp-text-in-file
+                  "* Test event
+:PROPERTIES:
+:ID:       b17d8f92-1beb-442e-be4d-d2060fa3c7ff
+:END:
+<2023-03-30 Thu>
+
+: Hello world"
+                  (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 "Hello world"))))
+      (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
+
+(ert-deftest test-ox-icalendar/inline-task-keyword ()
+  "Test that keywords are not included from inline tasks"
+  (let ((tmp-ics (org-test-with-temp-text-in-file
+                  "* This is test
+<2026-03-15 Sun>
+foo
+- item
+: fixed
+*************** this is test
+<2026-03-17 Tue>
+#+ICALENDAR: CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234
+*************** END
+** More test
+<2026-03-16 Mon>
+"
+                  (expand-file-name (org-icalendar-export-to-ics)))))
+    (unwind-protect
+        (with-temp-buffer
+          (insert-file-contents tmp-ics)
+          (print-buffer)
+          (save-excursion
+            (should (search-forward "CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234")))
+          (save-excursion
+            (should (not (search-forward "CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234" nil t 2)))))
+      (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