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