From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from mp11.migadu.com ([2001:41d0:2:bcc0::]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)) by ms9.migadu.com with LMTPS id 6IEaDRbqhWRRrQAASxT56A (envelope-from ) for ; Sun, 11 Jun 2023 17:36:54 +0200 Received: from aspmx1.migadu.com ([2001:41d0:2:bcc0::]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)) by mp11.migadu.com with LMTPS id uBZCDRbqhWRyZAAA9RJhRA (envelope-from ) for ; Sun, 11 Jun 2023 17:36:54 +0200 Received: from lists.gnu.org (lists.gnu.org [209.51.188.17]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by aspmx1.migadu.com (Postfix) with ESMTPS id 9F2C43206F for ; Sun, 11 Jun 2023 17:36:53 +0200 (CEST) Received: from localhost ([::1] helo=lists1p.gnu.org) by lists.gnu.org with esmtp (Exim 4.90_1) (envelope-from ) id 1q8N6r-0001ki-15; Sun, 11 Jun 2023 11:36:05 -0400 Received: from eggs.gnu.org ([2001:470:142:3::10]) by lists.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256) (Exim 4.90_1) (envelope-from ) id 1q8N6l-0001im-Ik for emacs-orgmode@gnu.org; Sun, 11 Jun 2023 11:36:00 -0400 Received: from mail-pl1-x629.google.com ([2607:f8b0:4864:20::629]) by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_128_GCM_SHA256:128) (Exim 4.90_1) (envelope-from ) id 1q8N6i-0005O0-CO for emacs-orgmode@gnu.org; Sun, 11 Jun 2023 11:35:59 -0400 Received: by mail-pl1-x629.google.com with SMTP id d9443c01a7336-1b3be6c0b4cso3101125ad.3 for ; Sun, 11 Jun 2023 08:35:54 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20221208; t=1686497753; x=1689089753; h=mime-version:message-id:date:references:in-reply-to:subject:cc:to :from:from:to:cc:subject:date:message-id:reply-to; bh=RR91Qz+w70/qlpC158K9+I1xkW3u/mXj74cWCB6Wce8=; b=HwLA7razGm67O5YAg/uvCfqfVRg3foB3sWlZten0NELyiqbB3aNgdfK0/3wZIlTOJJ 0DwZaXxI+y3saV+gY0Nml4aKwd+3yAMgihXzD2LYycYTW6UhVABgMYylNj02F9DBHQdJ +cXnBgTBPugGko92fYI0oI0bWM8SqCcca9gHubYllTUhjhAO8xvqXPhVqogZJuijzvz2 mhSksI2sjLkG3FQj9UMKuQnIBAojgIQJzodPUipqj1jMwlcKPAG3qJiN+KdeC7qwZlba IkXwF+bxgJ5jVu1J90T6XHU+PqBcaR1nsku6Q0W4zvXENvQNjeF28xK8TtCsBL7ZdjI2 TK1w== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1686497753; x=1689089753; h=mime-version:message-id:date:references:in-reply-to:subject:cc:to :from:x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=RR91Qz+w70/qlpC158K9+I1xkW3u/mXj74cWCB6Wce8=; b=MDBdZeyKrrX2upJOTVVfh15jTy0IXT2wvWEbYerx+Rkc53BAULqPbBNfvOtRQ/ylUw QYg1Mwhte5nKAPF3QS/jWqTDL3R4p/Ojgg5tpZ2b6+Z3/2EZw6HaqqokAq9sspUnSS67 xpoDb0oIVHdyFyovbUBwRQcRuaC32cfyD4AvktxHqQ6TIDl0Ne4lqU9lIxjUXCWKIicW uqEq2+25qhz63iD0Mme087vixNYjruAiE+L18NE7rmQuZMo1MREB71Hy01gBJ/6Mmu6r a+rkPYl0Odgqig/pHN5pdKfmsQr45FmIzMdA7xAiIHX7pav8OFq551+AhWHr68jb/+A5 qLng== X-Gm-Message-State: AC+VfDy/YOqDipHVz9eeMiA8tDx8xJZsrV5oCpicJLIHul78FY8sYWyE PTpALgbApe71vkKmm77j1ps= X-Google-Smtp-Source: ACHHUZ62sjNj9Zw35nxbyExQc2UfsAuw7jWeLLW173VL/+2lDt8toI1iwERbtxtcLirkDt7uf9Yymg== X-Received: by 2002:a17:902:dacc:b0:1af:c602:cd52 with SMTP id q12-20020a170902dacc00b001afc602cd52mr4852141plx.67.1686497752879; Sun, 11 Jun 2023 08:35:52 -0700 (PDT) Received: from localhost (157-131-78-143.fiber.dynamic.sonic.net. [157.131.78.143]) by smtp.gmail.com with ESMTPSA id 6-20020a170902c20600b001ac84f5559csm1354640pll.126.2023.06.11.08.35.51 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Sun, 11 Jun 2023 08:35:51 -0700 (PDT) From: Jack Kamm To: Ihor Radchenko Cc: emacs-orgmode@gnu.org, mail@nicolasgoaziou.fr Subject: [PATCH] ox-icalendar: Unscheduled tasks & repeating tasks In-Reply-To: <87o7oetneo.fsf@localhost> References: <874jq75okg.fsf@gmail.com> <87o7oetneo.fsf@localhost> Date: Sun, 11 Jun 2023 08:35:50 -0700 Message-ID: <87sfay3tbd.fsf@gmail.com> MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="=-=-=" Received-SPF: pass client-ip=2607:f8b0:4864:20::629; envelope-from=jackkamm@gmail.com; helo=mail-pl1-x629.google.com X-Spam_score_int: -20 X-Spam_score: -2.1 X-Spam_bar: -- X-Spam_report: (-2.1 / 5.0 requ) BAYES_00=-1.9, DKIM_SIGNED=0.1, DKIM_VALID=-0.1, DKIM_VALID_AU=-0.1, DKIM_VALID_EF=-0.1, FREEMAIL_FROM=0.001, RCVD_IN_DNSWL_NONE=-0.0001, SPF_HELO_NONE=0.001, SPF_PASS=-0.001, T_SCC_BODY_TEXT_LINE=-0.01 autolearn=ham autolearn_force=no X-Spam_action: no action X-BeenThere: emacs-orgmode@gnu.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: "General discussions about Org-mode." List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: emacs-orgmode-bounces+larch=yhetil.org@gnu.org Sender: emacs-orgmode-bounces+larch=yhetil.org@gnu.org X-Migadu-Country: US X-Migadu-Flow: FLOW_IN ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=yhetil.org; s=key1; t=1686497814; h=from:from:sender:sender:reply-to:subject:subject:date:date: message-id:message-id:to:to:cc:cc:mime-version:mime-version: content-type:content-type:in-reply-to:in-reply-to: references:references:list-id:list-help:list-unsubscribe: list-subscribe:list-post:dkim-signature; bh=RR91Qz+w70/qlpC158K9+I1xkW3u/mXj74cWCB6Wce8=; b=BKVHS440rIoRuBY6xmzK1S2wrCG01PEvH9FbS1mb5EGgFwhy8ig+mSiAyHEEBlHq2GpJNB uiQHOwGIvBH8q6roQH5zeWHDznaVZlhGMd1wGrRjxkXuGTy1n41MdxWTVLplkbccUMt7jm gAYdeve1KQ8hjHO663WTUgbIhAS1y9RKGYPcON+cTGUkoh/luHhzKQweEKm4987FclxxUe 4TFsRXLt/D0N+WO18w+osLMGbpHd1nBd/F79Gdj+C8qYfqvzYyptNvli/sNTd0YOZfhaLL WdvNVdWGXPZQOpeRuGxFEaJRcyhjuxaFH4YuzJoZ8stMNiGn1JnioJ2kZmJPiw== ARC-Authentication-Results: i=1; aspmx1.migadu.com; dkim=pass header.d=gmail.com header.s=20221208 header.b=HwLA7raz; dmarc=pass (policy=none) header.from=gmail.com; spf=pass (aspmx1.migadu.com: domain of "emacs-orgmode-bounces+larch=yhetil.org@gnu.org" designates 209.51.188.17 as permitted sender) smtp.mailfrom="emacs-orgmode-bounces+larch=yhetil.org@gnu.org" ARC-Seal: i=1; s=key1; d=yhetil.org; t=1686497814; a=rsa-sha256; cv=none; b=QSENu9tRBItpMBxa1jUiJWuAHPsXBYf05F4Mmbw9Dwi4BIPNM/02TS7ftWgG0WP64CZ+IG 8gmFe7jjL2qdRkRV2FfdNWRCngOLbl7noz8cICB7N31C64zvq5n0dvBQlVQtJiL4sI7IoZ tBi6Q5Q8OshVEJejN/VXbuC7g2wskeAOJiXbbbh9yVn6tIzeY2bzxflYm0Wjdt7QYeiVDk EF9Ly/gSgMUj+7+kuGodgVfitz01SY4HSbNshFVlI+DPA/914H4HOxor3GWHA/h0kXhngw 0H53JyVEl1NZqZeCNznuHoKO9BvF7TA3dWDgqllshGuCnDYGr5UpPrMXnV2BdA== X-Migadu-Scanner: scn1.migadu.com X-Migadu-Spam-Score: -6.37 Authentication-Results: aspmx1.migadu.com; dkim=pass header.d=gmail.com header.s=20221208 header.b=HwLA7raz; dmarc=pass (policy=none) header.from=gmail.com; spf=pass (aspmx1.migadu.com: domain of "emacs-orgmode-bounces+larch=yhetil.org@gnu.org" designates 209.51.188.17 as permitted sender) smtp.mailfrom="emacs-orgmode-bounces+larch=yhetil.org@gnu.org" X-Migadu-Queue-Id: 9F2C43206F X-Spam-Score: -6.37 X-TUID: 4a+kna5inA57 --=-=-= Content-Type: text/plain Hello, I am attaching an updated patch for ox-icalendar unscheduled and repeating TODOs, incorporating some of Ihor's feedback to my RFC some months ago. Compared to my original RFC, here are the main changes: - For unscheduled TODOs with repeating deadline, the deadline warning days is used as the start time by default, in order to comply with the iCalendar spec which demands a start time in this case. - Previously I had separate patches for unscheduled and repeating TODOs, but now I combine them into a single patch because of the way repeats and start times are intertwined for repeating deadlines. - New customization `org-icalendar-todo-unscheduled-start' controls the exported start time for unscheduled TODOs. It replaces `org-icalendar-todo-force-scheduling' from my previous version of the patch. - In case of a SCHEDULED repeater, and a DEADLINE with no repeater, the task repeats until the deadline, using the RRULE UNTIL keyword. - Added linting for the case where SCHEDULED and DEADLINE have mismatching repeaters. - Added several tests for ox-icalendar, and a test for the new lint as well. There are still a few cases that are not yet handled, but they are less common and will take some more work to implement, so I would prefer to leave them to future patches: - Case where SCHEDULED and DEADLINE have mismatched repeaters. We can use RDATE with differing DURATION for this. - Case where DEADLINE has repeater but SCHEDULED does not. We can use RDATE for the first instance, and RRULE for the subsequent repeats. - Case of catch-up "++" repeaters. We can use EXDATE to exclude repeats before today. - Case of restart ".+" repeaters. I don't think iCalendar can handle this case, and we should ignore it. --=-=-= Content-Type: text/x-patch Content-Disposition: inline; filename=0001-ox-icalendar-Add-support-for-unscheduled-and-repeati.patch >From 1135e3e7cb08353892c439b085d3bf0bf1072ecb Mon Sep 17 00:00:00 2001 From: Jack Kamm Date: Sun, 11 Jun 2023 07:50:20 -0700 Subject: [PATCH] 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--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. * 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. --- etc/ORG-NEWS | 64 ++++++++++++ lisp/org-lint.el | 34 ++++++ lisp/ox-icalendar.el | 165 +++++++++++++++++++++++++----- testing/lisp/test-org-lint.el | 7 ++ testing/lisp/test-ox-icalendar.el | 74 ++++++++++++++ 5 files changed, 320 insertions(+), 24 deletions(-) diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS index 7e7015064..a24caddfe 100644 --- a/etc/ORG-NEWS +++ b/etc/ORG-NEWS @@ -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 diff --git a/lisp/org-lint.el b/lisp/org-lint.el index c2ed007ab..bec1340c5 100644 --- a/lisp/org-lint.el +++ b/lisp/org-lint.el @@ -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, @@ -882,6 +883,34 @@ (defun org-lint-colon-in-name (ast) "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) @@ -1488,6 +1517,11 @@ (org-lint-add-checker 'invalid-block #'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 diff --git a/lisp/ox-icalendar.el b/lisp/ox-icalendar.el index 163b3b983..8c569752b 100644 --- a/lisp/ox-icalendar.el +++ b/lisp/ox-icalendar.el @@ -231,6 +231,38 @@ (defcustom org-icalendar-include-todo nil (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 +763,13 @@ (defun org-icalendar-entry (entry contents info) ;; 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 +795,11 @@ (\"PUBLIC\", \"CONFIDENTIAL\", and \"PRIVATE\") are predefined, others (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)) @@ -784,27 +822,106 @@ (defun org-icalendar--vtodo 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))) + ;; TODO Implement catch-up repeaters using EXDATE + (sc-repeat-p (and (eq (org-element-property :repeater-type sc) + 'cumulate) + (> (org-element-property :repeater-value sc) 0))) + (dl-repeat-p (and (eq (org-element-property :repeater-type dl) + 'cumulate) + (> (org-element-property :repeater-value dl) 0))) + (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 + (warn "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 + (warn "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)) diff --git a/testing/lisp/test-org-lint.el b/testing/lisp/test-org-lint.el index 6ee1b1fab..f61b8647c 100644 --- a/testing/lisp/test-org-lint.el +++ b/testing/lisp/test-org-lint.el @@ -406,6 +406,13 @@ (ert-deftest test-org-lint/colon-in-name () (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 diff --git a/testing/lisp/test-ox-icalendar.el b/testing/lisp/test-ox-icalendar.el index bfc756d51..6a0c961d7 100644 --- a/testing/lisp/test-ox-icalendar.el +++ b/testing/lisp/test-ox-icalendar.el @@ -40,5 +40,79 @@ (ert-deftest test-ox-icalendar/crlf-endings () (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))))) + (provide 'test-ox-icalendar) ;;; test-ox-icalendar.el ends here -- 2.40.1 --=-=-=--