ox-icalendar: Add support for unscheduled and repeating TODOs

* lisp/ox-icalendar.el (org-icalendar-todo-unscheduled-start): New
customization to control the exported start time of unscheduled tasks.
(org-icalendar--rrule): Helper function for RRULE export.
(org-icalendar--vevent): Use the new helper function for RRULE.
(org-icalendar--repeater-type): Helper function to get the repeater
type, and display warning if not supported.
(org-icalendar--vtodo): Change how unscheduled TODOs are handled using
the new customization option.  Export SCHEDULED and DEADLINE
repeaters.  In case of SCHEDULED repeater and a DEADLINE without
repeater, treat DEADLINE as RRULE UNTIL.  Emit a warning for tricky
edge cases that are not yet implemented.
* testing/lisp/test-ox-icalendar.el
(test-ox-icalendar/todo-repeater-shared): Test for exporting shared
SCHEDULED/DEADLINE repeater.
(test-ox-icalendar/todo-repeating-deadline-warndays): Test using
warning days as DTSTART of repeating deadline.
(test-ox-icalendar/todo-repeater-until): Test using DEADLINE as RRULE
UNTIL.
(test-ox-icalendar/todo-repeater-until-utc): Test RRULE UNTIL is in
UTC format when DTSTART is not in local time format.
(test-ox-icalendar/warn-unsupported-repeater): Unit test to warn for
unsupported repeater types.
* lisp/org-lint.el (org-lint-mismatched-planning-repeaters): Add lint
for mismatched SCHEDULED and DEADLINE repeaters.
* testing/lisp/test-org-lint.el
(test-org-lint/mismatched-planning-repeaters): Add test for linting of
mismatched SCHEDULED and DEADLINE repeaters.
* doc/org-manual.org (iCalendar Export): Add link to new variable
`org-icalendar-todo-unscheduled-start'.
This commit is contained in:
Jack Kamm 2023-06-11 07:50:20 -07:00
parent d50956e480
commit 294a4d2fe2
6 changed files with 353 additions and 28 deletions

View File

@ -16071,14 +16071,16 @@ standard iCalendar format.
#+vindex: org-icalendar-include-todo
#+vindex: org-icalendar-use-deadline
#+vindex: org-icalendar-use-scheduled
#+vindex: org-icalendar-todo-unscheduled-start
The iCalendar export backend can also incorporate TODO entries based
on the configuration of the ~org-icalendar-include-todo~ variable.
The backend exports plain timestamps as =VEVENT=, TODO items as
=VTODO=, and also create events from deadlines that are in non-TODO
items. The backend uses the deadlines and scheduling dates in Org
TODO items for setting the start and due dates for the iCalendar TODO
entry. Consult the ~org-icalendar-use-deadline~ and
~org-icalendar-use-scheduled~ variables for more details.
entry. Consult the ~org-icalendar-use-deadline~,
~org-icalendar-use-scheduled~, and
~org-icalendar-todo-unscheduled-start~ variables for more details.
#+vindex: org-icalendar-categories
#+vindex: org-icalendar-alarm-time

View File

@ -50,6 +50,21 @@ ox-icalendar. In particular, older versions of org-caldav may
encounter issues, and users are advised to update to the most recent
version of org-caldav. See [[https://github.com/dengste/org-caldav/commit/618bf4cdc9be140ca1993901d017b7f18297f1b8][this org-caldav commit]] for more information.
*** Icalendar export of unscheduled TODOs no longer have start time of today
For TODOs without a scheduled start time, ox-icalendar no longer
forces them to have a scheduled start time of today when exporting.
Instead, the new customization ~org-icalendar-todo-unscheduled-start~
controls the exported start date for unscheduled tasks. Its default
is ~recurring-deadline-warning~ which will export unscheduled tasks
with no start date, unless it has a recurring deadline (in which case
the iCalendar spec demands a start date, and
~org-deadline-warning-days~ is used for that).
To revert to the old behavior, set
~org-icalendar-todo-unscheduled-start~ to ~current-datetime~.
** New and changed options
*** Commands affected by ~org-fold-catch-invisible-edits~ can now be customized
@ -188,6 +203,28 @@ default settings of "Body only", "Visible only", and "Force
publishing" in the ~org-export-dispatch~ UI to be customized,
respectively.
*** New option ~org-icalendar-todo-unscheduled-start~ to control unscheduled TODOs in ox-icalendar
~org-icalendar-todo-unscheduled-start~ controls how ox-icalendar
exports the starting datetime for unscheduled TODOs. Note this option
only has an effect when ~org-icalendar-include-todo~ is non-nil.
By default, ox-icalendar will not export a start datetime for
unscheduled TODOs, except in cases where the iCalendar spec demands a
start (specifically, for recurring deadlines, in which case
~org-deadline-warning-days~ is used).
Currently implemented options are:
- ~recurring-deadline-warning~: The default as described above.
- ~deadline-warning~: Use ~org-deadline-warning-days~ to set the start
time if the unscheduled task has a deadline (recurring or not).
- ~current-datetime~: Revert to old behavior, using the current
datetime as the start of unscheduled tasks.
- ~nil~: Never add a start time for unscheduled tasks. For repeating
tasks this technically violates the iCalendar spec, but some
iCalendar programs support this usage.
** New features
*** ~org-insert-todo-heading-respect-content~ now accepts prefix arguments
@ -230,6 +267,33 @@ editing with Emacs while a ~:session~ block executes.
When ~org-return-follows-link~ is non-nil and cursor is over an
org-cite citation, ~org-return~ will call ~org-open-at-point~.
*** Add support for repeating tasks in iCalendar export
Repeating Scheduled and Deadline timestamps in TODOs are now exported
as recurring tasks in iCalendar export.
In case the TODO has just a single planning timestamp (Scheduled or
Deadline, but not both), its repeater is used as the iCalendar
recurrence rule (RRULE).
If the TODO has both Scheduled and Deadline planning timestamps, then
the following cases are implemented:
- If both have the same repeater, then it is used as the RRULE.
- Scheduled has repeater but Deadline does not: the Scheduled repeater
is used as RRULE, and Deadline is used as UNTIL (the end date for
the repeater). This is similar to ~repeated-after-deadline~ in
~org-agenda-skip-scheduled-if-deadline-is-shown~.
The following 2 cases are not yet implemented, and the repeater is
skipped (with a warning) if the ox-icalendar export encounters them:
- Deadline has a repeater but Scheduled does not.
- Scheduled and Deadline have different repeaters.
Also note that only vanilla repeaters are currently exported; the
special repeaters ~++~ and ~.+~ are skipped.
** Miscellaneous
*** =org-crypt.el= now applies initial visibility settings to decrypted entries

View File

@ -70,6 +70,7 @@
;; - non-footnote definitions in footnote section,
;; - probable invalid keywords,
;; - invalid blocks,
;; - mismatched repeaters in planning info line,
;; - misplaced planning info line,
;; - probable incomplete drawers,
;; - probable indented diary-sexps,
@ -905,6 +906,34 @@ Use \"export %s\" instead"
"Name \"%s\" contains a colon; Babel cannot use it as input"
name)))))))
(defun org-lint-mismatched-planning-repeaters (ast)
(org-element-map ast 'planning
(lambda (e)
(let* ((scheduled (org-element-property :scheduled e))
(deadline (org-element-property :deadline e))
(scheduled-repeater-type (org-element-property
:repeater-type scheduled))
(deadline-repeater-type (org-element-property
:repeater-type deadline))
(scheduled-repeater-value (org-element-property
:repeater-value scheduled))
(deadline-repeater-value (org-element-property
:repeater-value deadline)))
(when (and scheduled deadline
(memq scheduled-repeater-type '(cumulate catch-up))
(memq deadline-repeater-type '(cumulate catch-up))
(> scheduled-repeater-value 0)
(> deadline-repeater-value 0)
(not
(and
(eq scheduled-repeater-type deadline-repeater-type)
(eq (org-element-property :repeater-unit scheduled)
(org-element-property :repeater-unit deadline))
(eql scheduled-repeater-value deadline-repeater-value))))
(list
(org-element-property :begin e)
"Different repeaters in SCHEDULED and DEADLINE timestamps."))))))
(defun org-lint-misplaced-planning-info (_)
(let ((case-fold-search t)
reports)
@ -1516,6 +1545,11 @@ AST is the buffer parse tree."
#'org-lint-invalid-block
:trust 'low)
(org-lint-add-checker 'mismatched-planning-repeaters
"Report mismatched repeaters in planning info line"
#'org-lint-mismatched-planning-repeaters
:trust 'low)
(org-lint-add-checker 'misplaced-planning-info
"Report misplaced planning info line"
#'org-lint-misplaced-planning-info

View File

@ -168,8 +168,9 @@ This is a list with possibly several symbols in it. Valid symbols are:
`todo-start'
Scheduling time stamps in TODO entries become start date. Some
calendar applications show TODO entries only after that date."
Scheduling time stamps in TODO entries become start date. (See
also `org-icalendar-todo-unscheduled-start', which controls the
start date for TODO entries without a scheduling time stamp)"
:group 'org-export-icalendar
:type
'(set :greedy t
@ -231,6 +232,38 @@ t include tasks that are not in DONE state.
(repeat :tag "Specific TODO keywords"
(string :tag "Keyword"))))
(defcustom org-icalendar-todo-unscheduled-start 'recurring-deadline-warning
"Exported start date of unscheduled TODOs.
If `org-icalendar-use-scheduled' contains `todo-start' and a task
has a \"SCHEDULED\" timestamp, that is always used as the start
date. Otherwise, this variable controls whether a start date is
exported and what its value is.
Note that the iCalendar spec RFC 5545 does not generally require
tasks to have a start date, except for repeating tasks which do
require a start date. However some iCalendar programs ignore the
requirement for repeating tasks, and allow repeating deadlines
without a matching start date.
This variable has no effect when `org-icalendar-include-todo' is nil.
Valid values are:
`recurring-deadline-warning' If deadline repeater present,
use `org-deadline-warning-days' as start.
`deadline-warning' If deadline present,
use `org-deadline-warning-days' as start.
`current-datetime' Use the current date-time as start.
nil Never add a start time for unscheduled tasks."
:group 'org-export-icalendar
:type '(choice
(const :tag "Warning days if deadline recurring" recurring-deadline-warning)
(const :tag "Warning days if deadline present" deadline-warning)
(const :tag "Now" current-datetime)
(const :tag "No start date" nil))
:package-version '(Org . "9.7")
:safe #'symbolp)
(defcustom org-icalendar-include-bbdb-anniversaries nil
"Non-nil means a combined iCalendar file should include anniversaries.
The anniversaries are defined in the BBDB database."
@ -731,6 +764,13 @@ inlinetask within the section."
;; Don't forget components from inner entries.
contents))))
(defun org-icalendar--rrule (unit value)
(format "RRULE:FREQ=%s;INTERVAL=%d"
(cl-case unit
(hour "HOURLY") (day "DAILY") (week "WEEKLY")
(month "MONTHLY") (year "YEARLY"))
value))
(defun org-icalendar--vevent
(entry timestamp uid summary location description categories timezone class)
"Create a VEVENT component.
@ -756,12 +796,11 @@ Return VEVENT component as a string."
(org-icalendar-convert-timestamp timestamp "DTSTART" nil timezone) "\n"
(org-icalendar-convert-timestamp timestamp "DTEND" t timezone) "\n"
;; RRULE.
(when (org-element-property :repeater-type timestamp)
(format "RRULE:FREQ=%s;INTERVAL=%d\n"
(cl-case (org-element-property :repeater-unit timestamp)
(hour "HOURLY") (day "DAILY") (week "WEEKLY")
(month "MONTHLY") (year "YEARLY"))
(org-element-property :repeater-value timestamp)))
(when (org-element-property :repeater-type timestamp)
(concat (org-icalendar--rrule
(org-element-property :repeater-unit timestamp)
(org-element-property :repeater-value timestamp))
"\n"))
"SUMMARY:" summary "\n"
(and (org-string-nw-p location) (format "LOCATION:%s\n" location))
(and (org-string-nw-p class) (format "CLASS:%s\n" class))
@ -772,6 +811,23 @@ Return VEVENT component as a string."
(org-icalendar--valarm entry timestamp summary)
"END:VEVENT")))
(defun org-icalendar--repeater-type (elem)
"Return ELEM's repeater-type if supported, else warn and return nil."
(let ((repeater-value (org-element-property :repeater-value elem))
(repeater-type (org-element-property :repeater-type elem)))
(cond
((not (and repeater-type
repeater-value
(> repeater-value 0)))
nil)
;; TODO Add catch-up to supported repeaters (use EXDATE to implement)
((not (memq repeater-type '(cumulate)))
(org-display-warning
(format "Repeater-type %s not currently supported by iCalendar export"
(symbol-name repeater-type)))
nil)
(repeater-type))))
(defun org-icalendar--vtodo
(entry uid summary location description categories timezone class)
"Create a VTODO component.
@ -784,27 +840,101 @@ task. CATEGORIES defines the categories the task belongs to.
TIMEZONE specifies a time zone for this TODO only.
Return VTODO component as a string."
(let ((start (or (and (memq 'todo-start org-icalendar-use-scheduled)
(org-element-property :scheduled entry))
;; If we can't use a scheduled time for some
;; reason, start task now.
(let ((now (decode-time)))
(list 'timestamp
(list :type 'active
:minute-start (nth 1 now)
:hour-start (nth 2 now)
:day-start (nth 3 now)
:month-start (nth 4 now)
:year-start (nth 5 now)))))))
(let* ((sc (and (memq 'todo-start org-icalendar-use-scheduled)
(org-element-property :scheduled entry)))
(dl (and (memq 'todo-due org-icalendar-use-deadline)
(org-element-property :deadline entry)))
(sc-repeat-p (org-icalendar--repeater-type sc))
(dl-repeat-p (org-icalendar--repeater-type dl))
(repeat-value (or (org-element-property :repeater-value sc)
(org-element-property :repeater-value dl)))
(repeat-unit (or (org-element-property :repeater-unit sc)
(org-element-property :repeater-unit dl)))
(repeat-until (and sc-repeat-p (not dl-repeat-p) dl))
(start
(cond
(sc)
((eq org-icalendar-todo-unscheduled-start 'current-datetime)
(let ((now (decode-time)))
(list 'timestamp
(list :type 'active
:minute-start (nth 1 now)
:hour-start (nth 2 now)
:day-start (nth 3 now)
:month-start (nth 4 now)
:year-start (nth 5 now)))))
((or (and (eq org-icalendar-todo-unscheduled-start
'deadline-warning)
dl)
(and (eq org-icalendar-todo-unscheduled-start
'recurring-deadline-warning)
dl-repeat-p))
(let ((dl-raw (org-element-property :raw-value dl)))
(with-temp-buffer
(insert dl-raw)
(goto-char (point-min))
(org-timestamp-down-day (org-get-wdays dl-raw))
(org-element-timestamp-parser)))))))
(concat "BEGIN:VTODO\n"
"UID:TODO-" uid "\n"
(org-icalendar-dtstamp) "\n"
(org-icalendar-convert-timestamp start "DTSTART" nil timezone) "\n"
(and (memq 'todo-due org-icalendar-use-deadline)
(org-element-property :deadline entry)
(concat (org-icalendar-convert-timestamp
(org-element-property :deadline entry) "DUE" nil timezone)
"\n"))
(when start (concat (org-icalendar-convert-timestamp
start "DTSTART" nil timezone)
"\n"))
(when (and dl (not repeat-until))
(concat (org-icalendar-convert-timestamp
dl "DUE" nil timezone)
"\n"))
;; RRULE
(cond
;; SCHEDULED, DEADLINE have different repeaters
((and dl-repeat-p
(not (and (eq repeat-value (org-element-property
:repeater-value dl))
(eq repeat-unit (org-element-property
:repeater-unit dl)))))
;; TODO Implement via RDATE with changing DURATION
(org-display-warning "Not yet implemented: \
different repeaters on SCHEDULED and DEADLINE. Skipping.")
nil)
;; DEADLINE has repeater but SCHEDULED doesn't
((and dl-repeat-p (and sc (not sc-repeat-p)))
;; TODO SCHEDULED should only apply to first instance;
;; use RDATE with custom DURATION to implement that
(org-display-warning "Not yet implemented: \
repeater on DEADLINE but not SCHEDULED. Skipping.")
nil)
((or sc-repeat-p dl-repeat-p)
(concat
(org-icalendar--rrule repeat-unit repeat-value)
;; add UNTIL part to RRULE
(when repeat-until
(let* ((start-time
(org-element-property :minute-start start))
;; RFC5545 requires UTC iff DTSTART is not local time
(local-time-p
(and (not timezone)
(equal org-icalendar-date-time-format
":%Y%m%dT%H%M%S")))
(encoded
(org-encode-time
0
(or (org-element-property :minute-start repeat-until)
0)
(or (org-element-property :hour-start repeat-until)
0)
(org-element-property :day-start repeat-until)
(org-element-property :month-start repeat-until)
(org-element-property :year-start repeat-until))))
(concat ";UNTIL="
(cond
((not start-time)
(format-time-string "%Y%m%d" encoded))
(local-time-p
(format-time-string "%Y%m%dT%H%M%S" encoded))
((format-time-string "%Y%m%dT%H%M%SZ"
encoded t))))))
"\n")))
"SUMMARY:" summary "\n"
(and (org-string-nw-p location) (format "LOCATION:%s\n" location))
(and (org-string-nw-p class) (format "CLASS:%s\n" class))

View File

@ -406,6 +406,13 @@ This is not a node property
(org-test-with-temp-text "#+name: name\n| a |"
(org-lint '(colon-in-name)))))
(ert-deftest test-org-lint/mismatched-planning-repeaters ()
"Test `org-lint-mismatched-planning-repeaters' checker."
(should
(org-test-with-temp-text "* H
DEADLINE: <2023-03-26 Sun +2w> SCHEDULED: <2023-03-26 Sun +1w>"
(org-lint '(mismatched-planning-repeaters)))))
(ert-deftest test-org-lint/misplaced-planning-info ()
"Test `org-lint-misplaced-planning-info' checker."
(should

View File

@ -40,5 +40,93 @@
(should (eql 1 (coding-system-eol-type last-coding-system-used))))
(when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
(ert-deftest test-ox-icalendar/todo-repeater-shared ()
"Test shared repeater on todo scheduled and deadline."
(let* ((org-icalendar-include-todo 'all)
(tmp-ics (org-test-with-temp-text-in-file
"* TODO Both repeating
DEADLINE: <2023-04-02 Sun +1m> SCHEDULED: <2023-03-26 Sun +1m>"
(expand-file-name (org-icalendar-export-to-ics)))))
(unwind-protect
(with-temp-buffer
(insert-file-contents tmp-ics)
(save-excursion
(should (search-forward "DTSTART;VALUE=DATE:20230326")))
(save-excursion
(should (search-forward "DUE;VALUE=DATE:20230402")))
(save-excursion
(should (search-forward "RRULE:FREQ=MONTHLY;INTERVAL=1"))))
(when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
(ert-deftest test-ox-icalendar/todo-repeating-deadline-warndays ()
"Test repeating deadline with DTSTART as warning days."
(let* ((org-icalendar-include-todo 'all)
(org-icalendar-todo-unscheduled-start 'recurring-deadline-warning)
(tmp-ics (org-test-with-temp-text-in-file
"* TODO Repeating deadline
DEADLINE: <2023-04-02 Sun +2w -3d>"
(expand-file-name (org-icalendar-export-to-ics)))))
(unwind-protect
(with-temp-buffer
(insert-file-contents tmp-ics)
(save-excursion
(should (search-forward "DTSTART;VALUE=DATE:20230330")))
(save-excursion
(should (search-forward "DUE;VALUE=DATE:20230402")))
(save-excursion
(should (search-forward "RRULE:FREQ=WEEKLY;INTERVAL=2"))))
(when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
(ert-deftest test-ox-icalendar/todo-repeater-until ()
"Test repeater on todo scheduled until deadline."
(let* ((org-icalendar-include-todo 'all)
(tmp-ics (org-test-with-temp-text-in-file
"* TODO Repeating scheduled with nonrepeating deadline
DEADLINE: <2023-05-01 Mon> SCHEDULED: <2023-03-26 Sun +3d>"
(expand-file-name (org-icalendar-export-to-ics)))))
(unwind-protect
(with-temp-buffer
(insert-file-contents tmp-ics)
(save-excursion
(should (search-forward "DTSTART;VALUE=DATE:20230326")))
(save-excursion
(should (not (re-search-forward "^DUE" nil t))))
(save-excursion
(should (search-forward "RRULE:FREQ=DAILY;INTERVAL=3;UNTIL=20230501"))))
(when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
(ert-deftest test-ox-icalendar/todo-repeater-until-utc ()
"Test that UNTIL is in UTC when DTSTART is not in local time format."
(let* ((org-icalendar-include-todo 'all)
(org-icalendar-date-time-format ":%Y%m%dT%H%M%SZ")
(tmp-ics (org-test-with-temp-text-in-file
"* TODO Repeating scheduled with nonrepeating deadline
DEADLINE: <2023-05-02 Tue> SCHEDULED: <2023-03-26 Sun 15:00 +3d>"
(expand-file-name (org-icalendar-export-to-ics)))))
(unwind-protect
(with-temp-buffer
(insert-file-contents tmp-ics)
(save-excursion
(should (re-search-forward "DTSTART:2023032.T..0000")))
(save-excursion
(should (not (re-search-forward "^DUE" nil t))))
(save-excursion
(should (re-search-forward "RRULE:FREQ=DAILY;INTERVAL=3;UNTIL=2023050.T..0000Z"))))
(when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
(ert-deftest test-ox-icalendar/warn-unsupported-repeater ()
"Test warning is emitted for unsupported repeater type."
(let ((org-icalendar-include-todo 'all))
(should
(member
"Repeater-type restart not currently supported by iCalendar export"
(org-test-capture-warnings
(let ((tmp-ics (org-test-with-temp-text-in-file
"* TODO Unsupported restart repeater
SCHEDULED: <2023-03-26 Sun .+1m>"
(expand-file-name (org-icalendar-export-to-ics)))))
(when (file-exists-p tmp-ics)
(delete-file tmp-ics))))))))
(provide 'test-ox-icalendar)
;;; test-ox-icalendar.el ends here