GNU bug report logs - #74994
Improve Emacs iCalendar support

Please note: This is a static page, with minimal formatting, updated once a day.
Click here to see this page with the latest information and nicer formatting.

Package: emacs; Severity: wishlist; Reported by: Richard Lawrence <rwl@HIDDEN>; dated Fri, 20 Dec 2024 13:08:02 UTC; Maintainer for emacs is bug-gnu-emacs@HIDDEN.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 25 Nov 2025 20:31:34 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Tue Nov 25 15:31:33 2025
Received: from localhost ([127.0.0.1]:42295 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vNzh9-0004a5-PO
	for submit <at> debbugs.gnu.org; Tue, 25 Nov 2025 15:31:32 -0500
Received: from eggs.gnu.org ([2001:470:142:3::10]:60436)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <eliz@HIDDEN>) id 1vMl9H-0006Nw-NQ
 for 74994 <at> debbugs.gnu.org; Sat, 22 Nov 2025 05:47:25 -0500
Received: from fencepost.gnu.org ([2001:470:142:3::e])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <eliz@HIDDEN>)
 id 1vMl95-0002kT-93; Sat, 22 Nov 2025 05:47:11 -0500
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=gnu.org;
 s=fencepost-gnu-org; h=References:Subject:In-Reply-To:To:From:Date:
 mime-version; bh=2FOk71BU9xqf5eIaZ9PnimEgV/ccLeHNerua7HApf0Q=; b=b0C8TsZB7ul9
 I32syPyLAFkyswDqTrDWrRpIqiUdBKm8StIlxXUyazXQ2Ihu9imBbRBImXW72HGQas1VUG+7qxIs6
 x3Ebd9415CKLJEXqSezFI+HNp4Ydc8HUaBiE60W4KeqAlBhAPRLD9ONubBTmLLCTQK2bR/tfPgtU4
 7hDcognB3nEYfAUnSmY+ZB2/ZhqCwM2qQxWsRrFZ7mqWSUJPv3wE/z9lEr+qv40sVAXegtKwrpC+h
 P1FtoE4AVzzT4lUwp4Dlctf5LnP9ui4bRDRYxbPjWZFaBkigFEdoafomtqXEJOzjU4etSr3r8m8Ji
 o0TT2N4WjTsXuO54ihSu7A==;
Date: Sat, 22 Nov 2025 12:47:00 +0200
Message-Id: <86ikf2fior.fsf@HIDDEN>
From: Eli Zaretskii <eliz@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
In-Reply-To: <87ms4ncctj.fsf@HIDDEN> (message from Richard
 Lawrence on Sat, 15 Nov 2025 20:31:36 +0100)
Subject: Re: bug#74994: Improve Emacs iCalendar support
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <87h5vctpwt.fsf@HIDDEN> <86cy5s7otc.fsf@HIDDEN>
 <87pl9mcbzh.fsf@HIDDEN> <87ms4ncctj.fsf@HIDDEN>
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

> From: Richard Lawrence <rwl@HIDDEN>
> Cc: 74994 <at> debbugs.gnu.org
> Date: Sat, 15 Nov 2025 20:31:36 +0100
> 
> Richard Lawrence <rwl@HIDDEN> writes:
> 
> > I've got a draft of the new manual section and am working on etc/NEWS.
> > I'll hopefully be able to submit an updated patch this weekend.
> 
> Here it is! I've drafted new versions of the diary import/export section
> of the manual and created draft entries in etc/NEWS. I've also fixed all
> the docstrings in the library to align with the
> first-line-is-a-complete-sentence convention, the failing tests, and all
> the other minor bugs I've found in using this code over the last couple
> of weeks. So I think the patch is now in good shape -- hope you'll
> agree!

Thanks.  It's a very large patch, so below I only comment on some of
the documentation, as the rest of the code is not familiar grounds for
me.  Let me know if the code is ready to be installed, in your
opinion.

> diff --git a/doc/emacs/anti.texi b/doc/emacs/anti.texi
> index e525afd5abb..cc953c2015b 100644
> --- a/doc/emacs/anti.texi
> +++ b/doc/emacs/anti.texi
> @@ -91,6 +91,14 @@ Antinews
>  face of the Earth in the near past, and are preparing Emacs for that in
>  advance.
>  
> +@item
> +Who needs a calendar, when you live backwards in time?  The iCalendar
> +library, with its complicated parser and semantics, has been replaced
> +with a simpler one based on direct string translation.  Users will
> +appreciate the removal of many confusing customization options.  Authors
> +of packages processing calendar data are advised to implement their own
> +iCalendar support; let a thousand flowers bloom!

The Antinews section is update shortly before the release, and the
changes should go into a new section (for the next Emacs version),
which was not yet created.  So please omit this part from the patch.

> +@code{diary-icalendar-import-buffer} is also suitable for importing
> +iCalendar data from email attachments. For example, with the Rmail mail
                                        ^^

Our conventions are to leave two spaces between sentences.  Please
make sure you follow that, here and elsewhere in the patch.

> +@lisp
> +(require 'skeleton)
> +
> +(define-skeleton simple-vevent
> +  "Format a VEVENT summary and location on a single line"
> +  nil
> +  start-to-end & " " & summary & " "
> +  (when location "@@ ") & location "\n")
> +
> +(setopt diary-icalendar-vevent-skeleton-command #'simple-vevent)
> +@end lisp

If you want to prevent breaking a multi-line example between pages,
please use @group..@end group to indicate groups of lines that should
be placed on the same page.

> +Suppose, for example, that you download your calendar from an
> +external server to a file called `Appointments.ics'.  Then you can
                                    ^^^^^^^^^^^^^^^^^^
File names should have the @file markup, instead of being quoted `like
this' (here and elsewhere in the patch).

> +the variables named @samp{diary-icalendar-*-regexp} in the
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We use the @code markup for variables and functions, not @samp.

> +will be exported as four events, each on the same day, but with
> +different start times (except for the second event, "Start experiment
> +A", which has no start time).  See the docstring of ^^^^^^^^^^^^^^^^^
   ^^
Please quote ``like this'' in Texinfo, it produces nicer output.

> --- a/doc/emacs/emacs.texi
> +++ b/doc/emacs/emacs.texi
> @@ -1019,7 +1019,7 @@ Top
>  * Adding to Diary::        Commands to create diary entries.
>  * Special Diary Entries::  Anniversaries, blocks of dates, cyclic entries, etc.
>  * Appointments::           Reminders when it's time to do something.
> -* Importing Diary::        Converting diary events to/from other formats.
> +* Diary Conversion::       Converting diary events to/from other formats.

Please also update the @detailmenu in emacs.texi with the new nodes
you've added.

>  
>  @ifnottex
>  More advanced features of the Calendar and Diary
> diff --git a/etc/NEWS b/etc/NEWS
> index 63ea0b5a11f..a0edf5a2b31 100644
> --- a/etc/NEWS
> +++ b/etc/NEWS
> @@ -2903,6 +2903,42 @@ The user options 'calendar-mark-holidays-flag' and
>  'calendar-mark-diary-entries-flag' are not modified anymore when
>  changing the marking state in the calendar buffer.
>  
> +*** New library for iCalendar data.
> +The following files have been added to lisp/calendar:
> +
> +- icalendar-ast.el:     Abstract syntax tree implementation
> +- icalendar-macs.el:    Macros
> +- icalendar-mode.el:    Major mode
> +- icalendar-parser.el:  Parser and printer
> +- icalendar-recur.el:   Support for recurrence rules
> +- icalendar-utils.el:   Utility functions
> +
> +Together, these files implement a new library for handling iCalendar
> +(RFC5545) data. The library is designed for re-use in other parts of
> +Emacs and in third-party packages.
> +
> +Most of the code in the older icalendar.el has been marked obsolete and
> +now suggests appropriate replacements from the new library.  Another new
> +file, diary-icalendar.el, reimplements the diary-related features from
> +icalendar.el; see below.

I think this section should be rephrased to be in more user-facing
terms.  E.g., I see no need to mention the file names, just that there
are new implementations for handling iCalendar data, and that many
functions and variables from the old implementation are now obsolete.
(Btw, do the old variables and commands still work as they did
before?)

> --- a/lisp/calendar/cal-dst.el
> +++ b/lisp/calendar/cal-dst.el
> @@ -309,7 +309,9 @@ calendar-current-time-zone
>  UTC-DIFF is an integer specifying the number of minutes difference between
>      standard time in the current time zone and Coordinated Universal Time
>      (Greenwich Mean Time).  A negative value means west of Greenwich.
> -DST-OFFSET is an integer giving the daylight saving time offset in minutes.
> +DST-OFFSET is an integer giving the daylight saving time offset in minutes
> +    relative to UTC-DIFF. (That is, the total UTC offset during daylight saving
                           ^^
Two spaces between sentences in doc strings as well, please (here and
elsewhere).

> +  :version "32.1"

The version should be "31.1", not "32.1" (assuming you want this
installed soon and be part of the next major release).

> +(defgroup diary-icalendar nil
> +  "iCalendar import, export, and display in diary"
> +  :version 32
              ^^
This should be "31.1", and must be a string.

> +  (declare (obsolete nil "32.1"))
                            ^^^^^^
This should also be "31.1" (also elsewhere in the patch).

Thanks again for working on this.




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 25 Nov 2025 20:26:39 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Tue Nov 25 15:26:39 2025
Received: from localhost ([127.0.0.1]:42039 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vNzcV-0001ho-1v
	for submit <at> debbugs.gnu.org; Tue, 25 Nov 2025 15:26:39 -0500
Received: from eggs.gnu.org ([2001:470:142:3::10]:45820)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <eliz@HIDDEN>) id 1vNVjZ-0005jP-C2
 for 74994 <at> debbugs.gnu.org; Mon, 24 Nov 2025 07:31:59 -0500
Received: from fencepost.gnu.org ([2001:470:142:3::e])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <eliz@HIDDEN>)
 id 1vNVjO-0006r1-Af; Mon, 24 Nov 2025 07:31:47 -0500
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=gnu.org;
 s=fencepost-gnu-org; h=References:Subject:In-Reply-To:To:From:Date:
 mime-version; bh=F7yrpK1EMJl4kO7lzVCVm7/7chthioF/VVKrgrmhjdU=; b=fZUEkWJgzUpL
 0x2dFhIOHaFuPUYAnDx/pW7Ewb3lI0zymQhRGW35VbjHeKsfdPcekofdPk1LIUf88yxejXvjG4dqf
 jo4EEq72rYsPDS3+Vqa5QOkyWCUlo5/BlDQJCtTr6jWzO5hALynWKL2UpjvambxnBcH1JBI3SssO0
 bi/uYWeZOg6NP3GAaEvMS0IoxAqt+ELI3k/b2k1q8vCInGasVsqdtKuDrboXy2+se/Xei20pcFDeu
 ZmX33a+e/kozAWA7VmSwix+e2SmVK6JFjlzgg4qnYNrshk+gOdVNHP1OttPjBxTdnKGqs+AvNQ4TO
 vuCdH3PaSM+mtk/0FzE/vg==;
Date: Mon, 24 Nov 2025 14:30:56 +0200
Message-Id: <86h5ujehof.fsf@HIDDEN>
From: Eli Zaretskii <eliz@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>,
 Stefan Monnier <monnier@HIDDEN>
In-Reply-To: <87a50bbwfv.fsf@HIDDEN> (message from Richard
 Lawrence on Mon, 24 Nov 2025 10:40:20 +0100)
Subject: Re: bug#74994: Improve Emacs iCalendar support
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <87h5vctpwt.fsf@HIDDEN> <86cy5s7otc.fsf@HIDDEN>
 <87pl9mcbzh.fsf@HIDDEN> <87ms4ncctj.fsf@HIDDEN>
 <86ikf2fior.fsf@HIDDEN> <87a50bbwfv.fsf@HIDDEN>
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

> From: Richard Lawrence <rwl@HIDDEN>
> Cc: 74994 <at> debbugs.gnu.org
> Date: Mon, 24 Nov 2025 10:40:20 +0100
> 
> >> diff --git a/etc/NEWS b/etc/NEWS
> 
> > I think this section should be rephrased to be in more user-facing
> > terms. E.g., I see no need to mention the file names, just that there
> > are new implementations for handling iCalendar data, and that many
> > functions and variables from the old implementation are now obsolete.
> 
> How does this sound?
> 
> "A new library has been added to the calendar for handling iCalendar
> (RFC5545) data.  The library is designed for reuse in other parts of
> Emacs and in third-party packages.
> 
> Most of the functions and variables in the older icalendar.el have been
> marked obsolete and now suggest appropriate replacements from the new
> library.  diary-icalendar.el provides replacements for the diary-related
> features from icalendar.el; see below."

SGTM, thanks.

> Also, since this does not mention the file names, is there some other
> place where I can point e.g. package authors who may be interested in
> using the library to the new files?

You can mention the icalendar-*.el files, if you want.

> Since I've marked much of icalendar.el as obsolete, the byte compiler
> now spits out a lot of warnings when compiling it and when running its
> tests under `make check'. Is there some way to quiet these warnings at
> the level of the whole file? I tried adding
> 
> ;; Local Variables:
> ;; byte-compile-warnings: '(not obsolete)
> ;; End:
> 
> to the tests file, but this seems to have no effect. What's the right
> way to do this?

I'm not sure we have something suitable.  Maybe someone else knows?




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 25 Nov 2025 20:26:25 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Tue Nov 25 15:26:24 2025
Received: from localhost ([127.0.0.1]:42026 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vNzcF-0001fx-Qx
	for submit <at> debbugs.gnu.org; Tue, 25 Nov 2025 15:26:24 -0500
Received: from fhigh-b7-smtp.messagingengine.com ([202.12.124.158]:32899)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1vNT3j-000177-C4
 for 74994 <at> debbugs.gnu.org; Mon, 24 Nov 2025 04:40:37 -0500
Received: from phl-compute-04.internal (phl-compute-04.internal [10.202.2.44])
 by mailfhigh.stl.internal (Postfix) with ESMTP id 2B7087A0035;
 Mon, 24 Nov 2025 04:40:29 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-04.internal (MEProxy); Mon, 24 Nov 2025 04:40:29 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1763977229;
 x=1764063629; bh=kv28sZ/SK3tlwqyCLZImJiaoOeKViS31WaGj97QKf2A=; b=
 trKzyrEbO77eRfcE1SS5765sLSDqScPeQHi6Gx0MsSa5olcrpUwZxzb/KzrvNJDI
 Li2+XRvKDSzSzE98HyUI6IQqW2TcvSX5mV/m3dgfXj3zds5DeRhJktvzqSHpxTiF
 AQ8xMSGl8vQLMvfZeRfEq2q6POn4/ttScrMhOFFrYPTCW1e0V63nzwj8MNC6re4K
 cG2e7mvRNEpGOOmI80v1BSu2lKzUzsOKTc8qcnrs/g2AxHR4MIdxvMby+lRhywv1
 O5hbjxD3fgGU4sXWDke8j8L1S6vNGwO+85gaF+asbrlr+3k4PjcXQUOWBcxw4W8S
 V+Ck44ArBJJSnu5ddE7iRg==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1763977229; x=1764063629; bh=kv28sZ/SK3tlwqyCLZImJiaoOeKViS31WaG
 j97QKf2A=; b=qTPae/r31ohyyLNgqbrsLyVp+0nkbH3MIAL/S1ZDtJcd4BsV2pA
 HgMG8skh6XGHtJp+Q0CN8Xb/ipEKvybdI1b1STA4hooyKf8g6MB9Q0050SMhJuDI
 +x/y7XfNpOj74bNEw1eiqygFTb3+XLeoF6Ts1MlAH+eDly7NtHi7zAIXs1NfsyUt
 wy/YFaB85qTydNhd54tuWGENdUGPAfxeD1y0b6BZWHlGJnAq6TAHXbEu1at50Gp6
 rxw6ijcnnPrV6oUEIwi4HxJSlbBGtLk/aNs3CCJQk874SJK5k96GpCyviIhGpbsP
 udMWMB/BxxlZio//Ur7NRqZxLjVIL2OKO/w==
X-ME-Sender: <xms:DCgkadspCMT1zgaCt5SGQAGbVsGy8R51UyTPSg4rRDdnQ6j6YPOI1Q>
 <xme:DCgkacczjCWReagHAEyUfmz7xXdsWuSZOQjeX4hD2ScXQQ7GD7qz3vMbGZjrUoJk3
 0rfcrTHhnsNLaRVG3kSaVid3_gzYkTEhQEOy8N9-07y0OpwXD4ZdQ>
X-ME-Received: <xmr:DCgkaebL83RHGsWf5l4gOJZFOBKJwrJ319J_sbdpo-QdbUVL-NUUfp_rfSH7ediZdNr50XLQDieNvh8LdhnOwXN32g9W838JGwk>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeeffedrtdeggddvfeekvdeiucetufdoteggodetrf
 dotffvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdfurfetoffkrfgpnffqhgenuceu
 rghilhhouhhtmecufedttdenucesvcftvggtihhpihgvnhhtshculddquddttddmnecujf
 gurhephffvvefujghffffkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhgu
 ucfnrgifrhgvnhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqe
 enucggtffrrghtthgvrhhnpeefueffvdeffeeftdeutdfgjeettdduveduudefjedtkeej
 gfehhedvgffgffduhfenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrih
 hlfhhrohhmpehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgt
 phhtthhopedvpdhmohguvgepshhmthhpohhuthdprhgtphhtthhopegvlhhiiiesghhnuh
 drohhrghdprhgtphhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:DCgkafW94GiEfmXj62TQ9fcFuhVTDr49hJGuvzfZZTZ85l6K9rCocQ>
 <xmx:DCgkaQh9_Q-TJKySEe5NjIFEZGgkVIEyHc05oM8lLmzolLOugVozmQ>
 <xmx:DCgkadVXO15wbk4IsLWf__qw4A4qeOLtLkZWq3xcHNK_y9nXDdBuxg>
 <xmx:DCgkaXN2HvocW3UhjO4Dqtp9-JX4ETWfGmu2KxqshPTT6SStT9bC1Q>
 <xmx:DSgkacDQ2yBbKFoN-k3xG-ROSHvX_14daktjmYDhYugRfPMcEgC3WoIE>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Mon,
 24 Nov 2025 04:40:27 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <86ikf2fior.fsf@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <87h5vctpwt.fsf@HIDDEN> <86cy5s7otc.fsf@HIDDEN>
 <87pl9mcbzh.fsf@HIDDEN> <87ms4ncctj.fsf@HIDDEN>
 <86ikf2fior.fsf@HIDDEN>
Date: Mon, 24 Nov 2025 10:40:20 +0100
Message-ID: <87a50bbwfv.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Eli Zaretskii <eliz@HIDDEN> writes:

> Thanks. It's a very large patch, so below I only comment on some of
> the documentation, as the rest of the code is not familiar grounds for
> me. Let me know if the code is ready to be installed, in your opinion.

Thanks for your comments.  I've already incorporated most of them and
will post a new patch here soon.  A few questions and comments from me:

>> diff --git a/etc/NEWS b/etc/NEWS

> I think this section should be rephrased to be in more user-facing
> terms. E.g., I see no need to mention the file names, just that there
> are new implementations for handling iCalendar data, and that many
> functions and variables from the old implementation are now obsolete.

How does this sound?

"A new library has been added to the calendar for handling iCalendar
(RFC5545) data.  The library is designed for reuse in other parts of
Emacs and in third-party packages.

Most of the functions and variables in the older icalendar.el have been
marked obsolete and now suggest appropriate replacements from the new
library.  diary-icalendar.el provides replacements for the diary-related
features from icalendar.el; see below."

Also, since this does not mention the file names, is there some other
place where I can point e.g. package authors who may be interested in
using the library to the new files?

> (Btw, do the old variables and commands still work as they did
> before?)

Yes, I was quite careful about this; anyone who is using icalendar.el
for diary import/export should be able to continue using it unchanged,
and they should also be able to switch to diary-icalendar.el without any
change to their setups.

Since I've marked much of icalendar.el as obsolete, the byte compiler
now spits out a lot of warnings when compiling it and when running its
tests under `make check'. Is there some way to quiet these warnings at
the level of the whole file? I tried adding

;; Local Variables:
;; byte-compile-warnings: '(not obsolete)
;; End:

to the tests file, but this seems to have no effect. What's the right
way to do this?

Thanks!

-- 
Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 15 Nov 2025 19:31:55 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sat Nov 15 14:31:55 2025
Received: from localhost ([127.0.0.1]:36959 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vKM03-0003f2-9C
	for submit <at> debbugs.gnu.org; Sat, 15 Nov 2025 14:31:55 -0500
Received: from fout-b8-smtp.messagingengine.com ([202.12.124.151]:41131)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1vKM01-0003eo-1E
 for 74994 <at> debbugs.gnu.org; Sat, 15 Nov 2025 14:31:53 -0500
Received: from phl-compute-05.internal (phl-compute-05.internal [10.202.2.45])
 by mailfout.stl.internal (Postfix) with ESMTP id 003AB1D0005F;
 Sat, 15 Nov 2025 14:31:46 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-05.internal (MEProxy); Sat, 15 Nov 2025 14:31:47 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1763235106;
 x=1763321506; bh=82+HVMoudvcLW5k3BgWpuHhbnjHr/dthZWk6tpf1gBI=; b=
 pa9Mb2kraJVx2leWXoifMssq4ZR+lOnuQxwewhD1qrQv8eCkxCRxJZ+9J8BNKgli
 LsAB8Zr9V0K8v758QXptoUcZa+ckqoMntE4NgGXV9YovCHMdQ8sum4sa8E9wfwUG
 LQkLSS6nWXXkF6Aj5qPEi0CUNn6nBsda1NVshe/cSRb0twqkSo5R66BF+PhneuJJ
 1tJCn5SW0Dy08dXHWeIUmNDOCETV0FYjZ5mRcVf1KC51oc91e3wFH5LFJD1o84qO
 WIn53Zr0JZIK+1pFSHu/1cxO44t01yJ30zKoY37HrJsUIO5TdxyGAUV6MlprPink
 gKDwFfLDm8xxUWdMmql+5A==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1763235106; x=1763321506; bh=82+HVMoudvcLW5k3BgWpuHhbnjHr/dthZWk
 6tpf1gBI=; b=rkjmr/lDg53+7dEwQUzZ2n9H6qv7Eolr8W5Wqftrq3DyAzS6e5m
 DQIGNX1MTPgPLsii+UMDSVNqhSdnpwLz2gEI/czmu4XldAXQshb4NSd8PALHyo2H
 q7jBCwaYfLsClaofzW64Mnr+UrflqWmtINLd5mznahKvNdR1PHg/BHu4qByPlZMt
 yE2f5U+14dpwO4q9cxcYMOxv1Us6upLEbH7heS6t8zfYSpBBncS7cH/ul6Vc1EjL
 FXyJiIhcx4DURY9rbIEkDWmKVi+jpkW6xYWlf5O7njR7HiUkltX7hfCKvVPRSezY
 T8WXjJadEiDBpI4M4r7Vnvm69JiDKhBRPiQ==
X-ME-Sender: <xms:ItUYacBfXEG2Y9ZCWhZnb_CcopjLhQnNCaM-WqQoDVcRoiAlaz4WVw>
 <xme:ItUYaUjk7m-c0o_xymTGRTE54AUG1fhS3I0FynFw6pUMktZurtTvLHP-iDv2sysUA
 60J1940jgq0hTKacwti5riw-9RlyNz5yZuRR6pb9IABpiuIc1sBHg>
X-ME-Received: <xmr:ItUYadPYeGBEr4tZfidvumb5JteimPk-PUQ-GG1u7pnPWxjKfEm1BFoWRA1bX12wDXepH6KdE1FdA-SB1yipWZGqIg9Q0vQI>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeeffedrtdeggddvudefheehucetufdoteggodetrf
 dotffvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdfurfetoffkrfgpnffqhgenuceu
 rghilhhouhhtmecufedttdenucesvcftvggtihhpihgvnhhtshculddquddttddmnecujf
 gurhephffvvefujghffffkgggtsehmtderredttddtnecuhfhrohhmpeftihgthhgrrhgu
 ucfnrgifrhgvnhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqe
 enucggtffrrghtthgvrhhnpeefudethedtfeekudduleduffehteeugeelteehgfekhffh
 hfdugeffhefhhedvudenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrih
 hlfhhrohhmpehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgt
 phhtthhopedvpdhmohguvgepshhmthhpohhuthdprhgtphhtthhopegvlhhiiiesghhnuh
 drohhrghdprhgtphhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:ItUYad4soKpYYQRHJdIyCrs7uKazd8bnPdiUCE793S-SFYB_v_QO1w>
 <xmx:ItUYaT0M8Vj0iDNXNjy-nFIotmml0m0NMt1slJ1nXl6J_jJ8PIm5_g>
 <xmx:ItUYaWbvdDt4mzqcNvwNMuzaf88KBi2O37jDXyVvjMAJLlZcka7eAQ>
 <xmx:ItUYaTBFfGGC7ZpUJZ2bFZBsIFSuHWbCi3mrPIE6nH1oQdOUFiiUyg>
 <xmx:ItUYaYVpM5FZuFqEH624b5OQtph2k4ljMFgBn79Dt5rER6Iz01vy6oRW>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Sat,
 15 Nov 2025 14:31:44 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <87pl9mcbzh.fsf@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <87h5vctpwt.fsf@HIDDEN> <86cy5s7otc.fsf@HIDDEN>
 <87pl9mcbzh.fsf@HIDDEN>
Date: Sat, 15 Nov 2025 20:31:36 +0100
Message-ID: <87ms4ncctj.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="=-=-="
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>

--=-=-=
Content-Type: text/plain

Richard Lawrence <rwl@HIDDEN> writes:

> I've got a draft of the new manual section and am working on etc/NEWS.
> I'll hopefully be able to submit an updated patch this weekend.

Here it is! I've drafted new versions of the diary import/export section
of the manual and created draft entries in etc/NEWS. I've also fixed all
the docstrings in the library to align with the
first-line-is-a-complete-sentence convention, the failing tests, and all
the other minor bugs I've found in using this code over the last couple
of weeks. So I think the patch is now in good shape -- hope you'll
agree!

Best,
Richard


--=-=-=
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
 filename=0001-Updated-patch-for-Bug-74994.patch
Content-Transfer-Encoding: quoted-printable

From 3c913ce00fd157534bbbdfb87178b89bb8724784 Mon Sep 17 00:00:00 2001
From: Richard Lawrence <rwl@HIDDEN>
Date: Thu, 19 Dec 2024 14:30:57 +0100
Subject: [PATCH] Updated patch for Bug#74994

- draft new manual sections
- draft news entries for new iCalendar files
- draft anti-news entry
- align docstrings with first line convention
- fix tests that were failing under `make check'
- fix font locking bug with ignored properties
- other minor fixes in diary-icalendar.el
---
 doc/emacs/anti.texi                           |    8 +
 doc/emacs/calendar.texi                       |  301 +-
 doc/emacs/emacs.texi                          |    2 +-
 etc/NEWS                                      |   41 +
 lisp/calendar/cal-dst.el                      |    4 +-
 lisp/calendar/calendar.el                     |   60 +-
 lisp/calendar/diary-icalendar.el              | 3914 ++++++++++++++
 lisp/calendar/diary-lib.el                    |    6 +-
 lisp/calendar/icalendar-ast.el                |  855 +++
 lisp/calendar/icalendar-macs.el               | 1151 ++++
 lisp/calendar/icalendar-mode.el               |  605 +++
 lisp/calendar/icalendar-parser.el             | 4804 +++++++++++++++++
 lisp/calendar/icalendar-recur.el              | 2074 +++++++
 lisp/calendar/icalendar-utils.el              |  731 +++
 lisp/calendar/icalendar.el                    |  598 +-
 .../import-bug-11473.diary-american           |    9 +
 .../import-bug-11473.diary-european           |    9 +
 .../import-bug-11473.diary-iso                |    9 +
 .../import-bug-11473.ics                      |   54 +
 .../import-bug-22092.diary-american           |    6 +
 .../import-bug-22092.diary-european           |    6 +
 .../import-bug-22092.diary-iso                |    6 +
 .../import-bug-22092.ics                      |   30 +
 .../import-bug-24199.diary-all                |   12 +
 .../import-bug-24199.ics                      |   25 +
 .../import-bug-33277.diary-american           |    2 +
 .../import-bug-33277.diary-european           |    2 +
 .../import-bug-33277.diary-iso                |    2 +
 .../import-bug-33277.ics                      |   15 +
 .../import-bug-6766.diary-all                 |   13 +
 .../import-bug-6766.ics                       |   28 +
 .../import-duration-2.diary-all               |    6 +
 .../import-duration-2.ics                     |   17 +
 .../import-duration.diary-american            |    2 +
 .../import-duration.diary-european            |    2 +
 .../import-duration.diary-iso                 |    2 +
 .../import-duration.ics                       |   10 +
 .../import-legacy-function.diary-all          |   10 +
 .../import-legacy-function.ics                |   16 +
 .../import-legacy-vars.diary-american         |    8 +
 .../import-legacy-vars.diary-european         |    8 +
 .../import-legacy-vars.diary-iso              |    8 +
 .../import-legacy-vars.ics                    |   17 +
 .../import-multiple-vcalendars.diary-american |    8 +
 .../import-multiple-vcalendars.diary-european |    8 +
 .../import-multiple-vcalendars.diary-iso      |    8 +
 .../import-multiple-vcalendars.ics            |   21 +
 .../import-non-recurring-1.diary-american     |    2 +
 .../import-non-recurring-1.diary-european     |    2 +
 .../import-non-recurring-1.diary-iso          |    2 +
 .../import-non-recurring-1.ics                |   10 +
 ...mport-non-recurring-all-day.diary-american |    2 +
 ...mport-non-recurring-all-day.diary-european |    2 +
 .../import-non-recurring-all-day.diary-iso    |    2 +
 .../import-non-recurring-all-day.ics          |    9 +
 ...n-recurring-another-example.diary-american |    5 +
 ...n-recurring-another-example.diary-european |    5 +
 ...rt-non-recurring-another-example.diary-iso |    5 +
 .../import-non-recurring-another-example.ics  |   23 +
 .../import-non-recurring-block.diary-american |    5 +
 .../import-non-recurring-block.diary-european |    5 +
 .../import-non-recurring-block.diary-iso      |    5 +
 .../import-non-recurring-block.ics            |   16 +
 ...on-recurring-folded-summary.diary-american |    5 +
 ...on-recurring-folded-summary.diary-european |    5 +
 ...ort-non-recurring-folded-summary.diary-iso |    5 +
 .../import-non-recurring-folded-summary.ics   |   25 +
 ...-non-recurring-long-summary.diary-american |    2 +
 ...-non-recurring-long-summary.diary-european |    2 +
 ...mport-non-recurring-long-summary.diary-iso |    2 +
 .../import-non-recurring-long-summary.ics     |    9 +
 ...mport-real-world-2003-05-29.diary-american |    6 +
 ...mport-real-world-2003-05-29.diary-european |    6 +
 .../import-real-world-2003-05-29.ics          |   54 +
 ...port-real-world-2003-06-18a.diary-american |    6 +
 ...port-real-world-2003-06-18a.diary-european |    6 +
 .../import-real-world-2003-06-18a.ics         |   36 +
 ...port-real-world-2003-06-18b.diary-american |    6 +
 ...port-real-world-2003-06-18b.diary-european |    6 +
 .../import-real-world-2003-06-18b.ics         |   55 +
 ...mport-real-world-2004-11-19.diary-american |   19 +
 ...mport-real-world-2004-11-19.diary-european |   19 +
 .../import-real-world-2004-11-19.ics          |  120 +
 ...mport-real-world-2005-02-07.diary-american |    5 +
 ...mport-real-world-2005-02-07.diary-european |    5 +
 .../import-real-world-2005-02-07.ics          |   26 +
 ...mport-real-world-2005-03-01.diary-american |    2 +
 ...mport-real-world-2005-03-01.diary-european |    2 +
 .../import-real-world-2005-03-01.ics          |   11 +
 .../import-real-world-no-dst.diary-american   |    4 +
 .../import-real-world-no-dst.diary-european   |    4 +
 .../import-real-world-no-dst.ics              |   26 +
 .../import-rrule-anniversary.diary-all        |    2 +
 .../import-rrule-anniversary.ics              |   10 +
 ...mport-rrule-count-bi-weekly.diary-american |    1 +
 ...mport-rrule-count-bi-weekly.diary-european |    1 +
 .../import-rrule-count-bi-weekly.diary-iso    |    1 +
 .../import-rrule-count-bi-weekly.ics          |   11 +
 .../import-rrule-count-daily-long.diary-all   |    4 +
 .../import-rrule-count-daily-long.ics         |   11 +
 .../import-rrule-count-daily-short.diary-all  |    4 +
 .../import-rrule-count-daily-short.ics        |   11 +
 ...t-rrule-count-every-second-month.diary-all |    4 +
 .../import-rrule-count-every-second-month.ics |   11 +
 ...rt-rrule-count-every-second-year.diary-all |    4 +
 .../import-rrule-count-every-second-year.ics  |   10 +
 .../import-rrule-count-monthly.diary-all      |    4 +
 .../import-rrule-count-monthly.ics            |   11 +
 .../import-rrule-count-yearly.diary-all       |    4 +
 .../import-rrule-count-yearly.ics             |   11 +
 .../import-rrule-daily-two-day.diary-all      |    4 +
 .../import-rrule-daily-two-day.ics            |   10 +
 ...port-rrule-daily-with-exceptions.diary-all |    5 +
 .../import-rrule-daily-with-exceptions.ics    |   12 +
 .../import-rrule-daily.diary-all              |    3 +
 .../import-rrule-daily.ics                    |   11 +
 .../import-rrule-monthly-no-end.diary-all     |    4 +
 .../import-rrule-monthly-no-end.ics           |   11 +
 .../import-rrule-monthly-with-end.diary-all   |    4 +
 .../import-rrule-monthly-with-end.ics         |   11 +
 .../import-rrule-weekly.diary-all             |    3 +
 .../import-rrule-weekly.ics                   |   11 +
 .../import-rrule-yearly.diary-all             |    4 +
 .../import-rrule-yearly.ics                   |   11 +
 .../import-time-format-12hr-blank.diary-iso   |    2 +
 .../import-time-format-12hr-blank.ics         |    9 +
 .../import-with-attachment.diary-iso          |    4 +
 .../import-with-attachment.ics                |   10 +
 .../import-with-timezone.diary-iso            |    4 +
 .../import-with-timezone.ics                  |   27 +
 .../import-with-uid.diary-american            |    3 +
 .../import-with-uid.diary-european            |    3 +
 .../import-with-uid.diary-iso                 |    3 +
 .../import-with-uid.ics                       |   10 +
 test/lisp/calendar/diary-icalendar-tests.el   | 1206 +++++
 test/lisp/calendar/icalendar-parser-tests.el  | 2030 +++++++
 test/lisp/calendar/icalendar-recur-tests.el   | 2867 ++++++++++
 test/lisp/calendar/icalendar-tests.el         |   11 +
 138 files changed, 22379 insertions(+), 141 deletions(-)
 create mode 100644 lisp/calendar/diary-icalendar.el
 create mode 100644 lisp/calendar/icalendar-ast.el
 create mode 100644 lisp/calendar/icalendar-macs.el
 create mode 100644 lisp/calendar/icalendar-mode.el
 create mode 100644 lisp/calendar/icalendar-parser.el
 create mode 100644 lisp/calendar/icalendar-recur.el
 create mode 100644 lisp/calendar/icalendar-utils.el
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-24199.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-24199.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-6766.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-6766.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation-2.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation-2.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-function.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-function.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-05-29.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-05-29.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-05-29.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18a.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18a.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18a.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18b.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18b.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18b.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2004-11-19.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2004-11-19.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2004-11-19.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-02-07.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-02-07.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-02-07.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-03-01.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-03-01.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-03-01.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-no-dst.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-no-dst.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-no-dst.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-anniversary.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-anniversary.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-long.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-long.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-short.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-short.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-month.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-month.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-year.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-year.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-monthly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-monthly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-yearly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-yearly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-two-day.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-two-day.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-with-exceptions.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-with-exceptions.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-no-end.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-no-end.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-with-end.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-with-end.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-weekly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-weekly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-yearly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-yearly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-tim=
e-format-12hr-blank.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-tim=
e-format-12hr-blank.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-attachment.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-attachment.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-timezone.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-timezone.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-tests.el
 create mode 100644 test/lisp/calendar/icalendar-parser-tests.el
 create mode 100644 test/lisp/calendar/icalendar-recur-tests.el

diff --git a/doc/emacs/anti.texi b/doc/emacs/anti.texi
index e525afd5abb..cc953c2015b 100644
--- a/doc/emacs/anti.texi
+++ b/doc/emacs/anti.texi
@@ -91,6 +91,14 @@ Antinews
 face of the Earth in the near past, and are preparing Emacs for that in
 advance.
=20
+@item
+Who needs a calendar, when you live backwards in time?  The iCalendar
+library, with its complicated parser and semantics, has been replaced
+with a simpler one based on direct string translation.  Users will
+appreciate the removal of many confusing customization options.  Authors
+of packages processing calendar data are advised to implement their own
+iCalendar support; let a thousand flowers bloom!
+
 @item
 Support for LLDB in Grand Unified Debugger mode was dropped.  We decided
 that given LLDB's diminishing popularity, its support is just code
diff --git a/doc/emacs/calendar.texi b/doc/emacs/calendar.texi
index b3558448975..0615bc88f9f 100644
--- a/doc/emacs/calendar.texi
+++ b/doc/emacs/calendar.texi
@@ -999,7 +999,7 @@ Diary
 * Adding to Diary::        Commands to create diary entries.
 * Special Diary Entries::  Anniversaries, blocks of dates, cyclic entries,=
 etc.
 * Appointments::           Reminders when it's time to do something.
-* Importing Diary::        Converting diary events to/from other formats.
+* Diary Conversion::       Converting diary events to/from other formats.
 @end menu
=20
 @node Format of Diary File
@@ -1543,71 +1543,270 @@ Appointments
 list without affecting your diary file.  You delete entries from the
 appointment list with @kbd{M-x appt-delete}.
=20
-@node Importing Diary
+@node Diary Conversion
 @subsection Importing and Exporting Diary Entries
-@cindex importing diary entries
+@cindex diary import
+@cindex diary export
=20
-  You can transfer diary entries between Emacs diary files and a
-variety of other formats.
+You can transfer diary entries between Emacs diary files and other
+formats.
=20
-@vindex diary-outlook-formats
-  You can import diary entries from Outlook-generated appointment
-messages.  While viewing such a message in Rmail or Gnus, do @kbd{M-x
-diary-from-outlook} to import the entry.  You can make this command
-recognize additional appointment message formats by customizing the
-variable @code{diary-outlook-formats}.  Other mail clients can set
-@code{diary-from-outlook-function} to an appropriate value.
+@menu
+* Diary iCalendar Import::      Importing iCalendar data to the Diary.
+* Diary iCalendar Display::     Displaying iCalendar data without importin=
g.
+* Diary iCalendar Export::      Exporting Diary entries to iCalendar.
+* Diary Outlook Import::        Importing Outlook appointments to the Diar=
y.
+@end menu
+
+@node Diary iCalendar Import
+@subsubsection Importing iCalendar data as Diary Entries
+@cindex import iCalendar to diary
+
+@cindex iCalendar support in diary
+  @dfn{iCalendar} is an Internet standard format for exchanging calendar
+data.  Many calendar applications can export and import data in
+iCalendar format.  iCalendar data is also often sent as email
+attachments.  iCalendar data usually uses the `.ics' file extension, and
+is sent with the `text/calendar' MIME type in email.
+
+The @code{diary-icalendar} package allows you to make use of iCalendar
+data with the Emacs diary. You can import and export data between
+iCalendar format and your Emacs diary file, and also display iCalendar
+data directly in the diary.
+
+The following commands will import iCalendar data to your diary file:
+
+@ftable @code
+@item diary-icalendar-import-file
+Imports an iCalendar file to an Emacs diary file.
+
+@item diary-icalendar-import-buffer
+Imports iCalendar data from the current buffer to an Emacs diary file.
+@end ftable
=20
-@c FIXME the name of the RFC is hardly very relevant.
-@cindex iCalendar support
-  The icalendar package allows you to transfer data between your Emacs
-diary file and iCalendar files, which are defined in @cite{RFC
-2445---Internet Calendaring and Scheduling Core Object Specification
-(iCalendar)} (as well as the earlier vCalendar format).
-
-@c  Importing works for ordinary (i.e., non-recurring) events, but
-@c (at present) may not work correctly (if at all) for recurring events.
-@c Exporting of diary files into iCalendar files should work correctly
-@c for most diary entries.  This feature is a work in progress, so the
-@c commands may evolve in future.
-
-@findex icalendar-import-buffer
-  The command @code{icalendar-import-buffer} extracts
-iCalendar data from the current buffer and adds it to your
-diary file.  This function is also suitable for automatic extraction of
-iCalendar data; for example with the Rmail mail client one could use:
+@code{diary-icalendar-import-buffer} is also suitable for importing
+iCalendar data from email attachments. For example, with the Rmail mail
+client, you could use:
=20
 @example
-(add-hook 'rmail-show-message-hook 'icalendar-import-buffer)
+(add-hook 'rmail-show-message-hook #'diary-icalendar-import-buffer)
 @end example
=20
-@findex icalendar-import-file
-  The command @code{icalendar-import-file} imports an iCalendar file
-and adds the results to an Emacs diary file.  For example:
+Diary import depends on a number of user-customizable variables, which
+are in the @samp{diary-icalendar-import} customization group.  You can
+review and customize these variables with @kbd{M-x customize-group}.
+@xref{Customization Groups}.
+
+iCalendar data is grouped into @dfn{components} which represent calendar
+events (the VEVENT component), tasks (VTODO), and other text data
+(VJOURNAL). Because these components contain different types of data,
+they are imported by different functions, determined by the following
+variables:
+
+@vtable @code
+@item diary-icalendar-vevent-skeleton-command
+Function to format VEVENT components for the diary.
+
+@item diary-icalendar-vtodo-skeleton-command
+Function to format VTODO components for the diary.
+
+@item diary-icalendar-vjournal-skeleton-command
+Function to format VJOURNAL components for the diary.
+@end vtable
+
+You can customize the format of the imported diary entries by writing
+your own formatting functions.  It is convenient (but not required) to
+express such functions as templates called @dfn{skeletons}.
+@ifinfo
+@xref{Top, Autotyping, The Autotype Manual, autotype}, for more about
+skeletons.
+@end ifinfo
+
+For example, suppose you only want to import the date, time, summary,
+and location of each calendar event, and to write them on a single line
+like:
=20
 @example
-(icalendar-import-file "/here/is/calendar.ics"
-                       "/there/goes/ical-diary")
+2025/11/11 Summary @@ Some Location
 @end example
=20
 @noindent
-You can use an @code{#include} directive to add the import file contents
-to the main diary file, if these are different files.
-@iftex
-@xref{Fancy Diary Display,,, emacs-xtra, Specialized Emacs Features}.
-@end iftex
-@ifnottex
-@xref{Fancy Diary Display}.
-@end ifnottex
+Then you could write the import formatting function as a skeleton and
+set it to the value of @code{diary-icalendar-vevent-skeleton-command} as
+follows:
+
+@lisp
+(require 'skeleton)
+
+(define-skeleton simple-vevent
+  "Format a VEVENT summary and location on a single line"
+  nil
+  start-to-end & " " & summary & " "
+  (when location "@@ ") & location "\n")
+
+(setopt diary-icalendar-vevent-skeleton-command #'simple-vevent)
+@end lisp
+
+The variables @code{start-to-end}, @code{summary} and @code{location} in
+this example are dynamically bound to appropriate values when the
+skeleton is called.  See the docstring of
+@code{diary-icalendar-vevent-skeleton-command} for more information.
=20
=20
-@findex icalendar-export-file
-@findex icalendar-export-region
-@cindex export diary
-  Use @code{icalendar-export-file} to interactively export an entire
-Emacs diary file to iCalendar format.  To export only a part of a diary
-file, mark the relevant area, and call @code{icalendar-export-region}.
-In both cases, Emacs appends the result to the target file.
+@node Diary iCalendar Display
+@subsubsection Displaying iCalendar entries in the Diary
+@cindex display iCalendar in diary
+
+If you primarily store your calendar data outside of Emacs, but still
+want to see it in the Emacs calendar and diary, you can do so by
+including an iCalendar file from your diary file.
+
+Suppose, for example, that you download your calendar from an
+external server to a file called `Appointments.ics'.  Then you can
+include this file in your diary by writing a line like
+
+@example
+#include "path/to/Appointments.ics"
+@end example
+
+@noindent
+in your diary file.  You must also set up some hooks to display the
+data in that file as diary entries and mark them in the calendar:
+
+@lisp
+(add-hook 'diary-mark-entries-hook
+          #'diary-mark-included-diary-files)
+(add-hook 'diary-nongregorian-marking-hook
+          #'diary-icalendar-mark-entries)
+(add-hook 'diary-list-entries-hook
+          #'diary-include-other-diary-files)
+(add-hook 'diary-nongregorian-listing-hook
+          #'diary-icalendar-display-entries)
+@end lisp
+
+@noindent
+Events, tasks, and journal entries in `Appointments.ics' will then show
+up on the appropriate days when you display the diary from the calendar.
+@xref{Displaying the Diary}.
+
+The advantage of doing this is that you don't need to synchronize the
+data between the calendar server and your diary file.  This is simpler
+and more reliable than regularly importing and exporting between diary
+and iCalendar format.
+
+@findex diary-icalendar-mailcap-viewer
+  You can also display iCalendar attachments in email messages
+without importing them to your diary file using the function
+@code{diary-icalendar-mailcap-viewer}.  You can add this function, for
+example, to the variable @code{mailcap-user-mime-data}; see its docstring
+for more information.
+
+Displaying iCalendar entries uses the same infrastructure as importing
+them, so customizing the import format will also change the format of
+the displayed entries.  @xref{Diary iCalendar Import}.
+
+@node Diary iCalendar Export
+@subsubsection Exporting Diary Entries to iCalendar
+@cindex export diary to iCalendar
+
+The following commands will export diary entries in iCalendar format:
+
+@ftable @code
+@item diary-icalendar-export-file
+Exports a diary file to iCalendar format.
+
+@item diary-icalendar-export-region
+Exports a region of diary text to iCalendar format.
+@end ftable
+
+iCalendar export depends on a number of user-customizable variables, which
+are in the @samp{diary-icalendar-export} customization group.  You can
+review and customize these variables with @kbd{M-x customize-group}.
+@xref{Customization Groups}.
+
+Exporting diary entries to iCalendar requires you to respect certain
+conventions in your diary, so that iCalendar properties can be parsed
+from your diary entries.
+
+By default, the exporter will use the first line of the entry (after the
+date and time) as the iCalendar summary and the rest of the entry as its
+iCalendar description. Other iCalendar properties can also be encoded in
+the entry on separate lines, like this:
+
+@example
+2025/11/11 Bender's birthday bash
+  Location: Robot House
+  Attendees:
+    Fry <philip.fry@@mars.edu>
+    G=C3=BCnter <guenter@@mars.edu>
+@end example
+
+@noindent
+This format matches the format produced by the default import
+functions.
+
+@vindex diary-icalendar-address-regexp
+@vindex diary-icalendar-class-regexp
+@vindex diary-icalendar-description-regexp
+@vindex diary-icalendar-location-regexp
+@vindex diary-icalendar-organizer-regexp
+@vindex diary-icalendar-status-regexp
+@vindex diary-icalendar-summary-regexp
+@vindex diary-icalendar-todo-regexp
+@vindex diary-icalendar-uid-regexp
+@vindex diary-icalendar-url-regexp
+If you customize the import format, or you want to export diary entries
+in a different format, you will need to customize the export variables
+to detect the format of your diary entries.  The most common iCalendar
+properties are parsed from diary entries using regular expressions.  See
+the variables named @samp{diary-icalendar-*-regexp} in the
+@code{diary-icalendar-export} customization group to modify how these
+properties are parsed.
+
+@vindex diary-icalendar-other-properties-parser
+  If you need to export other iCalendar properties, or do more
+complicated parsing, you can define a function to do so and set it as
+the value of the variable @code{diary-icalendar-other-properties-parser};
+see its docstring for details.
+
+@vindex diary-icalendar-export-linewise
+  By default, the exporter assumes that each diary entry represents a
+single iCalendar event.  If you like to keep your diary in a
+one-entry-per-day format, with different events on continuation
+lines within the same entry, you can still export such entries as
+distinct iCalendar events.  To do this, set the variable
+@code{diary-icalendar-export-linewise} to a non-nil value.
+
+For example, after setting this variable, an entry like:
+
+@example
+2025-05-03
+  9AM Lab meeting
+    G=C3=BCnter to present on new assay
+  Start experiment A
+  12:30-1:30PM Lunch with Phil
+  16:00 Experiment A finishes; move to freezer
+@end example
+
+@noindent
+will be exported as four events, each on the same day, but with
+different start times (except for the second event, "Start experiment
+A", which has no start time).  See the docstring of
+@code{diary-icalendar-export-linewise} for more information.
+
+@node Diary Outlook Import
+@subsubsection Importing Outlook appointments as Diary Entries
+@cindex diary outlook import
+
+@vindex diary-outlook-formats
+@vindex diary-from-outlook-function
+  You can also import diary entries from Outlook-generated appointment
+messages.  While viewing such a message in Rmail or Gnus, do @kbd{M-x
+diary-from-outlook} to import the entry.  You can make this command
+recognize additional appointment message formats by customizing the
+variable @code{diary-outlook-formats}.  Other mail clients can set
+@code{diary-from-outlook-function} to an appropriate value.
+
=20
 @node Daylight Saving
 @section Daylight Saving Time
diff --git a/doc/emacs/emacs.texi b/doc/emacs/emacs.texi
index d7bd581f7fa..696e9aac05a 100644
--- a/doc/emacs/emacs.texi
+++ b/doc/emacs/emacs.texi
@@ -1019,7 +1019,7 @@ Top
 * Adding to Diary::        Commands to create diary entries.
 * Special Diary Entries::  Anniversaries, blocks of dates, cyclic entries,=
 etc.
 * Appointments::           Reminders when it's time to do something.
-* Importing Diary::        Converting diary events to/from other formats.
+* Diary Conversion::       Converting diary events to/from other formats.
=20
 @ifnottex
 More advanced features of the Calendar and Diary
diff --git a/etc/NEWS b/etc/NEWS
index 63ea0b5a11f..a0edf5a2b31 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -2903,6 +2903,42 @@ The user options 'calendar-mark-holidays-flag' and
 'calendar-mark-diary-entries-flag' are not modified anymore when
 changing the marking state in the calendar buffer.
=20
+*** New library for iCalendar data.
+The following files have been added to lisp/calendar:
+
+- icalendar-ast.el:     Abstract syntax tree implementation
+- icalendar-macs.el:    Macros
+- icalendar-mode.el:    Major mode
+- icalendar-parser.el:  Parser and printer
+- icalendar-recur.el:   Support for recurrence rules
+- icalendar-utils.el:   Utility functions
+
+Together, these files implement a new library for handling iCalendar
+(RFC5545) data. The library is designed for re-use in other parts of
+Emacs and in third-party packages.
+
+Most of the code in the older icalendar.el has been marked obsolete and
+now suggests appropriate replacements from the new library.  Another new
+file, diary-icalendar.el, reimplements the diary-related features from
+icalendar.el; see below.
+
+** Diary
+
+*** New user option 'diary-date-insertion-form'.
+This user option determines how dates are inserted into the diary by
+Lisp functions.  Its value is a pseudo-pattern of the same type as in
+'diary-date-forms'.  It is used by 'diary-insert-entry' when inserting
+entries from the calendar, or when importing them from other formats.
+
++++
+*** New library 'diary-icalendar'.
+This library reimplements features previously provided by icalendar.el:
+import from iCalendar format to the diary, and export from the diary to
+iCalendar.  It also adds the ability to include iCalendar files in the
+diary and display and mark their contents in the calendar without
+importing them to the diary file.  The library uses the new iCalendar
+library (see above) and makes diary import and export more customizable.
+
 ** Calc
=20
 *** New user option 'calc-string-maximum-character'.
@@ -2976,6 +3012,11 @@ See https://perldoc.perl.org/5.42.0/perldelta for de=
tails.
 
 * New Modes and Packages in Emacs 31.1
=20
+** New major mode 'icalendar-mode'.
+A major mode for displaying and editing iCalendar (RFC5545) data.  This
+mode handles line unfolding and fontification, including highlighting
+syntax errors in invalid data.
+
 ** New minor mode 'delete-trailing-whitespace-mode'.
 A simple buffer-local mode that runs 'delete-trailing-whitespace'
 before saving the buffer.
diff --git a/lisp/calendar/cal-dst.el b/lisp/calendar/cal-dst.el
index e948bdb558e..d8520c8583c 100644
--- a/lisp/calendar/cal-dst.el
+++ b/lisp/calendar/cal-dst.el
@@ -309,7 +309,9 @@ calendar-current-time-zone
 UTC-DIFF is an integer specifying the number of minutes difference between
     standard time in the current time zone and Coordinated Universal Time
     (Greenwich Mean Time).  A negative value means west of Greenwich.
-DST-OFFSET is an integer giving the daylight saving time offset in minutes.
+DST-OFFSET is an integer giving the daylight saving time offset in minutes
+    relative to UTC-DIFF. (That is, the total UTC offset during daylight s=
aving
+    time is UTC-DIFF + DST-OFFSET minutes.)
 STD-ZONE is a string giving the name of the time zone when no seasonal time
     adjustment is in effect.
 DST-ZONE is a string giving the name of the time zone when there is a seas=
onal
diff --git a/lisp/calendar/calendar.el b/lisp/calendar/calendar.el
index 04a42fcd38a..4e2f7b86f22 100644
--- a/lisp/calendar/calendar.el
+++ b/lisp/calendar/calendar.el
@@ -871,7 +871,15 @@ diary-date-forms
 a portion of the first word of the diary entry.
=20
 For examples of three common styles, see `diary-american-date-forms',
-`diary-european-date-forms', and `diary-iso-date-forms'."
+`diary-european-date-forms', and `diary-iso-date-forms'.
+
+If you customize this variable, you should also customize the variable
+`diary-date-insertion-form' to contain a pseudo-pattern which produces
+dates that match one of the forms in this variable. (If
+`diary-date-insertion-form' does not correspond to one of the patterns
+in this variable, then the diary will not recognize such dates,
+including those inserted into the diary from the calendar with
+`diary-insert-entry'.)"
   :type '(repeat (choice (cons :tag "Backup"
                                :value (backup . nil)
                                (const backup)
@@ -895,6 +903,52 @@ diary-date-forms
                 (diary))))
   :group 'diary)
=20
+(defconst diary-american-date-insertion-form '(month "/" day "/" year)
+  "Pseudo-pattern for American dates in `diary-date-insertion-form'")
+
+(defconst diary-european-date-insertion-form '(day "/" month "/" year)
+  "Pseudo-pattern for European dates in `diary-date-insertion-form'")
+
+(defconst diary-iso-date-insertion-form '(year "/" month "/" day)
+  "Pseudo-pattern for ISO dates in `diary-date-insertion-form'")
+
+(defcustom diary-date-insertion-form
+  (cond ((eq calendar-date-style 'iso) diary-iso-date-insertion-form)
+        ((eq calendar-date-style 'european) diary-european-date-insertion-=
form)
+        (t diary-american-date-insertion-form))
+  "Pseudo-pattern describing how to format a date for a new diary entry.
+
+A pseudo-pattern is a list of expressions that can include the symbols
+`month', `day', and `year' (all numbers in string form), and `monthname'
+and `dayname' (both alphabetic strings).  For example, a typical American
+form would be
+
+       (month \"/\" day \"/\" (substring year -2))
+
+whereas
+
+       ((format \"%9s, %9s %2s, %4s\" dayname monthname day year))
+
+would give the usual American style in fixed-length fields.
+
+This pattern will be used by `calendar-date-string' (which see) to
+format dates when inserting them with `diary-insert-entry', or when
+importing them from other formats into the diary.
+
+If you customize this variable, you should also customize the variable
+`diary-date-forms' to include a pseudo-pattern which matches dates
+produced by this pattern. (If there is no corresponding pattern in
+`diary-date-forms', then the diary will not recognize such dates,
+including those inserted into the diary from the calendar with
+`diary-insert-entry'.)"
+  :version "32.1"
+  :type 'sexp
+  :risky t
+  :set-after '(calendar-date-style diary-american-date-insertion-form
+                                   diary-european-date-insertion-form
+                                   diary-iso-date-insertion-form)
+  :group 'diary)
+
 ;; Next three are provided to aid in setting calendar-date-display-form.
 (defcustom calendar-iso-date-display-form '((format "%s-%.2d-%.2d" year
                                                (string-to-number month)
@@ -1028,7 +1082,9 @@ calendar-set-date-style
         calendar-month-header
         (symbol-value (intern-soft (format "calendar-%s-month-header" styl=
e)))
         diary-date-forms
-        (symbol-value (intern-soft (format "diary-%s-date-forms" style))))
+        (symbol-value (intern-soft (format "diary-%s-date-forms" style)))
+        diary-date-insertion-form
+        (symbol-value (intern-soft (format "diary-%s-date-insertion-form" =
style))))
   (calendar-redraw)
   (calendar-update-mode-line))
=20
diff --git a/lisp/calendar/diary-icalendar.el b/lisp/calendar/diary-icalend=
ar.el
new file mode 100644
index 00000000000..470aa513b39
--- /dev/null
+++ b/lisp/calendar/diary-icalendar.el
@@ -0,0 +1,3914 @@
+;;; diary-icalendar.el --- Display iCalendar data in diary  -*- lexical-bi=
nding: t; -*-
+
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: January 2025
+;; Keywords: calendar
+;; Human-Keywords: diary, calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file is a replacement for icalendar.el that uses a new parser
+;; and offers more features.
+
+;;; Code:
+
+(eval-when-compile (require 'cl-lib))
+(eval-when-compile (require 'icalendar-macs))
+(require 'icalendar)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'icalendar-recur)
+(require 'calendar)
+(require 'cal-dst)
+(require 'diary-lib)
+(require 'skeleton)
+(require 'seq)
+(require 'rx)
+(require 'pp)
+
+;; Customization
+(defgroup diary-icalendar nil
+  "iCalendar import, export, and display in diary"
+  :version 32
+  :group 'diary
+  :prefix 'diary-icalendar)
+
+
+;; Utilities for display and import
+
+;;; Error handling
+(define-error 'ical:diary-import-error "Unable to import iCalendar data"
+              'ical:error)
+
+(cl-defun di:signal-import-error (msg &key (diary-buffer (current-buffer))
+                                           (position (point))
+                                           line
+                                           (severity 2))
+  (let ((err-data
+          (list :message msg
+                :buffer diary-buffer
+                :position position
+                :line line
+                :severity severity)))
+    (signal 'ical:diary-import-error err-data)))
+
+;;; Backward compatibility with icalendar.el
+
+;; icalendar.el provided the following customization variables:
+;; `icalendar-import-format'
+;; `icalendar-import-format-class'
+;; `icalendar-import-format-description'
+;; `icalendar-import-format-location'
+;; `icalendar-import-format-organizer'
+;; `icalendar-import-format-summary'
+;; `icalendar-import-format-status'
+;; `icalendar-import-format-url'
+;; `icalendar-import-format-uid'
+;; These were all format strings: `icalendar-import-format' was the
+;; top-level format string, which would potentially incorporate the
+;; formatted output from the others. This approach to customization
+;; isn't very flexible, though, and doing it right requires a
+;; separate defcustom variable for each iCalendar property. (The above
+;; list is not nearly exhaustive.) I have abandoned this approach in
+;; what follows in favor of skeleton.el templates, but the following two
+;; functions provide backward compatibility for anyone who had
+;; customized the values of the above variables:
+(defun di:-use-legacy-vars-p ()
+  "Return non-nil if user has set `icalendar-import-format*' variables.
+If any of these variables have non-default values, they will be used by
+`diary-icalendar-import-format-entry' to import events. This function
+is for backward compatibility; please do not rely on it in new code."
+  (declare (obsolete nil "32.1"))
+  (or
+   (and (boundp 'icalendar-import-format)
+        (not (equal icalendar-import-format
+                    (custom--standard-value 'icalendar-import-format))))
+   (and (boundp 'icalendar-import-format-class)
+        (not (equal icalendar-import-format-class
+                    (custom--standard-value 'icalendar-import-format-class=
))))
+   (and (boundp 'icalendar-import-format-description)
+        (not (equal icalendar-import-format-description
+                    (custom--standard-value
+                     'icalendar-import-format-description))))
+   (and (boundp 'icalendar-import-format-location)
+        (not (equal icalendar-import-format-location
+                    (custom--standard-value 'icalendar-import-format-locat=
ion))))
+   (and (boundp 'icalendar-import-format-organizer)
+        (not (equal icalendar-import-format-organizer
+                    (custom--standard-value 'icalendar-import-format-organ=
izer))))
+   (and (boundp 'icalendar-import-format-summary)
+        (not (equal icalendar-import-format-summary
+                    (custom--standard-value 'icalendar-import-format-summa=
ry))))
+   (and (boundp 'icalendar-import-format-status)
+        (not (equal icalendar-import-format-status
+                    (custom--standard-value 'icalendar-import-format-statu=
s))))
+   (and (boundp 'icalendar-import-format-url)
+        (not (equal icalendar-import-format-url
+                    (custom--standard-value 'icalendar-import-format-url))=
))
+   (and (boundp 'icalendar-import-format-uid)
+        (not (equal icalendar-import-format-uid
+                    (custom--standard-value 'icalendar-import-format-uid))=
))))
+
+(defun di:-format-vevent-legacy (date class desc location organizer
+                                 summary status url uid)
+  "Format an entry on DATE using the values of obsolete import variables.
+This function is for backward compatibility; please do not rely on it in
+new code."
+  (declare (obsolete nil "32.1"))
+  (insert ical:import-format)
+  (replace-regexp-in-region "%c"
+                            (format ical:import-format-class class)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%d"
+                            (format ical:import-format-description desc)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%l"
+                            (format ical:import-format-location location)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%o"
+                            (format ical:import-format-organizer organizer)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%s"
+                            (format ical:import-format-summary summary)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%t"
+                            (format ical:import-format-status status)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%u"
+                            (format ical:import-format-url url)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%U"
+                            (format ical:import-format-uid uid)
+                            (point-min) (point-max))
+  (goto-char (point-min))
+  (insert date " "))
+
+(defun di:-vevent-to-legacy-alist (vevent)
+  "Convert an `icalendar-vevent' to an alist of the kind used by icalendar=
.el.
+This function is for backward compatibility; please do not rely on it in
+new code."
+  (declare (obsolete nil "32.1"))
+  ;; function values of `icalendar-import-format' expect a list like:
+  ;; ((VEVENT nil
+  ;;   ((PROP1 params val)
+  ;;    (PROP2 params val)
+  ;;    ...)))
+  (let ((vevent-children (ical:ast-node-children vevent))
+        children)
+    (dolist (p vevent-children)
+      (let* ((type (ical:ast-node-type p))
+             (list-sep (get type 'ical:list-sep))
+             (name (intern (car (rassq type ical:property-types))))
+             ;; icalendar.el did not interpret values when parsing, so we
+             ;; convert back to string representation:
+             (value (ical:ast-node-value p))
+             (value-str
+              (or (ical:ast-node-meta-get :original-value p)
+                  (if list-sep
+                      (string-join (mapcar #'ical:default-value-printer va=
lue)
+                                   list-sep)
+                    (ical:default-value-printer value))))
+             params)
+        (when (ical:ast-node-children p)
+          (dolist (param (ical:ast-node-children p))
+            (let* ((par-str (ical:print-param-node param))
+                   (split (string-split par-str "[;=3D]"))
+                   (parname (intern (nth 1 split)))
+                   (parval (nth 2 split)))
+              (push `(,parname nil ,parval) params)))
+          (setq params (nreverse params)))
+        (push `(,name ,params ,value-str) children)))
+    (setq children (nreverse children))
+    ;; Return the legacy alist:
+    `((VEVENT nil ,children))))
+
+(defsubst di:-nonempty (s)
+  "Ensure that string S is nonempty once trimmed: return the trimmed S, or=
 nil."
+  (when (and s (stringp s))
+    (let ((trimmed (string-trim s)))
+      (unless (equal "" trimmed) trimmed))))
+
+;; TODO: move to diary-lib.el?
+(defun di:entry-bounds ()
+  "Return markers (START END) bounding the diary entry around point.
+If point is not in an entry, return nil."
+  (save-excursion
+    (let* ((pt (point))
+           (bound (point-min))
+           (start (make-marker))
+           (end (make-marker)))
+      (when (re-search-backward "^[[:space:]]*$" nil t)
+        (setq bound (match-end 0)))
+      (goto-char pt)
+      (cond ((looking-at di:entry-regexp)
+             (set-marker start (match-beginning 0))
+             (set-marker end (match-end 0)))
+            ((re-search-backward di:entry-regexp bound t)
+             (set-marker start (match-beginning 0))
+             ;; match again forward, to ensure we get the full entry;
+             ;; see `re-search-backward':
+             (goto-char start)
+             (when (looking-at di:entry-regexp)
+               (set-marker end (match-end 0))))
+            (t nil))
+      (when (and (marker-position start) (marker-position end))
+        (list start end)))))
+
+(defun di:find-entry-with-uid (uid &optional diary-filename)
+  "Search DIARY-FILENAME (default: `diary-file') for an entry containing U=
ID.
+
+The UID must occur on a line matching `diary-icalendar-uid-regexp'.  If
+such an entry exists, return markers (START END) bounding it.
+Otherwise, return nil."
+  (let* ((diary-file (or diary-filename diary-file))
+         (diary-buffer (or (find-buffer-visiting diary-file)
+                           (find-file-noselect diary-file))))
+    (with-current-buffer diary-buffer
+      (save-excursion
+        (save-restriction
+          (widen)
+          (goto-char (point-min))
+          (catch 'found
+            (while (re-search-forward di:uid-regexp nil t)
+              (when (equal uid (match-string 1))
+                (throw 'found (di:entry-bounds))))
+            ;; continue search in included files:
+            ;; TODO: is this a good idea?
+            ;; (goto-char (point-min))
+            ;; (while (re-search-forward
+            ;;         (rx line-start (regexp diary-include-string)
+            ;;             ?\" (group-n 1 (one-or-more (not ?\")) ?\"))
+            ;;         nil t)
+            ;;   (let ((entry (di:find-entry-with-uid uid (match-string 1)=
)))
+            ;;     (when entry
+            ;;       (throw 'found entry))))
+            ;; nothing to return:
+            nil))))))
+
+(defun di:y-or-n-or-edit-p (prompt)
+  "Like `y-or-n-p', but with the option to enter a recursive edit.
+Adds a message to current binding of `help-form' explaining how."
+  (let* ((allow-edits-map
+          (let ((map (make-sparse-keymap)))
+            (define-key map [remap edit]
+                        (lambda ()
+                          (interactive)
+			  (save-excursion
+			    (save-window-excursion
+			      (recursive-edit)))))
+            map))
+         (y-or-n-p-map (make-composed-keymap allow-edits-map
+                                             y-or-n-p-map))
+         (help-form
+          (concat (when (stringp help-form) (concat help-form "\n\n"))
+                  ;; FIXME: should use substitute-command-keys here, but
+                  ;; for some reason, even with \<y-or-n-p-map>, it
+                  ;; doesn't find the C-r and C-M-c bindings and only
+                  ;; suggests M-x ...
+                  "Type C-r to enter recursive edit before answering "
+                  "(C-M-c to exit).")))
+    (save-excursion
+      (save-restriction
+        (y-or-n-p prompt)))))
+
+;;; Skeletons
+;;
+;; We use skeleton.el's templating facilities to make formatting of
+;; different iCalendar elements in the diary simple and easy to
+;; customize. There are default skeletons for each major type of
+;; iCalendar component (`di:vevent-skeleton', `di:vtodo-skeleton',
+;; `di:vjournal-skeleton'), and a corresponding defcustom pointing to
+;; each of these skeletons (`di:vevent-skeleton-command', etc.).
+;; `di:format-entry' calls these skeletons, or user-provided functions,
+;; to format individual components as diary entries. Since properties
+;; representing people (`icalendar-attendee', `icalendar-organizer') are
+;; important and relatively complex, another skeleton
+;; (`di:attendee-skeleton') takes care of formatting these for the
+;; top-level component skeletons.
+(define-skeleton di:attendee-skeleton
+  "Default skeleton to format an `icalendar-attendee' for the diary.
+
+Includes any data from the attendee's `icalendar-cnparam' and
+`icalendar-partstatparam', and does not insert any data if its
+`icalendar-cutypeparam' is non-nil and anything other than
+\"INDIVIDUAL\" or \"GROUP\".
+
+The result looks like:
+  <foo@HIDDEN>
+or
+  Baz Foo <foo@HIDDEN>
+or
+  Baz Foo <foo@HIDDEN> (declined)"
+  nil
+  ;; skip non-human "attendees":
+  (when (or (not cutype) (equal cutype "INDIVIDUAL") (equal cutype "GROUP"=
))
+    (skeleton-insert
+     '(nil
+       cn & " "
+       (format "<%s>" address) & " "
+       (when partstat (format "(%s)" (downcase partstat)))))))
+
+(defun di:format-attendee (attendee)
+  "Format ATTENDEE for the diary.
+
+ATTENDEE should be an `icalendar-attendee' or `icalendar-organizer'
+property node.  Returns a string representing an entry for the attendee,
+formatted by `diary-icalendar-attendee-skeleton-command', unless the
+attendee's address matches the regexp in
+`diary-icalendar-skip-addresses-regexp'; in that case, nil is returned."
+  (ical:with-property attendee
+    ((ical:cutypeparam :value cutype)
+     (ical:cnparam :value cn)
+     (ical:memberparam :values member)
+     (ical:roleparam :value role)
+     (ical:partstatparam :value partstat)
+     (ical:rsvpparam :value rsvp)
+     (ical:deltoparam :values delto)
+     (ical:delfromparam :values delfrom)
+     (ical:sentbyparam :value sentby)
+     (ical:dirparam :value dir)
+     (ical:languageparam :value language))
+    (calendar-dlet
+        ((full-address value)
+         (address (string-replace "mailto:" "" value))
+         (cn (when cn (string-trim cn)))
+         (cutype cutype)
+         (dir dir)
+         (role role)
+         (partstat partstat)
+         (rsvp rsvp)
+         (delfrom-full-addresses delfrom)
+         (delfrom-addresses
+          (mapcar (apply-partially #'string-replace "mailto:" "")
+                  delfrom))
+         (delto-full-addresses delto)
+         (delto-addresses
+          (mapcar (apply-partially #'string-replace "mailto:" "")
+                  delto))
+         (member-full-addresses member)
+         (member-addresses
+          (mapcar (apply-partially #'string-replace "mailto:" "")
+                  member))
+         (sentby-full-address sentby)
+         (sentby-address
+          (when sentby (string-replace "mailto:" "" sentby)))
+         (language language))
+      (unless (and di:skip-addresses-regexp
+                   (string-match-p di:skip-addresses-regexp full-address))
+        (with-temp-buffer
+          (funcall di:attendee-skeleton-command)
+          (buffer-string))))))
+
+(define-skeleton di:vevent-skeleton
+  "Default skeleton to format an `icalendar-vevent' for the diary."
+  nil
+  (when (or non-marking (equal transparency "TRANSPARENT"))
+    diary-nonmarking-symbol)
+  (or rrule-sexp start-to-end start) & " "
+  summary "\n"
+  @ ; start of body (for indentation)
+  (when (or location geo-location) "Location: ") (or location geo-location)
+  & "\n" (when url "URL: ") & url
+  & "\n" (when status "Status: ") & status
+  & "\n" (when organizer "Organizer: ") & organizer
+  & "\n" (di:format-list attendees "Attendee")
+  & "\n" (di:format-list categories "Category" "Categories")
+  & "\n" (di:format-list comments "Comment")
+  & "\n" (di:format-list contacts "Contact")
+  & "\n" (di:format-list attachments "Attachment")
+  & "\n" (when (and importing access) "Access: ") & access
+  & "\n" (when (and importing uid) "UID: ") & uid
+  & "\n" (when description "Description: ") & description
+  & "\n"
+  @ ; end of body
+  (let* ((end (pop skeleton-positions))
+         (start (pop skeleton-positions)))
+    ;; TODO: should diary define a customizable indentation level?
+    ;; For now, we use 1 because that's what icalendar.el chose
+    (indent-code-rigidly start end 1))
+  (when importing "\n"))
+
+(define-skeleton di:vjournal-skeleton
+  "Default skeleton to format an `icalendar-vjournal' for the diary."
+  nil
+  (when (or non-marking di:import-vjournal-as-nonmarking)
+    diary-nonmarking-symbol)
+  (or rrule-sexp start) & " "
+  summary "\n"
+  @ ; start of body (for indentation)
+  & "\n" (when url "URL: ") & url
+  & "\n" (when status "Status: ") & status
+  & "\n" (when organizer "Organizer: ") & organizer
+  & "\n" (di:format-list attendees "Attendee")
+  & "\n" (di:format-list categories "Category" "Categories")
+  & "\n" (di:format-list comments "Comment")
+  & "\n" (di:format-list contacts "Contact")
+  & "\n" (di:format-list attachments "Attachment")
+  & "\n" (when (and importing access) "Access: ") & access
+  & "\n" (when (and importing uid) "UID: ") & uid
+  ;; In a vjournal, multiple `icalendar-description's are allowed:
+  & "\n" (di:format-list descriptions "Description")
+  & "\n"
+  @ ; end of body
+  (let* ((end (pop skeleton-positions))
+         (start (pop skeleton-positions)))
+    (indent-code-rigidly start end 1))
+  (when importing "\n"))
+
+(define-skeleton di:vtodo-skeleton
+  "Default skeleton to format an `icalendar-vtodo' for the diary."
+  nil
+  (when non-marking diary-nonmarking-symbol)
+  (or rrule-sexp due) & " "
+  (when due "Due: ") summary
+  (when start (concat " (Start: " start ")"))
+  "\n"
+  @ ; start of body (for indentation)
+  & "\n" (when url "URL: ") & url
+  & "\n" (when status "Status: ") & status
+  & "\n" (when completed "Completed: ") & completed
+  & "\n" (when percent-complete (format "Progress: %d%%" percent-complete))
+  & "\n" (when organizer "Organizer: ") & organizer
+  & "\n" (di:format-list attendees "Attendee")
+  & "\n" (di:format-list categories "Category" "Categories")
+  & "\n" (di:format-list comments "Comment")
+  & "\n" (di:format-list contacts "Contact")
+  & "\n" (di:format-list attachments "Attachment")
+  & "\n" (when (and importing access) "Access: ") & access
+  & "\n" (when (and importing uid) "UID: ") & uid
+  & "\n" (when description "Description: ") & description
+  & "\n"
+  @ ; end of body
+  (let* ((end (pop skeleton-positions))
+         (start (pop skeleton-positions)))
+    (indent-code-rigidly start end 1))
+  (when importing "\n"))
+
+;;; Further utilities for formatting/importing special kinds of values:
+(defun di:format-geo-coordinates (geo)
+  "Format an `icalendar-geo-coordinates' value as degrees N/S and E/W."
+  (format "%.6f=C2=B0%s %.6f=C2=B0%s" ; RFC5545 says we may truncate after=
 6 decimal places
+          (abs (car geo)) (if (< 0 (car geo)) "N" "S")
+          (abs (cdr geo)) (if (< 0 (cdr geo)) "E" "W")))
+
+(defun ical:save-binary-attachment (base64-data dir &optional mimetype)
+  "Decode and save BASE64-DATA to a new file in DIR.
+
+The file will be named based on a unique prefix of BASE64-DATA with an
+extension based on MIMETYPE. It will be saved in a subdirectory named
+DIR of `diary-icalendar-attachment-directory', which will be created if
+necessary. Returns the (non-directory part of) the saved filename."
+  (require 'mailcap)
+  ;; Create the subdirectory for the attachment if necessary:
+  (unless (and (directory-name-p di:attachment-directory)
+               (file-writable-p di:attachment-directory))
+    (di:signal-import-error
+     (format "Cannot write to directory: %s" di:attachment-directory)))
+  (make-directory (file-name-concat di:attachment-directory dir) t)
+  ;; Create a unique filename for the attachment. Unfortunately RFC5545
+  ;; has no mechanism for suggesting a filename, so we just use a unique
+  ;; prefix of BASE64-DATA, or a random number as a fallback.
+  (let* ((nchars 4)
+         (max-chars (length base64-data))
+         (prefix (substring base64-data 0 nchars))
+         (extn (when mimetype
+                 (concat "." (symbol-name
+                              (mailcap-mime-type-to-extension mimetype)))))
+         (path (file-name-concat di:attachment-directory dir
+                                 (concat prefix extn))))
+    (while (file-exists-p path)
+      (cl-incf nchars)
+      (setq prefix (if (< nchars max-chars)
+                       (substring base64-data 0 nchars)
+                     (number-to-string (random max-chars))))
+      (setq path (file-name-concat di:attachment-directory dir
+                                   (concat prefix extn))))
+    ;; Save the file and return its name:
+    (let ((data (base64-decode-string base64-data))
+          (coding-system-for-write 'no-conversion))
+      (write-region data nil path)
+      (file-name-nondirectory path))))
+
+(defun di:save-attachments-from (attachment-nodes uid)
+  "Save attachments in ATTACHMENT-NODES and return a list of attachments.
+
+If these nodes contain binary data, rather than an URL, save the data to
+a file in `diary-icalendar-attachment-directory' (unless this variable
+is nil).  The returned list is a list of strings, which are either URLs
+or filenames."
+  (let (entry-attachments)
+    (dolist (node attachment-nodes)
+      (ical:with-property node
+        ((ical:fmttypeparam :value fmttype))
+        (when (and (eq 'ical:binary value-type)
+                   di:attachment-directory)
+          (let ((filename (ical:save-binary-attachment value uid fmttype)))
+            (push filename entry-attachments)))
+        (when (eq 'ical:url value-type)
+          (push value entry-attachments))))
+    ;; Return the list of filenames and URLs:
+    entry-attachments))
+
+(defun di:format-list (values &optional title plural-form sep indent)
+  "Smartly format VALUES for the diary.
+
+VALUES should be a list of strings. nil elements will be ignored, and an
+empty list will return nil.
+
+TITLE is a string to add to the beginning of the list; a colon will be
+appended. PLURAL-FORM is the plural of TITLE, to be used when VALUES
+contains more than one element (default: TITLE+\"s\").
+
+The strings in VALUES are first joined with SEP (default: \", \"), with
+\"TITLE: \" prepended. If the result is longer than the current value of
+`fill-column', the values are instead formatted one per line, with the
+title on its own line at the beginning, and the whole list indented
+relative to the title by INDENT spaces (default: 2). Thus, in the first
+case, the result looks like:
+  TITLE(s): VAL1, VAL2, ...
+and in the second:
+  TITLE(s):
+    VAL1
+    VAL2
+    ..."
+  (when (cdr values)
+    (setq title (when title (or plural-form (concat title "s")))))
+  (unless indent
+    (setq indent 2))
+  ;; Remove nil values and extra whitespace:
+  (setq values (mapcar #'string-trim (delq nil values)))
+  (when values
+    (let ((line (concat
+                 (when title (concat title ": "))
+                 (string-join values (or sep ", ")))))
+      (if (< (length line) fill-column)
+          line
+        ;; Otherwise, one value per line:
+        (with-temp-buffer
+          (insert (string-join values "\n"))
+          (indent-code-rigidly (point-min) (point-max) indent)
+          (goto-char (point-min))
+          (when title
+            (insert title ":\n"))
+          (buffer-string))))))
+
+(defun di:format-time (dt &optional tzname)
+  "Format the `icalendar-date-time' DT for the diary.
+The time is formatted according to `diary-icalendar-time-format', which se=
e.
+TZNAME, if specified, should be a string naming the time zone observance
+in which DT occurs."
+  ;; Diary does not support seconds, so silently truncate:
+  (let ((time (format-time-string di:time-format (encode-time dt))))
+    (if tzname
+        (concat time " " tzname)
+      time)))
+
+(defun di:format-time-as-local (dt &optional original-tzname)
+  "Format the time in `icalendar-date-time' DT for the diary.
+
+DT is translated to the system local time zone if necessary, and the
+original time specification is preserved in parentheses if it was given
+in a different zone.  ORIGINAL-TZNAME, if specified, should be a string
+naming the time zone observance in which DT was originally encoded in
+the iCalendar data."
+  (cl-typecase dt
+    (ical:date "")
+    (ical:date-time
+     (let* ((ts (encode-time dt))
+            (original-offset (decoded-time-zone dt))
+            (local-tz (current-time-zone ts))
+            (local-offset (car local-tz))
+            (local-dt (decode-time ts local-tz))
+            (local-str (di:format-time local-dt)))
+       (if (and original-tzname original-offset
+                (not (=3D original-offset local-offset)))
+           (format "%s (%s)" local-str (di:format-time dt original-tzname))
+         local-str)))))
+
+(defun di:format-date (dt)
+  "Format the `icalendar-date' or `icalendar-date-time' DT for the diary.
+If DT is a date-time, only the date part is considered. The date is
+formatted with `calendar-date-string' according to the pattern in
+`diary-date-insertion-form'."
+  (calendar-dlet ((calendar-date-display-form diary-date-insertion-form))
+    (cl-typecase dt
+      (ical:date (calendar-date-string dt t t))
+      (ical:date-time (calendar-date-string (ical:date-time-to-date dt) t =
t)))))
+
+(defun di:format-date/time-as-local (dt &optional original-tzname)
+  "Format the `icalendar-date' or `icalendar-date-time' DT for the diary.
+
+If DT is a plain date, only the date will be formatted.  If DT is a
+date-time, both the date and the time will formatted, after translating
+DT into a date and time into the system local time.
+
+If specified, ORIGINAL-TZNAME should be a string naming the time zone
+observance in which DT was originally encoded in the iCalendar data. In
+this case, the original clock time in DT will also be added in
+parentheses, with date if necessary. For example:
+  2025/05/01 09:00 (08:00 GMT)
+or
+  2025/05/01 18:00 (2025/05/02 08:00 JST)"
+  (let ((local-dt (ical:date/time-to-local dt)))
+    (cl-typecase local-dt
+      (ical:date (di:format-date local-dt))
+      (ical:date-time
+       (let ((date (di:format-date local-dt))
+             (time (di:format-time local-dt))
+             (orig-date (di:format-date dt))
+             (orig-time (di:format-time dt original-tzname)))
+         (if original-tzname
+             (format "%s %s (%s)" date time
+                     (if (equal date orig-date)
+                         orig-time
+                       (format "%s %s" orig-date orig-time)))
+           (format "%s %s" date time)))))))
+
+(defun di:format-time-range (start end &optional omit-start-date)
+  "Format a time range for the diary.
+
+START and END should be `icalendar-date-time' values where the date part
+is the same. (If they are not on the same date, nil is returned; use
+`diary-icalendar-format-time-block-sexp' to make a diary S-exp for this
+range instead.)
+
+The date is only formatted once, and the time is formatted as a range, lik=
e:
+  STARTDATE STARTTIME-ENDTIME
+If OMIT-START-DATE is non-nil, STARTDATE will be omitted."
+  (when (equal (ical:date/time-to-date start) (ical:date/time-to-date end))
+    (format "%s%s-%s"
+            (if omit-start-date ""
+              (concat (di:format-date start) " "))
+            (di:format-time-as-local start)
+            (di:format-time-as-local end))))
+
+(defun di:format-block-sexp (start end)
+  "Format a `diary-block' diary S-expression between START and END.
+
+START and END may be `icalendar-date' or `icalendar-date-time'
+values. If they are date-times, only the date parts will be considered.
+Returns a string like \"%%(diary-block ...)\" with the arguments properly
+ordered for the current value of `calendar-date-style'."
+  (unless (cl-typep start 'ical:date)
+    (setq start (ical:date-time-to-date start)))
+  (unless (cl-typep end 'ical:date)
+    (setq end (ical:date-time-to-date end)))
+  (concat
+   diary-sexp-entry-symbol
+   (apply #'format "(diary-block %d %d %d %d %d %d)"
+          (cl-case calendar-date-style
+            ;; M/D/Y
+            (american (list (calendar-extract-month start)
+                            (calendar-extract-day start)
+                            (calendar-extract-year start)
+                            (calendar-extract-month end)
+                            (calendar-extract-day end)
+                            (calendar-extract-year end)))
+            ;; D/M/Y
+            (european (list (calendar-extract-day start)
+                            (calendar-extract-month start)
+                            (calendar-extract-year start)
+                            (calendar-extract-day end)
+                            (calendar-extract-month end)
+                            (calendar-extract-year end)))
+            ;; Y/M/D
+            (iso      (list (calendar-extract-year start)
+                            (calendar-extract-month start)
+                            (calendar-extract-day start)
+                            (calendar-extract-year end)
+                            (calendar-extract-month end)
+                            (calendar-extract-day end)))))))
+
+(defun di:format-time-block-sexp (start end)
+  "Format a `diary-time-block' diary S-expression for times between START =
and END."
+  (concat
+   diary-sexp-entry-symbol
+   (format "(diary-time-block :start '%s :end '%s)" start end)))
+
+(defun di:format-rrule-sexp (component)
+  "Format the recurrence rule data in COMPONENT as a diary S-expression.
+
+The returned string looks like \"%%(diary-rrule ...)\", and contains the
+necessary data from COMPONENT for the calendar to compute recurrences of
+the event."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:dtend :value dtend)
+       (ical:duration :value duration)
+       (ical:rrule :value rrule)
+       (ical:rdate :all rdate-nodes)
+       (ical:exdate :all exdate-nodes))
+    (unless (or rrule rdate-nodes)
+      (di:signal-import-error "No recurrence data in component"))
+    (let ((exdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append
+                          (mapcar #'ical:ast-node-value exdate-nodes))))
+          (rdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append
+                          (mapcar #'ical:ast-node-value rdate-nodes))))
+          ;; N.B. we intentionally *don't* add any clock times to the
+          ;; imported diary entry, since they could conflict with the
+          ;; times generated by the recurrence rule, e.g. if the rule is
+          ;; an 'HOURLY rule.  Instead we always specify the end time
+          ;; (if any) via a duration, and take care of displaying the
+          ;; correct clocks times after computing recurrences during
+          ;; diary display (see `diary-rrule').
+          (dur-value (cond (duration duration)
+                           (dtend (unless (equal dtstart dtend)
+                                    (ical:duration-between dtstart dtend)))
+                           (t nil)))
+          (arg-plist nil))
+
+      (when exdates
+        (setq arg-plist (plist-put arg-plist :exclude `(quote ,exdates))))
+      (when rdates
+        (setq arg-plist (plist-put arg-plist :include `(quote ,rdates))))
+      (when dtstart
+        (setq arg-plist (plist-put arg-plist :start `(quote ,dtstart))))
+      (when dur-value
+        (setq arg-plist (plist-put arg-plist :duration `(quote ,dur-value)=
)))
+      (when rrule
+        ;; TODO: make this prettier to look at?
+        (setq arg-plist (append (list :rule `(quote ,rrule)) arg-plist)))
+      ;; TODO: timezones??
+
+      (setq arg-plist (cons 'diary-rrule arg-plist))
+      (string-trim ; removing trailing \n added by pp
+       (concat diary-sexp-entry-symbol
+               (with-output-to-string (pp arg-plist)))))))
+
+;; This function puts all of the above together to format individual
+;; iCalendar components as diary entries. The final formatting is done
+;; by the appropriate skeleton command for the component, or by
+;; `di:-format-vevent-legacy' if the legacy format string variables from
+;; icalendar.el are set.
+(defun di:format-entry (component index &optional non-marking)
+  "Format an iCalendar component for the diary.
+
+COMPONENT should be an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal'. INDEX should be an index into the calendar where
+COMPONENT occurs, as returned by `icalendar-parse-and-index'.
+
+Depending on the type of COMPONENT, the body will be formatted by one of:
+`diary-icalendar-vevent-skeleton-command'
+`diary-icalendar-vtodo-skeleton-command'
+`diary-icalendar-vjournal-skeleton-command'
+which see.
+
+The variable `non-marking' will be bound to the value of NON-MARKING in
+the relevant skeleton command. If it is non-nil, the user requested the
+entry to be non-marking.
+
+Returns a string containing the diary entry."
+  (ical:with-component component
+      ((ical:attach :all attach-nodes)
+       (ical:attendee :all attendee-nodes)
+       (ical:categories :all categories-nodes)
+       (ical:class :value access)
+       (ical:comment :all comment-nodes)
+       (ical:completed :value completed-dt)
+       (ical:contact :all contact-nodes)
+       (ical:created :value created-dt)
+       (ical:description :value description)
+       ;; in `icalendar-vjournal', multiple `icalendar-description'
+       ;; nodes are allowed:
+       (ical:description :all description-nodes)
+       (ical:dtend :first dtend-node :value dtend)
+       (ical:dtstamp :value dtstamp)
+       (ical:dtstart :first dtstart-node :value dtstart)
+       (ical:duration :value duration)
+       (ical:due :first due-node :value due-dt)
+       (ical:geo :value geo)
+       (ical:last-modified :value last-modified-dt)
+       (ical:location :value location)
+       (ical:organizer :first organizer-node  ; for skeleton formatting
+                       :value organizer-addr) ; for legacy formatting
+       (ical:percent-complete :value percent-complete)
+       (ical:priority :value priority)
+       (ical:recurrence-id :first recurrence-id-node :value recurrence-id-=
dt)
+       (ical:related-to :all related-to-nodes)
+       (ical:request-status :all request-status-nodes)
+       (ical:resources :all resources-nodes)
+       (ical:rrule :value rrule)
+       (ical:rdate :all rdate-nodes)
+       (ical:sequence :value revision)
+       (ical:status :value status)
+       (ical:summary :value summary)
+       (ical:transp :value transp)
+       (ical:uid :value uid)
+       (ical:url :value url)
+       (ical:valarm :all alarms))
+    (let* ((is-recurring (or rdate-nodes rrule))
+           (start-tz (when dtstart-node
+                       (ical:with-property dtstart-node
+                         ((ical:tzidparam :value tzid))
+                         (when tzid (ical:index-get index :tzid tzid)))))
+           (start-tzname (when start-tz (icr:tzname-on dtstart start-tz)))
+           (dtstart-local (ical:date/time-to-local dtstart))
+           (due-tz (when due-node
+                     (ical:with-property due-node
+                       ((ical:tzidparam :value tzid))
+                       (when tzid (ical:index-get index :tzid tzid)))))
+           (due-tzname (when due-tz (icr:tzname-on due-dt due-tz)))
+           (dtend
+            (cond (dtend dtend)
+                  ;; DTEND and DUE never occur in the same component,
+                  ;; so we alias dtend to due:
+                  (due-dt due-dt)
+                  (duration
+                   (ical:date/time-add-duration dtstart duration start-tz)=
)))
+           (dtend-local (ical:date/time-to-local dtend))
+           (end-tz
+            (cond (dtend-node
+                   (ical:with-property dtend-node
+                     ((ical:tzidparam :value tzid))
+                     (when tzid (ical:index-get index :tzid tzid))))
+                  (due-node due-tz)
+                  (duration start-tz)))
+           (end-tzname (when end-tz (icr:tzname-on dtend end-tz)))
+           (component-type (ical:ast-node-type component)))
+      (calendar-dlet
+          (;; TODO: interpret alarms? Diary has its own mechanism for
+           ;; this (but no syntax). We could theoretically use alarms to
+           ;; set up notifications. For now we just pass them on to
+           ;; user skeletons, so users can do this if desired.
+           (alarms alarms)
+           (attachments
+            (when attach-nodes
+              (di:save-attachments-from attach-nodes uid)))
+           (attendees (mapcar #'di:format-attendee attendee-nodes))
+           (categories
+            (mapcan
+             (lambda (node)
+               (mapcar #'ical:text-to-string (ical:ast-node-value node)))
+             categories-nodes))
+           (access (when access (downcase access)))
+           (comments
+            (mapcar
+             (lambda (node) (ical:text-to-string (ical:ast-node-value node=
)))
+             comment-nodes))
+           (contacts
+            (mapcar
+             (lambda (node) (ical:text-to-string (ical:ast-node-value node=
)))
+             contact-nodes))
+           (completed-dt completed-dt)
+           (completed
+            (when completed-dt (di:format-date/time-as-local completed-dt)=
))
+           (created-dt created-dt)
+           (created
+            (when created-dt (di:format-date/time-as-local created-dt)))
+           (description (when description (di:-nonempty description)))
+           (descriptions
+            (when (eq 'icalendar-vjournal component-type)
+              (mapcar
+               (lambda (node)
+                 (di:-nonempty (ical:text-to-string (ical:ast-node-value n=
ode))))
+               description-nodes)))
+           (dtstart dtstart)
+           (start
+            (when dtstart
+              (if (bound-and-true-p importing)
+                  (di:format-date/time-as-local dtstart start-tzname)
+                (di:format-time-as-local dtstart start-tzname))))
+           (dtend dtend)
+           (end
+            (when dtend
+              (if (bound-and-true-p importing)
+                  (di:format-date/time-as-local dtend end-tzname)
+                (di:format-time-as-local dtend end-tzname))))
+           (dtstamp dtstamp)
+           (start-to-end
+            (cond
+             ((not dtstart) nil)
+             ((or (not dtend) (equal dtstart dtend))
+              ;; without a distinct DTEND/DUE, same as start:
+              (if (bound-and-true-p importing)
+                  (di:format-date/time-as-local dtstart start-tzname)
+                (di:format-time-as-local dtstart start-tzname)))
+             ((and (bound-and-true-p importing)
+                   (cl-typep dtstart 'ical:date)
+                   (cl-typep dtend 'ical:date))
+              ;; Importing two dates:
+              ;; %%(diary-block ...)
+              (di:format-block-sexp
+               dtstart
+               ;; DTEND is an exclusive bound, while
+               ;; diary-block needs an inclusive bound, so
+               ;; subtract a day:
+               (ical:date-add dtend :day -1)))
+             ((and (bound-and-true-p importing)
+                   (equal (ical:date/time-to-date dtstart-local)
+                          (ical:date/time-to-date dtend-local)))
+              ;; Importing, start and end times on same day:
+              ;; DATE HH:MM-HH:MM
+              (di:format-time-range dtstart-local dtend-local))
+             ((bound-and-true-p importing)
+              ;; Importing at least one date-time, on different days:
+              ;; %%(diary-time-block :start ... :end ...)
+              (di:format-time-block-sexp dtstart-local dtend-local))
+             ((and (boundp 'date) ; bound when displaying diary
+                   (cl-typep dtstart-local 'ical:date-time)
+                   (cl-typep dtend-local 'ical:date-time)
+                   (equal date (ical:date-time-to-date dtstart-local))
+                   (equal date (ical:date-time-to-date dtend-local)))
+              ;; Displaying, start and end times on the day displayed:
+              ;; HH:MM-HH:MM
+              (di:format-time-range dtstart-local dtend-local t))
+             ((and (boundp 'date) ; bound when displaying diary
+                   (cl-typep dtstart-local 'ical:date-time)
+                   (cl-typep dtend-local 'ical:date-time))
+              ;; Displaying, start and/or end time on other days:
+              ;; HH:MM-HH:MM for just the times on `date'
+              (di:format-time-range
+               (ical:date/time-max dtstart-local
+                                   (ical:make-date-time
+                                    :year (calendar-extract-year date)
+                                    :month (calendar-extract-month date)
+                                    :day (calendar-extract-day date)
+                                    :hour 0 :minute 0 :second 0
+                                    :zone
+                                    (decoded-time-zone dtstart-local)))
+               (ical:date/time-min dtend-local
+                                   (ical:make-date-time
+                                    :year (calendar-extract-year date)
+                                    :month (calendar-extract-month date)
+                                    :day (calendar-extract-day date)
+                                    :hour 23 :minute 59 :second 59
+                                    :zone
+                                    (decoded-time-zone dtend-local)))))
+             (t
+              ;; That's all the cases we care about here.
+              nil)))
+           (duration duration)
+           (due-dt
+            (when (eq component-type 'ical:vtodo)
+              ;; in VTODO, DUE does the job of DTEND, so we alias them;
+              ;; see above
+              dtend))
+           (due
+            (when (eq component-type 'ical:vtodo)
+              (if due-node
+                  (di:format-date/time-as-local due-dt due-tzname)
+                ;; here we use start-tzname because due/dtend is calculate=
d from
+                ;; dtstart, not its own node with a tzid:
+                (di:format-date/time-as-local dtend start-tzname))))
+           (work-time-sexp
+            (when (and dtstart due-dt (bound-and-true-p importing))
+              (di:format-time-block-sexp dtstart-local due-dt)))
+           (coordinates geo)
+           (geo-location (when geo (di:format-geo-coordinates geo)))
+           (importing (bound-and-true-p importing))
+           (last-modified-dt last-modified-dt)
+           (last-modified (di:format-date/time-as-local last-modified-dt))
+           (location (di:-nonempty location))
+           (non-marking non-marking)
+           (organizer (di:format-attendee organizer-node))
+           (percent-complete percent-complete)
+           (priority priority)
+           (recurrence-id-dt recurrence-id-dt)
+           (recurrence-id
+            (di:format-date/time-as-local recurrence-id-dt))
+           (related-tos related-to-nodes)
+           (request-statuses request-status-nodes)
+           (resources
+            (mapcan
+             (lambda (node)
+               (mapcar #'ical:text-to-string (ical:ast-node-value node)))
+             resources-nodes))
+           (rrule-sexp
+            (when (and is-recurring (bound-and-true-p importing))
+              (di:format-rrule-sexp component)))
+           (revision revision)
+           (status (when status (di:-nonempty (downcase status))))
+           (summary (di:-nonempty summary))
+           (transparency transp)
+           (uid (di:-nonempty uid))
+           (url (di:-nonempty url)))
+        (with-temp-buffer
+          (cl-case (ical:ast-node-type component)
+            (ical:vevent
+             ;; N.B. icalendar.el *only* imported VEVENT components
+             (if (di:-use-legacy-vars-p)
+                 (if (functionp ical:import-format)
+                     (insert (funcall ical:import-format
+                                      (di:-vevent-to-legacy-alist componen=
t)))
+                   (di:-format-vevent-legacy (or rrule-sexp start-to-end s=
tart)
+                                             access description location
+                                             organizer-addr
+                                             summary status url uid))
+               (funcall di:vevent-skeleton-command)))
+            (ical:vtodo (funcall di:vtodo-skeleton-command))
+            (ical:vjournal (funcall di:vjournal-skeleton-command)))
+          (buffer-string))))))
+
+
+;; Import to Diary
+;;
+;; `di:import-file' and `di:import-buffer' are the main user commands
+;; for import. (These replace `icalendar-import-file' and
+;; `icalendar-import-buffer' defined by icalendar.el, which are now
+;; obsolete aliases to these commands.) `di:import-buffer-to-buffer' is
+;; the function underlying these commands; it is the main import
+;; function available for external Lisp code.
+
+;;; Import customizations
+(defgroup diary-icalendar-import nil
+  "iCalendar import into diary"
+  :version "32.1"
+  :group 'diary-icalendar
+  :prefix 'diary-icalendar)
+
+(defcustom di:always-import-quietly nil
+  "When non-nil, diary will never ask for confirmations when importing eve=
nts.
+
+`diary-icalendar-import-file' and `diary-icalendar-import-buffer' both
+accept an optional argument, QUIETLY, which determines whether these
+functions ask for confirmation when importing individual events and
+saving the diary file. If you set this variable to t, you will never be
+asked to confirm."
+  :version "32.1"
+  :type '(choice (const :tag "Ask for confirmations" nil)
+                 (const :tag "Never ask for confirmations" t)))
+
+(defcustom di:after-mailcap-viewer-hook nil
+  "Hook run after `diary-icalendar-mailcap-viewer'.
+
+The functions in this hook will be run after formatting the contents of
+iCalendar data as diary entries in a temporary buffer. You can add
+functions to this hook if you want, for example, to copy these entries
+somewhere else."
+  :version "32.1"
+  :type '(hook))
+
+(defcustom di:attachment-directory nil
+  "Directory in which to save iCalendar attachments when importing.
+
+If the value is nil, binary attachments encoded in an ATTACH property
+are never saved. If it is the name of a directory, attachments will be
+saved in per-component subdirectories of this directory, with each
+subdirectory named by the component's UID value."
+  :version "32.1"
+  :type '(choice
+          (const :tag "Do not save attachments" nil)
+          directory)
+  :group 'icalendar)
+
+(defcustom di:time-format "%H:%M"
+  "Format string to use for event times.
+
+The value must be a valid format string for `format-time-string'; see
+its docstring for more information. The value only needs to format clock
+times, and should format them in a way that will be recognized by
+`diary-time-regexp'. (Date information is formatted separately at the
+start of the imported entry.) Examples:
+
+  \"%H:%M\" - 24-hour, 0-padded: 09:00 or 21:00
+  \"%k.%Mh\" - 24-hour, blank-padded: 9.00h or 21.00h
+  \"%I:%M%p\" - 12-hour, 0-padded, with AM/PM: 09:00AM or 09:00PM
+  \"%l.%M%p\" - 12-hour, blank-padded, with AM/PM: 9.00AM or 9.00PM"
+  :version "32.1"
+  :type '(string))
+
+(defcustom di:attendee-skeleton-command 'di:attendee-skeleton
+  "Function to format ATTENDEE properties during diary import.
+
+This should be a symbol naming a function which inserts information
+about an `icalendar-attendee' into the current buffer.  It is convenient
+to express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information.
+
+The function will be called with no arguments and should insert
+information about the attendee into the current buffer.
+
+The following variables will be (dynamically) bound when the function is
+called.  All values will be strings (unless another type is noted), or
+nil:
+
+address - the attendee's calendar address, with \"mailto:\" removed
+full-address - the attendee's calendar address, with nothing removed
+cn - the attendee's common name (`icalendar-cnparam')
+dir - URL of attendee's directory entry (`icalendar-directoryparam')
+cutype - the attendee's user type (`icalendar-cutypeparam')
+language - a language abbreviation (`icalendar-languageparam')
+role - the attendee's role in the event (`icalendar-roleparam')
+partstat - the attendee's participation status (`icalendar-partstatparam')
+rsvp - whether an RSVP is requested (`icalendar-rsvpparam')
+member-addresses (list of strings) - any groups/lists where the attendee
+  is a member (`icalendar-memberparam'), with \"mailto:\" removed
+member-full-addresses - like member-addresses, but nothing removed
+delfrom-addresses (list of strings) - addresses of users who delegated
+  their participation to the attendee (`icalendar-delfromparam'), with
+  \"mailto:\" removed
+delfrom-full-addresses - like delfrom-addresses, but nothing removed
+delto-addresses (list of strings) - addresses of users to whom the
+  attendee delegated participation (`icalendar-deltoparam'), with
+  \"mailto:\" removed
+delto-full-addresses - like delto-addresses, but nothing removed
+sentby-address - address of user who sent the invitation on someone
+  else's behalf (`icalendar-sentbyparam'), with \"mailto:\" removed
+sentby-full-address - like sentby-address, but nothing removed"
+  :version "32.1"
+  :type '(radio (function-item di:attendee-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:skip-addresses-regexp user-mail-address
+  "Regular expression matching addresses to skip when importing.
+
+This regular expression should match calendar addresses (which are
+typically \"mailto:\" URIs) which should be skipped when importing
+ATTENDEE, ORGANIZER, and other iCalendar properties that identify a
+contact.
+
+You can make this match your own email address(es) to prevent them from
+being formatted by `diary-icalendar-attendee-skeleton-command' and
+listed in diary entries."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:vevent-skeleton-command #'di:vevent-skeleton
+  "Function to format VEVENT components for the diary.
+
+This should be a symbol naming a function which inserts information
+about an `icalendar-vevent' into the current buffer.  It is convenient
+to express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information.
+
+The following variables will be bound when the function is called.
+All values will be strings unless another type is noted, or nil:
+
+alarms (list of `icalendar-valarm' nodes) - notifications in the event
+as-alarm (symbol) - non-nil when the event should be formatted for an
+  alarm notification in advance of the event. The symbol indicates the
+  type of alarm: `email' means to format the event as the body of an email.
+  (Currently only used for EMAIL alarms; see `icalendar-export-alarms'.)
+attachments (list of strings) - URLs or filenames of attachments in the ev=
ent
+attendees (list of strings) - the participants of the event,
+  formatted by `diary-icalendar-attendee-skeleton-command'
+categories (list of strings) - categories specified in the event
+access - the event's access classification
+comments (list of strings) - comments specified in the event
+created-dt (an `icalendar-date-time' value) - when the event was created
+created - created-dt, formatted as a local date-time string
+description - the event's description
+dtstart (an `icalendar-date' or `icalendar-date-time' value) - when the ev=
ent
+  starts
+dtend (an `icalendar-date' or `icalendar-date-time' value) - when the
+  event ends; this is either the value of the `icalendar-dtend'
+  property, or the end time calculated by adding the event's
+  `icalendar-duration' to its `icalendar-dtstart' properties
+start - start date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time.
+end - end date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time.
+start-to-end - a single string containing both start and end date and
+  (local) time.  If the event starts and ends on the same day, the date
+  is not repeated.  When importing, dates are included, and the string
+  may contain a diary s-exp; when displaying, the string contains only
+  the times for the displayed date.  If there is no end date, same as star=
t.
+dtstamp (an `icalendar-date' or `icalendar-date-time' value) - when the ev=
ent
+  was last revised
+duration (an `icalendar-dur-value') - the event's duration
+coordinates (an `icalendar-geo-coordinates' value) - the event's geographi=
cal
+  coordinates
+geo-location - coordinates, formatted as a string with degrees N/S and E/W
+importing (a boolean) - t if the event should be formatted for import.
+  When nil, the event should be formatted for display rather than import.
+  When importing it is important to include all information from the event
+  that you want to be saved in the diary; when displaying, information like
+  the date (or date-related S-expressions) and UID can be left out.
+last-modified-dt (an `icalendar-date-time' value) - the date and time the =
event
+  was last modified
+last-modified - last-modified-dt, formatted as a local date and time string
+location - the event's location
+non-marking (a boolean) - if non-nil, the diary entry should be non-marking
+organizer - the event's organizer, formatted by
+  `diary-icalendar-attendee-skeleton-command'
+priority (a number) - the event's priority (1 =3D highest priority, 9 =3D =
lowest;
+  0 =3D undefined)
+recurrence-id-dt (an `icalendar-date' or `icalendar-date-time' value) - the
+  date or date-time of a particular recurrence of the event
+recurrence-id - recurrence-id-dt, formatted as a local date and time string
+related-tos (a list of `icalendar-related-to' property nodes) -
+  these contain the UIDs of related events and their relationship type
+request-statuses (a list of `icalendar-request-status' property nodes) -
+  these contain status information about requests made
+resources (a list of strings) - resources used or needed for the event
+rrule-sexp - a string containing a diary S-expression for a recurring even=
t.
+  If this is non-nil, you should normally use it instead of the start-* and
+  end-* variables to form the date of the entry.
+revision (a number) - the revision number of the event; see
+  `icalendar-sequence'
+status - overall status specified by the organizer (e.g. \"confirmed\")
+summary - a summary of the event
+transparency - the event's time transparency status; see `icalendar-transp'
+uid - the unique identifier of the event
+url - a URL for the event"
+  :version "32.1"
+  :type '(radio (function-item di:vevent-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:vjournal-skeleton-command #'di:vjournal-skeleton
+  "Function to format VJOURNAL components for the diary.
+
+This should be a symbol naming a function which inserts information about
+an `icalendar-vjournal' into the current buffer.  It is convenient to
+express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information, and see
+`diary-icalendar-vjournal-skeleton' for an example.
+
+The following variables will be bound when the function is called.
+All values will be strings unless another type is noted, or nil:
+
+alarms (list of `icalendar-valarm' nodes) - notifications in the journal e=
ntry
+attachments (list of strings) - URLs or filenames of attachments in the jo=
urnal
+  entry
+attendees (list of strings) - the participants of the journal entry,
+  formatted by `diary-icalendar-attendee-skeleton-command'
+categories (list of strings) - categories specified in the journal entry
+access - the journal entry's access classification
+comments (list of strings) - comments specified in the journal entry
+created-dt (an `icalendar-date-time' value) - the date and time the
+  journal entry was created
+created - created-dt, formatted as a local date-time string
+descriptions (list of strings) - the journal entry's descriptions
+  (more than one description is allowed in iCalendar VJOURNAL components)
+dtstamp (an `icalendar-date' or `icalendar-date-time' value) - when the
+  journal entry was last revised
+dtstart (an `icalendar-date' or `icalendar-date-time' value) - when the jo=
urnal
+  entry starts
+start - start date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time.
+importing (a boolean) - t if the journal entry should be formatted for imp=
ort.
+  When nil, the entry should be formatted for display rather than import.
+  When importing it is important to include all information from the entry
+  that you want to be saved in the diary; when displaying, information like
+  the date (or date-related S-expressions) and UID can be left out.
+last-modified-dt (an `icalendar-date-time' value) - the date and time
+  the journal entry was last modified
+last-modified - last-modified-dt, formatted as a local date and time string
+non-marking (a boolean) - if non-nil, the diary entry should be non-marking
+organizer - the journal entry's organizer, formatted by
+  `diary-icalendar-attendee-skeleton-command'
+recurrence-id-dt (an `icalendar-date' or `icalendar-date-time' value) - the
+  date or date-time of a particular recurrence of the journal entry
+recurrence-id - recurrence-id-dt, formatted as a local date and time string
+related-tos (a list of `icalendar-related-to' property nodes) -
+  these contain the UIDs of related journal entrys and their relationship =
type
+request-statuses (a list of `icalendar-request-status' property nodes) -
+  these contain status information about requests made
+rrule-sexp - a string containing a diary S-expression for a recurring
+  journal entry.  If this is non-nil, you should normally use it instead
+  of the start-* variables to form the date of the entry.
+revision (a number) - the revision number of the journal entry; see
+  `icalendar-sequence'
+status - overall status specified by the organizer (e.g. \"draft\")
+summary - a summary of the journal entry
+uid - the unique identifier of the journal entry
+url - a URL for the journal entry"
+  :version "32.1"
+  :type '(radio (function-item di:vjournal-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:import-vjournal-as-nonmarking t
+  "Whether to import VJOURNAL components as nonmarking diary entries.
+
+If this variable is non-nil, VJOURNAL components will be imported into
+the diary as \"nonmarking\" entries by prefixing
+`diary-nonmarking-symbol'. This means they will not cause their date to
+be marked in the calendar when the command `diary-mark-entries' is
+called.  See Info node `(emacs)Displaying the Diary' for more
+information."
+  :version "32.1"
+  :type '(choice (const :tag "Import as nonmarking entries" t)
+                 (const :tag "Import as normal (marking) entries" nil)))
+
+(defcustom di:vtodo-skeleton-command #'di:vtodo-skeleton
+  "Function to format VTODO components for the diary.
+
+This should be a symbol naming a function which inserts information about
+an `icalendar-vtodo' into the current buffer.  It is convenient to
+express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information.
+
+The following variables will be bound when the function is called.
+All values will be strings unless another type is noted, or nil:
+
+alarms (list of `icalendar-valarm' nodes) - notifications in the task
+as-alarm (symbol) - non-nil when the task should be formatted for an
+  alarm notification in advance of the task. The symbol indicates the
+  type of alarm: `email' means to format the task as the body of an email.
+  (Currently only used for EMAIL alarms; see `icalendar-export-alarms'.)
+attachments (list of strings) - URLs or filenames of attachments in the ta=
sk
+attendees (list of strings) - the participants of the task,
+  formatted by `diary-icalendar-attendee-skeleton-command'
+categories (list of strings) - categories specified in the task
+access - the task's access classification
+comments (list of strings) - comments specified in the task
+completed-dt (an `icalendar-date-time' value) - when the task was completed
+completed - completed-dt, formatted as a local date-time string
+created-dt (an `icalendar-date-time' value) - when the task was created
+created - created-dt, formatted as a local date-time string
+description - the task's description
+dtstamp (an `icalendar-date' or `icalendar-date-time' value) - when the ta=
sk
+  was last revised
+dtstart (an `icalendar-date' or `icalendar-date-time' value) - when the ta=
sk
+  starts
+start - start-date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time
+start-to-end - a single string containing both start and due date and
+  time.  If the task starts and ends on the same day, the date is not
+  repeated.  When importing, dates are included, and the string may
+  contain a diary s-exp; when displaying, the string contains only the
+  times for the displayed date.  If there is no end date, same as start.
+duration (an `icalendar-dur-value') - the task's duration
+due-dt (an `icalendar-date' or `icalendar-date-time' value) - when the
+  task is due
+dtend - same as `due-dt'
+due - due date and time in a single string
+end - same as `due'
+work-time-sexp - when the task has both a start date and a due date,
+  this is a %%(diary-time-block ...) diary S-expression representing the
+  time from the start date to the due date (only non-nil when
+  importing). You can use this e.g. to make a separate entry for the
+  task's work time, so that it shows up every day in the diary until it
+  is due.
+coordinates (an `icalendar-geo-coordinates' value) - the task's geographic=
al
+  coordinates
+geo-location - coordinates, formatted as a string with degrees N/S and E/W
+importing (a boolean) - t if the task should be formatted for import.
+  When nil, the task should be formatted for display rather than import.
+  When importing it is important to include all information from the task
+  that you want to be saved in the diary; when displaying, information like
+  the date (or date-related S-expressions) and UID can be left out.
+last-modified-dt (an `icalendar-date-time' value) - the date and time the =
task
+  was last modified
+last-modified - last-modified-dt, formatted as a local date and time string
+location - the task's location
+non-marking (a boolean) - if non-nil, the diary entry should be non-marking
+organizer - the task's organizer, formatted by
+  `diary-icalendar-attendee-skeleton-command'
+percent-complete (a number between 0 and 100) - the percentage of the task=
 which
+  has already been completed
+priority (a number) - the task's priority (1 =3D highest priority, 9 =3D l=
owest;
+  0 =3D undefined)
+recurrence-id-dt (an `icalendar-date' or `icalendar-date-time' value) - the
+  date or date-time of a particular recurrence of the task
+recurrence-id - recurrence-id-dt, formatted as a local date and time string
+related-tos (a list of `icalendar-related-to' property nodes) -
+  these contain the UIDs of related tasks and their relationship type
+request-statuses (a list of `icalendar-request-status' property nodes) -
+  these contain status information about requests made
+resources (a list of strings) - resources used or needed for the task
+rrule-sexp - a string containing a diary S-expression for a recurring task
+  (only non-nil when importing).  When this is non-nil, you should
+  normally use it instead of the start and end variables to form the
+  date of the entry.
+revision (a number) - the revision number of the task; see
+  `icalendar-sequence'
+status - overall status specified by the organizer (e.g. \"confirmed\")
+summary - a summary of the task
+uid - the unique identifier of the task
+url - a URL for the task"
+  :version "32.1"
+  :type '(radio (function-item di:vjournal-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:import-predicate #'identity
+  "Predicate to filter iCalendar components before importing.
+
+This function must accept one argument, which will be an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. It should return non-nil if this component should be
+formatted for import, or nil if it should be skipped.
+
+The default value will format all the events, todos, and journal entries
+in a given calendar."
+  :version "32.1"
+  :type '(radio (function-item identity)
+                (function :tag "Other predicate")))
+
+;; `di:import-buffer-to-buffer' is the underlying function that formats
+;; a complete `icalendar-vcalendar' as diary entries. This function runs
+;; `di:post-entry-format-hook' after formatting each component as an
+;; entry, and it runs `di:post-calendar-format-hook' after all entries
+;; have been formatted. These hooks enable e.g. user review and
+;; confirmation of each imported entry and of the whole imported
+;; calendar.
+(defvar di:post-entry-format-hook nil
+  "Hook run after formatting a single iCalendar component as a diary entry.
+
+The functions in this hook are run by `diary-icalendar-import-buffer-to-bu=
ffer'
+(which see) after each component it formats. Each function will be
+called in a (narrowed) buffer whose contents represent a single diary
+entry.")
+
+(defvar di:post-calendar-format-hook nil
+  "Hook run after formatting a complete `icalendar-vcalendar' as diary ent=
ries.
+
+The functions in this hook are run by `diary-icalendar-import-buffer-to-bu=
ffer'
+(which see) after formatting all the diary entries created from the
+calendar. Each function will be called in a buffer containing all the
+diary entries.")
+
+(defun di:sort-by-start-ascending (c1 c2)
+  "Sort iCalendar component C1 before C2 if C1 starts strictly before C2.
+Components with no start date/time are sorted after components that do."
+  (let ((c1start (ical:with-property-of c1 'ical:dtstart nil value))
+        (c2start (ical:with-property-of c2 'ical:dtstart nil value)))
+    (cond ((and c1start c2start)
+           (ical:date/time< c1start c2start))
+          ;; order anything with a start before anything without:
+          (c1start t)
+          (c2start nil)
+          ;; otherwise they can stay as-is:
+          (t t))))
+
+(defcustom di:import-comparison-function #'di:sort-by-start-ascending
+  "Comparison function for sorting imported iCalendar components.
+See the :lessp argument of `sort' for more information."
+  :version "32.1"
+  :type '(radio (function-item di:sort-by-start-ascending)
+                (function :tag "Other comparison function")))
+
+(defun di:import-buffer-to-buffer (&optional all-non-marking)
+  "Format iCalendar data in current buffer as diary entries.
+
+This function parses the first iCalendar VCALENDAR in the current buffer
+and formats its VEVENT, VJOURNAL, and VTODO components as diary entries.
+It returns a new buffer containing those diary entries. The caller
+should kill this buffer when it is no longer needed.
+
+If ALL-NON-MARKING is non-nil, all diary entries will be non-marking.
+
+The list of components to import can be filtered by binding
+`diary-icalendar-import-predicate'. After each component is formatted as
+a diary entry, `diary-icalendar-post-entry-format-hook' is run in a (narro=
wed)
+buffer containing that entry. After all components have been formatted,
+`diary-icalendar-post-calendar-format-hook' is run in the (widened) buffer
+containing all the entries.
+
+The formatting of imported entries depends on a number of
+user-customizable variables, including: `diary-date-forms',
+`calendar-date-style', `calendar-date-display-form' and customizations
+in the `diary-icalendar' group."
+  (unless (ical:contains-vcalendar-p (current-buffer))
+    (di:signal-import-error (format "No VCALENDAR object in buffer %s"
+                                    (buffer-name))))
+  (save-excursion
+    (goto-char (point-min))
+    (let (vcalendar index)
+      (ical:init-error-buffer)
+      (let ((vcal/idx (ical:parse-and-index (current-buffer))))
+        (when vcal/idx
+          (setq vcalendar (car vcal/idx))
+          (setq index (cadr vcal/idx))
+          (let* ((import-buf (generate-new-buffer " *diary-import*"))
+                 (to-import
+                  (sort
+                   (seq-filter
+                    (lambda (c)
+                      (and (or (ical:vevent-component-p c)
+                               (ical:vjournal-component-p c)
+                               (ical:vtodo-component-p c))
+                           (funcall di:import-predicate c)))
+                    (ical:ast-node-children vcalendar))
+                  :lessp di:import-comparison-function
+                  :in-place t))
+                 ;; prevent point from being reset from window-point
+                 ;; when narrowed buffer is displayed for confirmation:
+                 (window-point-insertion-type t)
+                 ;; position at start of each entry:
+                 entry-start)
+
+            (with-current-buffer import-buf
+              (calendar-dlet ((importing t)) ; inform skeletons we're impo=
rting
+                (dolist (component to-import)
+                  (setq entry-start (point))
+                  (insert (di:format-entry component index all-non-marking=
))
+                  (with-restriction entry-start (point)
+                    (save-excursion
+                      (run-hooks 'di:post-entry-format-hook)))
+                  (unless (bolp) (insert "\n"))))
+              (save-excursion
+                (run-hooks 'di:post-calendar-format-hook))
+              import-buf)))))))
+
+;; Internal variables needed by `di:-entry-import'. They are dynamically
+;; bound in `di:import-buffer'.
+(defvar di:-no-queries nil)
+(defvar di:-entry-count nil)
+
+(defun di:-entry-import ()
+  ;; Adds the formatted entry in the current restriction to the diary,
+  ;; after getting confirmation from the user.
+  ;; Used via `di:post-entry-format-hook' in `di:import-buffer', below.
+  (unless di:-no-queries
+    (display-buffer (current-buffer)))
+  (when (or di:-no-queries
+            (let ((help-form
+                   "Type y to add this entry to the diary, n to skip to ne=
xt."))
+              (di:y-or-n-or-edit-p "Add this entry to the diary?")))
+    (ical:condition-case err
+       (let* ((uid (save-excursion
+                     (goto-char (point-min))
+                     (when (re-search-forward di:uid-regexp nil t)
+                       (match-string 1))))
+              (other-entry (di:find-entry-with-uid uid))
+              (entry (buffer-string)))
+         (if (and other-entry
+                  (not di:-no-queries)
+                  (y-or-n-p "Replace existing entry with same UID?"))
+             (with-current-buffer (marker-buffer (car other-entry))
+               (replace-region-contents
+                (car other-entry) (cadr other-entry) entry))
+           ;; Otherwise, diary-make-entry inserts the new entry at the end
+           ;; of the main diary file:
+           (diary-make-entry
+            entry
+            nil ; skeleton has already interpreted non-marking
+            nil ; use dynamic value of `diary-file'
+            t   ; skeleton responsible for final spaces
+            t))  ; no need to show diary file while importing
+         (when other-entry
+           (set-marker (car other-entry) nil)
+           (set-marker (cadr other-entry) nil))
+         (cl-incf di:-entry-count)))))
+
+;;;###autoload
+(defun di:import-buffer (&optional diary-filename quietly all-non-marking)
+  "Import iCalendar events from current buffer into diary.
+
+This function parses the first iCalendar VCALENDAR in the current buffer
+and imports VEVENT, VJOURNAL, and VTODO components to the diary file
+DIARY-FILENAME (default: `diary-file').
+
+For each entry, you are asked whether to add it to the diary unless
+QUIETLY is non-nil.  After all entries are imported, you are also asked
+if you want to save the diary file unless QUIETLY is non-nil.  When
+called interactively, you are asked if you want to confirm each entry
+individually; answer No to make QUIETLY non-nil.
+
+ALL-NON-MARKING determines whether all diary events are created as
+non-marking entries. When called interactively, you are asked whether
+you want to make all entries non-marking.
+
+The formatting of imported entries in the diary depends on a number of
+user-customizable variables. Before running this command for the first
+time, you may especially wish to check the values of:
+`diary-file'
+`diary-date-forms'
+`diary-date-insertion-form'
+`calendar-date-style'
+`calendar-date-display-form'
+as well as variables in the customization group `diary-icalendar-import'."
+  (interactive
+   (list (read-file-name "Diary file: "
+                         (when diary-file (file-name-directory diary-file))
+                         (cons diary-file diary-included-files))
+         (or di:always-import-quietly
+             (not (y-or-n-p "Confirm entries individually?")))
+         (y-or-n-p "Make all entries non-marking?")))
+
+  (let* ((diary-file diary-filename) ; dynamically bound for `di:-entry-im=
port',
+         (di:-entry-count 0)         ; see above
+         (di:-no-queries quietly)    ;
+         (di:post-entry-format-hook
+          (append di:post-entry-format-hook (list #'di:-entry-import)))
+         (diary-buffer (or (find-buffer-visiting diary-filename)
+                           (find-file-noselect diary-filename)))
+         import-buffer)
+    (unwind-protect
+        (setq import-buffer (di:import-buffer-to-buffer all-non-marking))
+      (when (bufferp import-buffer)
+        (kill-buffer import-buffer)))
+    (display-buffer diary-buffer)
+    (when (or quietly
+              (y-or-n-p (format "%d entries imported. Save diary file?"
+                                di:-entry-count)))
+      (with-current-buffer diary-buffer
+        (goto-char (point-max))
+        (save-buffer)))))
+
+;;;###autoload
+(defun di:import-file (filename &optional diary-filename quietly non-marki=
ng)
+  "Import iCalendar diary entries from FILENAME into DIARY-FILENAME.
+
+This function parses the first iCalendar VCALENDAR in FILENAME and
+imports VEVENT, VJOURNAL, and VTODO components to the diary
+DIARY-FILENAME (default: `diary-file').
+
+For each entry, you are asked whether to add it to the diary unless
+QUIETLY is non-nil.  After all entries are imported, you are also asked
+if you want to save the diary file unless QUIETLY is non-nil. When
+called interactively, you are asked if you want to confirm each entry
+individually; answer No to make QUIETLY non-nil.
+
+NON-MARKING determines whether all diary events are created as
+non-marking entries. When called interactively, you are asked whether
+you want to make all entries non-marking.
+
+The formatting of imported entries in the diary depends on a number of
+user-customizable variables. Before running this command for the first
+time, you may especially wish to check the values of:
+`diary-file'
+`diary-date-forms'
+`diary-date-insertion-form'
+`calendar-date-style'
+`calendar-date-display-form'
+as well as variables in the customization group `diary-icalendar-import'."
+  (interactive
+   (list (read-file-name "iCalendar file: " nil nil 'confirm)
+         (read-file-name "Diary file: "
+                         (when diary-file (file-name-directory diary-file))
+                         (cons diary-file diary-included-files))
+         (or di:always-import-quietly
+             (not (y-or-n-p "Confirm entries individually?")))
+         (y-or-n-p "Make all entries non-marking?")))
+  (let ((parse-buf (ical:find-unfolded-buffer-visiting filename)))
+    (unless parse-buf
+      (ical:condition-case err
+        (setq parse-buf
+              (ical:unfolded-buffer-from-file (expand-file-name filename))=
)))
+    ;; Hand off to `di:import-buffer' for the actual import:
+    (if parse-buf
+        (with-current-buffer parse-buf
+          (di:import-buffer diary-filename quietly non-marking))
+      ;; If we get here, we weren't able to open the file for parsing:
+      (warn "Unable to open file %s; see %s"
+            filename (buffer-name (ical:error-buffer))))))
+
+;; Some simple support for viewing iCalendar data in MIME message
+;; parts. Mail readers may want to build their own viewer using the
+;; import functions above, but this is a good starting point:
+(defun di:mailcap-viewer ()
+  "View iCalendar data in the current message part as diary entries.
+
+This function is a suitable viewer for text/calendar parts in MIME
+messages, such as email attachments. To use this function as a viewer,
+customize the variable `mailcap-user-mime-data' and add an entry
+containing this function for the MIME type \"text/calendar\".
+
+To extend the behavior of this function, see
+`diary-icalendar-after-mailcap-viewer-hook'."
+  (let ((entries-buf (diary-icalendar-import-buffer-to-buffer)))
+    (unwind-protect
+        (progn
+          ;; Since this is already a temporary viewer buffer, we replace
+          ;; its contents with the imported entries, so we can (a) keep
+          ;; the window configuration setup by the calling mailcap code
+          ;; and (b) already kill the import buffer here.
+          (erase-buffer)
+          (insert-buffer-substring entries-buf)
+          (diary-mode)
+          (run-hooks di:after-mailcap-viewer-hook))
+      (kill-buffer entries-buf))))
+
+
+;; Export
+
+;;; Error handling
+(define-error 'ical:diary-export-error "Unable to export diary data" 'ical=
:error)
+
+(cl-defun di:signal-export-error (msg &key (diary-buffer (current-buffer))
+                                           (position (point))
+                                           line
+                                           (severity 2))
+  (let ((err-data
+          (list :message msg
+                :buffer diary-buffer
+                :position position
+                :line line
+                :severity severity)))
+    (signal 'ical:diary-export-error err-data)))
+
+;;; Customization
+(defgroup diary-icalendar-export nil
+  "iCalendar export from diary"
+  :version "32.1"
+  :group 'diary-icalendar
+  :prefix 'diary-icalendar)
+
+(defcustom di:address-regexp
+  (rx line-start
+      (one-or-more space)
+      (zero-or-one ;; property prefix, e.g. "Attendee:" or "Organizer:"
+       (seq (one-or-more word) ":"))
+      (group-n 2 (zero-or-more (not (any "<" "\n"))))
+      "<"
+      (group-n 1 (one-or-more (not (any "@" "\n")))
+                 "@"
+                 (one-or-more (not (any ">" "\n"))))
+      ">")
+  "Regular expression to match calendar user (email) addresses.
+
+The full address should match group 1; \"mailto:\" will be prepended to
+the full address during export, unless it or another URI scheme is
+present.  If there is a match in group 2, it will be used as the
+common name associated with the address (see `icalendar-cnparam').
+
+The default value matches names and addresses on lines like:
+
+  Ms. Baz <baz@HIDDEN>
+
+as well as on lines like:
+
+  Property: Ms. Baz <baz@HIDDEN> other data...
+
+Any matching address within a diary entry will be exported as an
+iCalendar ATTENDEE property, unless the line on which it appears is also
+a match for `diary-icalendar-organizer-regexp', in which case it will be
+exported as the ORGANIZER property."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:description-regexp nil
+  "Regular expression to match description in an entry.
+
+If this is nil, the entire entry (after the date and time specification)
+is used as the description. Thus, it is only necessary to set this
+variable if you want to export diary entries where the text to be used
+as the description should not include the full entry body. In that case,
+the description should match group 1 of this regexp."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:organizer-regexp
+  (rx line-start
+      (one-or-more space)
+      "Organizer:")
+  "Regular expression to match line of an entry specifying the ORGANIZER.
+
+This regular expression need *not* match the name and address of the
+organizer (`diary-icalendar-address-regexp' is responsible for that).
+It only needs to match a line on which the organizer's address appears,
+to distinguish the organizer's address from other addresses."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:class-regexp
+  (rx line-start
+      (one-or-more space)
+      (or "Class:" ; for backward compatibility
+          "Access:")
+      (zero-or-more space)
+      (group-n 1 (or "public" "private" "confidential")))
+  "Regular expression to match access classification.
+
+The access classification value should be matched by group 1. The default
+regexp matches access classifications like:
+  Access: C
+or
+  Class: C
+where C can be any of:
+  public
+  private
+  confidential"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:location-regexp
+  (rx line-start
+      (one-or-more space)
+      "Location:"
+      (zero-or-more space)
+      (group-n 1 (one-or-more not-newline)))
+  "Regular expression to match location of an event.
+
+The location value should be matched by group 1. The default regexp
+matches lines like:
+
+  Location: Some place"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:status-regexp
+  (rx line-start
+      (one-or-more space)
+      "Status:"
+      (zero-or-more space)
+      (group-n 1 (or "tentative" "confirmed" "cancelled" "needs-action" "c=
ompleted"
+                     "in-process" "draft" "final")))
+  "Regular expression to match status of an event.
+
+The status value should be matched by group 1. The default regexp
+matches statuses on lines like:
+
+  Status: S
+
+where S can be any of:
+
+  tentative
+  confirmed
+  cancelled
+  needs-action
+  completed
+  in-process
+  draft
+  final"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:summary-regexp nil
+  "Regular expression to match summary in an entry.
+
+If this is nil, the first line of the entry (after the date and time
+specification) is used as the summary. Thus, it is only necessary to set
+this variable if you want to export diary entries where the text to be
+used as the summary does not appear on the first line of the entry. In
+that case, the summary should match group 1 of this regexp."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:todo-regexp nil
+  "Regular expression that identifies an entry as a task (VTODO).
+
+If this is non-nil, any diary entry that matches this regexp will be
+exported as an iCalendar VTODO component (instead of VEVENT), with its
+due date equal to the entry date."
+  :version "32.1"
+  :type '(radio (const :tag "Do not export VTODO tasks" nil)
+                (regexp :tag "Regexp for tasks")))
+
+(defcustom di:uid-regexp
+  (rx line-start
+      (one-or-more space)
+      "UID:"
+      (zero-or-more space)
+      (group-n 1 (one-or-more not-newline)))
+  "Regular expression to match UID of an entry.
+
+The UID value should be matched by group 1. The default regexp matches
+UIDs on lines like:
+
+  UID: some-unique-identifier"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:url-regexp
+  (rx line-start
+      (one-or-more space)
+      "URL:"
+      (zero-or-more space)
+      (group-n 1 (eval 'ical:uri)))
+  "Regular expression to match URL of an entry.
+
+The full URL should be matched by group 1. The default regexp matches
+URLs on lines like:
+
+  URL: http://example.com/foo/bar"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:export-nonmarking-entries t
+  "Whether to export nonmarking diary entries.
+
+If this variable is nil, nonmarking diary entries (those prefixed with
+`diary-nonmarking-symbol') are never exported. If it is non-nil,
+nonmarking diary entries are exported; see also
+`diary-icalendar-export-nonmarking-as-vjournal' for more control over
+how they are exported."
+  :version "32.1"
+  :type '(choice (const :tag "Export nonmarking entries" t)
+                 (const :tag "Do not export nonmarking entries" nil)))
+
+(defcustom di:export-nonmarking-as-vjournal nil
+  "Whether to export nonmarking diary entries as VJOURNAL components.
+
+If this variable is non-nil, nonmarking diary entries (those prefixed
+with `diary-nonmarking-symbol') will be exported as iCalendar VJOURNAL
+components, rather than VEVENT components. VJOURNAL components are
+intended to represent notes, documents, or other data associated with a
+date. External calendar applications may treat VJOURNAL components
+differently than VEVENTs, so consult your application's documentation
+before setting this variable to t.
+
+If this variable is nil, nonmarking entries will be exported as VEVENT
+components which do not take up busy time in the calendar (i.e., with
+the TRANSP property set to \"TRANSPARENT\"; see `icalendar-transp')."
+  :version "32.1"
+  :type '(choice (const :tag "Export nonmarking entries as VEVENT" nil)
+                 (const :tag "Export nonmarking entries as VJOURNAL" t))
+  :link '(url-link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3"))
+
+(defcustom di:export-alarms
+  nil
+  "Determine whether and how alarms are included in exported diary events.
+
+If this variable is nil, no alarms are created during export.
+If it is non-nil, it should be a list of lists like:
+
+((TYPE LEAD-TIME [OPTIONS]) ...)
+
+In each inner list, the first element TYPE should be a symbol indicating
+an alarm type to generate: one of \\=3D'audio, \\=3D'display, or \\=3D'ema=
il.
+The second element LEAD-TIME should be an integer specifying the amount
+of time before the event, in minutes, when the alarm should be
+triggered. For audio alarms, there are currently no other
+OPTIONS.
+
+For display and email alarms, the next OPTION is a format string for the
+displayed alarm, or the email subject line. In this string, \"%t\" will
+be replaced with LEAD-TIME and \"%s\" with the event's summary.
+
+If TYPE is \\=3D'email, the next OPTION should be a list whose members
+specify the email addresses to which email alarms should be sent. These
+can either be email addresses (as strings), or the symbol
+\\=3D'from-entry, meaning that these addresses should be taken from the
+exported diary entry (see `diary-icalendar-address-regexp')."
+  :version "32.1"
+  :type
+  '(choice (const :tag "Do not include alarms when exporting diary entries=
" nil)
+           (set :tag "Create alarms of these types"
+                (list :tag "Audio alarms"
+                      (const :tag "Options" audio)
+                      (integer :tag "Advance time (in minutes)"
+                               :value 10)
+                      ;; TODO: specify an audio file to attach?
+                      ;; TODO: other options we could have here and below:
+                      ;; - whether alarm is before event start or end
+                      ;; - repetitions and delays between repetitions
+                      )
+                (list :tag "Display alarms"
+                      (const :tag "Options" display)
+                      (integer :tag "Advance time (minutes)"
+                               :value 10)
+                      (string :tag "Display format"
+                              :value "In %t minutes: %s")
+                      ;; TODO: other options?
+                      )
+                (list :tag "Email alarms"
+                      (const :tag "Options" email)
+                      (integer :tag "Advance time (minutes)"
+                               :value 10)
+                      ;; TODO: other options?
+                      (string :tag "Subject line format"
+                              :value "In %t minutes: %s")
+                      (set
+                       :tag "Attendees"
+                       (const :tag "Parse addresses from entry"
+                              from-entry)
+                       (repeat :tag "Other addresses"
+                               (string :tag "Email address")))))))
+
+(defcustom di:export-sexp-enumeration-days
+  14
+  "Number of days over which an S-expression diary entry is enumerated.
+
+Some S-expression entries cannot be translated to iCalendar format.
+They are therefore enumerated, i.e., explicitly evaluated for a
+certain number of days, and then exported. The enumeration starts
+on the current day and continues for the number of days given here.
+
+See `icalendar-export-sexp-enumerate-all' for a list of sexp
+entries which by default are NOT enumerated."
+  :version "32.1"
+  :type 'integer
+  :group 'icalendar)
+
+(defcustom di:export-sexp-enumerate-all
+  nil
+  "Whether all S-expression diary entries are enumerated.
+
+If this variable is non-nil, all S-expression diary entries are
+enumerated for `diary-icalendar-export-sexp-enumeration-days' days
+instead of translating them into an iCalendar equivalent.
+This causes the following S-expression entries to be enumerated
+instead of translated to a recurrence rule:
+ `diary-anniversary'
+ `diary-block'
+ `diary-cyclic'
+ `diary-date'
+ `diary-float'
+ `diary-remind'
+ `diary-rrule'
+ `diary-time-block'
+All other S-expression entries are enumerated in any case."
+  :version "32.1"
+  :type '(choice (const :tag "Export without enumeration when possible" ni=
l)
+                 (const :tag "Always enumerate S-expression entries" t)))
+
+(defcustom di:recurring-start-year
+  (1- (decoded-time-year (decode-time)))
+  "Start year for recurring events.
+
+Set this to a year just before the start of your personal calendar.
+It is needed when exporting certain diary S-expressions to iCalendar
+recurring events, and because some calendar browsers only propagate
+recurring events for several years beyond the start time."
+  :version "32.1"
+  :type 'integer)
+
+(defun di:-tz-info-sexp-p (_ sexp)
+  "Validate that SEXP gives time zone info like from `calendar-current-tim=
e-zone'."
+  (and (listp sexp)
+       (length=3D sexp 8)
+       (let ((utc-diff (nth 0 sexp))
+             (dst-offset (nth 1 sexp))
+             (std-zone (nth 2 sexp))
+             (dst-zone (nth 3 sexp))
+             (dst-starts (nth 4 sexp))
+             (dst-ends (nth 5 sexp))
+             (dst-starts-time (nth 6 sexp))
+             (dst-ends-time (nth 7 sexp)))
+         (and
+          (integerp utc-diff) (< (abs utc-diff) (* 60 24))
+          (integerp dst-offset) (< (abs utc-diff) (* 60 24))
+          (stringp std-zone)
+          (stringp dst-zone)
+          (or (and (listp dst-starts) (memq 'year (flatten-list dst-starts=
)))
+              (and (null dst-starts) (equal std-zone dst-zone)))
+          (or (and (listp dst-ends) (memq 'year (flatten-list dst-ends)))
+              (and (null dst-ends) (equal std-zone dst-zone)))
+          (or (and (integerp dst-starts-time) (< (abs dst-starts-time) (* =
60 24)))
+              (null dst-starts-time))
+          (or (and (integerp dst-ends-time) (< (abs dst-ends-time) (* 60 2=
4)))
+              (null dst-ends-time))))))
+
+(defcustom di:time-zone-export-strategy
+  'local
+  "Strategy to use for exporting clock times in diary files.
+
+The symbol `local' (the default) means to assume that times are in the
+time zone determined by `calendar-current-time-zone'. The time zone
+information returned by that function will be exported as an iCalendar
+VTIMEZONE component, and clock times in the diary file will be exported
+with a reference to that time zone definition.
+
+On some systems, `calendar-current-time-zone' cannot determine time zone
+information for the local time zone. In that case, you can set this
+variable to a list in the format returned by that function:
+
+ (UTC-DIFF DST-OFFSET STD-ZONE DST-ZONE
+  DST-STARTS DST-ENDS DST-STARTS-TIME DST-ENDS-TIME)
+
+This list describes the time zone you would like to use for export. See
+the docstring of `calendar-current-time-zone' for details. Times in the
+diary file will be exported like with `local' for this time zone.
+
+The other possible values for this variable avoid the need to include
+any time zone information in the exported iCalendar data:
+
+The symbol `to-utc' means to re-encode all exported times to UTC
+time. In this case, export will assume that times are in Emacs local
+time, and rely on `encode-time' and `decode-time' to convert them to UTC
+times.
+
+The symbol `floating' means to export clock times without any time
+zone identifier, which the iCalendar standard (RFC5545) calls
+\"floating\" times. RFC5545 specifies that floating times should be
+interpreted as local to whichever time zone the recipient of the
+iCalendar data is currently in (which might be different from your local
+time zone). You should only use this if that behavior makes sense for
+the events you are exporting."
+  :version "32.1"
+  :type
+  '(radio (const :tag "Use TZ from `calendar-current-time-zone'" local)
+          (const :tag "Convert local times to UTC" to-utc)
+          (const :tag "Use floating times" floating)
+          (sexp :tag "User-provided TZ information"
+                :match di:-tz-info-sexp-p
+                :type-error
+                "See `calendar-current-time-zone' for format"))
+  :link '(url-link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5"))
+
+(defcustom di:export-linewise
+  nil
+  "Export entries with multiple lines to distinct events.
+
+If this is non-nil, each line of a diary entry will be exported as a
+separate iCalendar event.
+
+If you write your diary entries in a one-entry-per-day style, with
+multiple events or appointments per day, you can use this variable to
+export these individual events to iCalendar format. For example, an
+entry like:
+
+2025-05-03
+  9AM Lab meeting
+    G=C3=BCnter to present on new assay
+  Start experiment A
+  12:30-1:30PM Lunch with Phil
+  16:00 Experiment A finishes; move to freezer
+
+will be exported as four events, each on 2025-05-03 but with different
+start times (except for the second event, \"Start experiment A\", which
+has no start time). An event line can be continued onto subsequent lines
+via additional indentation, as in the first event in this entry.
+
+If this variable is non-nil, each distinct event must begin on a
+continuation line of the entry (below the date); any text on the same
+line as the date is ignored.  A time specification can only appear at
+the beginning of each continuation line of the entry, immediately after
+the leading whitespace.
+
+If this variable is nil, each entry will be exported as exactly one
+event, and only a time specification immediately following the date will
+determine the start and end times for that event. Thus, in the example
+above, the exported event would have a start date but no start time or
+end time.  The times in the entry would be preserved as text in the
+event description."
+  :version "32.1"
+  :type '(radio (const :tag "Do not export linewise" nil)
+                (const :tag "Export linewise" t)))
+
+(defcustom di:other-properties-parser nil
+  "Function to parse additional iCalendar properties from diary entries.
+
+If you like to keep your diary entries in a particular format, you can
+set this to a function which parses that format to iCalendar properties
+during iCalendar export, so that other calendar applications can use
+them.
+
+The parsing function will be called with no arguments, with the current
+restriction set to the boundaries of a diary entry. If
+`diary-icalendar-export-linewise' is true, the restriction will
+correspond to a single event in a multi-line diary entry.
+
+The function should return a list of iCalendar property nodes, which
+will be incorporated into the `icalendar-vevent', `icalendar-vjournal',
+or `icalendar-vtodo' component node created from the current entry.  See
+the docstrings of those symbols for more information on the properties
+they can contain, and the `icalendar-make-property' macro for a simple
+way to create property nodes from values parsed from the entry.
+
+When the function is called, the variables `type' and `properties' will
+be dynamically bound. `type' is bound to the iCalendar type symbol (one
+of \\=3D'icalendar-vevent, \\=3D'icalendar-vjournal, or \\=3D'icalendar-vt=
odo)
+for the component being generated for the entry. `properties' is bound
+to the list of property nodes that `diary-icalendar-parse-entry' has
+already parsed from the entry and will be included in the exported
+component."
+  :version "32.1"
+  :type '(radio (const :tag "Do not parse additional properties" nil)
+                (function :tag "Parsing function")))
+
+;;; Export utility functions
+(defun di:parse-attendees-and-organizer ()
+  "Parse `icalendar-attendee' and `icalendar-organizer' nodes from entry.
+
+Searches the entry in the current restriction for addresses matching
+`diary-icalendar-address-regexp'. If an address is found on a
+line that also matches `diary-icalendar-organizer-regexp', it will be
+parsed as an `icalendar-organizer' node, or otherwise as an
+`icalendar-attendee'. Returns the list of nodes for all addresses found."
+  (goto-char (point-min))
+  (let (attendees organizer)
+    (while (re-search-forward di:address-regexp nil t)
+      (let ((addr (match-string 1))
+            (cn (match-string 2)))
+        (unless (string-match ":" addr) ; URI scheme already present
+          (setq addr (concat "mailto:" addr)))
+        (when cn
+          (setq cn (di:-nonempty cn)))
+        (if (string-match di:organizer-regexp
+                          (buffer-substring (line-beginning-position)
+                                            (line-end-position)))
+            (setq organizer
+                  (ical:make-property ical:organizer addr (ical:cnparam cn=
)))
+          (push (ical:make-property ical:attendee addr (ical:cnparam cn))
+                attendees))))
+    (if organizer
+        (cons organizer attendees)
+      attendees)))
+
+(defun di:parse-location ()
+  "Parse `icalendar-location' node from entry.
+
+Searches the entry in the current restriction for a location matching
+`diary-icalendar-location-regexp'. If a location is found, it will be
+parsed as an `icalendar-location' node. Returns a list containing just
+this node, or nil."
+  (goto-char (point-min))
+  (when (and di:location-regexp
+             (re-search-forward di:location-regexp nil t))
+    (ical:make-property ical:location (di:-nonempty (match-string 1)))))
+
+(defun di:parse-class ()
+  "Parse `icalendar-class' node from entry.
+
+Searches the entry in the current restriction for an access
+classification matching `diary-icalendar-class-regexp'. If a
+classification is found, it will be parsed as an `icalendar-class'
+node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:class-regexp
+             (re-search-forward di:class-regexp nil t))
+    (ical:make-property ical:class
+                        (upcase (string-trim (match-string 1))))))
+
+(defun di:parse-status ()
+  "Parse `icalendar-status' node from entry.
+
+Searches the entry in the current restriction for a status matching
+`diary-icalendar-status-regexp'. If a status is found, it will be parsed
+as an `icalendar-status' node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:status-regexp
+             (re-search-forward di:status-regexp nil t))
+    (ical:make-property ical:status
+                        (upcase (string-trim (match-string 1))))))
+
+(defun di:parse-url ()
+  "Parse `icalendar-url' node from entry.
+
+Searches the entry in the current restriction for an URL matching
+`diary-icalendar-url-regexp'. If an URL is found, it will be parsed as an
+`icalendar-url' node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:url-regexp
+             (re-search-forward di:url-regexp nil t))
+    (ical:make-property ical:url (di:-nonempty (match-string 1)))))
+
+(defun di:parse-uid ()
+  "Parse `icalendar-uid' node from entry.
+
+Searches the entry in the current restriction for a UID matching
+`diary-icalendar-uid-regexp'. If a UID is found, it will be parsed as an
+`icalendar-uid' node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:uid-regexp
+             (re-search-forward di:uid-regexp nil t))
+    (ical:make-property ical:uid (di:-nonempty (match-string 1)))))
+
+(defun di:parse-summary-and-description ()
+  "Parse summary and description nodes from current restriction.
+
+When `diary-icalendar-summary-regexp' or
+`diary-icalendar-description-regexp' are non-nil, and the entry matches
+them, the matches will be used to generate the summary and description.
+
+Otherwise, the first line of the entry (after any nonmarking symbol and
+date and time specification) is used as the summary.  The description is
+the full body of the entry, excluding the nonmarking symbol, date and
+time, but including the summary.
+
+Returns a list containing an `icalendar-summary' node and
+`icalendar-description' node, or nil."
+  (goto-char (point-min))
+  (let (summary description)
+    (when (and di:summary-regexp
+               (re-search-forward di:summary-regexp nil t))
+      (setq summary (match-string 1)))
+    (goto-char (point-min))
+    (when (and di:description-regexp
+               (re-search-forward di:description-regexp nil t))
+      (setq description (match-string 1)))
+    ;; Fall back to using first line and entire entry:
+    (goto-char (point-min))
+    (while (looking-at-p "[[:space:]]")
+      (forward-char))
+    (unless summary
+      (setq summary (buffer-substring (point) (line-end-position))))
+    (unless description
+      (setq description (buffer-substring (point) (point-max))))
+    ;; Remove any indentation on subsequent lines from description:
+    (setq description (replace-regexp-in-string "^[[:space:]]+" "" descrip=
tion))
+
+    (list (ical:make-property ical:summary summary)
+          (ical:make-property ical:description description))))
+
+(defun di:parse-entry-type ()
+  "Return the type symbol for the component type used to export an entry.
+
+Default is `icalendar-vevent'. If the entry is nonmarking and
+`diary-icalendar-export-nonmarking-as-vjournal' is non-nil,
+`icalendar-vjournal' is returned. If `diary-icalendar-todo-regexp' is
+non-nil and the entry matches it, `icalendar-vtodo' is returned.
+
+If the entry is nonmarking and `diary-icalendar-export-nonmarking-entries'
+is nil, nil is returned, indicating that the entry should not be
+exported."
+  (let (type)
+    (goto-char (point-min))
+    (unless (and (looking-at-p diary-nonmarking-symbol)
+                 (not di:export-nonmarking-entries))
+      (setq type 'ical:vevent)
+      (when (and (looking-at-p diary-nonmarking-symbol)
+                 di:export-nonmarking-as-vjournal)
+        (setq type 'ical:vjournal))
+      (when (and di:todo-regexp (re-search-forward di:todo-regexp nil t))
+        (setq type 'ical:vtodo)))
+    type))
+
+(defun di:parse-transparency (type)
+  "Return the iCalendar time transparency of an entry.
+
+TYPE should be the type symbol for the component to be exported, as
+returned by `diary-icalendar-parse-entry-type'. If the entry is
+non-marking (i.e., begins with `diary-nonmarking-symbol'), and it is to
+be exported as an `icalendar-vevent' (according to TYPE), then this
+function returns a list containing the appropriate `icalendar-transp'
+property node to mark the event as transparent, and moves the current
+restriction past the non-marking symbol.  Otherwise it returns nil."
+  (save-excursion
+    (goto-char (point-min))
+    (when (and (eq type 'ical:vevent)
+               (re-search-forward (concat "^" diary-nonmarking-symbol)
+                                  (line-end-position) t))
+      (narrow-to-region (point) (point-max))
+      (list
+       (ical:make-property ical:transp "TRANSPARENT")))))
+
+;; TODO: move to diary-lib?
+(defun di:parse-date-form ()
+  "Parse a date matching `diary-date-forms' on the current line.
+
+If a date is found, moves the current restriction past the end of the
+date and returns a list (MONTH DAY YEAR), where each value is an integer
+or `t' if the date is generic in that unit. Otherwise returns nil."
+  (goto-char (point-min))
+  (catch 'date
+    (let (date-regexp backup)
+      (dolist (date-sexp diary-date-forms)
+        (when (eq 'backup (car date-sexp))
+          (setq date-sexp (cdr date-sexp))
+          (setq backup t))
+        (setq date-regexp (di:date-form-to-regexp date-sexp))
+        (when backup (beginning-of-line))
+        (when (let ((case-fold-search t))
+                (re-search-forward date-regexp nil t))
+          (let ((year
+                 (let ((match (match-string 1)))
+                   (if (or (null match) (equal match "*"))
+                       t
+                     (if (and diary-abbreviated-year-flag (length=3D match=
 2))
+                         ;; from diary-lib.el:
+                         ;; Add 2-digit year to current century.
+                         ;; If more than 50 years in the future,
+                         ;; assume last century. If more than 50
+                         ;; years in the past, assume next century.
+                         (let* ((current-y
+                                 (calendar-extract-year (calendar-current-=
date)))
+                                (y (+ (string-to-number match)
+                                      ;; Current century, eg 2000.
+                                      (* 100 (/ current-y 100))))
+                                (offset (- y current-y)))
+                           (cond ((> offset 50)
+                                  (- y 100))
+                                 ((< offset -50)
+                                  (+ y 100))
+                                 (t y)))
+                       (string-to-number match)))))
+                (month
+                 (let ((month-num (match-string 2))
+                       (month-name (match-string 4)))
+                   (cond ((or (equal month-name "*") (equal month-num "*")=
) t)
+                         (month-num (string-to-number month-num))
+                         (month-name
+                          (alist-get
+                           (capitalize month-name)
+                           (calendar-make-alist
+                            calendar-month-name-array
+                            1 nil
+                            calendar-month-abbrev-array
+                            (mapcar (lambda (e) (format "%s." e))
+                                    calendar-month-abbrev-array))
+                           nil nil #'equal)))))
+                (day
+                 (let ((day-num (match-string 3))
+                       (day-name (match-string 5)))
+                   (cond
+                    ;; We don't care about the day name here, unless it
+                    ;; is "*", since it won't help us identify a day of
+                    ;; the month.  Weekly entries under a weekday name
+                    ;; are parsed by `di:parse-weekday-name', below.
+                    ((or (equal day-name "*") (equal day-num "*")) t)
+                    (day-num (string-to-number day-num))))))
+            (when (and year month day)
+              (narrow-to-region (match-end 0) (point-max))
+              (throw 'date (list month day year)))))))))
+
+(defun di:date-form-to-regexp (date-sexp)
+  "Convert DATE-SEXP to a regular expression.
+
+DATE-SEXP should be an S-expression in the variables `year', `month',
+`day', `monthname', and `dayname', as found e.g. in `diary-date-forms'.
+The returned regular expression matches dates of this form, including
+generic dates specified with \"*\", and abbreviated and long-form month
+and day names (based on `calendar-month-name-array' and
+`calendar-month-abbrev-array', and similarly for day names). The match
+groups contain the following data:
+
+Group 1: the 2-4 digit year, or a literal *
+Group 2: the 1-2 digit month number, or a literal *
+Group 3: the 1-2 digit day number, or a literal *
+Group 4: the (long-form or abbreviated) month name, or a literal *
+Group 5: the (long-form or abbreviated) day name, or a literal *"
+  (when (eq 'backup (car date-sexp))
+    (setq date-sexp (cdr date-sexp)))
+  (let ((month-names-regexp
+         (rx
+          (group-n 4
+            (or (regexp (diary-name-pattern calendar-month-name-array
+                                            calendar-month-abbrev-array))
+                "*"))))
+        (day-names-regexp
+         (rx
+          (group-n 5
+            (or (regexp (diary-name-pattern calendar-day-name-array
+                                            calendar-day-abbrev-array))
+                "*"))))
+          date-regexp)
+    (calendar-dlet
+        ((prefix (rx line-start
+                     (zero-or-one (regexp diary-nonmarking-symbol))))
+         (year (rx (group-n 1 (or (** 2 4 digit) "*"))))
+         (month (rx (group-n 2 (or (** 1 2 digit) "*"))))
+         (day (rx (group-n 3 (or (** 1 2 digit) "*"))))
+         (monthname month-names-regexp)
+         (dayname day-names-regexp))
+      (setq date-regexp (apply #'concat (cons prefix (mapcar #'eval date-s=
exp)))))
+  date-regexp))
+
+(defun di:parse-weekday-name ()
+  "Parse a weekday name on the current line.
+
+The day name must appear in `calendar-day-name-array' or
+`calendar-day-abbrev-array'. If a day name is found, move the current
+restriction past it, and return a day number between 0 (=3DSunday) and
+6 (=3DSaturday). Otherwise, return nil."
+  (goto-char (point-min))
+  (let ((day-names-regexp
+         (rx line-start
+             (zero-or-one (regexp diary-nonmarking-symbol))
+             (group-n 1
+               (regexp (diary-name-pattern calendar-day-name-array
+                                           calendar-day-abbrev-array))))))
+    (when (re-search-forward day-names-regexp (line-end-position) t)
+      (let ((day-name (capitalize (match-string 1))))
+        (narrow-to-region (match-end 0) (point-max))
+        (alist-get
+         day-name
+         (calendar-make-alist calendar-day-name-array 0 nil
+                              calendar-day-abbrev-array
+                              (mapcar (lambda (e) (format "%s." e))
+                                      calendar-day-abbrev-array))
+         nil nil #'equal)))))
+
+(defun di:weekday-to-recurrence (weekday)
+  "Convert WEEKDAY to a WEEKLY iCalendar recurrence rule.
+
+WEEKDAY must be an integer between 0 (=3DSunday) and 6 (=3DSaturday).
+Returns a list (START RRULE), with START being an `icalendar-dtstart'
+property and RRULE an `icalendar-rrule'."
+  (let ((dtstart (calendar-nth-named-day 1 weekday 1 ical:recurring-start-=
year))
+        (rrule `((FREQ WEEKLY)
+                 (BYDAY (,weekday)))))
+    (list (ical:make-property ical:dtstart dtstart)
+          (ical:make-property ical:rrule rrule))))
+
+;; TODO: give this value to diary-time-regexp?
+(defconst di:time-regexp
+  (rx-let ((hours (seq (opt (any "0-2")) (any "0-9")))
+           (minutes (seq (any "0-5") (any "0-9")))
+           (am/pm (seq (any "ap") "m"))) ;; am, pm
+    (rx
+     (group-n 1 ;; START
+       (group-n 11 hours) ;; start hour
+       (or "h" ;; 10h
+           (group-n 13 am/pm) ;; 10am
+           (zero-or-one
+            (or
+             ;; 10:00 or 10h00:
+             (seq (or ":" "h") (group-n 12 minutes) (opt (group-n 13 am/pm=
)))
+             ;; 10.00h or 10.00am: (a bare "10.00" is not matched)
+             (seq "." (group-n 12 minutes) (or "h" (group-n 13 am/pm)))))))
+     (zero-or-one
+      (one-or-more "-")
+      (group-n 2 ;; END
+        (group-n 21 hours) ;; end hour
+        (or "h" ;; 10h
+            (group-n 23 am/pm) ;; 10am
+            (zero-or-one
+             (or
+              ;; 10:00 or 10h00:
+              (seq (or ":" "h") (group-n 22 minutes) (opt (group-n 23 am/p=
m)))
+              ;; 10.00h or 10.00am:
+              (seq "." (group-n 22 minutes) (or "h" (group-n 23 am/pm)))))=
)))
+     (one-or-more space)))
+  "Regular expression to match diary appointment times.
+
+Accepted time formats look like e.g.:
+  9AM 9:00 09:00 9h 9h00 9.00am 9.00h
+  9PM 9:00pm 21:00 21h00 21.00pm 21.00h
+  9AM-1PM 09:00-13:00
+
+Group 1 matches the start time:
+  Group 11 matches the hours digits
+  Group 12 matches the minutes digits
+  Group 13 matches an AM/PM specification
+
+Group 2 matches the end time:
+  Group 21 matches the hours digits
+  Group 22 matches the minutes digits
+  Group 23 matches an AM/PM specification")
+
+(defun di:parse-time ()
+  "Parse diary time string in the current restriction.
+
+If a time specification is found, move the current restriction past it,
+and return a list (START END), where START and END are decoded-time
+values containing the hours and minutes slots parsed from the time
+specification. END may be nil if no end time was specified."
+  (goto-char (point-min))
+  (let ((regexp di:time-regexp)
+        (case-fold-search t))
+    (when di:export-linewise
+      ;; In this case, only look for a time following whitespace,
+      ;; at the beginning of a continuation line of the full entry:
+      (setq regexp (concat "^[[:space:]]+" di:time-regexp)))
+
+    (when (re-search-forward regexp (line-end-position) t)
+      (let* ((start-hh (string-to-number (match-string 11)))
+             (start-am/pm (when (match-string 13)
+                            (upcase (match-string 13))))
+             (start-hours (if (and (equal start-am/pm "PM") (< start-hh 12=
))
+                              (+ 12 start-hh)
+                            start-hh))
+             (start-minutes (string-to-number (or (match-string 12) "0")))
+             (start
+              (when (and start-hours start-minutes)
+                (make-decoded-time :hour start-hours
+                                   :minute start-minutes
+                                   :second 0)))
+             (end-hh (when (match-string 21)
+                       (string-to-number (match-string 21))))
+             (end-am/pm (when (match-string 23)
+                          (upcase (match-string 23))))
+             (end-hours (if (and end-hh (equal end-am/pm "PM") (< end-hh 1=
2))
+                            (+ 12 end-hh)
+                          end-hh))
+             (end-minutes (when end-hours
+                            (string-to-number (or (match-string 22) "0"))))
+             (end (when (and end-hours end-minutes)
+                    (make-decoded-time :hour end-hours
+                                       :minute end-minutes
+                                       :second 0))))
+        (narrow-to-region (match-end 0) (point-max))
+        ;; Return the times:
+        (list start end)))))
+
+(defun di:convert-time-via-strategy (dt &optional vtimezone)
+  "Reinterpret the local time DT per the time zone export strategy.
+
+The export strategy is determined by
+`diary-icalendar-time-zone-export-strategy', which see.
+
+DT may be an `icalendar-date' or `icalendar-date-time'. If it is a date,
+it is returned unmodified. If it is a date-time, depending on the
+strategy and any existing zone information in DT, it will be converted
+to a correct local, UTC, or floating time."
+  (cl-typecase dt
+    (ical:date dt)
+    (ical:date-time
+     (cond
+       ((or (eq 'local di:time-zone-export-strategy)
+            (listp di:time-zone-export-strategy))
+        (unless (ical:vtimezone-component-p vtimezone)
+          (di:signal-export-error
+           (format
+            "%s time export strategy requires a time zone definition;\n%s"
+            (if (eq 'local di:time-zone-export-strategy) "`local'" "list-b=
ased")
+            (concat
+             "check the value of `diary-icalendar-time-zone-export-strateg=
y'\n"
+             "and the output of `calendar-current-time-zone'"))))
+        (if (decoded-time-zone dt)
+            (icr:tz-decode-time (encode-time dt) vtimezone)
+          (icr:tz-set-zone dt vtimezone :error)))
+       ((eq 'to-utc di:time-zone-export-strategy)
+        (decode-time (encode-time dt) t))
+       ((eq 'floating di:time-zone-export-strategy)
+        (setf (decoded-time-zone dt) nil)
+        dt)))))
+
+(defun di:parse-sexp ()
+  "Parse a diary S-expression at the beginning of the current restriction.
+
+The S-expression must appear at the start of line, immediately after
+`diary-sexp-entry-symbol'.  If an S-expression is found, move the
+current restriction past it, and return the S-expression. Otherwise,
+return nil."
+  (goto-char (point-min))
+  (let ((regexp (rx line-start
+                    (regexp diary-sexp-entry-symbol))))
+    (when (re-search-forward regexp (line-end-position) t)
+      (let ((sexp (read (current-buffer))))
+        (narrow-to-region (point) (point-max))
+        sexp))))
+
+(defun di:anniversary-sexp-to-recurrence (sexp)
+  "Convert `diary-anniversary' SEXP to `icalendar-dtstart' and `icalendar-=
rrule'.
+Returns a pair of nodes (START RRULE)."
+  (let* ((d1 (nth 1 sexp))
+         (d2 (nth 2 sexp))
+         (d3 (nth 3 sexp))
+         (dtstart (diary-make-date d1 d2 (or d3 di:recurring-start-year)))
+         (rrule '((FREQ YEARLY))))
+    (list
+     (ical:make-property ical:dtstart dtstart (ical:valuetypeparam 'ical:d=
ate))
+     (ical:make-property ical:rrule rrule))))
+
+(defun di:block-sexp-to-recurrence (sexp)
+  "Convert `diary-block' SEXP to `icalendar-dtstart' and `icalendar-rrule'=
 nodes.
+Returns a pair of nodes (START RRULE)."
+  (let* ((dtstart (diary-make-date (nth 1 sexp) (nth 2 sexp) (nth 3 sexp)))
+         (end (diary-make-date (nth 4 sexp) (nth 5 sexp) (nth 6 sexp)))
+         (rrule `((FREQ DAILY)
+                  (UNTIL ,end))))
+    (list (ical:make-property ical:dtstart dtstart
+            (ical:valuetypeparam 'ical:date))
+          (ical:make-property ical:rrule rrule))))
+
+(defun di:time-block-sexp-to-start-end (sexp &optional vtimezone)
+  "Convert `diary-time-block' SEXP to `icalendar-dtstart' and `icalendar-d=
tend'.
+Returns a pair of nodes (START END).
+
+VTIMEZONE, if specified, should be an `icalendar-vtimezone'.  Times in
+SEXP will be reinterpreted as local to VTIMEZONE, as UTC, or as floating
+times according to `diary-icalendar-time-zone-export-strategy'."
+  (let* ((start (plist-get sexp :start))
+         (dtstart (di:convert-time-via-strategy start vtimezone))
+         (end (plist-get sexp :end))
+         (dtend (di:convert-time-via-strategy end vtimezone))
+         (tzid (ical:with-property-of vtimezone 'ical:tzid)))
+    (list (ical:make-property ical:dtstart dtstart (ical:tzidparam tzid))
+          (ical:make-property ical:dtend dtend (ical:tzidparam tzid)))))
+
+(defun di:cyclic-sexp-to-recurrence (sexp)
+  "Convert `diary-cyclic' SEXP to `icalendar-dtstart' and `icalendar-rrule=
'.
+Returns a pair of nodes (START RRULE)."
+  (let* ((ndays (nth 1 sexp))
+         (dtstart (diary-make-date (nth 2 sexp) (nth 3 sexp) (nth 4 sexp)))
+         (rrule `((FREQ DAILY)
+                  (INTERVAL ,ndays))))
+    (list
+     (ical:make-property ical:dtstart dtstart (ical:valuetypeparam 'ical:d=
ate))
+     (ical:make-property ical:rrule rrule))))
+
+(defun di:float-sexp-to-recurrence (sexp)
+  "Convert `diary-float' SEXP to `icalendar-dtstart' and `icalendar-rrule'.
+Returns a pair of nodes (START RRULE)."
+  (let* ((month-exp (nth 1 sexp))
+         (months (cond ((eq month-exp t) nil) ; don't add a BYMONTH clause
+                       ((integerp month-exp) (list month-exp))
+                       ((and (listp month-exp) (eq 'quote (car month-exp)))
+                        (eval month-exp nil)) ; unquote a literal list of =
ints
+                       (t month-exp)))
+         (_ (unless (seq-every-p #'integerp months)
+              (di:signal-export-error
+               (format "Malformed month(s) in `diary-float' S-expression:\=
n%s"
+                       sexp))))
+         (dow (nth 2 sexp))
+         (n (nth 3 sexp))
+         (day (or (nth 4 sexp)
+                  (if (< 0 n) 1
+                    'last))) ; =3D "last day of the month" for any month
+         ;; Calculate the offset within the month from day, n:
+         (offset
+          (cond ((eq day 'last) n)
+                ((and (< 0 day) (< 0 n))
+                 ;; In this case, to get the offset relative to
+                 ;; the start of the month, we need to add to n
+                 ;; the number of weeks in the month before day:
+                 ;; e.g. if day =3D 8, n =3D 2, then we are looking
+                 ;; for the second DOW after the 8th of the
+                 ;; month, which is the 3rd DOW after the 1st of
+                 ;; the month
+                 (+ n (/ (1- day) 7)))
+                ((and (< 0 day) (< n 0) (< day (* 7 (abs n))))
+                 ;; In this case, we need to cross into the
+                 ;; previous month and adjust the offset
+                 ;; accordingly to reflect the correct number of
+                 ;; weeks before the end of the month.
+                 ;; e.g. if day =3D 15, n =3D -3, we're looking for the
+                 ;; 3rd DOW before the 15th of the month,
+                 ;; which is the 1st DOW "before" the end of the
+                 ;; previous month (where "before" is inclusive,
+                 ;; e.g offset =3D -1 will work when DOW is the last
+                 ;; day of the month)
+                 (when months
+                   (setq months
+                         (sort
+                          :in-place t
+                          (mapcar
+                           (lambda (m) (if (eql m 1) 12 (1- m)))
+                           months))))
+                 (+ n (/ (1- day) 7)))))
+         (rrule (delq nil
+                      `((FREQ MONTHLY)
+                        ,(when months
+                           (list 'BYMONTH months))
+                        (BYDAY ((,dow . ,offset))))))
+         (dtstart
+          (calendar-nth-named-day n dow
+                                  (if months (apply #'min months) 1)
+                                  di:recurring-start-year
+                                  (unless (eq day 'last) day))))
+
+    ;; if at this point we have an offset which could put us outside the
+    ;; month boundaries, warn the user that this may not be supported:
+    (when (< 4 (abs offset))
+      (ical:warn
+       (format
+        "`diary-float' with large N=3D%d may not be supported on other sys=
tems" n)))
+
+    (list (ical:make-property ical:dtstart dtstart
+                              (ical:valuetypeparam 'ical:date))
+          (ical:make-property ical:rrule rrule))))
+
+(defun di:offset-sexp-to-nodes (sexp)
+  "Convert a `diary-offset' SEXP to a list of property nodes.
+
+SEXP must have the form (diary-offset INNER-SEXP NDAYS). The conversion
+is only possible for relatively simple cases of INNER-SEXP. The
+INNER-SEXP is first converted to a list of property nodes (see
+`diary-icalendar-export-sexp'), and then any date, time, period, and
+recurrence rule values in these nodes are adjusted NDAYS forward."
+  (let* ((arg1 (nth 1 sexp))
+         (inner-sexp (if (eq (car arg1) 'quote)
+                         (eval arg1 nil) ; unquote a quoted inner sexp
+                       arg1))
+         (nodes (di:sexp-to-nodes inner-sexp))
+         (ndays (nth 2 sexp)))
+    (dolist (node nodes)
+      (ical:with-property node nil
+       (cl-case (ical:ast-node-type node)
+         ((ical:dtstart ical:dtend)
+          (ical:ast-node-set-value
+           value-node
+           (ical:date/time-add value :day ndays)))
+         (ical:exdate
+          (dolist (val-node value-nodes)
+            (ical:with-node-value val-node nil
+              (ical:ast-node-set-value
+               val-node
+               (ical:date/time-add value :day ndays)))))
+         (ical:rdate
+          (dolist (val-node value-nodes)
+            (ical:ast-node-set-value
+              val-node
+              (ical:with-node-value val-node nil
+               (cl-typecase value
+                (ical:period
+                 (ical:make-period
+                  (ical:date/time-add (ical:period-start value) :day ndays)
+                  :end (when (ical:period--defined-end value)
+                         (ical:date/time-add
+                          (ical:period--defined-end value) :day ndays))
+                  :duration (ical:period-dur-value value)))
+                (t (ical:date/time-add value :day ndays)))))))
+         (ical:rrule
+          (let ((mdays (ical:recur-by* 'BYMONTHDAY value))
+                (ydays (ical:recur-by* 'BYYEARDAY value))
+                (dows (ical:recur-by* 'BYDAY value))
+                (bad-clause
+                 (cond ((ical:recur-by* 'BYSETPOS value) 'BYSETPOS)
+                       ((ical:recur-by* 'BYWEEKNO value) 'BYWEEKNO))))
+            ;; We can't reliably subtract days in the following cases, so =
bail:
+            (when (< 28 ndays)
+              (di:signal-export-error
+               (format "Cannot export `diary-offset' with large offset %d"=
 ndays)))
+            (when bad-clause
+              (di:signal-export-error
+               (format "Cannot export `diary-offset': inner SEXP %s contai=
ns %s"
+                       sexp bad-clause)))
+            (when (seq-some (lambda (md)
+                              (or (and (< 0 md) (< 28 (+ md ndays)))
+                                  (and (< md 0) (< 0 (+ md ndays)))))
+                            mdays)
+              (di:signal-export-error
+               (format "Cannot export `diary-offset': inner SEXP %s contai=
ns %s"
+                       inner-sexp
+                       "BYMONTHDAY clause that could cross month bounds")))
+            (when (seq-some (lambda (yd)
+                              (or (and (< 0 yd) (< 365 (+ yd ndays)))
+                                  (and (< yd 0) (< 0 (+ yd ndays)))))
+                            ydays)
+              (di:signal-export-error
+               (format "Cannot export `diary-offset': inner SEXP %s contai=
ns %s"
+                       inner-sexp
+                       "BYYEARDAY clause that could cross year bounds")))
+            ;; Adjust the rule's clauses to account for the offset:
+            (when mdays
+              (setf (alist-get 'BYMONTHDAY value)
+                    (list ; FIXME: make recur-values plists or dotted alis=
ts
+                     (mapcar (apply-partially #'+ ndays) mdays))))
+            (when ydays
+              (setf (alist-get 'BYYEARDAY value)
+                    (list
+                     (mapcar (apply-partially #'+ ndays) ydays))))
+            (when dows
+              (setf (alist-get 'BYDAY value)
+                    (list
+                     (mapcar
+                      (lambda (dow)
+                        (if (integerp dow)
+                            (mod (+ dow ndays) 7)
+                          (let* ((wkday (car dow))
+                                 (new-wkday (mod (+ wkday ndays) 7))
+                                 (new-offs
+                                  (if (< new-wkday wkday) ; FIXME: this is=
 not right, should be sth like < (abs (+ wkday ndays)) 7
+                                      ;; we moved into another week, so we=
 need
+                                      ;; to modify the offset within the m=
onth/year
+                                      ;; by the number of weeks moved:
+                                      (+ 1 (/ (+ wkday ndays) 7) (cdr dow))
+                                    ;; otherwise it stays the same:
+                                    (cdr dow))))
+                            (cons new-wkday new-offs))))
+                      dows)))))))))
+    ;; Return the modified nodes:
+    nodes))
+
+;; Converts a legacy value of `icalendar-export-alarms' to new format of
+;; `diary-icalendar-export-alarms':
+(defun di:-convert-legacy-alarm-options (alarm-options)
+  (let ((lead-time (car alarm-options))
+        (by-types (cadr alarm-options)))
+    (mapcar
+     (lambda (l)
+       (cl-case (car l)
+         (audio `(audio ,lead-time))
+         (display `(display ,lead-time "%s"))
+         (email `(email ,lead-time "%s" ,(cadr l)))))
+     by-types)))
+
+(defun di:add-valarms (component &optional vtimezone)
+  "Add VALARMs to COMPONENT according to `diary-icalendar-export-alarms'.
+
+COMPONENT should be an `icalendar-vevent' or `icalendar-vtodo'. The
+generated VALARM components will be added to this node's children.
+Returns the modified COMPONENT."
+  (let* ((alarm-options
+         (if (and (bound-and-true-p icalendar-export-alarms)
+                  (null di:export-alarms))
+             ;; For backward compatibility with icalendar.el:
+             (di:-convert-legacy-alarm-options icalendar-export-alarms)
+           di:export-alarms))
+         valarms)
+    (dolist (opts alarm-options)
+      (let* ((type (nth 0 opts))
+             (minutes (nth 1 opts)))
+        (cl-case type
+          (audio
+           (push (ical:make-valarm
+                  (ical:action "AUDIO")
+                  (ical:trigger (make-decoded-time :minute (* -1 minutes))=
))
+                 valarms))
+          (display
+           (ical:with-component component
+             ((ical:summary :value summary)
+              (ical:description :value description))
+             (let* ((displayed-summary
+                     (replace-regexp-in-string
+                      "%t" (number-to-string minutes)
+                      (replace-regexp-in-string
+                       "%s" summary
+                       (nth 2 opts)))))
+               (push (ical:make-valarm
+                      (ical:action "DISPLAY")
+                      (ical:trigger (make-decoded-time :minute (* -1 minut=
es)))
+                      (ical:summary displayed-summary)
+                      (ical:description description))
+                     valarms))))
+          (email
+           (ical:with-component component
+             ((ical:summary :value summary)
+              (ical:attendee :all entry-attendees))
+             (let* ((subject
+                     (replace-regexp-in-string
+                      "%t" (number-to-string minutes)
+                      (replace-regexp-in-string
+                       "%s" summary
+                       (nth 2 opts))))
+                    (index (ical:index-insert-tz (ical:make-index) vtimezo=
ne))
+                    (body
+                     (calendar-dlet ((as-alarm 'email))
+                       (di:format-entry component index)))
+                    (addresses (nth 3 opts))
+                    all-attendees)
+               (dolist (address addresses)
+                 (cond
+                  ((eq address 'from-entry)
+                   (setq all-attendees (append entry-attendees all-attende=
es)))
+                  ((stringp address)
+                   (push (ical:make-property ical:attendee
+                                             (concat "mailto:" address))
+                         all-attendees))))
+               (push (ical:make-valarm
+                      (ical:action "EMAIL")
+                      (ical:trigger (make-decoded-time :minute (* -1 minut=
es)))
+                      (ical:summary subject)
+                      (ical:description body)
+                      (@ all-attendees))
+                     valarms)))))))
+    (apply #'ical:ast-node-adopt-children component valarms)
+    component))
+
+(defun di:rrule-sexp-to-recurrence (sexp &optional vtimezone)
+  "Convert a `diary-rrule' SEXP to iCalendar recurrence rule properties.
+Returns a list containing at least `icalendar-dtstart' and
+`icalendar-rrule' nodes, and zero or more `icalendar-rdate',
+`icalendar-exdate', and `icalendar-duration' nodes.
+
+VTIMEZONE, if specified, should be an `icalendar-vtimezone'.  Times in
+SEXP will be reinterpreted as local to VTIMEZONE, as UTC, or as floating
+times according to `diary-icalendar-time-zone-export-strategy'."
+  (let* ((args (cdr sexp))
+         (start (plist-get args :start))
+         (dtstart (di:convert-time-via-strategy
+                   (if (eq 'quote (car start)) (eval start nil) start)
+                   vtimezone))
+         (rule (plist-get args :rule))
+         (rrule (if (eq 'quote (car rule)) (eval rule nil) rule))
+         (included (plist-get args :include))
+         (rdates (mapcar
+                  (lambda (dt) (di:convert-time-via-strategy dt vtimezone))
+                  (if (eq 'quote (car included)) (eval included nil) inclu=
ded)))
+         (excluded (plist-get args :exclude))
+         (exdates (mapcar
+                   (lambda (dt) (di:convert-time-via-strategy dt vtimezone=
))
+                   (if (eq 'quote (car excluded)) (eval excluded nil) excl=
uded)))
+         (duration (eval (plist-get args :duration)))
+         (dur-value
+          (if (eq 'quote (car duration)) (eval duration nil) duration))
+         (tzid (ical:with-property-of vtimezone 'ical:tzid))
+         nodes)
+    (push (ical:make-property ical:rrule rrule) nodes)
+    (push (ical:make-property ical:dtstart dtstart (ical:tzidparam tzid))
+          nodes)
+    (when rdates
+      (push (ical:make-property ical:rdate rdates (ical:tzidparam tzid))
+            nodes))
+    (when exdates
+      (push (ical:make-property ical:exdate exdates (ical:tzidparam tzid))
+            nodes))
+    (when duration
+      (push (ical:make-property ical:duration dur-value) nodes))
+    nodes))
+
+(defun di:dates-to-recurrence (months days years)
+  "Convert values representing one or more dates to iCalendar recurrences.
+
+MONTHS, DAYS, and YEARS should either be integers, lists of integers, or
+the symbol `t'.
+
+Returns a pair of nodes (START R), where START is an `icalendar-dtstart'
+node and R is an `icalendar-rrule' node or `icalendar-rdate' node (or
+nil, if MONTHS, DAYS and YEARS are all integers)."
+  (if (and (integerp months) (integerp days) (integerp years))
+      ;; just a regular date, without recurrence data:
+      (list
+       (ical:make-property ical:dtstart (list months days years))
+       nil)
+
+    (when (integerp months) (setq months (list months)))
+    (when (integerp days) (setq days (list days)))
+    (when (integerp years) (setq years (list years)))
+    (let (dtstart freq bymonth bymonthday rdates rdate-type)
+      (cond ((and (eq days t) (eq months t) (eq years t))
+             (setq freq 'DAILY
+                   dtstart (list 1 1 ical:recurring-start-year)))
+            ((and (eq months t) (eq years t))
+             (setq freq 'MONTHLY
+                   bymonthday days
+                   dtstart (list 1 (car days) ical:recurring-start-year)))
+            ((and (eq years t) (eq days t))
+             (setq freq 'DAILY
+                   bymonth months
+                   dtstart (list (apply #'min months)
+                                 1
+                                 ical:recurring-start-year)))
+            ((eq years t)
+             (setq freq 'YEARLY
+                   bymonth months
+                   bymonthday days
+                   dtstart
+                   (list (apply #'min months)
+                         (apply #'min days)
+                         di:recurring-start-year)))
+            ;; The remaining cases are not representable as RRULEs,
+            ;; because there is no BYYEAR clause. So we generate an RDATE
+            ;; covering each specified date.
+            ((and (eq months t) (eq days t))
+             ;; In this case we represent each of the specified years as a=
 period:
+             (setq rdate-type 'ical:period
+                   rdates
+                   (mapcar
+                    (lambda (y)
+                      (ical:make-period
+                       (ical:make-date-time :year y :month 1 :day 1
+                                            :hour 0 :minute 0 :second 0)
+                       :end
+                       (ical:make-date-time :year (1+ y) :month 1 :day 1
+                                            :hour 0 :minute 0 :second 0)))
+                    years)
+                   dtstart (ical:date-time-to-date
+                            (ical:period-start (car rdates)))))
+            (t
+             ;; Otherwise, represent each date individually:
+             (setq rdate-type 'ical:date
+                   rdates
+                   (mapcan
+                    (lambda (y)
+                      (mapcan
+                       (lambda (m)
+                         (mapcar
+                          (lambda (d) (list m d y))
+                          (if (listp days) days
+                            ;; days =3D t:
+                            (number-sequence 1 (calendar-last-day-of-month=
 m y)))))
+                       (if (listp months) months
+                         ;; months =3D t:
+                         (number-sequence 1 12))))
+                    years)
+                   ;; ensure dtstart is the earliest recurrence:
+                   dtstart (apply #'ical:date/time-min rdates)
+                   rdates (seq-remove (apply-partially #'equal dtstart) rd=
ates))))
+
+      ;; Return the pair of nodes (DTSTART RRULE) or (DTSTART RDATE):
+      (let* ((recur-value
+              (delq nil
+                    `((FREQ ,freq)
+                      ,(when bymonth (list 'BYMONTH bymonth))
+                      ,(when bymonthday (list 'BYMONTHDAY bymonthday)))))
+           (rrule-node (when freq (ical:make-property ical:rrule recur-val=
ue)))
+           (rdate-node (when rdates
+                         (ical:make-property ical:rdate rdates
+                                             (ical:valuetypeparam rdate-ty=
pe))))
+           (dtstart-node (ical:make-property ical:dtstart dtstart)))
+        (list dtstart-node (or rrule-node rdate-node))))))
+
+(defun di:date-sexp-to-recurrence (sexp)
+  "Convert a `diary-date' SEXP to an `icalendar-rrule' or `icalendar-rdate=
' node.
+Returns a pair of nodes (START R), where START is an `icalendar-dtstart'
+node and R is the RRULE or RDATE node."
+  (let* ((d1 (nth 1 sexp))
+         (d2 (nth 2 sexp))
+         (d3 (nth 3 sexp))
+         years months days)
+    (cl-case calendar-date-style
+      (iso (setq years (if (integerp d1) (list d1) d1)
+                 months (if (integerp d2) (list d2) d2)
+                 days (if (integerp d3) (list d3) d3)))
+      (american (setq months (if (integerp d1) (list d1) d1)
+                      days (if (integerp d2) (list d2) d2)
+                      years (if (integerp d3) (list d3) d3)))
+      (european (setq days (if (integerp d1) (list d1) d1)
+                      months (if (integerp d2) (list d2) d2)
+                      years (if (integerp d3) (list d3) d3))))
+
+    ;; unquote lists of integers read as quoted lists:
+    (when (and (listp months) (eq 'quote (car months)))
+      (setq months (eval months nil)))
+    (when (and (listp days) (eq 'quote (car days)))
+      (setq days (eval days nil)))
+    (when (and (listp years) (eq 'quote (car years)))
+      (setq years (eval years nil)))
+
+    ;; if at this point we don't have lists of integers or "t", user
+    ;; entered a malformed diary-date sexp:
+    (unless (or (eq months t) (seq-every-p #'integerp months))
+      (di:signal-export-error
+       (format "Malformed months in `diary-date' S-expression:\n%s" sexp)))
+    (unless (or (eq days t) (seq-every-p #'integerp days))
+      (di:signal-export-error
+       (format "Malformed days in `diary-date' S-expression:\n%s" sexp)))
+    (unless (or (eq years t) (seq-every-p #'integerp years))
+      (di:signal-export-error
+       (format "Malformed years in `diary-date' S-expression:\n%s" sexp)))
+
+    (di:dates-to-recurrence months days years)))
+
+(defun di:other-sexp-to-recurrence (sexp)
+  "Convert diary SEXP to `icalendar-rdate' by enumerating its recurrences.
+
+The enumeration starts on the current date and includes recurrences in
+the next `diary-icalendar-export-sexp-enumeration-days' days. Returns a
+list (START COMMENT RDATE), where START is an `icalendar-dtstart',
+COMMENT is an `icalendar-comment' containing SEXP, and RDATE is an
+`icalendar-rdate' containing the enumerated recurrences.  If there are
+no recurrences, (START COMMENT EXDATE) is returned, where START is the
+current date, and EXDATE is an `icalendar-exdate' excluding that start
+date as a recurrence. (This is because `icalendar-dtstart' is a required
+property and must be present even if the recurrence set is empty.)"
+  (let* ((today (calendar-absolute-from-gregorian (calendar-current-date)))
+         (end (+ today (1- di:export-sexp-enumeration-days)))
+        dtstart rdates exdates)
+    (dolist (absdate (number-sequence today end))
+      (calendar-dlet ((date (calendar-gregorian-from-absolute absdate)))
+        (when (eval sexp)
+          (push date rdates))))
+    (if rdates
+        (progn
+          (setq rdates (nreverse rdates))
+          (setq dtstart (car rdates)
+                rdates (cdr rdates)))
+      (ical:warn
+       (format "No recurrences in the next %d days: %s"
+               di:export-sexp-enumeration-days
+               sexp)
+       :severity 0)
+      ;; When there are no recurrences, we still need a DTSTART, but we
+      ;; can exclude it via an EXDATE:
+      (setq dtstart (calendar-current-date)
+            exdates (list dtstart)))
+
+    (append
+     (list
+      (ical:make-property ical:dtstart dtstart
+                          (ical:valuetypeparam 'ical:date))
+      ;; TODO: should we maybe use an X-name property for this?
+      (ical:make-property ical:comment (format "%s" sexp)))
+     (if rdates
+         (list
+          (ical:make-property ical:rdate rdates
+                              (ical:valuetypeparam 'ical:date)))
+       (list
+        (ical:make-property ical:exdate exdates
+                            (ical:valuetypeparam 'ical:date)))))))
+
+(defun di:sexp-to-nodes (sexp &optional vtimezone)
+  "Convert a diary S-expression SEXP to a list of iCalendar property nodes.
+
+The fully supported S-expressions are:
+`diary-anniversary'
+`diary-block'
+`diary-cyclic'
+`diary-date'
+`diary-float'
+`diary-remind'
+`diary-rrule'
+`diary-time-block'
+
+There is partial support for `diary-offset' S-expressions; see
+`diary-icalendar-offset-to-nodes'.
+
+Other S-expressions are only supported via enumeration. Their
+recurrences are enumerated for
+`diary-icalendar-export-sexp-enumeration-days' starting from the current
+date; see `diary-icalendar-other-sexp-to-recurrence'.  If
+`diary-icalendar-export-sexp-enumerate-all' is non-nil, all
+S-expressions are enumerated rather than converted to recurrence rules.
+
+VTIMEZONE, if specified, should be an `icalendar-vtimezone'.  Times in
+SEXP will be reinterpreted as local to VTIMEZONE, as UTC, or as floating
+times according to `diary-icalendar-time-zone-export-strategy'."
+  (if di:export-sexp-enumerate-all ;; see Bug#7911 for motivation
+      (di:other-sexp-to-recurrence sexp)
+    (cl-case (car sexp)
+      (diary-anniversary (di:anniversary-sexp-to-recurrence sexp))
+      (diary-block (di:block-sexp-to-recurrence sexp))
+      (diary-cyclic (di:cyclic-sexp-to-recurrence sexp))
+      (diary-date (di:date-sexp-to-recurrence sexp))
+      (diary-float (di:float-sexp-to-recurrence sexp))
+      (diary-offset (di:offset-sexp-to-nodes sexp))
+      (diary-rrule (di:rrule-sexp-to-recurrence sexp vtimezone))
+      (diary-time-block (di:time-block-sexp-to-start-end sexp vtimezone))
+      ;; For `diary-remind' we only handle the inner sexp:
+      (diary-remind (di:sexp-to-nodes (nth 1 sexp) vtimezone))
+      (t (di:other-sexp-to-recurrence sexp)))))
+
+;;; Time zone handling during export:
+
+(defconst di:-tz-warning
+  "This time zone information was inferred from incomplete system informat=
ion; it should be correct for the date-times within this calendar file refe=
rencing this zone, but you should not rely on it more widely.")
+
+(defconst di:-emacs-local-tzid
+  "Emacs_Local_")
+
+(defun di:current-tz-to-vtimezone (&optional tz tzid start-year)
+  "Convert TZ to an `icalendar-vtimezone'.
+
+TZ defaults to the output of `calendar-current-time-zone'; if specified,
+it should be a list of the same form as that function returns.
+
+TZID, if specified, should be a string to identify this time zone; it
+defaults to `diary-icalendar--emacs-local-tzid' plus the name of the
+standard observance according to `calendar-current-time-zone'.
+
+START-YEAR, if specified, should be an integer giving the year in which
+to start the observances in the time zone. It defaults to 1970."
+  (when (and tz (not (di:-tz-info-sexp-p nil tz)))
+    (di:signal-export-error
+     (format "Invalid time zone data: %s.\n%s." tz
+             "Check the value of `diary-icalendar-time-zone-export-strateg=
y'")))
+  (let* ((tzdata (or tz (calendar-current-time-zone)))
+         (std-offset (* 60 (nth 0 tzdata)))
+         (dst-offset (+ std-offset
+                        (* 60 (nth 1 tzdata))))
+         (std-name (nth 2 tzdata))
+         (dst-name (nth 3 tzdata))
+         (dst-starts (nth 4 tzdata))
+         (dst-ends (nth 5 tzdata))
+         (dst-start-minutes (nth 6 tzdata))
+         (dst-end-minutes (nth 7 tzdata)))
+
+    (unless (and std-offset
+                 (or (equal std-name dst-name)
+                     (and dst-starts dst-ends dst-start-minutes dst-end-mi=
nutes)))
+      (di:signal-export-error
+       "Insufficient time zone information to create VTIMEZONE"))
+
+    (if (equal std-name dst-name)
+        ;; Local time zone doesn't use DST:
+        (ical:make-vtimezone
+         (ical:tzid (or tzid (concat di:-emacs-local-tzid std-name)))
+         (ical:make-standard
+          (ical:tzname std-name)
+          (ical:dtstart (ical:make-date-time :year (or start-year 1970)
+                                             :month 1 :day 1
+                                             :hour 0 :minute 0 :second 0))
+          (ical:tzoffsetfrom std-offset)
+          (ical:tzoffsetto std-offset)
+          (ical:comment di:-tz-warning)))
+
+      ;; Otherwise we can provide both STANDARD and DAYLIGHT subcomponents:
+      (let* ((std->dst-rule
+              (if (eq (car dst-starts) 'calendar-nth-named-day)
+                  `((FREQ YEARLY)
+                    (BYMONTH (,(nth 3 dst-starts)))
+                    (BYDAY (,(cons (nth 2 dst-starts)
+                                   (nth 1 dst-starts)))))
+                ;; The only other rules that `calendar-current-time-zone'
+                ;; can return are based on the Persian calendar, which we
+                ;; cannot express in an `icalendar-recur' value, at least
+                ;; pending an implementation of RFC 7529
+                (di:signal-export-error
+                 (format "Unable to export DST rule for current time zone:=
 %s"
+                         dst-starts))))
+             (dst-start-date (calendar-dlet ((year (or start-year 1970)))
+                               (eval dst-starts)))
+             (dst-start
+              (ical:date-to-date-time dst-start-date
+                                      :hour (/ dst-start-minutes 60)
+                                      :minute (mod dst-start-minutes 60)
+                                      :second 0))
+             (dst->std-rule
+              (if (eq (car dst-ends) 'calendar-nth-named-day)
+                  `((FREQ YEARLY)
+                    (BYMONTH (,(nth 3 dst-ends)))
+                    (BYDAY (,(cons (nth 2 dst-ends)
+                                   (nth 1 dst-ends)))))
+                (di:signal-export-error
+                 (format "Unable to export DST rule for current time zone:=
 %s"
+                         dst-ends))))
+             (std-start-date (calendar-dlet ((year (1- (or start-year 1970=
))))
+                               (eval dst-ends)))
+             (std-start
+              (ical:date-to-date-time std-start-date
+                                      :hour (/ dst-end-minutes 60)
+                                      :minute (mod dst-end-minutes 60)
+                                      :second 0)))
+
+      (ical:make-vtimezone
+       (ical:tzid (or tzid (concat di:-emacs-local-tzid std-name)))
+       (ical:make-standard
+        (ical:tzname std-name)
+        (ical:dtstart std-start)
+        (ical:rrule dst->std-rule)
+        (ical:tzoffsetfrom dst-offset)
+        (ical:tzoffsetto std-offset)
+        (ical:comment di:-tz-warning))
+       (ical:make-daylight
+        (ical:tzname dst-name)
+        (ical:dtstart dst-start)
+        (ical:rrule std->dst-rule)
+        (ical:tzoffsetfrom std-offset)
+        (ical:tzoffsetto dst-offset)
+        (ical:comment di:-tz-warning)))))))
+
+;;; Parsing complete diary entries:
+
+(defun di:parse-entry-linewise (begin end vtimezone type date-nodes)
+  "Convert the entry between BEGIN and END linewise to iCalendar component=
s.
+
+\"Linewise\" means each line of a diary entry will be exported as a
+distinct event; see `diary-icalendar-export-linewise'.
+Returns a list of component nodes representing the events.
+
+VTIMEZONE must be the `icalendar-vtimezone' in which times in the entry
+appear (or nil). TYPE and DATE-NODES must contain the iCalendar component
+type and date information parsed from the beginning of the entry which
+apply to all of the events. These arguments are passed on in recursive
+calls to `diary-icalendar-parse-entry'."
+  (save-restriction
+    (narrow-to-region begin end)
+    (goto-char (point-min))
+    (let ((subentry-regexp
+           ;; match to the end of lines which have indentation equal to
+           ;; or greater than the current one:
+           (rx line-start
+               (group-n 1 (+ space))
+               (* not-newline)
+               (* "\n" (backref 1) (+ space) (* not-newline))))
+          components)
+
+      (while (re-search-forward subentry-regexp end t)
+        (let ((next-pos (1+ (match-end 0))))
+          (setq components
+                (append
+                 (di:parse-entry (match-beginning 0) (match-end 0)
+                                  vtimezone type date-nodes)
+                 components))
+          (goto-char next-pos)))
+      components)))
+
+(defun di:parse-entry (begin end &optional vtimezone type date-nodes)
+  "Convert the entry between BEGIN and END to a list of iCalendar componen=
ts.
+
+The region between BEGIN and END will be parsed for a date, time,
+summary, description, attendees, and UID. This information will be
+combined into an `icalendar-vevent' (or `icalendar-vjournal' or
+`icalendar-vtodo', depending on the values of
+`diary-icalendar-export-nonmarking-entries',
+`diary-icalendar-export-nonmarking-as-vjournal' and
+`diary-icalendar-todo-regexp') and that component will be returned
+wrapped in a list. Returns nil if the entry should not be exported
+according to `diary-icalendar-export-nonmarking-entries'.
+
+If `diary-icalendar-export-linewise' is non-nil, then a top-level call
+to this function will return a list of several such components. (Thus,
+the function always returns a list of components.)
+
+VTIMEZONE, if specified, should be the `icalendar-vtimezone' in which
+times in the entry appear. If
+`diary-icalendar-time-zone-export-strategy' is not either \\=3D'to-utc or
+\\=3D'floating, VTIMEZONE must be provided.
+
+DATE-NODES and TYPE should be nil in a top-level call; they are used in
+recursive calls to this function made by
+`diary-icalendar-parse-entry-linewise'."
+  (save-restriction
+    (narrow-to-region begin end)
+    (goto-char (point-min))
+    (let (sexp dateform weekday tzid transparency all-props should-recurse)
+      (setq should-recurse (and di:export-linewise (not date-nodes) (not t=
ype)))
+      (when (ical:vtimezone-component-p vtimezone)
+        (setq tzid (ical:with-property-of vtimezone 'ical:tzid)))
+      (unless date-nodes
+        ;; If we don't already have date information, we are in a
+        ;; top-level call and need to collect the date and type
+        ;; information from the start of the entry:
+        (setq type (di:parse-entry-type))
+        ;; N.B. the following four parsing functions successively
+        ;; narrow the current restriction past anything they parse:
+        (setq transparency (di:parse-transparency type))
+        (setq sexp (di:parse-sexp))
+        (setq dateform (di:parse-date-form))
+        (setq weekday (di:parse-weekday-name))
+        (setq date-nodes
+              (append
+               transparency
+               (when sexp (di:sexp-to-nodes sexp vtimezone))
+               (when dateform
+                 (apply #'di:dates-to-recurrence dateform))
+               (when (and weekday (not dateform))
+                 (di:weekday-to-recurrence weekday)))))
+
+      (when type ; nil means entry should not be exported
+        (if should-recurse
+            ;; If we are in a top level call and should export linewise,
+            ;; do that recursively now:
+            (di:parse-entry-linewise (point) end vtimezone type date-nodes)
+
+          ;; Otherwise, we are either in a recursive call with a
+          ;; narrower restriction, or don't need to export linewise. In
+          ;; both cases, we gather the remaining data from the current
+          ;; restriction and combine everything into a component node:
+          (let* ((times (di:parse-time))
+                 (start-time (when times (car times)))
+                 (end-time (when times (cadr times))))
+            ;; Combine clock time values in the current restriction with
+            ;; date information parsed at the top level. Doing this here
+            ;; allows us to combine a different time on each line of an
+            ;; entry exported linewise with the date information for the
+            ;; whole entry:
+            (dolist (node date-nodes)
+              (ical:with-property node nil
+                (cond
+                 ((and (ical:dtstart-property-p node)
+                       (eq 'ical:date value-type)
+                       start-time)
+                  (let ((dtstart
+                         (di:convert-time-via-strategy
+                          (ical:date-time-variant
+                           start-time
+                           :year (calendar-extract-year value)
+                           :month (calendar-extract-month value)
+                           :day (calendar-extract-day value))
+                          vtimezone)))
+                    (push (ical:make-property ical:dtstart dtstart
+                            (ical:tzidparam tzid))
+                          all-props)
+                    (when end-time
+                      ;; an end time parsed from a time specification
+                      ;; in the entry is always on the same day as
+                      ;; DTSTART.
+                      (let* ((dtend
+                              (di:convert-time-via-strategy
+                               (ical:date-time-variant
+                                end-time
+                                :year (calendar-extract-year value)
+                                :month (calendar-extract-month value)
+                                :day (calendar-extract-day value))
+                               vtimezone))
+                             (is-recurring
+                              (seq-find
+                               (lambda (n) (or (ical:rrule-property-p n)
+                                               (ical:rdate-property-p n)))
+                               date-nodes)))
+                        (if is-recurring
+                            ;; If the entry is recurring, we interpret
+                            ;; the end time as giving us a duration for all
+                            ;; recurrences:
+                            (progn
+                              (when (seq-find #'ical:duration-property-p
+                                              date-nodes)
+                                (ical:warn
+                                 (concat "Parsed both duration and end tim=
e; "
+                                         "ignoring end time specification")
+                                 :buffer (current-buffer)
+                                 :position (point)))
+                              (push (ical:make-property ical:duration
+                                      (ical:duration-between dtstart dtend=
))
+                                    all-props))
+                          ;; Otherwise we make a normal DTEND:
+                          (push (ical:make-property ical:dtend dtend)
+                                all-props))))))
+
+                 ((and (ical:rdate-property-p node)
+                       start-time
+                       (seq-every-p (apply-partially #'eq 'ical:date)
+                                    value-types))
+                  (let ((rdates
+                         (mapcar
+                          (lambda (dt)
+                            (if end-time
+                                (ical:make-period
+                                 (di:convert-time-via-strategy
+                                  (ical:date-time-variant
+                                   start-time
+                                   :year (calendar-extract-year dt)
+                                   :month (calendar-extract-month dt)
+                                   :day (calendar-extract-day dt))
+                                  vtimezone)
+                                 :end
+                                 (di:convert-time-via-strategy
+                                  (ical:date-time-variant
+                                   end-time
+                                   :year (calendar-extract-year dt)
+                                   :month (calendar-extract-month dt)
+                                   :day (calendar-extract-day dt))
+                                  vtimezone))
+                              (di:convert-time-via-strategy
+                               (ical:date-time-variant
+                                start-time
+                                :year (calendar-extract-year dt)
+                                :month (calendar-extract-month dt)
+                                :day (calendar-extract-day dt))
+                               vtimezone)))
+                          values)))
+                    (push (ical:make-property ical:rdate rdates
+                                              (ical:tzidparam tzid))
+                          all-props)))
+
+                   ;; preserve any other node read from date, e.g. RRULE, =
as is:
+                   (node (push node all-props))))))
+
+          ;; In a VTODO, entry date must become the DUE date; either
+          ;; DTEND becomes DUE, or if there is no DTEND, then DTSTART:
+          (when (eq type 'ical:vtodo)
+            (unless (catch 'found-dtend
+                      (dolist (node all-props)
+                        (when (ical:dtend-property-p node)
+                          (ical:ast-node-set-type node 'ical:due)
+                          (throw 'found-dtend t))))
+              (dolist (node all-props)
+                (when (ical:dtstart-property-p node)
+                  (ical:ast-node-set-type node 'ical:due)))))
+
+          ;; Collect the remaining properties:
+          (setq all-props (append (di:parse-summary-and-description) all-p=
rops))
+          (setq all-props (append (di:parse-attendees-and-organizer) all-p=
rops))
+          (push (ical:make-property ical:dtstamp (decode-time nil t)) all-=
props)
+          (let ((class (di:parse-class))
+                (location (di:parse-location))
+                (status (di:parse-status))
+                (url (di:parse-url)))
+            (when class (push class all-props))
+            (when location (push location all-props))
+            (when status (push status all-props))
+            (when url (push url all-props)))
+          (push (or (di:parse-uid)
+                     (ical:make-property ical:uid
+                      (ical:make-uid all-props)))
+                all-props)
+
+          ;; Allow users to add to the properties parsed:
+          (when (functionp di:other-properties-parser)
+            (calendar-dlet
+                ((type type)
+                 (properties all-props))
+              (let ((others (funcall di:other-properties-parser)))
+                (dolist (p others)
+                  (condition-case nil
+                      (push (ical:ast-node-valid-p p)
+                            all-props)
+                    (ical:validation-error
+                     (ical:warn
+                      (format "`%s' returned invalid `%s' property; ignori=
ng"
+                              di:other-properties-parser
+                              (ical:ast-node-type p))
+                      :buffer (current-buffer)
+                      :position (point))))))))
+
+          ;; Construct, validate and return a component of the appropriate=
 type:
+          (let ((component
+                 (ical:ast-node-valid-p
+                  (ical:make-ast-node type nil all-props))))
+
+            ;; Add alarms per `diary-icalendar-export-alarms', except for
+            ;; in VJOURNAL, where alarms are not allowed:
+            ;; TODO: should we also add alarms for `diary-remind' sexps?
+            (when (not (eq type 'ical:vjournal))
+              (di:add-valarms component vtimezone))
+
+            ;; Return the component wrapped in a list (for type consistenc=
y):
+            (list component)))))))
+
+(defconst di:entry-regexp
+  (rx line-start
+      (group-n 1 ; first line of entry
+        (or (group-n 2 (regexp diary-nonmarking-symbol))
+            (not (any "\t\n #")))
+        (one-or-more not-newline))
+      (group-n 3 ; continuation lines of entry
+        (zero-or-more "\n" (any space) (zero-or-more not-newline))))
+  "Regular expression to match a full diary entry.
+
+Group 1 matches the first line of the entry. Group 2 contains
+`diary-nonmarking-symbol', if it was present at the start of the first
+line. Group 3 contains any continuation lines of the entry.")
+
+;;;###autoload
+(defun di:export-region (begin end filename &optional erase)
+  "Export diary entries between BEGIN and END to iCalendar format in FILEN=
AME.
+
+If FILENAME exists and is not empty, this function asks whether to erase
+its contents first. If ERASE is non-nil, the contents of FILENAME will
+always be erased without asking. Otherwise the exported data will be
+appended to the end of FILENAME.
+
+The export depends on a number of user-customizable variables. Before
+running this command for the first time, you may especially wish to
+check the values of:
+`diary-file'
+`diary-date-forms'
+`calendar-date-style'
+as well as variables in the customization group `diary-icalendar-export'."
+  (interactive (list (region-beginning)
+                     (region-end)
+                     (expand-file-name
+                      (read-file-name "iCalendar file: "))))
+
+  (ical:init-error-buffer)
+  (let (output-buffer local-tz components vcalendar)
+    (when (and (null erase)
+               (file-exists-p filename)
+               (< 0 (file-attribute-size (file-attributes filename)))
+               (y-or-n-p (format "Delete existing contents of %s?" filenam=
e)))
+      (setq erase t))
+    (ical:condition-case err
+      (setq output-buffer (find-file-noselect filename)))
+    (when output-buffer
+      (save-excursion
+        (save-restriction
+          (narrow-to-region begin end)
+          (goto-char (point-min))
+          (cond ((eq 'local di:time-zone-export-strategy)
+                 (setq local-tz (di:current-tz-to-vtimezone)))
+                ((listp di:time-zone-export-strategy)
+                 (setq local-tz (di:current-tz-to-vtimezone
+                                 di:time-zone-export-strategy))))
+          (while (re-search-forward di:entry-regexp nil t)
+            (let ((entry-start (match-beginning 0))
+                  (entry-end (match-end 0))
+                  (first-line (match-string 1)))
+              (ical:condition-case err-data
+                 (setq components
+                       (append (di:parse-entry entry-start entry-end local=
-tz)
+                               components))
+                (ical:export-error
+                 (ical:warn
+                  (concat
+                   (format "Unable to export entry \"%s...\"; skipping" fi=
rst-line)
+                   "\nError was:\n"
+                   (plist-get err-data :message))
+                  :position entry-start
+                  :buffer (current-buffer))))
+              (goto-char (1+ entry-end))))
+          (setq components (nreverse components))
+          (when local-tz (push local-tz components))
+          (ical:condition-case err-data
+             (setq vcalendar (ical:make-vcalendar (@ components))))
+
+          (when vcalendar
+            (with-current-buffer output-buffer
+              (when erase (erase-buffer))
+              (goto-char (point-max)) ; append, if user chose not to erase
+              (unless (bolp) (insert "\n"))
+              (ical:condition-case err-data
+                 (insert (ical:print-calendar-node vcalendar)))
+              (let ((coding-system-for-write 'utf-8-dos)) ;; TODO
+                (save-buffer))))))))
+
+  (message
+   (if (ical:errors-p)
+       (format "iCalendar export completed with errors; see buffer %s"
+               (buffer-name (ical:error-buffer)))
+     "iCalendar export completed successfully.")))
+
+;;;###autoload
+(defun di:export-file (diary-filename filename &optional erase)
+  "Export DIARY-FILENAME to iCalendar format in FILENAME.
+
+The diary entries in DIARY-FILENAME will be exported to iCalendar format
+and the resulting calendar will be saved to FILENAME.
+
+If FILENAME exists and is not empty, this function asks whether to erase
+its contents first. If ERASE is non-nil, the contents of FILENAME will
+always be erased without asking. Otherwise the exported data will be
+appended to the end of FILENAME.
+
+The export depends on a number of user-customizable variables. Before
+running this command for the first time, you may especially wish to
+check the values of:
+`diary-file'
+`diary-date-forms'
+`calendar-date-style'
+as well as variables in the customization group `diary-icalendar-export'."
+  (interactive (list
+                (read-file-name "Diary file: "
+                                (when diary-file (file-name-directory diar=
y-file))
+                                (cons diary-file diary-included-files)
+                                'confirm)
+                (read-file-name "iCalendar file: "
+                                (when diary-file (file-name-directory diar=
y-file))
+                                (when diary-file
+                                  (concat
+                                   (file-name-sans-extension diary-file)
+                                   ".ics")))))
+  (when (and (null erase)
+             (file-exists-p filename)
+             (< 0 (file-attribute-size (file-attributes filename)))
+             (y-or-n-p (format "Delete existing contents of %s?" filename)=
))
+      (setq erase t))
+  (with-current-buffer (find-file-noselect diary-filename)
+    (di:export-region (point-min) (point-max) filename erase)))
+
+
+;; Display in Diary
+
+;;; Functions implementing diary-icalendar sexps.
+;;; TODO: move these to diary-lib.el?
+
+;; To be called from diary-sexp-entry, where DATE, ENTRY are bound.
+(cl-defun diary-time-block (&key start end)
+  "Diary S-expression for time blocks.
+
+Entry applies if the queried date occurs between START and END,
+inclusive. START and END may be `icalendar-date' or
+`icalendar-date-time' values."
+  (with-no-warnings (defvar date) (defvar entry))
+  (when (and (ical:date/time<=3D start date) (ical:date/time<=3D date end))
+    entry))
+
+;; To be called from diary-sexp-entry, where DATE, ENTRY are bound.
+(cl-defun diary-rrule (&key rule start duration include exclude)
+  "Diary S-expression for iCalendar recurrence rules.
+
+Entry applies if the queried date matches the recurrence rule.
+
+The keyword arguments RULE, START, INCLUDE and EXCLUDE should contain
+the recurrence data from an iCalendar component. RULE should be an
+`icalendar-recur' value, START an `icalendar-date' or
+`icalendar-date-time', DURATION an `icalendar-dur-value', and INCLUDE
+and EXCLUDE should be lists of `icalendar-date' or `icalendar-date-time'
+values (of the same type as START)."
+  (with-no-warnings (defvar date) (defvar entry))
+  (when (ical:date<=3D start date)
+    (let* ((vevent (ical:make-vevent
+                    (ical:rrule rule)
+                    (ical:dtstart start)
+                    (ical:rdate include)
+                    (ical:exdate exclude)))
+           (interval (icr:find-interval date start rule)))
+      (cl-typecase start
+        (ical:date
+         (when (member date (icr:recurrences-in-interval interval vevent))
+           entry))
+        (ical:date-time
+         ;; TODO. If start is a date-time, it was probably imported from
+         ;; an iCalendar file, but in order to calculate recurrences, we
+         ;; really need all the time zone information from that file,
+         ;; not just the rule, start, include and exclude. But encoding
+         ;; all that tz info in a diary s-exp is cumbersome and ugly and
+         ;; probably not worth the trouble. Since this is the diary, we
+         ;; assume that all we really care about here is whether there
+         ;; are recurrences on a particular day. Thus we convert
+         ;; HOURLY/MINUTELY/SECONDLY rules to a DAILY rule, and all
+         ;; values to plain dates. This keeps things simple (and
+         ;; hopefully quicker) but means that information gets lost.  I
+         ;; hope this can be changed to do things right at some point,
+         ;; but that will require first adding more robust time zone
+         ;; support to the diary somehow -- perhaps via #included
+         ;; iCalendar files?
+         (let* ((date-rule (copy-sequence rule))
+                (start-date (ical:date-time-to-date start))
+                (include-dates (mapcar #'ical:date-time-to-date include))
+                (exclude-dates (mapcar #'ical:date-time-to-date exclude))
+                ;; Preserve the clock times in the entry:
+                (entry-time
+                 (if duration
+                     (di:format-time-range
+                      start
+                      (ical:date/time-add-duration start duration))
+                   (di:format-time-as-local start)))
+                (date-entry (concat entry-time " " entry)))
+           (when (memq (ical:recur-freq date-rule) '(HOURLY MINUTELY SECON=
DLY))
+             (setf (alist-get 'FREQ date-rule) 'DAILY)
+             (setf (alist-get 'INTERVAL date-rule) 1)
+             (setf (alist-get 'BYHOUR date-rule nil t) nil)
+             (setf (alist-get 'BYMINUTE date-rule nil t) nil)
+             (setf (alist-get 'BYSECOND date-rule nil t) nil))
+           ;; Recurse with the plain date values:
+           (calendar-dlet
+               ((date date)
+                (entry date-entry))
+             (diary-rrule :rule date-rule :start start-date
+                          :include include-dates :exclude exclude-dates)))=
)))))
+
+(defun di:display-entries ()
+  "Display iCalendar data from a file in the diary.
+
+This function allows you to display the data in an iCalendar-formatted
+file in the diary without importing it. The data is read directly from
+the currently value of `diary-file'. If this file contains iCalendar
+data, any events, tasks, and journal entries in the file which occur on
+`original-date' and `number' of days after are formatted for display in
+the diary. (All three of these variables are dynamically bound by the
+diary when this function is called.)
+
+To use this function, add an '#include \"FILE\"' entry in your diary
+file for each iCalendar file you want to display (see
+`diary-include-string').  Then add `diary-include-other-diary-files' to
+`diary-list-entries-hook'. (Consider also adding `diary-sort-entries' at
+the end of this hook if you want entries to be displayed in order.)
+Finally, add this function to `diary-nongregorian-listing-hook', so that
+it is called once for each included file when the diary is displayed."
+  (with-no-warnings (defvar original-date) ; the start date
+                    (defvar number) ; number of days to generate entries f=
or
+                    (defvar diary-file)) ; dyn. bound to included file name
+  (let ((diary-buffer (or (find-buffer-visiting diary-file)
+                          (find-file-noselect diary-file))))
+    (when (ical:contains-vcalendar-p diary-buffer)
+      (let ((vcal/idx (ical:parse-and-index diary-file)))
+        (when vcal/idx
+          (let* ((index (cadr vcal/idx))
+                 (absstart (calendar-absolute-from-gregorian original-date=
))
+                 (absend (+ absstart (1- number))))
+
+            (dolist (absdate (number-sequence absstart absend))
+              (let* ((date (calendar-gregorian-from-absolute absdate))
+                     (to-format (ical:index-get index :date date)))
+                (dolist (component to-format)
+                  ;; Format the entry, with a pointer back to its location
+                  ;; in the parsed buffer:
+                  (let ((marker (make-marker)))
+                    (set-marker marker
+                                (ical:ast-node-meta-get :begin component)
+                                (ical:ast-node-meta-get :buffer component))
+                    (diary-add-to-list
+                     date
+                     (di:format-entry component index)
+                     ""
+                     marker)))))))))))
+
+(defun di:marking-dates-of (component index)
+  "Return the dates in COMPONENT that should be marked in the calendar.
+
+The dates to mark are derived from COMPONENT's start and end date and
+time, and any recurrences it has within the year currently displayed by
+the calendar.
+
+No dates are returned if COMPONENT's `icalendar-transp' property has the
+value \"TRANSPARENT\" (which means the component does not form a block
+of busy time on a schedule), or if COMPONENT is an `icalendar-vjournal'
+and `diary-icalendar-import-vjournal-as-nonmarking' is non-nil."
+  (ical:with-component component
+    ((ical:dtstart :first dtstart-node :value dtstart)
+     (ical:dtend :first dtend-node :value dtend)
+     (ical:due :value due)
+     (ical:duration :value duration)
+     (ical:rdate :first rdate)
+     (ical:rrule :first rrule)
+     (ical:transp :value transparency))
+    (let* ((start-tz (ical:with-param-of dtstart-node 'ical:tzidparam
+                                         (ical:index-get index :tzid value=
)))
+           (end
+            (cond
+             (dtend dtend)
+             (due due)
+             (duration (ical:date/time-add-duration dtstart duration start=
-tz))))
+           dates)
+
+      (unless (or (equal transparency "TRANSPARENT")
+                  (and di:import-vjournal-as-nonmarking
+                       (ical:vjournal-component-p component)))
+        ;; Mark the start date(s) for every (marking) entry:
+        (setq dates (if end
+                        (ical:dates-until dtstart end t)
+                      (list (ical:date/time-to-date
+                             (ical:date/time-to-local dtstart)))))
+        ;; Mark the dates for any recurrences in the displayed calendar ye=
ar:
+        (let ((year (when (boundp 'displayed-year) ; bound by calendar
+                      displayed-year)))
+          (when (and year (or rdate rrule))
+            (let* ((low (list 1 1 year))
+                   (high (list 12 31 year))
+                   (recs (icr:recurrences-in-window-w/end-times
+                          low high component start-tz)))
+              (dolist (rec recs)
+                (setq dates (append (ical:dates-until (car rec) (cadr rec)=
 t)
+                                    dates)))))))
+      dates)))
+
+(defun di:mark-entries ()
+  "Mark calendar dates for iCalendar data from a file.
+
+This function allows you to mark the dates in an iCalendar-formatted
+file in the calendar without importing it. The data is read directly
+from the current value of `diary-file' (which is dynamically bound by
+the diary when this function is called).
+
+To use this function, add an '#include \"FILE\"' entry in your diary
+file for each iCalendar file you want to display (see
+`diary-include-string').  Then add `diary-mark-included-diary-files' to
+`diary-mark-entries-hook'.  Finally, add this function to
+`diary-nongregorian-marking-hook', so that it is called once for each
+included file when dates are marked in the calendar."
+  (with-no-warnings (defvar diary-file)) ; dyn. bound to included file name
+  (let ((diary-buffer (or (find-buffer-visiting diary-file)
+                          (find-file-noselect diary-file))))
+    (when (ical:contains-vcalendar-p diary-buffer)
+      (let ((vcal/idx (ical:parse-and-index diary-buffer)))
+        (when vcal/idx
+          (let* ((index (cadr vcal/idx))
+                 (vcalendar (car vcal/idx))
+                 (to-mark
+                  (append (ical:ast-node-children-of 'ical:vevent vcalenda=
r)
+                          (ical:ast-node-children-of 'ical:vjournal vcalen=
dar)
+                          (ical:ast-node-children-of 'ical:vtodo vcalendar=
)))
+                 (all-dates (mapcan (lambda (c) (di:marking-dates-of c ind=
ex))
+                                    to-mark))
+                 (dates (seq-uniq
+                         (sort all-dates :lessp #'ical:date< :in-place t))=
))
+
+            (dolist (date dates)
+              (let ((month (calendar-extract-month date))
+                    (year (calendar-extract-year date)))
+                ;; avoid marking outside the displayed months,
+                ;; to speed things up:
+                (with-current-buffer calendar-buffer
+                  (when (and (=3D year displayed-year)
+                             (<=3D (1- displayed-month) month)
+                             (<=3D month (1+ displayed-month)))
+                    (calendar-mark-visible-date date)))))))))))
+
+
+
+(provide 'diary-icalendar)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-=
recur-") ("di:" . "diary-icalendar-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/diary-lib.el b/lisp/calendar/diary-lib.el
index 056360bbede..e794ccd891d 100644
--- a/lisp/calendar/diary-lib.el
+++ b/lisp/calendar/diary-lib.el
@@ -2117,8 +2117,9 @@ diary-insert-entry
 Prefix argument ARG makes the entry nonmarking."
   (interactive
    (list current-prefix-arg last-nonmenu-event))
-  (diary-make-entry (calendar-date-string (calendar-cursor-to-date t event=
) t t)
-                    arg))
+  (calendar-dlet ((calendar-date-display-form diary-date-insertion-form))
+    (diary-make-entry (calendar-date-string (calendar-cursor-to-date t eve=
nt) t t)
+                      arg)))
=20
 ;;;###cal-autoload
 (defun diary-insert-weekly-entry (arg)
@@ -2315,6 +2316,7 @@ diary-time-regexp
   ;; Accepted formats: 10:00 10.00 10h00 10h 10am 10:00am 10.00am
   ;; Use of "." as a separator annoyingly matches numbers, eg "123.45".
   ;; Hence often prefix this with "\\(^\\|\\s-\\)."
+  ;; FIXME.
   (concat "[0-9]?[0-9]\\([AaPp][mM]\\|\\("
           "[Hh]\\([0-9][0-9]\\)?\\|[:.][0-9][0-9]"
           "\\)\\([AaPp][Mm]\\)?\\)")
diff --git a/lisp/calendar/icalendar-ast.el b/lisp/calendar/icalendar-ast.el
new file mode 100644
index 00000000000..657a8070210
--- /dev/null
+++ b/lisp/calendar/icalendar-ast.el
@@ -0,0 +1,855 @@
+;;; icalendar-ast.el --- Syntax trees for iCalendar  -*- lexical-binding: =
t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the abstract syntax tree representation for
+;; iCalendar data. The AST is based on `org-element-ast' (which see;
+;; that feature will eventually be renamed and moved out of the Org tree
+;; into the main tree).
+
+;; The file contains low-level functions for constructing and
+;; manipulating the AST, most of which are minimal wrappers around the
+;; functions provided by `org-element-ast'. This low-level API is
+;; primarily used by `icalendar-parser'. It also contains a higher-level
+;; API for constructing AST nodes in Lisp code. Finally, it defines
+;; functions for validating AST nodes.
+
+
+;;; Code:
+(eval-when-compile (require 'cl-lib))
+(require 'org-element-ast)
+(require 'icalendar)
+
+;;; Type symbols and metadata
+
+;; All nodes in the syntax tree have a type symbol as their first element.
+;; We use the following symbol properties (all prefixed with 'icalendar-')
+;; to associate type symbols with various important data about the type:
+;;
+;; is-type - t (marks this symbol as an icalendar type)
+;; is-value, is-param, is-property, or is-component - t
+;;   (specifies what sort of value this type represents)
+;; list-sep - for property and parameters types, a string (typically
+;;   "," or ";") which separates individual printed values, if the
+;;   type allows lists of values. If this is non-nil, syntax nodes of
+;;   this type should always have a list of values in their VALUE
+;;   field (even if there is only one value)
+;; matcher - a function to match this type. This function matches the
+;;   regular expression defined under the type's name; it is used to provi=
de
+;;   syntax highlighting in `icalendar-mode'
+;; begin-rx, end-rx - for component-types, an `rx' regular expression which
+;;   matches the BEGIN and END lines that form its boundaries
+;; value-rx - an `rx' regular expression which matches individual values
+;;   of this type, with no consideration for quoting or lists of values.
+;;   (For value types, this is just a synonym for the rx definition
+;;   under the type's symbol)
+;; values-rx - for types that accept lists of values, an `rx' regular
+;;   expression which matches the whole list (including quotes, if require=
d)
+;; full-value-rx - for property and parameter types, an `rx' regular
+;;   expression which matches a valid value expression in group 2, or
+;;   an invalid value in group 3
+;; value-reader - for value types, a function which creates syntax
+;;   nodes of this type given a string representing their value
+;; value-printer - for value types, a function to print individual
+;;   values of this type. It accepts a value and returns its string
+;;   representation.
+;; default-value - for property and parameter types, a string
+;;   representing a default value for nodes of this type. This is the
+;;   value assumed when no node of this type is present in the
+;;   relevant part of the syntax tree.
+;; substitute-value - for parameter types, a string representing a value
+;;   which will be substituted at parse times for unrecognized values.
+;;   (This is normally the same as default-value, but differs from it
+;;   in at least one case in RFC5545, thus it is stored separately.)
+;; default-type - for property types which can have values of multiple
+;;   types, this is the default type when no type for the value is
+;;   specified in the parameters. Any type of value other than this
+;;   one requires a VALUE=3D... parameter when the property is read or pri=
nted.
+;; other-types - for property types which can have values of multiple type=
s,
+;;   this is a list of other types that the property can accept.
+;; child-spec - for property and component types, a plist describing the
+;;   required and optional child nodes. See `icalendar-define-property' and
+;;   `icalendar-define-component' for details.
+;; other-validator - a function to perform type-specific validation
+;;   for nodes of this type. If present, this function will be called
+;;   by `icalendar-ast-node-valid-p' during validation.
+;; type-documentation - a string documenting the type. This documentation =
is
+;;   printed in the help buffer when `describe-symbol' is called on TYPE.
+;; link - a hyperlink to the documentation of the type in the relevant sta=
ndard
+
+(defun ical:type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is an iCalendar type symbol.
+
+This function only checks that SYMBOL has been marked as a type;
+it returns t for value types defined by `icalendar-define-type',
+but also e.g. for types defined by `icalendar-define-param' and
+`icalendar-define-property'. To check that SYMBOL names a value
+type for property or parameter values, see
+`icalendar-value-type-symbol-p' and
+`icalendar-printable-value-type-symbol-p'."
+  (and (symbolp symbol)
+       (get symbol 'ical:is-type)))
+
+(defun ical:value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol for a value type.
+
+This means that SYMBOL must both satisfy `icalendar-type-symbol-p' and
+have the property `icalendar-is-value'. It does not require the type to
+be associated with a print name in `icalendar-value-types'; for that see
+`icalendar-printable-value-type-symbol-p'."
+  (and (ical:type-symbol-p symbol)
+       (get symbol 'ical:is-value)))
+
+(defun ical:expects-list-of-values-p (type)
+  "Return non-nil if TYPE expects a list of values.
+
+This is never t for value types or component types. For property and
+parameter types defined with `icalendar-define-param' and
+`icalendar-define-property', it is true if the :list-sep argument was
+specified in the definition."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:list-sep)))
+
+(defun ical:param-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar parameter."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-param)))
+
+(defun ical:property-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar property."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-property)))
+
+(defun ical:component-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar component."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-component)))
+
+;; TODO: we could define other accessors here for the other metadata
+;; properties, but at the moment I see no advantage to this; they would
+;; all just be long-winded wrappers around `get'.
+
+
+;; The basic, low-level API for the AST, mostly intended for use by
+;; `icalendar-parser'. These functions are mostly aliases and simple
+;; wrappers around functions provided by `org-element-ast', which does
+;; the heavy lifting.
+(defalias 'ical:ast-node-type #'org-element-type)
+
+(defsubst ical:ast-node-value (node)
+  "Return the value of iCalendar syntax node NODE.
+In component nodes, this is nil. Otherwise, it is a syntax node
+representing an iCalendar (property or parameter) value."
+  (org-element-property :value node))
+
+(defalias 'ical:ast-node-children #'org-element-contents)
+
+;; TODO: probably don't want &rest form for this
+(defalias 'ical:ast-node-set-children #'org-element-set-contents)
+
+(defalias 'ical:ast-node-adopt-children #'org-element-adopt-elements)
+
+(defalias 'ical:ast-node-meta-get #'org-element-property)
+
+(defalias 'ical:ast-node-meta-set #'org-element-put-property)
+
+(defun ical:ast-node-set-type (node type)
+  "Set the type of iCalendar syntax node NODE to TYPE.
+
+This function is probably not what you want! It directly modifies the
+type of NODE in-place, which could make the node invalid if its value or
+children do not match the new TYPE. If you do not know in advance that
+the data in NODE is compatible with the new TYPE, it is better to
+construct a new syntax node."
+  (setcar node type))
+
+(defun ical:ast-node-set-value (node value)
+  "Set the value of iCalendar syntax node NODE to VALUE."
+  (ical:ast-node-meta-set node :value value))
+
+(defun ical:make-ast-node (type props &optional children)
+  "Construct a syntax node of TYPE with meta-properties PROPS and CHILDREN.
+
+This is a low-level constructor. If you are constructing iCalendar
+syntax nodes directly in Lisp code, consider using one of the
+higher-level macros based on `icalendar-make-node-from-templates'
+instead, which expand to calls to this function but also perform type
+checking and validation.
+
+TYPE should be an iCalendar type symbol.  CHILDREN, if given, should be
+a list of syntax nodes. In property nodes, these should be the
+parameters of the property.  In component nodes, these should be the
+properties or subcomponents of the component. CHILDREN should otherwise
+be nil.
+
+PROPS should be a plist with any of the following keywords:
+
+:value - in value nodes, this should be the Elisp value parsed from a
+  property or parameter's value string. In parameter and property nodes,
+  this should be a value node or list of value nodes. In component
+  nodes, it should not be present.
+:buffer - buffer from which VALUE was parsed
+:begin - position at which this node begins in BUFFER
+:end - position at which this node ends in BUFFER
+:value-begin - position at which VALUE begins in BUFFER
+:value-end - position at which VALUE ends in BUFFER
+:original-value - a string containing the original, uninterpreted value
+  of the node. This can differ from (a string represented by) VALUE
+  if e.g. a default VALUE was substituted for an unrecognized but
+  syntactically correct value.
+:original-name - a string containing the original, uninterpreted name
+  of the parameter, property or component this node represents.
+  This can differ from (a string representing) TYPE
+  if e.g. a default TYPE was substituted for an unrecognized but
+  syntactically correct one."
+  ;; automatically mark :value as a "secondary property" for org-element-a=
st
+  (let ((full-props (if (plist-member props :value)
+                        (plist-put props :secondary (list :value))
+                      props)))
+    (apply #'org-element-create type full-props children)))
+
+(defun ical:ast-node-p (val)
+  "Return non-nil if VAL is an iCalendar syntax node."
+  (and (listp val)
+       (length> val 1)
+       (ical:type-symbol-p (ical:ast-node-type val))
+       (plistp (cadr val))
+       (listp (ical:ast-node-children val))))
+
+(defun ical:param-node-p (node)
+  "Return non-nil if NODE is a syntax node whose type is a parameter type."
+  (and (ical:ast-node-p node)
+       (ical:param-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:property-node-p (node)
+  "Return non-nil if NODE is a syntax node whose type is a property type."
+  (and (ical:ast-node-p node)
+       (ical:property-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:component-node-p (node)
+  "Return non-nil if NODE is a syntax node whose type is a component type."
+  (and (ical:ast-node-p node)
+       (ical:component-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:ast-node-first-child-of (type node)
+  "Return the first child of NODE of type TYPE, or nil."
+  (assq type (ical:ast-node-children node)))
+
+(defun ical:ast-node-children-of (type node)
+  "Return a list of all the children of NODE of type TYPE."
+  (seq-filter (lambda (c) (eq type (ical:ast-node-type c)))
+              (ical:ast-node-children node)))
+
+
+;; A high-level API for constructing iCalendar syntax nodes in Lisp code:
+
+(defun ical:type-of (value &optional types)
+  "Find the iCalendar type symbol for the type to which VALUE belongs.
+
+TYPES, if specified, should be a list of type symbols to check.
+TYPES defaults to all type symbols listed in `icalendar-value-types'."
+  (require 'icalendar-parser) ; for ical:value-types
+  (catch 'found
+    (when (ical:ast-node-p value)
+      (throw 'found (ical:ast-node-type value)))
+    (dolist (type (or types (mapcar #'cdr ical:value-types)))
+      (if (ical:expects-list-of-values-p type)
+          (when (ical:list-of-p value type)
+            (throw 'found type))
+        (when (cl-typep value type)
+          (throw 'found type))))))
+
+;; A more flexible constructor for value nodes which can choose the
+;; correct type from a list. This helps keep templates succinct and easy
+;; to use in `icalendar-make-node-from-templates', and related macros
+;; below.
+(defun ical:make-value-node-of (type value)
+  "Make an iCalendar syntax node of type TYPE containing VALUE as its valu=
e.
+
+TYPE should be a symbol for an iCalendar value type, and VALUE should be
+a value of that type. If TYPE is the symbol \\=3D'plain-text, VALUE should
+be a string, and in that case VALUE is returned as-is.
+
+TYPE may also be a list of type symbols; in that case, the first type in
+the list which VALUE satisfies is used as the returned node's type.  If
+the list is nil, VALUE will be checked against all types in
+`icalendar-value-types'.
+
+If VALUE is nil, and `icalendar-boolean' is not (in) TYPE, nil is
+returned. Otherwise, a \\=3D'wrong-type-argument error is signaled if
+VALUE does not satisfy (any type in) TYPE."
+  (require 'icalendar-parser)
+  (cond
+   ((and (null value)
+         (not (if (listp type) (memq 'ical:boolean type)
+                (eq 'ical:boolean type))))
+    ;; Instead of signaling an error, we just return nil in this case.
+    ;; This allows the `ical:make-*' macros higher up the stack to
+    ;; filter out templates that evaluate to nil at run time:
+    nil)
+   ((eq type 'plain-text)
+    (unless (stringp value)
+      (signal 'wrong-type-argument (list 'stringp value)))
+    value)
+   ((symbolp type)
+    (unless (ical:value-type-symbol-p type)
+      (signal 'wrong-type-argument (list 'icalendar-value-type-symbol-p ty=
pe)))
+    (if (ical:expects-list-of-values-p type)
+        (unless (ical:list-of-p value type)
+          (signal 'wrong-type-argument (list `(list-of ,type) value)))
+      (unless (cl-typep value type)
+        (signal 'wrong-type-argument (list type value)))
+    (ical:make-ast-node type (list :value value))))
+   ((listp type)
+    ;; N.B. nil is allowed; in that case, `ical:type-of' will check all
+    ;; types in `ical:value-types':
+    (let ((the-type (ical:type-of value type)))
+      (if the-type
+          (ical:make-ast-node the-type (list :value value))
+        (signal 'wrong-type-argument (list (if (length> type 1) (cons 'or =
type) (car type))
+                                           value)))))
+   (t (signal 'wrong-type-argument (list '(or symbolp listp) type)))))
+
+(defmacro ical:make-param (type value)
+  "Construct an iCalendar parameter node of TYPE with value VALUE.
+
+TYPE should be an iCalendar type symbol satisfying
+`icalendar-param-type-symbol-p'; it should not be quoted.
+
+VALUE should evaluate to a value appropriate for TYPE. In particular, if
+TYPE expects a list of values (see `icalendar-expects-list-p'), VALUE
+should be such a list. If necessary, the value(s) in VALUE will be
+wrapped in syntax nodes indicating their type.
+
+For example,
+
+  (icalendar-make-param icalendar-deltoparam
+    (list \"mailto:minionA@HIDDEN\" \"mailto:minionB@HIDDEN\"))
+
+will return an `icalendar-deltoparam' node whose value is a list of
+`icalendar-cal-address' nodes containing the two addresses.
+
+The resulting syntax node is checked for validity by
+`icalendar-ast-node-valid-p' before it is returned."
+  ;; TODO: support `ical:otherparam'
+  (unless (ical:param-type-symbol-p type)
+    (error "Not an iCalendar param type: %s" type))
+  (let ((value-type (or (get type 'ical:value-type) 'plain-text))
+        (needs-list (ical:expects-list-of-values-p type)))
+    `(let* ((raw-value ,value)
+            (value-type (quote ,value-type))
+            (value
+             ,(if needs-list
+                  '(if (seq-every-p #'ical:ast-node-p raw-value)
+                       raw-value
+                     (mapcar
+                      (lambda (c) (ical:make-value-node-of value-type c))
+                      raw-value))
+                '(if (ical:ast-node-p raw-value)
+                     raw-value
+                    (ical:make-value-node-of value-type raw-value)))))
+        (when value
+          (ical:ast-node-valid-p
+           (ical:make-ast-node
+            (quote ,type)
+            (list :value value)))))))
+
+(defmacro ical:make-property (type value &rest param-templates)
+  "Construct an iCalendar property node of TYPE with value VALUE.
+
+TYPE should be an iCalendar type symbol satisfying
+`icalendar-property-type-symbol-p'; it should not be quoted.
+
+VALUE should evaluate to a value appropriate for TYPE. In particular, if
+TYPE expects a list of values (see
+`icalendar-expects-list-of-values-p'), VALUE should be such a list. If
+necessary, the value(s) in VALUE will be wrapped in syntax nodes
+indicating their type. If VALUE is not of the default value type for
+TYPE, an `icalendar-valuetypeparam' will automatically be added to TEMPLAT=
ES.
+
+Each element of PARAM-TEMPLATES should represent a parameter node; see
+`icalendar-make-node-from-templates' for the format of such TEMPLATES.
+A template can also have the form (@ L), where L evaluates to a list of
+parameter nodes to be added to the component.
+
+PARAM-TEMPLATES which evaluate to nil are removed when the property node
+is constructed.
+
+For example,
+
+  (icalendar-make-property icalendar-rdate (list \\=3D'(2 1 2025) \\=3D'(3=
 1 2025)))
+
+will return an `icalendar-rdate' node whose value is a list of
+`icalendar-date' nodes containing the dates above as their values.
+
+The resulting syntax node is checked for validity by
+`icalendar-ast-node-valid-p' before it is returned."
+  ;; TODO: support `ical:other-property', maybe like
+  ;; (ical:other-property "X-NAME" value ...)
+  (unless (ical:property-type-symbol-p type)
+    (error "Not an iCalendar property type: %s" type))
+  (let ((value-types (cons (get type 'ical:default-type)
+                           (get type 'ical:other-types)))
+        (needs-list (ical:expects-list-of-values-p type))
+        params-expr children lists-of-children)
+    (dolist (c param-templates)
+      (cond ((and (listp c) (ical:type-symbol-p (car c)))
+             ;; c is a template for a child node, so it should be
+             ;; recursively expanded:
+             (push (cons 'ical:make-node-from-templates c)
+                   children))
+            ((and (listp c) (eq '@ (car c)))
+             ;; c is a template (@ L) where L evaluates to a list of child=
ren:
+             (push (cadr c) lists-of-children))
+            (t
+             ;; otherwise, just pass c through as is; this allows
+             ;; interleaving templates with other expressions that
+             ;; evaluate to syntax nodes:
+             (push c children))))
+    (when (or children lists-of-children)
+      (setq params-expr
+            `(seq-filter #'identity
+                         (append (list ,@children) ,@lists-of-children))))
+
+    `(let* ((raw-value ,value)
+            (value-types (quote ,value-types))
+            (value
+               ,(if needs-list
+                    '(if (seq-every-p #'ical:ast-node-p raw-value)
+                         raw-value
+                       (mapcar
+                        (lambda (c) (ical:make-value-node-of value-types c=
))
+                        raw-value))
+                  '(if (ical:ast-node-p raw-value)
+                       raw-value
+                     (ical:make-value-node-of value-types raw-value)))))
+       (when value
+         (ical:ast-node-valid-p
+          (ical:maybe-add-value-param
+           (ical:make-ast-node
+            (quote ,type)
+            (list :value value)
+            ,params-expr)))))))
+
+(defmacro ical:make-component (type &rest templates)
+  "Construct an iCalendar component node of TYPE from TEMPLATES.
+
+TYPE should be an iCalendar type symbol satisfying
+`icalendar-component-type-symbol-p'; it should not be quoted.
+
+Each expression in TEMPLATES should represent a child node of the
+component; see `icalendar-make-node-from-templates' for the format of
+such TEMPLATES. A template can also have the form (@ L), where L
+evaluates to a list of child nodes to be added to the component.
+
+Any value in TEMPLATES that evaluates to nil will be removed before the
+component node is constructed.
+
+If TYPE is `icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal',
+or `icalendar-vfreebusy', the properties `icalendar-dtstamp' and
+`icalendar-uid' will be automatically provided, if they are absent in
+TEMPLATES. Likewise, if TYPE is `icalendar-vcalendar', the properties
+`icalendar-prodid', `icalendar-version', and `icalendar-calscale' will
+be automatically provided if absent.
+
+For example,
+
+  (icalendar-make-component icalendar-vevent
+     (icalendar-summary \"Party\")
+     (icalendar-location \"Robot House\")
+     (@ list-of-other-properties))
+
+will return an `icalendar-vevent' node containing the provided
+properties as well as `icalendar-dtstamp' and `icalendar-uid'
+properties.
+
+The resulting syntax node is checked for validity by
+`icalendar-ast-node-valid-p' before it is returned."
+  ;; TODO: support `ical:other-component', maybe like
+  ;; (ical:other-component "X-NAME" templates ...)
+  (unless (ical:component-type-symbol-p type)
+    (error "Not an iCalendar component type: %s" type))
+  ;; Add templates for required properties automatically if we can:
+  (when (memq type '(ical:vevent ical:vtodo ical:vjournal ical:vfreebusy))
+    (unless (assq 'ical:dtstamp templates)
+      (push '(ical:dtstamp (decode-time nil t))
+            templates))
+    (unless (assq 'ical:uid templates)
+      (push `(ical:uid ,(ical:make-uid templates))
+            templates)))
+  (when (eq type 'ical:vcalendar)
+    (unless (assq 'ical:prodid templates)
+      (push `(ical:prodid ,ical:vcalendar-prodid)
+            templates))
+    (unless (assq 'ical:version templates)
+      (push `(ical:version ,ical:vcalendar-version)
+            templates))
+    (unless (assq 'ical:calscale templates)
+      (push '(ical:calscale "GREGORIAN")
+            templates)))
+  (when (null templates)
+    (error "At least one template is required"))
+
+  (let (children lists-of-children)
+    (dolist (c templates)
+      (cond ((and (listp c) (ical:type-symbol-p (car c)))
+             ;; c is a template for a child node, so it should be
+             ;; recursively expanded:
+             (push (cons 'ical:make-node-from-templates c)
+                   children))
+            ((and (listp c) (eq '@ (car c)))
+             ;; c is a template (@ L) where L evaluates to a list of child=
ren:
+             (push (cadr c) lists-of-children))
+            (t
+             ;; otherwise, just pass c through as is; this allows
+             ;; interleaving templates with other expressions that
+             ;; evaluate to syntax nodes:
+             (push c children))))
+    (when (or children lists-of-children)
+      `(ical:ast-node-valid-p
+        (ical:make-ast-node
+         (quote ,type)
+         nil
+         (seq-filter #'identity
+                     (append (list ,@children) ,@lists-of-children)))))))
+
+;; TODO: allow disabling the validity check??
+(defmacro ical:make-node-from-templates (type &rest templates)
+  "Construct an iCalendar syntax node of TYPE from TEMPLATES.
+
+TYPE should be an iCalendar type symbol; it should not be quoted.  This
+macro (and the derived macros `icalendar-make-vcalendar',
+`icalendar-make-vevent', `icalendar-make-vtodo',
+`icalendar-make-vjournal', `icalendar-make-vfreebusy',
+`icalendar-make-valarm', `icalendar-make-vtimezone',
+`icalendar-make-standard', and `icalendar-make-daylight') makes it easy
+to write iCalendar syntax nodes of TYPE as Lisp code.
+
+Each expression in TEMPLATES represents a child node of the constructed
+node.  It must either evaluate to such a node, or it must have one of
+the following forms:
+
+(VALUE-TYPE VALUE) - constructs a node of VALUE-TYPE containing the
+  value VALUE.
+
+(PARAM-TYPE VALUE) - constructs a parameter node of PARAM-TYPE
+  containing the VALUE.
+
+(PROPERTY-TYPE VALUE [PARAM ...]) - constructs a property node of
+  PROPERTY-TYPE containing the value VALUE and PARAMs as child
+  nodes. Each PARAM should be a template (PARAM-TYPE VALUE), as above,
+  or any other expression that evaluates to a parameter node.
+
+(COMPONENT-TYPE CHILD [CHILD ...]) - constructs a component node of
+  COMPONENT-TYPE with CHILDs as child nodes. Each CHILD should either be
+  a template for a property (as above), a template for a
+  sub-component (of the same form), or any other expression that
+  evaluates to an iCalendar syntax node.
+
+If TYPE is an iCalendar component or property type, a TEMPLATE can also
+have the form (@ L), where L evaluates to a list of child nodes to be
+added to the component or property node.
+
+For example, an iCalendar VEVENT could be written like this:
+
+  (icalendar-make-node-from-templates icalendar-vevent
+    (icalendar-dtstamp (decode-time (current-time) 0))
+    (icalendar-uid \"some-unique-id\")
+    (icalendar-summary \"Party\")
+    (icalendar-location \"Robot House\")
+    (icalendar-organizer \"mailto:bender@HIDDEN\")
+    (icalendar-attendee  \"mailto:philip.j.fry@HIDDEN\"
+      (icalendar-partstatparam \"ACCEPTED\"))
+    (icalendar-attendee  \"mailto:gunther@HIDDEN\"
+      (icalendar-partstatparam \"DECLINED\"))
+    (icalendar-categories (list \"MISCHIEF\" \"DOUBLE SECRET PROBATION\"))
+    (icalendar-dtstart (icalendar-make-date-time :year 3003 :month 3 :day =
13
+                                                 :hour 22 :minute 0 :secon=
d 0)
+       (icalendar-tzidparam \"Mars/University_Time\")))
+
+Before the constructed node is returned, it is validated by
+`icalendar-ast-node-valid-p'."
+  (cond
+   ((not (ical:type-symbol-p type))
+    (error "Not an iCalendar type symbol: %s" type))
+   ((ical:value-type-symbol-p type)
+    `(ical:ast-node-valid-p
+      (ical:make-value-node-of (quote ,type) ,(car templates))))
+   ((ical:param-type-symbol-p type)
+    `(ical:make-param ,type ,(car templates)))
+   ((ical:property-type-symbol-p type)
+    `(ical:make-property ,type ,(car templates) ,@(cdr templates)))
+   ((ical:component-type-symbol-p type)
+    `(ical:make-component ,type ,@templates))))
+
+(defmacro ical:make-vcalendar (&rest templates)
+  "Construct an iCalendar VCALENDAR object from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vcalendar' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-prodid' and
+`icalendar-version' properties, they will be automatically added; see
+the variables `icalendar-vcalendar-prodid' and
+`icalendar-vcalendar-version'."
+  `(ical:make-node-from-templates ical:vcalendar ,@templates))
+
+(defmacro ical:make-vevent (&rest templates)
+  "Construct an iCalendar VEVENT node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vevent' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vevent ,@templates))
+
+(defmacro ical:make-vtodo (&rest templates)
+  "Construct an iCalendar VTODO node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vtodo' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vtodo ,@templates))
+
+(defmacro ical:make-vjournal (&rest templates)
+  "Construct an iCalendar VJOURNAL node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vjournal' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vjournal ,@templates))
+
+(defmacro ical:make-vfreebusy (&rest templates)
+  "Construct an iCalendar VFREEBUSY node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vfreebusy' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vfreebusy ,@templates))
+
+(defmacro ical:make-valarm (&rest templates)
+  "Construct an iCalendar VALARM node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-valarm' for the permissible child types."
+  `(ical:make-node-from-templates ical:valarm ,@templates))
+
+(defmacro ical:make-vtimezone (&rest templates)
+  "Construct an iCalendar VTIMEZONE node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vtimezone' for the permissible child types."
+  `(ical:make-node-from-templates ical:vtimezone ,@templates))
+
+(defmacro ical:make-standard (&rest templates)
+  "Construct an iCalendar STANDARD node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-standard' for the permissible child types."
+  `(ical:make-node-from-templates ical:standard ,@templates))
+
+(defmacro ical:make-daylight (&rest templates)
+  "Construct an iCalendar DAYLIGHT node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-daylight' for the permissible child types."
+  `(ical:make-node-from-templates ical:daylight ,@templates))
+
+
+;;; Validation:
+
+;; Errors at the validation stage:
+;; e.g. property/param values did not match, or are of the wrong type,
+;; or required properties not present in a component
+(define-error 'ical:validation-error "Invalid iCalendar data" 'ical:error)
+
+(cl-defun ical:signal-validation-error (msg &key node (severity 2))
+  (signal 'ical:validation-error
+              (list :message msg
+                    :buffer (ical:ast-node-meta-get :buffer node)
+                    :position (ical:ast-node-meta-get :begin node)
+                    :severity severity
+                    :node node)))
+
+(defun ical:ast-node-required-child-p (child parent)
+  "Return non-nil if CHILD is required by PARENT's node type."
+  (let* ((type (ical:ast-node-type parent))
+         (child-spec (get type 'ical:child-spec))
+         (child-type (ical:ast-node-type child)))
+    (or (memq child-type (plist-get child-spec :one))
+        (memq child-type (plist-get child-spec :one-or-more)))))
+
+(defun ical:ast-node-valid-value-p (node)
+  "Validate that NODE's value satisfies the requirements of its type.
+Signals an `icalendar-validation-error' if NODE's value is
+invalid, or returns NODE."
+  (let* ((type (ical:ast-node-type node))
+         (value (ical:ast-node-value node)))
+    (cond ((ical:value-type-symbol-p type)
+           (unless (cl-typep value type) ; see `ical:define-type'
+             (ical:signal-validation-error
+              (format "Invalid value for `%s' node: %s" type value)
+              :node node))
+           node)
+          ((ical:component-node-p node)
+           ;; component types have no value, so no need to check anything
+           node)
+          ((and (or (ical:param-type-symbol-p type)
+                    (ical:property-type-symbol-p type))
+                (null (get type 'ical:value-type))
+                (stringp value))
+           ;; property and param nodes with no value type are assumed to c=
ontain
+           ;; strings which match a value regex:
+           (unless (string-match (rx-to-string (get type 'ical:value-rx)) =
value)
+             (ical:signal-validation-error
+              (format "Invalid string value for `%s' node: %s" type value)
+              :node node))
+           node)
+          ;; otherwise this is a param or property node which itself
+          ;; should have one or more syntax nodes as a value, so
+          ;; recurse on value(s):
+          ((ical:expects-list-of-values-p type)
+           (unless (listp value) ;; TODO: check elements' types...?
+             (ical:signal-validation-error
+              (format "Expected list of values for `%s' node" type)
+              :node node))
+           (mapc #'ical:ast-node-valid-value-p value)
+           node)
+          (t
+           (unless (ical:ast-node-p value)
+             (ical:signal-validation-error
+              (format "Invalid value for `%s' node: %s" type value)
+              :node node))
+           (ical:ast-node-valid-value-p value)))))
+
+(defun ical:count-children-by-type (node)
+  "Count NODE's children by type.
+Returns an alist mapping type symbols to the number of NODE's children
+of that type."
+  (let ((children (ical:ast-node-children node))
+        (map nil))
+    (dolist (child children map)
+      (let* ((type (ical:ast-node-type child))
+             (n (alist-get type map)))
+        (setf (alist-get type map) (1+ (or n 0)))))))
+
+(defun ical:ast-node-valid-children-p (node)
+  "Validate that NODE's children satisfy its type's :child-spec.
+
+The :child-spec is associated with NODE's type by
+`icalendar-define-component', `icalendar-define-property',
+`icalendar-define-param', or `icalendar-define-type', which see.
+Signals an `icalendar-validation-error' if NODE is invalid, or returns
+NODE.
+
+Note that this function does not check that the children of NODE
+are themselves valid; for that, see `ical:ast-node-valid-p'."
+  (let* ((type (ical:ast-node-type node))
+         (child-spec (get type 'ical:child-spec))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when child-spec
+
+      (dolist (child-type (plist-get child-spec :one))
+        (unless (=3D 1 (alist-get child-type child-counts 0))
+          (ical:signal-validation-error
+            (format "iCalendar `%s' node must contain exactly one `%s'"
+                    type child-type)
+            :node node)))
+
+      (dolist (child-type (plist-get child-spec :one-or-more))
+        (unless (<=3D 1 (alist-get child-type child-counts 0))
+          (ical:signal-validation-error
+           (format "iCalendar `%s' node must contain one or more `%s'"
+                   type child-type)
+           :node node)))
+
+      (dolist (child-type (plist-get child-spec :zero-or-one))
+        (unless (<=3D (alist-get child-type child-counts 0)
+                    1)
+          (ical:signal-validation-error
+           (format "iCalendar `%s' node may contain at most one `%s'"
+                   type child-type)
+           :node node)))
+
+      ;; check that all child nodes are allowed:
+      (unless (plist-get child-spec :allow-others)
+        (let ((allowed-types (append (plist-get child-spec :one)
+                                     (plist-get child-spec :one-or-more)
+                                     (plist-get child-spec :zero-or-one)
+                                     (plist-get child-spec :zero-or-more)))
+              (appearing-types (mapcar #'car child-counts)))
+
+          (dolist (child-type appearing-types)
+            (unless (member child-type allowed-types)
+              (ical:signal-validation-error
+               (format "`%s' may not contain `%s'" type child-type)
+               :node node))))))
+    ;; success:
+    node))
+
+(defun ical:ast-node-valid-p (node &optional recursively)
+  "Check that NODE is a valid iCalendar syntax node.
+By default, the check will only validate NODE itself, but if
+RECURSIVELY is non-nil, it will recursively check all its
+descendants as well. Signals an `icalendar-validation-error' if
+NODE is invalid, or returns NODE."
+  (unless (ical:ast-node-p node)
+    (ical:signal-validation-error
+     "Not an iCalendar syntax node"
+     :node node))
+
+  (ical:ast-node-valid-value-p node)
+  (ical:ast-node-valid-children-p node)
+
+  (let* ((type (ical:ast-node-type node))
+         (other-validator (get type 'ical:other-validator)))
+
+    (unless (ical:type-symbol-p type)
+      (ical:signal-validation-error
+       (format "Node's type `%s' is not an iCalendar type symbol" type)
+       :node node))
+
+    (when (and other-validator (not (functionp other-validator)))
+      (ical:signal-validation-error
+       (format "Bad validator function `%s' for type `%s'" other-validator=
 type)))
+
+    (when other-validator
+      (funcall other-validator node)))
+
+  (when recursively
+    (dolist (c (ical:ast-node-children node))
+      (ical:ast-node-valid-p c recursively)))
+
+  ;; success:
+  node)
+
+(provide 'icalendar-ast)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-ast.el ends here
diff --git a/lisp/calendar/icalendar-macs.el b/lisp/calendar/icalendar-macs=
.el
new file mode 100644
index 00000000000..b6eeee04e02
--- /dev/null
+++ b/lisp/calendar/icalendar-macs.el
@@ -0,0 +1,1151 @@
+;;; icalendar-macs.el --- Macros for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', used in
+;; icalendar-parser.el to define the particular value types, parameters,
+;; properties and components in the standard as type symbols.
+
+;; TODOs:
+;;   - in the define* macros, :default needs rethinking.
+;;     I had made this a string because otherwise you can't distinguish
+;;     an unspecified default from an explicit "FALSE" for icalendar-boole=
an
+;;     But this might not be true/might not matter anyway, and it's a pain
+;;     to have to read the default value where you need it. Probably
+;;     should just change these to be the value as read.
+
+
+(eval-when-compile (require 'cl-lib))
+
+(declare-function ical:ast-node-p "icalendar-ast")
+(declare-function ical:ast-node-type "icalendar-ast")
+(declare-function ical:ast-node-value "icalendar-ast")
+(declare-function ical:type-symbol-p "icalendar-ast")
+(declare-function ical:value-type-symbol-p "icalendar-ast")
+(declare-function ical:expects-list-of-values-p "icalendar-ast")
+
+;; Some utilities:
+
+;; TODO: This may have outlived its usefulness. Delete?
+(defun ical:protected-intern (sym-name)
+  "Call `intern' on SYM-NAME and return the result, but warn if the
+resulting symbol already has icalendar-relevant properties."
+  (let ((sym (intern sym-name)))
+    (when (or (fboundp sym)
+              (get sym 'rx-definition)
+              (get sym 'ical:is-type))
+      (warn "Symbol `%s' already has iCalendar properties" sym))
+    sym))
+
+(defun ical:format-child-spec (child-spec)
+  "Format CHILD-SPEC as a table for use in symbol documentation."
+  (concat
+   (format "%-30s%6s\n" "Type" "Number")
+   (make-string 36 ?-) "\n"
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1"))
+    (plist-get child-spec :one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1+"))
+    (plist-get child-spec :one-or-more))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0-1"))
+    (plist-get child-spec :zero-or-one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0+"))
+    (plist-get child-spec :zero-or-more))))
+
+
+;; Define value types:
+(cl-defmacro ical:define-type (symbolic-name print-name doc specifier matc=
her
+                               &key link
+                                    (reader #'identity)
+                                    (printer #'identity))
+  "Define an iCalendar value type named SYMBOLIC-NAME.
+
+PRINT-NAME should be the string used to represent this type in
+the value of an `icalendar-valuetypeparam' property parameter, or
+nil if this is not a type that should be specified there. DOC
+should be a documentation string for the type. SPECIFIER should
+be a type specifier in the sense of `cl-deftype'. MATCHER should
+be an RX definition body (see `rx-define'; argument lists are not
+supported).
+
+Before the type is defined with `cl-deftype', a function will be
+defined named `icalendar-match-PRINT-NAME-value'
+(or `icalendar-match-OTHER-value', if PRINT-NAME is nil, where
+OTHER is derived from SYMBOLIC-NAME by removing any prefix
+\"icalendar-\" and suffix \"value\"). This function takes a
+string argument and matches it against MATCHER. This function may
+thus occur in SPECIFIER (e.g. in a (satisfies ...) clause).
+
+See the functions `icalendar-read-value-node',
+`icalendar-parse-value-node', and `icalendar-print-value-node' to
+convert values defined with this macro to and from their text
+representation in iCalendar format.
+
+The following keyword arguments are accepted:
+
+:reader - a function to read data of this type. It will be passed
+  a string matching MATCHER and should return an Elisp data structure.
+  Its name does not need to be quoted. (default: identity)
+
+:printer - a function to convert an Elisp data structure of this
+  type to a string. Its name does not need to be quoted.
+  (default: identity)
+
+:link - a string containing an URL for further documentation of this type"
+  (declare (doc-string 2))
+  (let* (;; Related functions:
+         (type-dname (if print-name
+                         (downcase print-name)
+                       (string-trim
+                        (symbol-name symbolic-name)
+                        "icalendar-" "value")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" type-dname "-value")))
+
+         ;; Documentation:
+         (header "It names a value type defined by `icalendar-define-type'=
.")
+         (matcher-doc (format
+"Strings representing values of this type can be matched with
+`%s'.\n" matcher-name))
+         (reader-doc (format "They can be read with `%s'\n" reader))
+         (printer-doc (format "and printed with `%s'." printer))
+         (full-doc (concat header "\n\n" doc "\n\n"
+                           matcher-doc reader-doc printer-doc "\n\n"
+"A syntax node of this type can be read with
+`icalendar-read-value-node' or parsed with `icalendar-parse-value-node',
+and printed with `icalendar-print-value-node'.")))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-value t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-rx (quote ,symbolic-name)
+                    'ical:value-reader (function ,reader)
+                    'ical:value-printer (function ,printer)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       (rx-define ,symbolic-name
+         ,matcher)
+
+       (defun ,matcher-name (s)
+         ,(format "Match string S against rx `%s'." symbolic-name)
+         (string-match (rx ,symbolic-name) s))
+
+       (cl-deftype ,symbolic-name () ,specifier)
+
+       ;; Store the association between the print name and the type
+       ;; symbol in ical:value-types. The check against print name
+       ;; here allows us to also define value types that aren't
+       ;; "really" types according to the standard, like
+       ;; `ical:geo-coordinates'. Only types that have a
+       ;; print-name can be specified in a VALUE parameter.
+       (when ,print-name
+         (push (cons ,print-name (quote ,symbolic-name)) ical:value-types)=
))))
+
+;; TODO: not sure this is needed. I've only used it once in the parser.
+(cl-defmacro ical:define-keyword-type (symbolic-name print-name doc matcher
+                                       &key link
+                                            (reader 'intern)
+                                            (printer 'symbol-name))
+  "Like `icalendar-define-type', except that string values matching MATCHER
+are assumed to be type-specific keywords that should be interned
+as symbols when read. (Thus no type specifier is necessary: it is
+always just \\=3D'symbol.) Their printed representation is their
+symbol name."
+  `(ical:define-type ,symbolic-name ,print-name ,doc
+                     'symbol
+                     ,matcher
+                     :link ,link
+                     :reader ,reader
+                     :printer ,printer))
+
+
+;; Define parameters:
+(cl-defmacro ical:define-param (symbolic-name param-name doc value
+                                &key quoted
+                                     list-sep
+                                     default
+                                     (unrecognized default)
+                                     ((:name-face name-face)
+                                      'ical:parameter-name nondefault-name=
-face)
+                                     ((:value-face value-face)
+                                      'ical:parameter-value nondefault-val=
ue-face)
+                                     ((:warn-face warn-face)
+                                      'ical:warning nondefault-warn-face)
+                                     extra-faces
+                                     link)
+  "Define iCalendar parameter PARAM-NAME under the symbol SYMBOLIC-NAME.
+PARAM-NAME should be the parameter name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing values.
+If it is a regular expression, it is assumed that the values of
+this parameter are strings which match that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME which matches the
+parameter is defined:
+  Group 1 of this regex matches PARAM-NAME
+    (or any valid parameter name, if PARAM-NAME is nil).
+  Group 2 matches VALUE, which specifies a correct value
+    for this parameter according to RFC5545.
+  Group 3, if matched, contains any parameter value which does
+    *not* match VALUE, and is incorrect according to the standard.
+
+This regex matches the entire string representing this parameter,
+from \";\" to the end of its value. Another regular expression
+named `SYMBOLIC-NAME-value' is also defined to match just the
+value part, after \";PARAM-NAME=3D\", with groups 2 and 3 as above.
+
+A function to match the complete parameter expression called
+`icalendar-match-PARAM-NAME-param' is defined
+(or `icalendar-match-OTHER-param-value' if PARAM-NAME is nil,
+where OTHER is derived from SYMBOLIC-NAME by removing any prefix
+`icalendar-' and suffix `param'). This function is used
+to provide syntax highlighting in `icalendar-mode'.
+
+See the functions `icalendar-read-param-value',
+`icalendar-parse-param-value', `icalendar-parse-params' and
+`icalendar-print-param-node' to convert parameters defined with
+this macro to and from their text representation in iCalendar
+format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if the
+  parameter is not specified on a given property.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but syntactically
+  correct according to RFC5545. Unrecognized values must be in match
+  group 5 of the regex determined by VALUE. An unrecognized value will
+  be preserved in the syntax tree metadata and printed instead of this
+  value when the node is printed. Defaults to any value specified for
+  :default.
+
+:quoted - non-nil if values of this parameter must always be surrounded
+  by (double-)quotation marks when printed, according to RFC5545.
+
+:list-sep - if the parameter accepts a list of values, this should be a
+  string which separates the values (typically \",\"). If :list-sep is
+  non-nil, the value string will first be split on the separator, then
+  if :quoted is non-nil, the individual values will be unquoted, then
+  each value will be read according to VALUE and collected into a list
+  when parsing.  When printing, the inverse happens: values are quoted
+  if :quoted is non-nil, then joined with :list-sep. Passing this
+  argument marks SYMBOLIC-NAME as a type that accepts a list of values
+  for `icalendar-expects-list-of-values-p'.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-parameter-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-parameter-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form accepted for HIGHLIGHT in
+  `font-lock-keywords'.  In particular,
+    ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]]) ...)
+  can be used to apply different faces to different
+  match subgroups.
+
+:link - a string containing a URL for documentation of this parameter.
+  The URL will be provided in the documentation shown by
+  `describe-symbol' for SYMBOLIC-NAME."
+  (declare (doc-string 2))
+  (let* (;; Related function names:
+         (param-dname (if param-name
+                          (downcase param-name)
+                        (string-trim (symbol-name symbolic-name)
+                                     "icalendar-" "param")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" param-dname "-param")))
+
+         (type-predicate-name
+          (ical:protected-intern (concat "icalendar-" param-dname "-param-=
p")))
+         ;; Value regexes:
+         (qvalue-rx (if quoted `(seq ?\" ,value ?\") value))
+         (values-rx (when list-sep
+                     `(seq ,qvalue-rx (zero-or-more ,list-sep ,qvalue-rx))=
))
+         (full-value-rx-name (ical:protected-intern
+                               (concat (symbol-name symbolic-name) "-value=
")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-value-face
+                        nondefault-warn-face extra-faces))
+         ;; Documentation:
+         (header "It names a parameter type defined by `icalendar-define-p=
aram'.")
+         (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep ".=
..")
+                     "VAL"))
+         (s (if list-sep "s" "")) ; to make plurals
+         (val-doc (concat "VAL" s " "
+                          "must be " (unless list-sep "a ") (when quoted "=
quoted ")
+                          (if (ical:value-type-symbol-p value)
+                              (format "`%s' value%s" (symbol-name value) s)
+                            (format "string%s matching rx `%s'" s value))))
+         (syntax-doc (format "Syntax: %s=3D%s\n%s"
+                             (or param-name "(NAME)") val-list val-doc))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-param t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:is-quoted ,quoted
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regex which matches just the value of the parameter:
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx qvalue-rx))
+             (group-n 3 ical:param-value)))
+
+       ;; Regex which matches the full parameter:
+       ;; Group 1: the parameter name,
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,symbolic-name
+         (seq ";"
+              ;; if the parameter name has no printed form, the best we
+              ;; can do is match ical:param-name:
+              (group-n 1 ,(or param-name 'ical:param-name))
+              "=3D"
+              ,full-value-rx-name))
+
+       ;; CL-type to represent syntax nodes for this parameter:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s parameter." para=
m-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Matcher for the full param string, for syntax highlighting:
+       (defun ,matcher-name (limit)
+         ,(concat (format "Matcher for %s parameter.\n" param-name)
+                  "(Defined by `icalendar-define-param'.)")
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; Entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `ical:parse-params' and `ical:print-param':
+       (when ,param-name
+         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types)=
))))
+
+
+;; Define properties:
+(cl-defmacro ical:define-property (symbolic-name property-name doc value
+                                   &key default
+                                        (unrecognized default)
+                                        (default-type
+                                         (if (ical:value-type-symbol-p val=
ue)
+                                             value
+                                           'ical:text))
+                                        other-types
+                                        list-sep
+                                        child-spec
+                                        other-validator
+                                        ((:name-face name-face)
+                                         'ical:property-name nondefault-na=
me-face)
+                                        ((:value-face value-face)
+                                         'ical:property-value nondefault-v=
alue-face)
+                                        ((:warn-face warn-face)
+                                         'ical:warning nondefault-warn-fac=
e)
+                                        extra-faces
+                                        link)
+  "Define iCalendar property PROPERTY-NAME under SYMBOLIC-NAME.
+PROPERTY-NAME should be the property name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing the
+property's value. If it is a regular expression, it is assumed
+that the values are strings of type `icalendar-text' which match
+that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME is defined to
+match the property:
+  Group 1 of this regex matches PROPERTY-NAME.
+  Group 2 matches VALUE.
+  Group 3, if matched, contains any property value which does
+   *not* match VALUE, and is incorrect according to the standard.
+  Group 4, if matched, contains the (unparsed) property parameters;
+   its boundaries can be used for parsing these.
+
+This regex matches the entire string representing this property,
+from the beginning of the content line to the end of its value.
+Another regular expression named `SYMBOLIC-NAME-value' is also
+defined to match just the value part, after the separating colon,
+with groups 2 and 3 as above.
+
+A function to match the complete property expression called
+`icalendar-match-PROPERTY-NAME-property' is defined. This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+See the functions `icalendar-read-property-value',
+`icalendar-parse-property-value', `icalendar-parse-property', and
+`icalendar-print-property-node' to convert properties defined
+with this macro to and from their text representation in
+iCalendar format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if
+  the property is not specified in a given component.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but
+  syntactically correct according to RFC5545. Unrecognized values
+  must be in match group 5 of the regex determined by VALUE. An
+  unrecognized value will be preserved in the syntax tree
+  metadata and printed instead of this value when the node is
+  printed. Defaults to any value specified for :default.
+
+:default-type - a type symbol naming the default type of the
+  property's value. If the property's value differs from this
+  type, an `icalendar-valuetypeparam' parameter will be added to
+  the property's syntax node and printed when the node is
+  printed. Default is VALUE if VALUE is a value type symbol,
+  otherwise the type `icalendar-text'.
+
+:other-types - a list of type symbols naming value types other
+  than :default-type. These represent alternative types for the
+  property's value. If parsing the property's value under its
+  default type fails, these types will be tried in turn, and only
+  if the property's value matches none of them will an error be
+  signaled.
+
+:list-sep - if the property accepts a list of values, this should
+  be a string which separates the values (typically \",\"). If
+  :list-sep is non-nil, the value string will first be split on
+  the separator, then each value will be read according to VALUE
+  and collected into a list when parsing. When printing, the
+  inverse happens: values are printed individually and then
+  joined with :list-sep. Passing this argument marks
+  SYMBOLIC-NAME as a type that accepts a list of values for
+  `icalendar-expects-list-of-values-p'.
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - parameters that must appear exactly once
+  :one-or-more   - parameters that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - parameters that must appear at most once
+  :zero-or-more  - parameters that may appear more than once
+  :allow-others  - if non-nil, other parameters besides those listed in
+                   the above are allowed to appear. (In this case, a
+                   :zero-or-more clause is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-property-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-property-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form for HIGHLIGHT in `font-lock-keywords'.
+  In particular, ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]])...)
+  can be used to apply different faces to different match subgroups.
+
+:link - a string containing a URL for documentation of this property"
+  (declare (doc-string 2))
+  (let* (;; Value RX:
+        (full-value-rx-name
+         (ical:protected-intern
+          (concat (symbol-name symbolic-name) "-property-value")))
+        (values-rx (when list-sep
+                    `(seq ,value (zero-or-more ,list-sep ,value))))
+        ;; Related functions:
+        (property-dname (if property-name
+                            (downcase property-name)
+                          (string-trim (symbol-name symbolic-name)
+                                       "icalendar-" "-property")))
+        (matcher-name (ical:protected-intern
+                       (concat "icalendar-match-"
+                               property-dname
+                               "-property")))
+        (type-predicate-name
+         (ical:protected-intern (concat "icalendar-"
+                                        property-dname
+                                        "-property-p")))
+        ;; Faces:
+        (has-faces (or nondefault-name-face nondefault-value-face
+                       nondefault-warn-face extra-faces))
+        ;; Documentation:
+        (header "It names a property type defined by `icalendar-define-pro=
perty'.")
+        (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "..=
.")
+                    "VAL"))
+        (default-doc (if default (format "The default value is: \"%s\"\n" =
default)
+                       ""))
+        (s (if list-sep "s" "")) ; to make plurals
+        (val-doc (concat "VAL" s " "
+                         "must be " (unless list-sep "a ")
+                         (format "value%s of one of the following types:\n=
" s)
+                         (string-join
+                          (cons
+                           (format "`%s' (default)" default-type)
+                           (mapcar (lambda (type) (format "`%s'" type))
+                                   other-types))
+                          "\n")
+                         default-doc))
+        (name-doc (if property-name "" "NAME must match rx `icalendar-name=
'"))
+        (syntax-doc (format "Syntax: %s[;PARAM...]:%s\n%s\n%s\n"
+                            (or property-name "NAME") val-list name-doc va=
l-doc))
+        (child-doc
+         (concat
+          "The following parameters are required or allowed\n"
+          "as children in syntax nodes of this type:\n\n"
+          (ical:format-child-spec child-spec)
+          (when (plist-get child-spec :allow-others)
+            "\nOther parameters of any type are also allowed.\n")))
+        (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child=
-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-property t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:default-type (quote ,default-type)
+                    'ical:other-types (quote ,other-types)
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Value regex which matches:
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx value))
+             (group-n 3 (zero-or-more any))))
+
+       ;; Full property regex which matches:
+       ;; Group 1: the property name,
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,symbolic-name
+         (seq line-start
+              (group-n 1 ,(or property-name 'ical:name))
+              (group-n 4 (zero-or-more ical:other-param-safe))
+              ":"
+              ,full-value-rx-name
+              line-end))
+
+       ;; Matcher:
+       (defun ,matcher-name (limit)
+         ,(concat (format "Matcher for `%s' property.\n" symbolic-name)
+                  "(Defined by icalendar-define-property.)")
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; CL-type to represent syntax nodes for this property:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s property." prope=
rty-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-property', `icalendar-print-property-node', etc=
.:
+       (when ,property-name
+         (push (cons ,property-name (quote ,symbolic-name)) ical:property-=
types))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords)))))
+
+
+;; Define components:
+(cl-defmacro ical:define-component (symbolic-name component-name doc
+                                    &key
+                                    ((:keyword-face keyword-face)
+                                     'ical:keyword nondefault-keyword-face)
+                                    ((:name-face name-face)
+                                     'ical:component-name nondefault-name-=
face)
+                                    child-spec
+                                    other-validator
+                                    link)
+  "Define iCalendar component COMPONENT-NAME under SYMBOLIC-NAME.
+COMPONENT-NAME should be the name of the component as it should
+appear in iCalendar data.
+
+Regular expressions to match the component boundaries are defined
+named `COMPONENT-NAME-begin' and `COMPONENT-NAME-end' (or
+`OTHER-begin' and `OTHER-end', where `OTHER' is derived from
+SYMBOLIC-NAME by removing any prefix `icalendar-' and suffix
+`-component' if COMPONENT-NAME is nil).
+  Group 1 of these regexes matches the \"BEGIN\" or \"END\"
+    keyword that marks a component boundary.
+  Group 2 matches the component name.
+
+A function to match the component boundaries is defined called
+`icalendar-match-COMPONENT-NAME-component' (or
+`icalendar-match-OTHER-component', with OTHER as above). This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+The following keyword arguments are accepted:
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - properties or components that must appear exactly once
+  :one-or-more   - properties or components that must appear at least once=
 and
+                   may appear more than once
+  :zero-or-one   - properties or components that must appear at most once
+  :zero-or-more  - properties or components that may appear more than once
+  :allow-others  - if non-nil, other children besides those listed in the =
above
+                   are allowed to appear. (In this case, a :zero-or-more c=
lause
+                   is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:keyword-face - a face symbol for highlighting the BEGIN/END keyword
+  (default: `icalendar-keyword')
+
+:name-face - a face symbol for highlighting the component name
+  (default: `icalendar-component-name')
+
+:link - a string containing a URL for documentation of this component"
+  (declare (doc-string 2))
+  (let* (;; Regexes:
+         (name-rx (or component-name 'ical:name))
+         (component-dname (if component-name
+                              (downcase component-name)
+                            (string-trim (symbol-name symbolic-name)
+                                         "icalendar-" "-component")))
+         (begin-rx-name (ical:protected-intern
+                         (concat "icalendar-" component-dname "-begin")))
+         (end-rx-name (ical:protected-intern
+                       (concat "icalendar-" component-dname "-end")))
+         ;; Related functions:
+         (matcher-name
+          (ical:protected-intern
+           (concat "icalendar-match-" component-dname "-component")))
+         (type-predicate-name
+          (ical:protected-intern
+           (concat "icalendar-" component-dname "-component-p")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-keyword-face))
+         ;; Documentation:
+         (header "It names a component type defined by
+`icalendar-define-component'.")
+         (name-doc (if (not component-name)
+                       "\nNAME must match rx `icalendar-name'"
+                     ""))
+         (syntax-doc (format "Syntax:\nBEGIN:%s\n[contentline ...]\nEND:%1=
$s%s"
+                             (or component-name "NAME")
+                             name-doc))
+         (child-doc
+          (concat
+           "The following properties and components are required or "
+           "allowed\nas children in syntax nodes of this type:\n\n"
+           (ical:format-child-spec child-spec)
+           (when (plist-get child-spec :allow-others)
+             "\nOther properties and components of any type are also allow=
ed.\n")))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" chil=
d-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-component t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:begin-rx (quote ,begin-rx-name)
+                    'ical:end-rx (quote ,end-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regexes which match:
+       ;; Group 1: BEGIN or END, and
+       ;; Group 2: the component name
+       (rx-define ,begin-rx-name
+         (seq line-start
+              (group-n 1 "BEGIN")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (rx-define ,end-rx-name
+         (seq line-start
+              (group-n 1  "END")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (defun ,matcher-name (limit)
+         ,(concat (format "Matcher for %s component boundaries.\n"
+                          (or component-name "unrecognized"))
+                  "(Defined by `icalendar-define-component'.)")
+           (re-search-forward (rx (or ,begin-rx-name ,end-rx-name)) limit =
t))
+
+       ;; CL-type to represent syntax nodes for this component:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s component."
+                  (or component-name "unrecognized"))
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,keyword-face) t t)
+                       (2 (quote ,name-face) t t)))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-component', `icalendar-print-component' etc.:
+       (when ,component-name
+         (push (cons ,component-name (quote ,symbolic-name))
+               ical:component-types)))))
+
+
+;; Macros for destructuring and binding AST nodes
+;;
+;; TODO: move these to icalendar-ast.el? It makes no sense to have these
+;; here but the various icalendar-make-* macros there.
+
+(defmacro ical:with-node-children (node bindings &rest body)
+  "Bind the variables in BINDINGS to the corresponding
+child nodes in NODE, and execute BODY with these bindings.  NODE should
+be an iCalendar syntax node representing a component or property.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :first - the first child node of TYPE
+   :default - the default value, if any, for TYPE
+   :value-node - the value of the node in :first
+   :value-type - the type of the node in :value-node (if it is a node).
+   :value - the value of the node in :value-node, if it is a node,
+     or :value-node itself, if it is not.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values of the node in :first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the nodes in :value-nodes (if they are
+     nodes), or the :value-nodes themselves (if they are not).
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not."
+  (declare (indent 2))
+  ;; Static checks on the bindings prevent various annoying bugs:
+  (dolist (b bindings)
+    (let ((type (car b))
+          (kwargs (cdr b)))
+      (unless (ical:type-symbol-p type)
+        (error "Not an iCalendar type symbol: %s" type))
+      (when (and (plist-member kwargs :all)
+                 (> 2 (length kwargs)))
+        (error ":all may not be combined with other bindings"))
+      (if (ical:expects-list-of-values-p type)
+            (when (or (plist-member kwargs :value-node)
+                      (plist-member kwargs :value-type)
+                      (plist-member kwargs :value))
+              (error "Type `%s' expects a list of values" type))
+        (when (or (plist-member kwargs :value-nodes)
+                  (plist-member kwargs :value-types)
+                  (plist-member kwargs :values))
+              (error "Type `%s' does not expect a list of values" type)))))
+
+  (let ((nd (gensym "icalendar-node")))
+    `(let* ((,nd ,node)
+            ,@(mapcan
+               (lambda (tv)
+                 (let ((type (car tv))
+                       (vars (cdr tv)))
+                   (when (and (symbolp (car vars)) (null (cdr vars)))
+                     ;; the simple (TYPE VAR) case:
+                     (setq vars (list :first (car vars))))
+
+                   (let ((first-var (or (plist-get vars :first)
+                                        (gensym "first")))
+                         (default-var (or (plist-get vars :default)
+                                          (gensym "default")))
+                         (vnode-var (or (plist-get vars :value-node)
+                                        (gensym "value-node")))
+                         (vtype-var (or (plist-get vars :value-type)
+                                        (gensym "value-type")))
+                         (vval-var (or (plist-get vars :value)
+                                       (gensym "value")))
+
+                         (vnodes-var (or (plist-get vars :value-nodes)
+                                         (gensym "value-nodes")))
+                         (vtypes-var (or (plist-get vars :value-types)
+                                         (gensym "value-types")))
+                         (vvals-var (or (plist-get vars :values)
+                                        (gensym "values")))
+
+                         (all-var (or (plist-get vars :all)
+                                      (gensym "all")))
+                         ;; The corresponding vars for :all are mostly
+                         ;; too complicated to be useful, I think, so
+                         ;; not implementing them for now.
+                         ;; TODO: but it *would* be helpful to have an
+                         ;; :all-values clause especially for RDATE and
+                         ;; EXDATE, since they both accept lists, and
+                         ;; can also occur multiple times.
+                         ;; I've found myself needing to write
+                         ;; (mapcar #'ical:ast-node-value
+                         ;;   (apply #'append
+                         ;;     (mapcar #'ical:ast-node-value rdate-nodes))
+                         ;; a bit too often.
+                         )
+                     (delq nil
+                           (list
+                            (when (plist-member vars :all)
+                              `(,all-var (ical:ast-node-children-of
+                                          (quote ,type) ,nd)))
+                            (when (not (plist-member vars :all))
+                              `(,first-var (ical:ast-node-first-child-of
+                                            (quote ,type) ,nd)))
+                            (when (plist-member vars :default)
+                              `(,default-var (get (quote ,type)
+                                                  'ical:default-value)))
+                            ;; Single value:
+                            (when (or (plist-member vars :value-node)
+                                      (plist-member vars :value-type)
+                                      (plist-member vars :value))
+                              `(,vnode-var (when (ical:ast-node-p ,first-v=
ar)
+                                             (ical:ast-node-value ,first-v=
ar))))
+                            (when (plist-member vars :value-type)
+                              `(,vtype-var
+                                (when ,vnode-var
+                                  (ical:ast-node-type ,vnode-var))))
+                            (when (plist-member vars :value)
+                              `(,vval-var
+                                (when ,vnode-var
+                                  (if (ical:ast-node-p ,vnode-var)
+                                      (ical:ast-node-value ,vnode-var)
+                                    ,vnode-var))))
+
+                            ;; List of values:
+                            (when (or (plist-member vars :value-nodes)
+                                      (plist-member vars :value-types)
+                                      (plist-member vars :values))
+                              `(,vnodes-var
+                                (when (ical:ast-node-p ,first-var)
+                                  (ical:ast-node-value ,first-var))))
+                            (when (plist-member vars :value-types)
+                              `(,vtypes-var
+                                (when ,vnodes-var
+                                  (mapcar #'ical:ast-node-type ,vnodes-var=
))))
+                            (when (plist-member vars :values)
+                              `(,vvals-var
+                                (when ,vnodes-var
+                                  (if (ical:ast-node-p (car ,vnodes-var))
+                                      (mapcar #'ical:ast-node-value
+                                              ,vnodes-var)
+                                    ,vnodes-var)))))))))
+
+               bindings))
+       ,@body)))
+
+(defalias 'ical:with-component #'ical:with-node-children
+    "Execute BODY with properties of NODE bound as in BINDINGS.
+
+NODE should be an iCalendar syntax node representing an iCalendar
+component: `icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal',
+`icalendar-vtimezone', `icalendar-vfreebusy', `icalendar-standard',
+`icalendar-daylight'. It may also be an entire `icalendar-vcalendar'.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :default - the default value, if any, for TYPE
+   :first - the first child node of TYPE
+   :value-node - the value (which is itself a node) of the node in :first
+   :value-type - the type of the node in :value-node.
+   :value - the value of the node in :value-node.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values (which are themselves nodes) of the node in :=
first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the node in :value-node.
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not.=
")
+
+(defmacro ical:with-node-value (node &optional bindings &rest body)
+  "Execute BODY with bindings in BINDINGS taken from NODE and its children.
+
+NODE should be an iCalendar syntax node representing a property or
+parameter. If NODE is not a syntax node, this form evalutes to nil
+without binding the variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is a list of syntax nodes, then within BODY,
+`value-nodes' will be bound to those value nodes, `value-types' will be
+bound to a list of their types, and `values' will be bound to their
+values.
+
+If NODE's value is not a syntax node, then `value' is instead bound
+directly to NODE's value, and `value-type' and `value-node' are bound to
+nil.
+
+If BODY is nil, it is assumed to be the symbol `value'; thus
+  (icalendar-with-node-value some-node)
+is equivalent to
+  (icalendar-with-node-value some-node nil value)
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form."
+  (let ((vn (gensym "icalendar-node"))
+        (val (gensym "icalendar-value"))
+        (is-list (gensym "is-list")))
+    `(let ((,vn ,node))
+       (when (ical:ast-node-p ,vn)
+         (let* ((,val (ical:ast-node-value ,vn))
+                (value-node (when (ical:ast-node-p ,val) ,val))
+                (value-type (when (ical:ast-node-p value-node)
+                              (ical:ast-node-type value-node)))
+                (value (if (ical:ast-node-p value-node)
+                           (ical:ast-node-value value-node)
+                         ,val))
+                (,is-list (ical:expects-list-of-values-p (ical:ast-node-ty=
pe ,vn)))
+                (value-nodes (when ,is-list
+                               (seq-filter #'ical:ast-node-p ,val)))
+                (value-types (when ,is-list
+                               (mapcar #'ical:ast-node-type value-nodes)))
+                (values (when ,is-list
+                          (mapcar #'ical:ast-node-value value-nodes))))
+           (ignore value-type ; Silence the byte compiler when
+                   value      ; one of these goes unused
+                   value-types
+                   values)
+           (ical:with-node-children ,vn ,bindings ,@(or body (list 'value)=
)))))))
+
+(defalias 'ical:with-property #'ical:with-node-value
+    "Execute BODY with BINDINGS taken from the value and parameters in NOD=
E.
+
+NODE should be an iCalendar syntax node representing a property. If NODE
+is not a syntax node, this form evalutes to nil without binding the
+variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is a list of syntax nodes, then within BODY,
+`value-nodes' will be bound to those value nodes, `value-types' will be
+bound to a list of their types, and `values' will be bound to their
+values.
+
+If NODE's value is not a syntax node, then `value' is bound directly to
+NODE's value, and `value-type' and `value-node' are bound to nil.
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form.")
+
+(defmacro ical:with-param (parameter &rest body)
+  "Bind the value in PARAMETER and execute BODY.
+
+PARAMETER should be an iCalendar syntax node representing a
+parameter. If PARAMETER is nil, this form evalutes to nil without
+executing BODY.
+
+Within BODY, if PARAMETER's value is a syntax node, the symbol
+`value-node' will be bound to that syntax node, `value-type' will be
+bound to the value node's type, and `value' will be bound to the value
+node's value.
+
+If PARAMETER's value is not a syntax node, then `value' is bound
+directly to PARAMETER's value, and `value-type' and `value-node' are
+bound to nil."
+  `(ical:with-node-value ,parameter nil ,@body))
+
+(defmacro ical:with-child-of (node type &optional bindings &rest body)
+  "Like `icalendar-with-node-value', but for the relevant node's parent.
+
+Find the first child node of type TYPE in NODE, bind that
+child node's value and any of its children in BINDINGS and execute BODY
+with these bindings.  If there is no such node, this form evalutes to
+nil without executing BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound as in `icalendar-with-node-value'.
+If BODY is nil, it is assumed to be the symbol `value'; thus
+  (icalendar-with-child-of some-node some-type)
+is equivalent to
+  (icalendar-with-child-of some-node some-type nil value)
+
+See `icalendar-with-node-children' for the form of BINDINGS."
+  (let ((child (gensym "icalendar-node")))
+    `(let ((,child (ical:ast-node-first-child-of ,type ,node)))
+       (ical:with-node-value ,child ,bindings ,@body))))
+
+(defalias 'ical:with-property-of #'ical:with-child-of
+  "Like `icalendar-with-property', but for components containing that prop=
erty.
+
+Find the first property node of type TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the property's value node, type and value as in
+`icalendar-with-node-value'.  If BODY is nil, it is assumed to be the
+symbol `value'; thus
+  (icalendar-with-property-of some-component some-type)
+is equivalent to
+  (icalendar-with-property-of some-component some-type nil value)
+
+BINDINGS can be used to bind the property's parameters; see
+`icalendar-with-node-children' for the form of BINDINGS.")
+
+(defmacro ical:with-param-of (node type &rest body)
+  "Like `icalendar-with-param', but for properties containing that param.
+
+Find the first parameter node of TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the parameter's value node, type and value as in
+`icalendar-with-node-value'.  If BODY is nil, it is assumed to be the
+symbol `value'; thus
+  (icalendar-with-param-of some-property some-type)
+is equivalent to
+  (icalendar-with-param-of some-property some-type nil value)
+"
+  `(ical:with-child-of ,node ,type nil ,@body))
+
+(provide 'icalendar-macs)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-macs.el ends here
diff --git a/lisp/calendar/icalendar-mode.el b/lisp/calendar/icalendar-mode=
.el
new file mode 100644
index 00000000000..e864224345b
--- /dev/null
+++ b/lisp/calendar/icalendar-mode.el
@@ -0,0 +1,605 @@
+;;; icalendar-mode.el --- Major mode for iCalendar format  -*- lexical-bin=
ding: t; -*-
+;;;
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines icalendar-mode, a major mode for iCalendar
+;; data. Its main job is to provide syntax highlighting using the
+;; matching functions created for iCalendar syntax in
+;; icalendar-parser.el.
+
+;; When activated, icalendar-mode offers to unfold content lines if
+;; necessary, and switch to a new buffer containing the unfolded data;
+;; see `ical:maybe-switch-to-unfolded-buffer'. This is because the
+;; parsing functions, and thus syntax highlighting, assume that content
+;; lines have already been unfolded. When a buffer is saved,
+;; icalendar-mode also offers to fold long content if necessary, as
+;; required by RFC5545; see `ical:before-save-checks'.
+
+;;; Code:
+
+(require 'icalendar-parser)
+
+;; Faces and font lock:
+(defgroup ical:faces
+  '((ical:property-name custom-face)
+    (ical:property-value custom-face)
+    (ical:parameter-name custom-face)
+    (ical:parameter-value custom-face)
+    (ical:component-name custom-face)
+    (ical:keyword custom-face)
+    (ical:binary-data custom-face)
+    (ical:date-time-types custom-face)
+    (ical:numeric-types custom-face)
+    (ical:recurrence-rule custom-face)
+    (ical:warning custom-face)
+    (ical:ignored custom-face))
+  "Faces for icalendar-mode."
+  :version "32.1"
+  :group 'icalendar
+  :prefix 'icalendar)
+
+(defface ical:property-name
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for iCalendar property names")
+
+(defface ical:property-value
+  '((default . (:inherit default)))
+  "Face for iCalendar property values")
+
+(defface ical:parameter-name
+  '((default . (:inherit font-lock-property-name-face)))
+  "Face for iCalendar parameter names")
+
+(defface ical:parameter-value
+  '((default . (:inherit font-lock-property-use-face)))
+  "Face for iCalendar parameter values")
+
+(defface ical:component-name
+  '((default . (:inherit font-lock-constant-face)))
+  "Face for iCalendar component names")
+
+(defface ical:keyword
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for other iCalendar keywords")
+
+(defface ical:binary-data
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar values that represent binary data")
+
+(defface ical:date-time-types
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar values that represent dates, date-times,
+durations, periods, and UTC offsets")
+
+(defface ical:numeric-types
+  '((default . (:inherit ical:property-value-face)))
+  "Face for iCalendar values that represent integers, floats, and geolocat=
ions")
+
+(defface ical:recurrence-rule
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar recurrence rule values")
+
+(defface ical:uri
+  '((default . (:inherit ical:property-value-face :underline t)))
+  "Face for iCalendar values that are URIs (including URLs and mail addres=
ses)")
+
+(defface ical:warning
+  '((default . (:inherit font-lock-warning-face)))
+  "Face for iCalendar syntax errors")
+
+(defface ical:ignored
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar syntax which is parsed but ignored")
+
+;;; Font lock:
+(defconst ical:params-font-lock-keywords
+  '((ical:match-other-param
+     (1 'font-lock-comment-face t t)
+     (2 'font-lock-comment-face t t)
+     (3 'ical:warning t t))
+    (ical:match-value-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sent-by-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-rsvp-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-role-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-reltype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-related-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-range-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-partstat-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-member-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-language-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-fbtype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-fmttype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-encoding-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-dir-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-to-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-from-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-cutype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-cn-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-altrep-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar property parameters in `font-lock-keywords'.")
+
+(defconst ical:properties-font-lock-keywords
+  '((ical:match-request-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-other-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sequence-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-last-modified-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstamp-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-created-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-trigger-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-repeat-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-action-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-rrule-property
+     (1 'ical:property-name t t)
+     (2 'ical:recurrence-rule t t)
+     (3 'ical:warning t t))
+    (ical:match-rdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-exdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-uid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-url-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-related-to-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-recurrence-id-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-organizer-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-contact-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attendee-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzurl-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetto-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetfrom-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzname-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-transp-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-freebusy-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-duration-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstart-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-due-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtend-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-completed-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-summary-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-resources-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-priority-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-percent-complete-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-location-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-geo-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-description-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-comment-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-class-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-categories-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attach-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t)
+     (13 'ical:uri t t)
+     (14 'ical:binary-data t t))
+    (ical:match-version-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-prodid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-method-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-calscale-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar properties in `font-lock-keywords'.")
+
+(defconst ical:ignored-properties-font-lock-keywords
+  `((,(rx ical:other-property) (1 'ical:ignored keep t)
+                               (2 'ical:ignored keep t)))
+  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
+
+(defconst ical:components-font-lock-keywords
+  '((ical:match-vcalendar-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-other-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-valarm-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-daylight-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-standard-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtimezone-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vfreebusy-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vjournal-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtodo-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vevent-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t)))
+  "Entries for iCalendar components in `font-lock-keywords'.")
+
+(defvar ical:font-lock-keywords
+  (append ical:params-font-lock-keywords
+          ical:properties-font-lock-keywords
+          ical:components-font-lock-keywords
+          ical:ignored-properties-font-lock-keywords)
+  "Value of `font-lock-keywords' for icalendar-mode.")
+
+
+;; The major mode:
+
+;;; Mode hook
+(defvar ical:mode-hook nil
+  "Hook run when activating `icalendar-mode'.")
+
+;;; Activating the mode for .ics files:
+(add-to-list 'auto-mode-alist '("\\.ics\\'" . icalendar-mode))
+
+;;; Syntax table
+(defvar ical:mode-syntax-table
+    (let ((st (make-syntax-table)))
+      ;; Characters for which the standard syntax table suffices:
+      ;; ; (punctuation): separates some property values, and property par=
ameters
+      ;; " (string): begins and ends string values
+      ;; : (punctuation): separates property name (and parameters) from pr=
operty
+      ;;                  values
+      ;; , (punctuation): separates values in a list
+      ;; CR, LF (whitespace): content line endings
+      ;; space (whitespace): when at the beginning of a line, continues the
+      ;;                     previous line
+
+      ;; Characters which need to be adjusted from the standard syntax tab=
le:
+      ;; =3D is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?=3D ".   " st)
+      ;; / is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?/ ".   " st)
+      st)
+    "Syntax table used in `icalendar-mode'.")
+
+;;; Commands
+
+(defun ical:switch-to-unfolded-buffer ()
+  "Switch to viewing the contents of the current buffer in a new
+buffer where content lines have been unfolded.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets; `unfolding'
+means removing the extra whitespace inserted by folding. The
+iCalendar standard (RFC5545) requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it. In icalendar-mode, folded lines may not have proper
+syntax highlighting; this command allows you to view iCalendar
+data with proper syntax highlighting, as the parser sees it.
+
+If the current buffer is visiting a file, this function will
+offer to save the buffer first, and then reload the contents from
+the file, performing unfolding with `icalendar-unfold-undecoded-region'
+before decoding it. This is the most reliable way to unfold lines.
+
+If it is not visiting a file, it will unfold the new buffer
+with `icalendar-unfold-region'. This can in some cases have
+undesirable effects (see its docstring), so the original contents
+are preserved unchanged in the current buffer.
+
+In both cases, after switching to the new buffer, this command
+offers to kill the original buffer.
+
+It is recommended to turn off `auto-fill-mode' when viewing an
+unfolded buffer, so that filling does not interfere with syntax
+highlighting. This function offers to disable `auto-fill-mode' if
+it is enabled in the new buffer; consider using
+`visual-line-mode' instead."
+  (interactive)
+  (when (and buffer-file-name (buffer-modified-p))
+    (when (y-or-n-p (format "Save before reloading from %s?"
+                            (file-name-nondirectory buffer-file-name)))
+      (save-buffer)))
+  (let ((old-buffer (current-buffer))
+        (mmode major-mode)
+        (uf-buffer (if buffer-file-name
+                       (ical:unfolded-buffer-from-file buffer-file-name)
+                     (ical:unfolded-buffer-from-buffer (current-buffer)))))
+    (switch-to-buffer uf-buffer)
+    ;; restart original major mode, in case the new buffer is
+    ;; still in fundamental-mode: TODO: is this necessary?
+    (funcall mmode)
+    (when (y-or-n-p (format "Unfolded buffer is shown. Kill %s?"
+                            (buffer-name old-buffer)))
+      (kill-buffer old-buffer))
+    (when (and auto-fill-function
+               (y-or-n-p "Disable auto-fill-mode?"))
+      (auto-fill-mode -1))))
+
+(defun ical:maybe-switch-to-unfolded-buffer ()
+  "Check for folded lines and ask for confirmation before calling
+`icalendar-switch-to-unfolded-buffer', which see.
+
+This function is intended to be run via `icalendar-mode-hook'
+when `icalendar-mode' is activated."
+  (interactive)
+  (when (and (ical:contains-folded-lines-p)
+             (y-or-n-p "Buffer contains folded lines; unfold in new buffer=
?"))
+        (ical:switch-to-unfolded-buffer)))
+
+(add-hook 'ical:mode-hook #'ical:maybe-switch-to-unfolded-buffer)
+
+(defun ical:before-save-checks ()
+  "Offer to change coding system and fold content lines in the
+current buffer when saving a buffer in `icalendar-mode'.
+
+The iCalendar standard requires CR-LF line endings, so if
+`buffer-file-coding-system' does not use a coding system which
+specifies them, this command offers to switch to a corresponding
+coding system which does.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets. The iCalendar
+standard requires folding lines when serializing data to
+iCalendar format, so if the buffer contains unfolded lines, this
+command asks you whether you want to fold them."
+  (interactive)
+  (when (eq major-mode 'icalendar-mode)
+    (let* ((cs buffer-file-coding-system)
+           (suggested-cs (if cs (coding-system-change-eol-conversion cs 'd=
os)
+                           'prefer-utf-8-dos)))
+      (when (and (not (coding-system-equal cs suggested-cs))
+                 (y-or-n-p
+                  (format "Current coding system %s does not use CR-LF lin=
e endings. Change to %s for save?" cs suggested-cs)))
+        (set-buffer-file-coding-system suggested-cs))
+      (when (and (ical:contains-unfolded-lines-p)
+                 (y-or-n-p "Fold content lines before saving?"))
+        (ical:fold-region (point-min) (point-max))))))
+
+(add-hook 'before-save-hook #'ical:before-save-checks)
+
+;;; Mode definition
+(define-derived-mode icalendar-mode text-mode "iCalendar"
+  "Major mode for viewing and editing iCalendar (RFC5545) data.
+
+This mode provides syntax highlighting for iCalendar components,
+properties, values, and property parameters, and commands to deal
+with folding and unfolding iCalendar content lines.
+
+`Folding' means inserting whitespace characters to continue long
+lines; `unfolding' means removing the extra whitespace inserted
+by folding. The iCalendar standard requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it.
+
+Thus icalendar-mode's syntax highlighting is designed to work with
+unfolded lines. When icalendar-mode is activated, it will offer to
+unfold lines; see `icalendar-switch-to-unfolded-buffer'. It will also
+offer to fold lines when saving a buffer to a file; see
+`icalendar-before-save-checks'. That function also offers to convert the
+line endings in the file to CR-LF, as the standard requires."
+  :group 'icalendar
+  :syntax-table ical:mode-syntax-table
+  ;; TODO: Keymap?
+  ;; TODO: buffer-local variables?
+  ;; TODO: indent-line-function and indentation variables
+  ;; TODO: mode-specific menu and context menus
+  ;; TODO: eldoc integration
+  ;; TODO: completion of keywords
+  ;; TODO: hook for folding in change-major-mode-hook?
+  (progn
+    (setq font-lock-defaults '(ical:font-lock-keywords nil t))))
+
+(provide 'icalendar-mode)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-mode.el ends here
diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-pa=
rser.el
new file mode 100644
index 00000000000..8dadd53f321
--- /dev/null
+++ b/lisp/calendar/icalendar-parser.el
@@ -0,0 +1,4804 @@
+;;; icalendar-parser.el --- Parse iCalendar grammar  -*- lexical-binding: =
t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines regular expressions, constants and functions that
+;; implement the iCalendar grammar according to RFC5545.
+;;
+;; iCalendar data is grouped into *components*, such as events or
+;; to-do items. Each component contains one or more *content lines*,
+;; which each contain a *property* name and its *value*, and possibly
+;; also property *parameters* with additional data that affects the
+;; interpretation of the property.
+;;
+;; The macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', defined in
+;; icalendar-macs.el, each create rx-style regular expressions for one
+;; of these categories in the grammar and are used here to define the
+;; particular value types, parameters, properties and components in the
+;; standard as type symbols. These type symbols store all the metadata
+;; about the relevant types, and are used for type-based dispatch in the
+;; parser and printer functions. In the abstract syntax tree, each node
+;; contains a type symbol naming its type. A number of other regular
+;; expressions which encode basic categories of the grammar are also
+;; defined in this file.
+;;
+;; The following functions provide the high-level interface to the parser:
+;;
+;;   `icalendar-parse-and-index'
+;;   `icalendar-parse'
+;;   `icalendar-parse-calendar'
+;;   `icalendar-parse-component'
+;;   `icalendar-parse-property'
+;;   `icalendar-parse-params'
+;;
+;; The format of the abstract syntax tree which these functions create
+;; is documented in icalendar-ast.el. Nodes in this tree can be
+;; serialized to iCalendar format with the corresponding printer
+;; functions:
+;;
+;;   `icalendar-print-calendar-node'
+;;   `icalendar-print-component-node'
+;;   `icalendar-print-property-node'
+;;   `icalendar-print-params'
+
+;;; Code:
+
+(require 'icalendar)
+(eval-when-compile (require 'icalendar-macs))
+(require 'icalendar-ast)
+(eval-when-compile (require 'cl-lib))
+(require 'subr-x)
+(require 'seq)
+(require 'rx)
+(require 'calendar)
+(require 'time-date)
+(require 'simple)
+(require 'help-mode)
+
+;;; Customization
+(defgroup icalendar-parser nil
+  "iCalendar parsing options"
+  :version "32.1"
+  :group 'icalendar
+  :prefix 'icalendar)
+
+(defcustom ical:parse-strictly nil
+  "When non-nil, iCalendar data will be parsed strictly.
+
+By default, the iCalendar parser accepts certain harmless deviations
+from RFC5545 that are common in real-world data (e.g., unescaped commas
+in text values). Setting this to t will cause the parser to produce
+errors instead of silently accepting such data."
+  :version "32.1"
+  :type '(choice (const :tag "Ignore minor errors" nil)
+                 (const :tag "Parse strictly" t)))
+
+;;; Functions for folding and unfolding
+;;
+;; According to RFC5545, iCalendar content lines longer than 75 octets
+;; should be *folded* by inserting extra line breaks and leading
+;; whitespace to continue the line. Such lines must be *unfolded*
+;; before they can be parsed.  Unfolding can only reliably happen
+;; before Emacs decodes a region of text, because decoding potentially
+;; replaces the CR-LF line endings which terminate content lines.
+;; Programs that can control when decoding happens should use the
+;; stricter `ical:unfold-undecoded-region' to unfold text; programs
+;; that must work with decoded data should use the looser
+;; `ical:unfold-region'. `ical:fold-region' will fold content lines
+;; using line breaks appropriate to the buffer's coding system.
+;;
+;; All the parsing-related code belows assumes that lines have
+;; already been unfolded if necessary.
+(defcustom ical:pre-unfolding-hook nil
+  "Hook run before unfolding iCalendar data.
+
+The functions in this hook will be run before the iCalendar data is
+\"unfolded\", i.e., before whitespace introduced for breaking long lines
+is removed (see `icalendar-unfold-region' and
+`icalendar-unfold-undecoded-region').  If you routinely receive
+iCalendar data that is not correctly folded, you can add functions to
+this hook which clean up that data before unfolding is attempted.
+
+Each function should accept zero arguments and should perform its
+operation on the entire current buffer."
+  :version "32.1"
+  :type '(hook))
+
+(defun ical:unfold-undecoded-region (start end &optional buffer)
+  "Unfold an undecoded region in BUFFER between START and END.
+If omitted, BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation). RFC5545 specifies these whitespace
+characters to be a CR-LF sequence followed by a single space or
+tab character. Unfolding can only be done reliably before a
+region is decoded, since decoding potentially replaces CR-LF line
+endings.
+
+When `icalendar-parse-strictly' is non-nil, this function searches
+strictly for CR-LF sequences and will fail if they have already been
+replaced, so it should only be called with a region that has not yet
+been decoded. Otherwise, it also searches for folds containing
+Unix-style LF line endings, since these are common in real data."
+  (with-current-buffer (or buffer (current-buffer))
+    (with-restriction start end
+      (run-hooks 'ical:pre-unfolding-hook)
+      (goto-char (point-min))
+      ;; Testing reveals that a *significant* amount of real-world data
+      ;; does not use CR-LF line endings, even if it is otherwise
+      ;; OK. So unless we're explicitly parsing strictly, we allow the
+      ;; CR to be missing, as we do in `icalendar-unfold-region':
+      (let ((fold (if ical:parse-strictly (rx (seq "\r\n" (or " " "\t")))
+                    (rx (seq (zero-or-one "\r") "\n" (or " " "\t"))))))
+      (while (re-search-forward fold nil t)
+        (replace-match "" nil nil))))))
+
+(defun ical:unfold-region (start end &optional buffer)
+  "Unfold region between START and END in BUFFER (default: current buffer).
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation).
+
+WARNING: Unfolding can only be done reliably before text is
+decoded, since decoding potentially replaces CR-LF line endings.
+Unfolding an already-decoded region could lead to unexpected
+results, such as displaying multibyte characters incorrectly,
+depending on the contents and the coding system used.
+
+This function attempts to do the right thing even if the region
+is already decoded. If it is still undecoded, it is better to
+call `icalendar-unfold-undecoded-region' directly instead, and
+decode it afterward."
+  ;; TODO: also make this a command so it can be run manually?
+  (with-current-buffer (or buffer (current-buffer))
+    (let ((was-multibyte enable-multibyte-characters)
+          (start-char (position-bytes start))
+          (end-char (position-bytes end)))
+      ;; we put the buffer in unibyte mode and later restore its
+      ;; previous state, so that if the buffer was already multibyte,
+      ;; any multibyte characters where line folds broke up their
+      ;; bytes can be reinterpreted:
+      (set-buffer-multibyte nil)
+      (with-restriction start-char end-char
+        (run-hooks 'ical:pre-unfolding-hook)
+        (goto-char (point-min))
+        ;; since we can't be sure that line folds have a leading CR
+        ;; in already-decoded regions, do the best we can:
+        (while (re-search-forward (rx (seq (zero-or-one "\r") "\n"
+                                           (or " " "\t")))
+                                  nil t)
+          (replace-match "" nil nil)))
+      ;; restore previous state, possibly reinterpreting characters:
+      (set-buffer-multibyte was-multibyte))))
+
+(defun ical:unfolded-buffer-from-region (start end &optional buffer)
+  "Create a new, unfolded buffer with the same contents as the region.
+
+Copies the buffer contents between START and END (in BUFFER, if
+provided) to a new buffer and performs line unfolding in the new buffer
+with `icalendar-unfold-region'. That function can in some cases have
+undesirable effects; see its docstring. If BUFFER is visiting a file, it
+may be better to reload its contents from that file and perform line
+unfolding before decoding; see `icalendar-unfolded-buffer-from-file'.
+Returns the new buffer."
+  (let* ((old-buffer (or buffer (current-buffer)))
+         (contents (with-current-buffer old-buffer
+                     (buffer-substring start end)))
+         (uf-buffer (generate-new-buffer
+                     (concat (buffer-name old-buffer)
+                             "~UNFOLDED")))) ;; TODO: again, move to model=
ine?
+    (with-current-buffer uf-buffer
+      (insert contents)
+      (ical:unfold-region (point-min) (point-max))
+      ;; ensure we'll use CR-LF line endings on write, even if they weren't
+      ;; in the source data. The standard also says UTF-8 is the default
+      ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+      ;; is nil.
+      (setq buffer-file-coding-system
+            (if last-coding-system-used
+                (coding-system-change-eol-conversion last-coding-system-us=
ed
+                                                     'dos)
+              'prefer-utf-8-dos))
+      ;; inhibit auto-save-mode, which will otherwise create save
+      ;; files containing the unfolded data; these are probably
+      ;; not useful to the user and a nuisance when running tests:
+      (auto-save-mode -1))
+    uf-buffer))
+
+(defun ical:unfolded-buffer-from-buffer (buffer)
+  "Create a new, unfolded buffer with the same contents as BUFFER.
+
+Copies the contents of BUFFER to a new buffer and performs line
+unfolding there with `icalendar-unfold-region'. That function can in
+some cases have undesirable effects; see its docstring. If BUFFER is
+visiting a file, it may be better to reload its contents from that file
+and perform line unfolding before decoding; see
+`icalendar-unfolded-buffer-from-file'. Returns the new buffer."
+  (with-current-buffer buffer
+    (ical:unfolded-buffer-from-region (point-min) (point-max) buffer)))
+
+(defun ical:find-unfolded-buffer-visiting (filename)
+  "Find an existing unfolded buffer visiting FILENAME."
+  ;; FIXME: I was previously using
+  ;;   (find-buffer-visiting filename #'ical:unfolded-p)
+  ;; for this, but found that it would sometimes return nil even when an
+  ;; unfolded buffer already existed for FILENAME, leading to buffers
+  ;; getting unfolded and parsed multiple times. Hence this kludge.
+  (catch 'unfolded
+    (let ((exp-name (expand-file-name filename)))
+      (dolist (buf (match-buffers "UNFOLDED"))
+        (when (and (equal exp-name (buffer-file-name buf))
+                   (ical:unfolded-p buf))
+          (throw 'unfolded buf))))))
+
+(defun ical:unfolded-buffer-from-file (filename &optional visit beg end)
+    "Return a buffer visiting FILENAME with unfolded lines.
+
+If an unfolded buffer is already visiting FILENAME, return
+it. Otherwise, create a new buffer with the contents of FILENAME and
+perform line unfolding with `icalendar-unfold-undecoded-region', then
+decode the buffer, setting an appropriate value for
+`buffer-file-coding-system', and return the new buffer. Optional
+arguments VISIT, BEG, END are as in `insert-file-contents'."
+    (unless (and (file-exists-p filename)
+                 (file-readable-p filename))
+      (error "File cannot be read: %s" filename))
+    ;; TODO: instead of messing with the buffer name, it might be more
+    ;; useful to keep track of the folding state in a variable and
+    ;; display it somewhere else in the mode line
+    (or (ical:find-unfolded-buffer-visiting filename)
+        (let ((uf-buffer
+               (generate-new-buffer
+                (concat " *UNFOLDED:" (file-name-nondirectory filename)))))
+          (with-current-buffer uf-buffer
+            (set-buffer-multibyte nil)
+            (insert-file-contents-literally filename visit beg end t)
+            (ical:unfold-undecoded-region (point-min) (point-max))
+            (set-buffer-multibyte t)
+            (decode-coding-inserted-region (point-min) (point-max) filenam=
e)
+            ;; ensure we'll use CR-LF line endings on write, even if they =
weren't
+            ;; in the source data. The standard also says UTF-8 is the def=
ault
+            ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-=
used
+            ;; is nil. FIXME: for some reason, this doesn't seem to run at=
 all!
+            (setq buffer-file-coding-system
+                  (if last-coding-system-used
+                      (coding-system-change-eol-conversion last-coding-sys=
tem-used
+                                                           'dos)
+                    'prefer-utf-8-dos))
+            ;; restore buffer name after renaming by set-visited-file-name:
+            (let ((bname (buffer-name)))
+              (set-visited-file-name filename t)
+              (rename-buffer bname))
+            ;; inhibit auto-save-mode, which will otherwise create save
+            ;; files containing the unfolded data; these are probably
+            ;; not useful to the user and a nuisance when running tests:
+            (auto-save-mode -1))
+        uf-buffer)))
+
+(defun ical:fold-region (begin end &optional use-tabs)
+  "Fold all content lines in the region longer than 75 octets.
+
+\"Folding\" means inserting a line break and a single space
+character at the beginning of the new line. If USE-TABS is
+non-nil, insert a tab character instead of a single space.
+
+RFC5545 specifies that lines longer than 75 *octets* (excluding
+the line-ending CR-LF sequence) must be folded, and allows that
+some implementations might fold lines in the middle of a
+multibyte character. This function takes care not to do that in a
+buffer where `enable-multibyte-characters' is non-nil, and only
+folds between character boundaries. If the buffer is in unibyte
+mode, however, and contains undecoded multibyte data, it may fold
+lines in the middle of a multibyte character."
+  ;; TODO: also make this a command so it can be run manually?
+  (save-excursion
+    (goto-char begin)
+    (when (not (bolp))
+      (let ((inhibit-field-text-motion t))
+        (beginning-of-line)))
+    (let ((bol (point))
+          (eol (make-marker))
+          (reg-end (make-marker))
+          (line-fold (if use-tabs "\n\t" "\n ")))
+      (set-marker reg-end end)
+      (while (< bol reg-end)
+        (let ((inhibit-field-text-motion t))
+          (end-of-line))
+        (set-marker eol (point))
+        (when (< 75 (- (position-bytes (marker-position eol))
+                       (position-bytes bol)))
+          (goto-char
+           ;; the max of 75 excludes the two CR-LF
+           ;; characters we're about to add:
+           (byte-to-position (+ 75 (position-bytes bol))))
+          (insert line-fold)
+          (set-marker eol (point)))
+        (setq bol (goto-char (1+ eol)))))))
+
+(defun ical:contains-folded-lines-p (&optional buffer)
+  "Return non-nil if BUFFER contains folded content lines.
+
+BUFFER defaults to the current buffer. Folded content lines need to be
+unfolded before parsing the buffer or performing syntax
+highlighting. Returns the position at the end of the first fold, or nil."
+  (with-current-buffer (or buffer (current-buffer))
+    (save-excursion
+      (goto-char (point-min))
+      (re-search-forward (rx (seq line-start (or " " "\t")))
+                         nil t))))
+
+(defun ical:unfolded-p (&optional buffer)
+  "Return non-nil if BUFFER does not contain any folded content lines.
+BUFFER defaults to the current buffer."
+  (not (ical:contains-folded-lines-p buffer)))
+
+(defun ical:contains-unfolded-lines-p (&optional buffer)
+  "Return non-nil if BUFFER contains long content lines that should be fol=
ded.
+
+Lines longer than 75 bytes need to folded before saving or transmitting
+the data in BUFFER (default: current buffer). If BUFFER contains such
+lines, return the position at the beginning of the first line that
+requires folding."
+  (with-current-buffer (or buffer (current-buffer))
+    (save-excursion
+      (goto-char (point-min))
+      (let ((bol (point))
+            (eol (make-marker)))
+        (catch 'unfolded-line
+          (while (< bol (point-max))
+            (let ((inhibit-field-text-motion t))
+              (end-of-line))
+            (set-marker eol (point))
+            ;; the max of 75 excludes the two CR-LF characters
+            ;; after position eol:
+            (when (< 75 (- (position-bytes (marker-position eol))
+                           (position-bytes bol)))
+              (throw 'unfolded-line bol))
+            (setq bol (goto-char (1+ eol))))
+          nil)))))
+
+(defun ical:folded-p (&optional buffer)
+  "Return non-nil if BUFFER contains no content lines that require folding.
+BUFFER defaults to the current buffer."
+  (not (ical:contains-unfolded-lines-p buffer)))
+
+
+;; Parsing-related code starts here. All the parsing code assumes that
+;; content lines have already been unfolded.
+
+;;;; Error handling:
+
+;; Errors at the parsing stage:
+;; e.g. value does not match expected regex
+(define-error 'ical:parse-error "Could not parse iCalendar data" 'ical:err=
or)
+
+(cl-defun ical:signal-parse-error (msg &key (buffer (current-buffer))
+                                       (position (point))
+                                       (severity 2)
+                                       (line (line-number-at-pos position))
+                                       column restart-at)
+  (signal 'ical:parse-error
+              (list :message msg
+                    :line line
+                    :column column
+                    :severity severity
+                    :position position
+                    :buffer buffer
+                    :restart-at restart-at)))
+
+(defun ical:handle-parse-error (err-data &optional skip-msg err-buffer)
+  (let* ((err-sym (car err-data))
+         (err-plist (cdr err-data))
+         (buf (plist-get err-plist :buffer))
+         (restart-pos (plist-get err-plist :restart-at))
+         (new-msg
+          (concat (plist-get err-plist :message)
+                  "..."
+                  (cond (skip-msg skip-msg)
+                        (restart-pos (format "skipping to %d" restart-pos))
+                        (t "skipping")))))
+    (setq err-plist (plist-put err-plist :message new-msg))
+    (setq err-plist (plist-put err-plist :severity 1))
+    (ical:handle-generic-error (cons err-sym err-plist) err-buffer)
+    (when restart-pos
+      (with-current-buffer buf
+        (goto-char restart-pos)))))
+
+;; Errors at the printing stage:
+;; e.g. default print function doesn't know how to print value
+(define-error 'ical:print-error "Unable to print iCalendar data" 'ical:err=
or)
+
+(cl-defun ical:signal-print-error (msg &key (severity 2) node)
+  (signal 'ical:print-error
+          (list :message msg
+                :node node
+                :buffer (ical:ast-node-meta-get :buffer node)
+                :severity severity
+                :position (ical:ast-node-meta-get :begin node))))
+
+(defun ical:handle-print-error (err-data &optional skip-msg err-buffer)
+  (let* ((err-sym (car err-data))
+         (err-plist (cdr err-data))
+         (new-msg (concat (plist-get err-plist :message)
+                          "..."
+                          (or skip-msg "skipping"))))
+    (setq err-plist (plist-put err-plist :message new-msg))
+    (setq err-plist (plist-put err-plist :severity 1))
+    (ical:handle-generic-error (cons err-sym err-plist) err-buffer))
+  (ical:handle-generic-error err-data err-buffer))
+
+;;;; Some utilities:
+(defun ical:parse-from-string (type s)
+  "Parse string S to an iCalendar syntax node of type TYPE."
+  (with-temp-buffer
+    (insert s)
+    (goto-char (point-min))
+    ;; TODO: unfold?
+    (cond ((ical:component-type-symbol-p type)
+           (ical:parse-component (point-max)))
+          ((ical:property-type-symbol-p type)
+           (ical:parse-property (point-max)))
+          ((ical:param-type-symbol-p type)
+           (unless (looking-at-p ";")
+             (insert ";")
+             (backward-char))
+           (ical:parse-params (point-max)))
+          ((ical:value-type-symbol-p type)
+           (ical:parse-value-node type (point-max)))
+          (t
+           (error "Don't know how to parse type %s" type)))))
+
+(defun ical:parse-one-of (types limit)
+  "Parse a value, from point up to LIMIT, of one of the TYPES.
+
+TYPES should be a list of type symbols. For each type in TYPES, the
+parser function associated with that type will be called at point. The
+return value of the first successful parser function is returned. If
+none of the parser functions are able to parse a value, an
+`icalendar-parse-error' is signaled."
+  (let* ((value nil)
+         (start (point))
+         (type (car types))
+         (parser (get type 'ical:value-parser))
+         (rest (cdr types)))
+    (while (and parser (not value))
+      (condition-case nil
+          (setq value (funcall parser limit))
+        (ical:parse-error
+         ;; value of this type not found, so try again:
+         (goto-char start)
+         (setq type (car rest)
+               rest (cdr rest)
+               parser (get type 'ical:value-parser)))))
+    (unless value
+      (ical:signal-parse-error
+       (format "Unable to parse any of %s between %d and %d" types start l=
imit)
+       :position start))
+    value))
+
+(defun ical:read-list-with (reader string
+                            &optional value-regex separators omit-nulls tr=
im)
+  "Read a list of values from STRING with READER.
+
+READER should be a reader function that accepts a single string argument.
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string'.
+SEPARATORS defaults to \"[^\\][,;]\". TRIM defaults to matching a
+double quote character.
+
+VALUE-REGEX should be a regular expression if READER assumes that
+individual substrings in STRING have previously been matched
+against this regex. In this case, each value in S is placed in a
+temporary buffer and the match against VALUE-REGEX is performed
+before READER is called."
+  (let* ((wrapped-reader
+           (if (not value-regex)
+               ;; no need for temp buffer:
+               reader
+             ;; match the regex in a temp buffer before calling reader:
+             (lambda (s)
+               (with-temp-buffer
+                 (insert s)
+                 (goto-char (point-min))
+                 (unless (looking-at value-regex)
+                   (ical:signal-parse-error
+                    (format "Expected list of values matching '%s'" value-=
regex)))
+                 (funcall reader (match-string 0))))))
+         (seps (or separators "[^\\][,;]"))
+         (trm (or trim "\""))
+         (raw-values (split-string string seps omit-nulls trm)))
+
+    (unless (functionp reader)
+      (signal 'ical:parser-error
+              (list (format "`%s' is not a reader function" reader))))
+
+    (mapcar wrapped-reader raw-values)))
+
+(defun ical:read-list-of (type string
+                          &optional separators omit-nulls trim)
+  "Read a list of values of type TYPE from STRING.
+
+TYPE should be a value type symbol. The reader function
+associated with that type will be called to read the successive
+values in STRING, and the values will be returned as a list of
+syntax nodes.
+
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string' and
+will be passed on, if provided, to `icalendar-read-list-with'."
+  (let* ((reader (lambda (s) (ical:read-value-node type s)))
+         (val-regex (rx-to-string (get type 'ical:value-rx))))
+    (ical:read-list-with reader string val-regex
+                         separators omit-nulls trim)))
+
+(defun ical:list-of-p (list type)
+  "Return non-nil if each value in LIST satisfies TYPE.
+TYPE should be a type specifier for `cl-typep'."
+  (seq-every-p (lambda (val) (cl-typep val type)) list))
+
+(defun ical:default-value-printer (val)
+  "Default printer for a *single* property or parameter value.
+
+If VAL is a string, just return it unchanged.
+
+Otherwise, VAL should be a syntax node representing a value. In
+that case, return the original string value if another was
+substituted at parse time, or look up the printer function for
+the node's type and call it on the value inside the node.
+
+For properties and parameters that only allow a single value,
+this function should be a sufficient value printer. It is not
+sufficient for those that allow lists of values, or which have
+other special requirements like quoting or escaping."
+  (cond ((stringp val) val)
+        ((and (ical:ast-node-p val)
+              (get (ical:ast-node-type val) 'ical:value-printer))
+         (or (ical:ast-node-meta-get :original-value val)
+             (let* ((stored-value (ical:ast-node-value val))
+                    (type (ical:ast-node-type val))
+                    (printer (get type 'ical:value-printer)))
+               (funcall printer stored-value))))
+        ;; TODO: other cases to make things easy?
+        ;; e.g. symbols print as their names?
+        (t (ical:signal-print-error
+            (format "Don't know how to print value: %s" val)))))
+
+
+;;; Section 3.1: Content lines
+
+;; Regexp constants for parsing:
+
+;; In the following regexps and define-* declarations, because
+;; Emacs does not have named groups, we observe the following
+;; convention so that the regexps can be combined in sensible ways:
+;;
+;; - Groups 1 through 5 are reserved for the highest-level regexes
+;;   created by define-param, define-property and define-component and
+;;   used in the match-* functions. Group 1 always represents a 'key'
+;;   (e.g. param or property name), group 2 always represents a
+;;   correctly parsed value for that key, and group 3 (if matched) an
+;;   invalid or unknown value.
+;;
+;;   Groups 4 and 5 are reserved for other information in these
+;;   highest-level regexes, such as the parameter string between a
+;;   property name and its value, or unrecognized values allowed by
+;;   the standard and required to be treated like a default value.
+;;
+;; - Groups 6 through 10 are currently unused
+;; - Groups 11 through 20 are reserved for significant sub-expressions
+;;   of individual value expressions, e.g. the number of weeks in a
+;;   duration value. The various read-* functions rely on these groups
+;;   when converting iCalendar data to Elisp data structures.
+
+(rx-define ical:iana-token
+  (one-or-more (any "A-Za-z0-9" "-")))
+
+(rx-define ical:x-name
+  (seq "X-"
+      (zero-or-one (>=3D 3 (any "A-Za-z0-9")) "-") ; Vendor ID
+      (one-or-more (any "A-Za-z0-9" "-")))) ; Name
+
+(rx-define ical:name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:crlf
+  (seq #x12 #xa))
+
+(rx-define ical:control
+  ;; All the controls except HTAB
+  (any (#x00 . #x08) (#x0A . #x1F) #x7F))
+
+;; TODO: double check that "nonascii" class actually corresponds to
+;; the range in the standard
+(rx-define ical:safe-char
+  ;; Any character except ical:control, ?\", ?\;, ?:, ?,
+  (any #x09 #x20 #x21  (#x23 . #x2B) (#x2D . #x39) (#x3C . #x7E) nonascii))
+
+(rx-define ical:qsafe-char
+  ;; Any character except ical:control and ?\"
+  (any #x09 #x20 #x21 (#x23 . #x7E) nonascii))
+
+(rx-define ical:quoted-string
+  (seq ?\" (zero-or-more ical:qsafe-char) ?\"))
+
+(rx-define ical:paramtext
+  ;; RFC5545 allows *zero* characters here, but that would mean we could
+  ;; have parameters like ;FOO=3D;BAR=3D"somethingelse", and what would th=
en
+  ;; be the value of FOO? I see no reason to allow this and it breaks
+  ;; parameter parsing so I have required at least one char here
+  (one-or-more ical:safe-char))
+
+(rx-define ical:param-name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:param-value
+  (or ical:paramtext ical:quoted-string))
+
+(rx-define ical:value-char
+  (any #x09 #x20 (#x21 . #x7E) nonascii))
+
+(rx-define ical:value
+  (zero-or-more ical:value-char))
+
+;; some helpers for brevity, not defined in the standard:
+(rx-define ical:comma-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?, item-rx))))
+
+(rx-define ical:semicolon-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?\; item-rx))))
+
+
+;;; Section 3.3: Property Value Data Types
+
+;; Note: These definitions are here (out of order with respect to the
+;; standard) because a few of them are already required for property
+;; parameter definitions (section 3.2) below.
+
+(defconst ical:value-types nil ;; populated by define-type
+  "Alist mapping value type strings in `icalendar-valuetypeparam'
+parameters to type symbols defined with `icalendar-define-type'")
+
+(defun ical:read-value-node (type s)
+  "Read an iCalendar value of type TYPE from string S to a syntax node.
+Returns a syntax node containing the value."
+  (let ((reader (get type 'ical:value-reader)))
+    (ical:make-ast-node type (list :value (funcall reader s)))))
+
+(defun ical:parse-value-node (type limit)
+  "Parse an iCalendar value of type TYPE from point up to LIMIT.
+Returns a syntax node containing the value."
+  (let ((value-regex (rx-to-string (get type 'ical:value-rx))))
+
+    (unless (re-search-forward value-regex limit t)
+      (ical:signal-parse-error
+       (format "No %s value between %d and %d" type (point) limit)))
+
+    (let ((begin (match-beginning 0))
+          (end (match-end 0))
+          (node (ical:read-value-node type (match-string 0))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      (ical:ast-node-meta-set node :begin begin)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-value-node (node)
+  "Serialize an iCalendar syntax node containing a value to a string."
+  (let* ((type (ical:ast-node-type node))
+         (value-printer (get type 'ical:value-printer)))
+    (funcall value-printer (ical:ast-node-value node))))
+
+(defun ical:printable-value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL represents a printable iCalendar value type.
+
+This means that SYMBOL names a type for a property or parameter value
+defined by `icalendar-define-type' which has a print name (mainly for
+use in `icalendar-valuetypeparam' parameters). That is, SYMBOL must *both*
+satisfy `icalendar-value-type-symbol-p' and be associated with a print
+name in `icalendar-value-types'."
+  (and (ical:value-type-symbol-p symbol)
+       (rassq symbol ical:value-types)))
+
+(defun ical:value-node-p (node)
+  "Return non-nil if NODE is a syntax node whose type is a value type."
+  (and (ical:ast-node-p node)
+       (ical:value-type-symbol-p (ical:ast-node-type node))))
+
+;;;; 3.3.1 Binary
+;; from https://www.rfc-editor.org/rfc/rfc4648#section-4:
+(rx-define ical:base64char
+  (any (?A . ?Z) (?a . ?z) (?0 . ?9) ?+ ?/))
+
+(ical:define-type ical:binary "BINARY"
+   "Type for Binary values.
+
+The parsed and printed representations are the same: a string of characters
+representing base64-encoded data."
+   '(and string (satisfies ical:match-binary-value))
+   (seq (zero-or-more (=3D 4 ical:base64char))
+        (zero-or-one (or (seq (=3D 2 ical:base64char) "=3D=3D")
+                         (seq (=3D 3 ical:base64char) "=3D"))))
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.1")
+
+;;;; 3.3.2 Boolean
+(defun ical:read-boolean (s)
+  "Read an `icalendar-boolean' value from a string S.
+S should be a match against rx `icalendar-boolean'."
+  (let ((upcased (upcase s)))
+    (cond ((equal upcased "TRUE") t)
+          ((equal upcased "FALSE") nil)
+          (t (ical:signal-parse-error
+              (format "Expected 'TRUE' or 'FALSE'; got %s" s))))))
+
+(defun ical:print-boolean (b)
+  "Serialize an `icalendar-boolean' value B to a string."
+    (if b "TRUE" "FALSE"))
+
+(ical:define-type ical:boolean "BOOLEAN"
+   "Type for Boolean values.
+
+When printed, either the string 'TRUE' or 'FALSE'.
+When read, either t or nil."
+   'boolean
+   (or "TRUE" "FALSE")
+   :reader ical:read-boolean
+   :printer ical:print-boolean
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.2")
+
+;;;; 3.3.3 Calendar User Address
+;; Defined with URI, below
+
+;; Dates and Times:
+
+;;;; 3.3.4 Date
+(cl-deftype ical:numeric-year () '(integer 0 9999))
+(cl-deftype ical:numeric-month () '(integer 1 12))
+(cl-deftype ical:numeric-monthday () '(integer 1 31))
+
+(rx-define ical:year
+  (=3D 4 digit))
+
+(rx-define ical:month
+  (=3D 2 digit))
+
+(rx-define ical:mday
+  (=3D 2 digit))
+
+(defun ical:read-date (s)
+  "Read an `icalendar-date' from a string S.
+S should be a match against rx `icalendar-date'."
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8))))
+    (list month day year)))
+
+(defun ical:print-date (d)
+  "Serialize an `icalendar-date' to a string."
+  (format "%04d%02d%02d"
+          (calendar-extract-year d)
+          (calendar-extract-month d)
+          (calendar-extract-day d)))
+
+(ical:define-type ical:date "DATE"
+   "Type for Date values.
+
+When printed, a date is a string of digits in YYYYMMDD format.
+
+When read, a date is a list (MONTH DAY YEAR), with the three
+values being integers in the appropriate ranges; see calendar.el
+for functions that work with this representation."
+   '(and (satisfies calendar-date-is-valid-p))
+   (seq ical:year ical:month ical:mday)
+   :reader ical:read-date
+   :printer ical:print-date
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.4")
+
+;;;; 3.3.12 Time
+;; (Defined here so that ical:time RX can be used in ical:date-time)
+(cl-deftype ical:numeric-hour () '(integer 0 23))
+(cl-deftype ical:numeric-minute () '(integer 0 59))
+(cl-deftype ical:numeric-second () '(integer 0 60)) ; 60 represents a leap=
 second
+
+(declare-function ical:make-date-time "icalendar-utils")
+
+(defun ical:read-time (s)
+  "Read an `icalendar-time' from a string S.
+S should be a match against rx `icalendar-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular re=
quire
+  (let ((hour (string-to-number (substring s 0 2)))
+        (minute (string-to-number (substring s 2 4)))
+        (second (string-to-number (substring s 4 6)))
+        (utcoffset (if (and (length=3D s 7)
+                            (equal "Z" (substring s 6 7)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :zone utcoffset)))
+
+(defun ical:print-time (time)
+  "Serialize an `icalendar-time' to a string."
+  (format "%02d%02d%02d%s"
+          (decoded-time-hour time)
+          (decoded-time-minute time)
+          (decoded-time-second time)
+          (if (eql 0 (decoded-time-zone time))
+              "Z" "")))
+
+(defun ical:-decoded-time-p (val)
+  "Return non-nil if VAL is a valid decoded *time*.
+This predicate does not check date-related values in VAL;
+for that, see `icalendar--decoded-date-time-p'."
+  (and (listp val)
+       (length=3D val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(ical:define-type ical:time "TIME"
+  "Type for Time values.
+
+When printed, a time is a string of six digits HHMMSS, followed
+by the letter 'Z' if it is in UTC.
+
+When read, a time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values. When
+read, the DAY, MONTH, YEAR, and DOW fields are nil, and these
+fields and DST are ignored when printed."
+  '(satisfies ical:-decoded-time-p)
+  (seq (=3D 6 digit) (zero-or-one ?Z))
+  :reader ical:read-time
+  :printer ical:print-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.12")
+
+;;;; 3.3.5 Date-Time
+(defun ical:-decoded-date-time-p (val)
+  (and (listp val)
+       (length=3D val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-day val) 'ical:numeric-monthday)
+       (cl-typep (decoded-time-month val) 'ical:numeric-month)
+       (cl-typep (decoded-time-year val) 'ical:numeric-year)
+       (calendar-date-is-valid-p (list (decoded-time-month val)
+                                       (decoded-time-day val)
+                                       (decoded-time-year val)))
+       ;; FIXME: the weekday slot value should be automatically
+       ;; calculated from month, day, and year, like:
+       ;;   (calendar-day-of-week (list month day year))
+       ;; Although `ical:read-date-time' does this correctly,
+       ;; `make-decoded-time' does not. Thus we can't use
+       ;; `make-decoded-time' to construct valid `ical:date-time'
+       ;; values unless this check is turned off,
+       ;; which means it's annoying to write tests and anything
+       ;; that uses cl-typecase to dispatch on values created by
+       ;; `make-decoded-time':
+       ;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(defun ical:read-date-time (s)
+  "Read an `icalendar-date-time' from a string S.
+S should be a match against rx `icalendar-date-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular re=
quires
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8)))
+        ;; "T" is index 8
+        (hour (string-to-number (substring s 9 11)))
+        (minute (string-to-number (substring s 11 13)))
+        (second (string-to-number (substring s 13 15)))
+        (utcoffset (if (and (length=3D s 16)
+                            (equal "Z" (substring s 15 16)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :day day
+                         :month month
+                         :year year
+                         :zone utcoffset)))
+
+(defun ical:print-date-time (datetime)
+  "Serialize an `icalendar-date-time' to a string."
+  (format "%04d%02d%02dT%02d%02d%02d%s"
+          (decoded-time-year datetime)
+          (decoded-time-month datetime)
+          (decoded-time-day datetime)
+          (decoded-time-hour datetime)
+          (decoded-time-minute datetime)
+          (decoded-time-second datetime)
+          (if (ical:date-time-is-utc-p datetime)
+              "Z" "")))
+
+(defun ical:date-time-is-utc-p (datetime)
+  "Return non-nil if DATETIME is in UTC time."
+  (let ((offset (decoded-time-zone datetime)))
+    (and offset (=3D 0 offset))))
+
+(ical:define-type ical:date-time "DATE-TIME"
+   "Type for Date-Time values.
+
+When printed, a date-time is a string of digits like:
+  YYYYMMDDTHHMMSS
+where the 'T' is literal, and separates the date string from the
+time string.
+
+When read, a date-time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values."
+   '(satisfies ical:-decoded-date-time-p)
+  (seq ical:date ?T ical:time)
+  :reader ical:read-date-time
+  :printer ical:print-date-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5")
+
+;;;; 3.3.6 Duration
+(rx-define ical:dur-second
+  (seq (group-n 19 (one-or-more digit)) ?S))
+
+(rx-define ical:dur-minute
+  (seq (group-n 18 (one-or-more digit)) ?M (zero-or-one ical:dur-second)))
+
+(rx-define ical:dur-hour
+  (seq (group-n 17 (one-or-more digit)) ?H (zero-or-one ical:dur-minute)))
+
+(rx-define ical:dur-day
+  (seq (group-n 16 (one-or-more digit)) ?D))
+
+(rx-define ical:dur-week
+  (seq (group-n 15 (one-or-more digit)) ?W))
+
+(rx-define ical:dur-time
+  (seq ?T (or ical:dur-hour ical:dur-minute ical:dur-second)))
+
+(rx-define ical:dur-date
+  (seq ical:dur-day (zero-or-one ical:dur-time)))
+
+;; TODO: This function already exists! Super: replace with iso8601-parse-d=
uration
+(defun ical:read-dur-value (s)
+  "Read an `icalendar-dur-value' from a string S.
+S should be a match against rx `icalendar-dur-value'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((sign (if (equal (match-string 20) "-") -1 1)))
+    (if (match-string 15)
+        ;; dur-value specified in weeks, so just return an integer:
+        (* sign (string-to-number (match-string 15)))
+      ;; otherwise, make a time delta from the other units:
+      (let* ((days (match-string 16))
+             (ndays (* sign (if days (string-to-number days) 0)))
+             (hours (match-string 17))
+             (nhours (* sign (if hours (string-to-number hours) 0)))
+             (minutes (match-string 18))
+             (nminutes (* sign (if minutes (string-to-number minutes) 0)))
+             (seconds (match-string 19))
+             (nseconds (* sign (if seconds (string-to-number seconds) 0))))
+        (make-decoded-time :second nseconds :minute nminutes :hour nhours
+                           :day ndays)))))
+
+(defun ical:print-dur-value (dur)
+  "Serialize an `icalendar-dur-value' to a string."
+  (if (integerp dur)
+      ;; dur-value specified in weeks can only contain weeks:
+      (format "%sP%dW" (if (< dur 0) "-" "") (abs dur))
+    ;; otherwise, show all the time units present:
+    (let* ((days+- (or (decoded-time-day dur) 0))
+           (hours+- (or (decoded-time-hour dur) 0))
+           (minutes+- (or (decoded-time-minute dur) 0))
+           (seconds+- (or (decoded-time-second dur) 0))
+           ;; deal with the possibility of mixed positive and negative val=
ues
+           ;; in a time delta list:
+           (sum (+ seconds+-
+                   (* 60 minutes+-)
+                   (* 60 60 hours+-)
+                   (* 60 60 24 days+-)))
+           (abssum (abs sum))
+           (days (/ abssum (* 60 60 24)))
+           (sumnodays (mod abssum (* 60 60 24)))
+           (hours (/ sumnodays (* 60 60)))
+           (sumnohours (mod sumnodays (* 60 60)))
+           (minutes (/ sumnohours 60))
+           (seconds (mod sumnohours 60))
+           (sign (when (< sum 0) "-"))
+           (time-sep (unless (and (zerop hours) (zerop minutes) (zerop sec=
onds))
+                       "T")))
+      (concat sign
+              "P"
+              (unless (zerop days) (format "%dD" days))
+              time-sep
+              (unless (zerop hours) (format "%dH" hours))
+              (unless (zerop minutes) (format "%dM" minutes))
+              (unless (zerop seconds) (format "%dS" seconds))))))
+
+(defun ical:-time-delta-p (val)
+  (and (listp val)
+       (length=3D val 9)
+       (let ((seconds (decoded-time-second val))
+             (minutes (decoded-time-minute val))
+             (hours (decoded-time-hour val))
+             (days (decoded-time-day val))) ; other values in list are ign=
ored
+         (or (and (integerp seconds) (not (zerop seconds)))
+             (and (integerp minutes) (not (zerop minutes)))
+             (and (integerp hours) (not (zerop hours)))
+             (and (integerp days) (not (zerop days)))))))
+
+(ical:define-type ical:dur-value "DURATION"
+  "Type for Duration values.
+
+When printed, a duration is a string containing:
+  - possibly a +/- sign
+  - the letter 'P'
+  - one or more sequences of digits followed by a letter representing a un=
it
+    of time: 'W' for weeks, 'D' for days, etc. Units smaller than a day are
+    separated from days by the letter 'T'. If a duration is specified in w=
eeks,
+    other units of time are not allowed.
+
+For example, a duration of 15 days, 5 hours, and 20 seconds would be print=
ed:
+   P15DT5H0M20S
+and a duration of 7 weeks would be printed:
+   P7W
+
+When read, a duration is either an integer, in which case it
+represents a number of weeks, or a decoded time, in which case it
+must represent a time delta in the sense of `decoded-time-add'.
+Note that, in the time delta representation, units of time longer
+than a day are not supported and will be ignored if present.
+
+This type is named `icalendar-dur-value' rather than
+`icalendar-duration' for consistency with the text of RFC5545 and
+so that its name does not collide with the symbol for the
+`DURATION' property."
+  '(or integer (satisfies ical:-time-delta-p))
+  ;; Group 15: weeks
+  ;; Group 16: days
+  ;; Group 17: hours
+  ;; Group 18: minutes
+  ;; Group 19: seconds
+  ;; Group 20: sign
+  (seq
+   (group-n 20 (zero-or-one (or ?+ ?-)))
+   ?P
+   (or ical:dur-date ical:dur-time ical:dur-week))
+  :reader ical:read-dur-value
+  :printer ical:print-dur-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.6")
+
+
+;;;; 3.3.7 Float
+(ical:define-type ical:float "FLOAT"
+   "Type for Float values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits,
+and possibly a decimal. When read, an Elisp float value."
+   '(float * *)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit)
+    (zero-or-one (seq ?. (one-or-more digit))))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.7")
+
+;;;; 3.3.8 Integer
+(ical:define-type ical:integer "INTEGER"
+   "Type for Integer values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits.
+When read, an Elisp integer value between -2147483648 and 2147483647."
+   '(integer -2147483648 2147483647)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8")
+
+;;;; 3.3.9 Period
+(defsubst ical:period-start (period)
+  "Return the `icalendar-date-time' which marks the start of PERIOD."
+  (car period))
+
+(defsubst ical:period--defined-end (period)
+  "Return the `icalendar-date-time' which marks the end of PERIOD, or nil."
+  (cadr period))
+
+
+(declare-function ical:date/time-add-duration "icalendar-utils")
+
+(defun ical:period-end (period &optional vtimezone)
+  "Return the `icalendar-date-time' which marks the end of PERIOD.
+If the end is not explicitly specified, it will be computed from the
+period's start and duration.  VTIMEZONE, if given, should be the
+`icalendar-vtimezone' in which to compute the end time."
+  (require 'icalendar-utils) ; for date/time-add-duration; avoids circular=
 import
+  (or (ical:period--defined-end period)
+      ;; compute end from duration and cache it:
+      (setf (cadr period)
+            (ical:date/time-add-duration
+             (ical:period-start period)
+             (ical:period-dur-value period)
+             vtimezone))))
+
+(defsubst ical:period-dur-value (period)
+  "Return the `icalendar-dur-value' which gives the length of PERIOD, or n=
il."
+  (caddr period))
+
+(defun ical:period-p (val)
+  (and (listp val)
+       (length=3D val 3)
+       (cl-typep (ical:period-start val) 'ical:date-time)
+       (cl-typep (ical:period-end val) '(or null ical:date-time))
+       (cl-typep (ical:period-dur-value val) '(or null ical:dur-value))))
+
+(cl-defun ical:make-period (start &key end duration)
+  "Make an `icalendar-period' value.
+
+START and END (if given) should be `icalendar-date-time' values.
+DURATION, if given, should be an `icalendar-dur-value'. It is an error
+to pass both END and DURATION, or neither."
+  (when (and end duration)
+    (signal 'wrong-type-argument (list end duration)))
+  (unless (or end duration)
+    (signal 'wrong-type-argument (list end duration)))
+  (list start end duration))
+
+;; TODO: This function already exists! Super: replace with iso8601-parse-i=
nterval
+(defun ical:read-period (s)
+  "Read an `icalendar-period' from a string S.
+S should have been matched against rx `icalendar-period'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((start (ical:read-date-time (match-string 11)))
+        (end (when (match-string 12) (ical:read-date-time (match-string 12=
))))
+        (dur (when (match-string 13) (ical:read-dur-value (match-string 13=
)))))
+    (ical:make-period start :end end :duration dur)))
+
+(defun ical:print-period (per)
+  "Serialize an `icalendar-period' to a string."
+  (let ((start (ical:period-start per))
+        (end (ical:period-end per))
+        (dur (ical:period-dur-value per)))
+    (concat (ical:print-date-time start)
+            "/"
+            (if dur
+                (ical:print-dur-value dur)
+              (ical:print-date-time end)))))
+
+(ical:define-type ical:period "PERIOD"
+   "Type for Period values.
+
+A period of time is specified as a starting date-time together
+with either an explicit date-time as its end, or a duration which
+gives its length and implicitly marks its end.
+
+When printed, the starting date-time is separated from the end or
+duration by a / character.
+
+When read, a period is represented as a list (START END DUR), where
+START is an `icalendar-date-time', END is either an
+`icalendar-date-time' or nil, and DUR is either an `icalendar-dur-value'
+or nil. See the functions `icalendar-make-period',
+`icalendar-period-start', `icalendar-period-end', and
+`icalendar-period-dur-value' to work with period values."
+  '(satisfies ical:period-p)
+  (seq (group-n 11 ical:date-time)
+       "/"
+       (or (group-n 12 ical:date-time)
+           (group-n 13 ical:dur-value)))
+  :reader ical:read-period
+  :printer ical:print-period
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.9")
+
+;;;; 3.3.10 Recurrence rules:
+(rx-define ical:freq
+   (or "SECONDLY" "MINUTELY" "HOURLY" "DAILY" "WEEKLY" "MONTHLY" "YEARLY"))
+
+(rx-define ical:weekday
+   (or "SU" "MO" "TU" "WE" "TH" "FR" "SA"))
+
+(rx-define ical:ordwk
+  (** 1 2 digit)) ; 1 to 53
+
+(rx-define ical:weekdaynum
+  ;; Group 19: Week num, if present
+  ;; Group 20: week day abbreviation
+   (seq (zero-or-one
+         (group-n 19 (seq (zero-or-one (or ?+ ?-))
+                          ical:ordwk)))
+        (group-n 20 ical:weekday)))
+
+(rx-define ical:weeknum
+  (seq (zero-or-one (or ?+ ?-))
+       ical:ordwk))
+
+(rx-define ical:monthdaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 31
+
+(rx-define ical:monthnum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 12
+
+(rx-define ical:yeardaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 3 digit))) ; 1 to 366
+
+(defconst ical:weekday-numbers
+  '(("SU" . 0)
+    ("MO" . 1)
+    ("TU" . 2)
+    ("WE" . 3)
+    ("TH" . 4)
+    ("FR" . 5)
+    ("SA" . 6))
+  "Alist mapping two-letter weekday abbreviations to numbers 0 to 6.
+Weekday abbreviations in recurrence rule parts are translated to
+and from numbers for compatibility with calendar-* and
+decoded-time-* functions.")
+
+(defun ical:read-weekdaynum (s)
+  "Read a weekday abbreviation to a number.
+If the abbreviation is preceded by an offset, read a dotted
+pair (WEEKDAY . OFFSET). Thus \"SU\" becomes 0, \"-1SU\"
+becomes (0 . -1), etc. S should have been matched against
+`icalendar-weekdaynum'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((dayno (cdr (assoc (match-string 20) ical:weekday-numbers)))
+        (weekno (match-string 19)))
+    (if weekno
+        (cons dayno (string-to-number weekno))
+      dayno)))
+
+(defun ical:print-weekdaynum (val)
+  "Serialize a number or dotted pair VAL to a string.
+The result is in the format required for a BYDAY recurrence rule clause.
+See `icalendar-read-weekdaynum' for the format of VAL."
+  (if (consp val)
+      (let* ((dayno (car val))
+             (day (car (rassq dayno ical:weekday-numbers)))
+             (offset (cdr val)))
+        (concat (number-to-string offset) day))
+    ;; number alone just stands for a day:
+    (car (rassq val ical:weekday-numbers))))
+
+(defun ical:read-recur-rule-part (s)
+  "Read an `icalendar-recur-rule-part' from string S.
+S should have been matched against `icalendar-recur-rule-part'.
+The return value is a list (KEYWORD VALUE), where VALUE may
+itself be a list, depending on the values allowed by KEYWORD."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((keyword (intern (upcase (match-string 11))))
+        (values (match-string 12)))
+    (list keyword
+      (cl-case keyword
+        (FREQ (intern (upcase values)))
+        (UNTIL (if (length> values 8)
+                   (ical:read-date-time values)
+                 (ical:read-date values)))
+        ((COUNT INTERVAL)
+         (string-to-number values))
+        ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH B=
YSETPOS)
+         (ical:read-list-with #'string-to-number values nil ","))
+        (BYDAY
+         (ical:read-list-with #'ical:read-weekdaynum values
+                              (rx ical:weekdaynum) ","))
+        (WKST (cdr (assoc values ical:weekday-numbers)))))))
+
+(defun ical:print-recur-rule-part (part)
+  "Serialize recur rule part PART to a string."
+  (let ((keyword (car part))
+        (values (cadr part))
+        values-str)
+    (cl-case keyword
+      (FREQ (setq values-str (symbol-name values)))
+      (UNTIL (setq values-str (cl-typecase values
+                                (ical:date-time (ical:print-date-time valu=
es))
+                                (ical:date (ical:print-date values)))))
+      ((COUNT INTERVAL)
+       (setq values-str (number-to-string values)))
+      ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYS=
ETPOS)
+       (setq values-str (string-join (mapcar #'number-to-string values)
+                                     ",")))
+      (BYDAY
+       (setq values-str (string-join (mapcar #'ical:print-weekdaynum value=
s)
+                                     ",")))
+      (WKST (setq values-str (car (rassq values ical:weekday-numbers)))))
+
+    (concat (symbol-name keyword) "=3D" values-str)))
+
+(rx-define ical:recur-rule-part
+  ;; Group 11: keyword
+  ;; Group 12: value(s)
+  (or (seq (group-n 11 "FREQ") "=3D" (group-n 12 ical:freq))
+      (seq (group-n 11 "UNTIL") "=3D" (group-n 12 (or ical:date-time ical:=
date)))
+      (seq (group-n 11 "COUNT") "=3D" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "INTERVAL") "=3D" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "BYSECOND") "=3D" (group-n 12 ; 0 to 60
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYMINUTE") "=3D" (group-n 12 ; 0 to 59
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYHOUR") "=3D" (group-n 12 ; 0 to 23
+                                       (ical:comma-list (** 1 2 digit)))) =
; 0 to 23
+      (seq (group-n 11 "BYDAY") "=3D" (group-n 12 ; weeknum? daynum, e.g. =
SU or 34SU
+                                      (ical:comma-list ical:weekdaynum)))
+      (seq (group-n 11 "BYMONTHDAY") "=3D" (group-n 12
+                                           (ical:comma-list ical:monthdayn=
um)))
+      (seq (group-n 11 "BYYEARDAY") "=3D" (group-n 12
+                                          (ical:comma-list ical:yeardaynum=
)))
+      (seq (group-n 11 "BYWEEKNO") "=3D" (group-n 12 (ical:comma-list ical=
:weeknum)))
+      (seq (group-n 11 "BYMONTH") "=3D" (group-n 12 (ical:comma-list ical:=
monthnum)))
+      (seq (group-n 11 "BYSETPOS") "=3D" (group-n 12
+                                         (ical:comma-list ical:yeardaynum)=
))
+      (seq (group-n 11 "WKST") "=3D" (group-n 12 ical:weekday))))
+
+(defun ical:read-recur (s)
+  "Read a recurrence rule value from string S.
+S should be a match against rx `icalendar-recur'."
+  ;; TODO: let's switch to keywords and a plist, so we can more easily
+  ;; write these clauses also in diary sexp entries without so many parens
+  (ical:read-list-with #'ical:read-recur-rule-part s (rx ical:recur-rule-p=
art) ";"))
+
+(defun ical:print-recur (val)
+  "Serialize a recurrence rule value VAL to a string."
+  ;; RFC5545 sec. 3.3.10: "to ensure backward compatibility with
+  ;; applications that pre-date this revision of iCalendar the
+  ;; FREQ rule part MUST be the first rule part specified in a
+  ;; RECUR value."
+  (string-join
+   (cons
+    (ical:print-recur-rule-part (assq 'FREQ val))
+    (mapcar #'ical:print-recur-rule-part
+            (seq-filter (lambda (part) (not (eq 'FREQ (car part))))
+                        val)))
+   ";"))
+
+(defconst ical:-recur-value-types
+  ;; `list-of' is not a cl-type specifier, just a symbol here; it is
+  ;; handled specially when checking types in `ical:recur-value-p':
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+    UNTIL (or ical:date-time ical:date)
+    COUNT (integer 1 *)
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) (satisfies ical:dayno-offset-p)))
+    BYMONTHDAY (list-of (or (integer -31 -1) (integer 1 31)))
+    BYYEARDAY (list-of (or (integer -366 -1) (integer 1 366)))
+    BYWEEKNO (list-of (or (integer -53 -1) (integer 1 53)))
+    BYMONTH (list-of (integer 1 12)) ; unlike the others, months cannot be=
 negative
+    BYSETPOS (list-of (or (integer -366 -1) (integer 1 366)))
+    WKST (integer 0 6))
+  "Plist mapping `icalendar-recur' keywords to type specifiers")
+
+(defun ical:dayno-offset-p (val)
+  "Return non-nil if VAL is a pair (DAYNO . OFFSET).
+DAYNO must be in [0..6] and OFFSET in [-53..53], excluding 0."
+  (and (consp val)
+       (cl-typep (car val) '(integer 0 6))
+       (cl-typep (cdr val) '(or (integer -53 -1) (integer 1 53)))))
+
+(defun ical:recur-value-p (vals)
+  "Return non-nil if VALS is an iCalendar recurrence rule value."
+  (and (listp vals)
+       ;; FREQ is always required:
+       (assq 'FREQ vals)
+       ;; COUNT and UNTIL are mutually exclusive if present:
+       (not (and (assq 'COUNT vals) (assq 'UNTIL vals)))
+       ;; If BYSETPOS is present, another BYXXX clause must be too:
+       (or (not (assq 'BYSETPOS vals))
+           (assq 'BYMONTH vals)
+           (assq 'BYWEEKNO vals)
+           (assq 'BYYEARDAY vals)
+           (assq 'BYMONTHDAY vals)
+           (assq 'BYDAY vals)
+           (assq 'BYHOUR vals)
+           (assq 'BYMINUTE vals)
+           (assq 'BYSECOND vals))
+       (let ((freq (ical:recur-freq vals))
+             (byday (ical:recur-by* 'BYDAY vals))
+             (byweekno (ical:recur-by* 'BYWEEKNO vals))
+             (bymonthday (ical:recur-by* 'BYMONTHDAY vals))
+             (byyearday (ical:recur-by* 'BYYEARDAY vals)))
+         (and
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value when the FREQ rule part is not set to MONTHLY or
+          ;; YEARLY."
+          (or (not (consp (car byday)))
+              (memq freq '(MONTHLY YEARLY)))
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value with the FREQ rule part set to YEARLY when the
+          ;; BYWEEKNO rule part is specified." This also covers:
+          ;; "[The BYWEEKNO] rule part MUST NOT be used when the FREQ
+          ;; rule part is set to anything other than YEARLY."
+          (or (not byweekno)
+              (and (eq freq 'YEARLY)
+                   (not (consp (car byday)))))
+          ;; "The BYMONTHDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to WEEKLY."
+          (not (and bymonthday (eq freq 'WEEKLY)))
+          ;; "The BYYEARDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
+          (not (and byyearday (memq freq '(DAILY WEEKLY MONTHLY))))))
+       ;; check types of all rule parts:
+       (seq-every-p
+        (lambda (kv)
+          (when (consp kv)
+            (let* ((keyword (car kv))
+                   (val (cadr kv))
+                   (type (plist-get ical:-recur-value-types keyword)))
+              (and keyword val type
+                   (if (and (consp type)
+                            (eq (car type) 'list-of))
+                       (ical:list-of-p val (cadr type))
+                     (cl-typep val type))))))
+         vals)))
+
+(ical:define-type ical:recur "RECUR"
+  "Type for Recurrence Rule values.
+
+When printed, a recurrence rule value looks like
+  KEY1=3DVAL1;KEY2=3DVAL2;...
+where the VALs may themselves be lists or have other syntactic
+structure; see RFC5545 sec. 3.3.10 for all the details.
+
+The KEYs and their associated value types when read are as follows.
+The first is required:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+These two are mutually exclusive; at most one may appear:
+    UNTIL (or icalendar-date-time icalendar-date)
+    COUNT (integer 1 *)
+All others are optional:
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) ; day of week
+                       (pair (integer 0 6)  ; (day of week . offset)
+                             (integer -53 53))) ; except 0
+    BYMONTHDAY (list-of (integer -31 31))  ; except 0
+    BYYEARDAY (list-of (integer -366 366)) ; except 0
+    BYWEEKNO (list-of (integer -53 53))    ; except 0
+    BYMONTH (list-of (integer 1 12))       ; months cannot be negative
+    BYSETPOS (list-of (integer -366 366))  ; except 0
+    WKST (integer 0 6))
+
+When read, these KEYs and their associated VALs are gathered into
+an alist.
+
+In general, the VALs consist of integers or lists of integers.
+Abbreviations for weekday names are translated into integers
+0 (=3DSunday) through 6 (=3DSaturday), for compatibility with
+calendar.el and decoded-time-* functions.
+
+Some examples:
+
+1) Printed: FREQ=3DDAILY;COUNT=3D10;INTERVAL=3D2
+   Meaning: 10 occurrences that occur every other day
+   Read: ((FREQ DAILY)
+          (COUNT 10)
+          (INTERVAL 2))
+
+2) Printed: FREQ=3DYEARLY;UNTIL=3D20000131T140000Z;BYMONTH=3D1;BYDAY=3DSU,=
MO,TU,WE,TH,FR,SA
+   Meaning: Every day in January of every year until 2000/01/31 at 14:00 U=
TC
+   Read: ((FREQ YEARLY)
+          (UNTIL (0 0 14 31 1 2000 1 -1 0))
+          (BYMONTH (1))
+          (BYDAY (0 1 2 3 4 5 6)))
+
+3) Printed: FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-2
+   Meaning: Every month on the second-to-last weekday of the month
+   Read: ((FREQ MONTHLY)
+          (BYDAY (1 2 3 4 5))
+          (BYSETPOS (-2)))
+
+Notice that singleton values are still wrapped in a list when the
+KEY accepts a list of values, but not when the KEY always has a
+single (e.g. integer) value."
+  '(satisfies ical:recur-value-p)
+  (ical:semicolon-list ical:recur-rule-part)
+  :reader ical:read-recur
+  :printer ical:print-recur
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10")
+
+(defun ical:recur-freq (recur-value)
+  "Return the frequency in RECUR-VALUE."
+  (car (alist-get 'FREQ recur-value)))
+
+(defun ical:recur-interval-size (recur-value)
+  "Return the interval size in RECUR-VALUE, or the default of 1."
+  (or (car (alist-get 'INTERVAL recur-value)) 1))
+
+(defun ical:recur-until (recur-value)
+  "Return the UNTIL date(-time) in RECUR-VALUE."
+  (car (alist-get 'UNTIL recur-value)))
+
+(defun ical:recur-count (recur-value)
+  "Return the COUNT in RECUR-VALUE."
+  (car (alist-get 'COUNT recur-value)))
+
+(defun ical:recur-weekstart (recur-value)
+  "Return the weekday which starts the work week in RECUR-VALUE.
+If no starting weekday is specified in RECUR-VALUE, returns the default,
+1 (=3D Monday)."
+  (or (car (alist-get 'WKST recur-value)) 1))
+
+(defun ical:recur-by* (byunit recur-value)
+  "Return the values in the BYUNIT clause in RECUR-VALUE.
+BYUNIT should be a symbol: \\=3D'BYMONTH, \\=3D'BYDAY, etc.
+See `icalendar-recur' for all the possible BYUNIT values."
+  (car (alist-get byunit recur-value)))
+
+;;;; 3.3.11 Text
+(rx-define ical:escaped-char
+   (seq ?\\ (or ?\\ ?\; ?, ?N ?n)))
+
+(rx-define ical:text-safe-char
+  ;; "Any character except CONTROLs not needed by the current character
+  ;; set, DQUOTE, ";", ":", "\", "," "
+  (any #x09 #x20 #x21 ; htab, space, and "!"
+       (#x23 . #x2B) (#x2D . #x39) ; "#".."9" skipping #x2C=3D","
+       (#x3C . #x5B) (#x5D . #x7E) ; "<".."~" skipping #x5C=3D"\"
+       nonascii))
+
+(defun ical:text-region-p (val)
+  "Return t if VAL represents a region of text."
+  (and (listp val)
+       (markerp (car val))
+       (not (null (marker-buffer (car val))))
+       (markerp (cdr val))))
+
+(defun ical:make-text-region (&optional buffer begin end)
+  "Return an object that represents a region of text.
+The region is taken from BUFFER between BEGIN and END. BUFFER defaults
+to the current buffer, and BEGIN and END default to point and mark in
+BUFFER."
+  (let ((buf (or buffer (current-buffer)))
+        (b (make-marker))
+        (e (make-marker)))
+    (with-current-buffer buf
+      (set-marker b (or begin (region-beginning)) buf)
+      (set-marker e (or end (region-end)))
+      (cons b e))))
+
+(defsubst ical:text-region-begin (r)
+  "Return the marker at the beginning of the text region R."
+  (car r))
+
+(defsubst ical:text-region-end (r)
+  "Return the marker at the end of the text region R."
+  (cdr r))
+
+(defun ical:unescape-text-in-region (begin end)
+ "Unescape the text between BEGIN and END.
+Unescaping replaces literal '\\n' and '\\N' with newline, and removes
+backslashes that escape commas, semicolons, and backslashes."
+ (with-restriction begin end
+   (save-excursion
+    (replace-string-in-region "\\N" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\n" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\," "," (point-min) (point-max))
+    (replace-string-in-region "\\;" ";" (point-min) (point-max)))
+    (replace-string-in-region (concat "\\" "\\") "\\" (point-min) (point-m=
ax))))
+
+(defun ical:unescape-text-string (s)
+ "Unescape the text in string S.
+Unescaping replaces literal '\\n' and '\\N' with newline, and removes
+backslashes that escape commas, semicolons, and backslashes."
+  (with-temp-buffer
+    (insert s)
+    (ical:unescape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:escape-text-in-region (begin end)
+  "Escape the text between BEGIN and END in the current buffer.
+Escaping replaces newlines with literal '\\n', and escapes commas,
+semicolons and backslashes with a backslash."
+ (with-restriction begin end
+  (save-excursion
+    ;; replace backslashes first, so the ones introduced when
+    ;; escaping other characters don't end up double-escaped:
+    (replace-string-in-region "\\" (concat "\\" "\\") (point-min) (point-m=
ax))
+    (replace-string-in-region "\n" "\\n" (point-min) (point-max))
+    (replace-string-in-region "," "\\," (point-min) (point-max))
+    (replace-string-in-region ";" "\\;" (point-min) (point-max)))))
+
+(defun ical:escape-text-string (s)
+  "Escape the text in string S.
+Escaping replaces newlines with literal '\\n', and escapes commas,
+semicolons and backslashes with a backslash."
+  (with-temp-buffer
+    (insert s)
+    (ical:escape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:read-text (s)
+  "Read an `icalendar-text' value from a string S.
+S should be a match against rx `icalendar-text'."
+  (ical:unescape-text-string s))
+
+(defun ical:print-text (val)
+  "Serialize an iCalendar text value.
+VAL may be a string or text region (see `icalendar-make-text-region').
+The text will be escaped before printing. If VAL is a region, the text
+it contains will not be modified; it is copied before escaping."
+  (if (stringp val)
+      (ical:escape-text-string val)
+    ;; val is a region, so copy and escape its contents:
+    (let* ((beg (ical:text-region-begin val))
+           (buf (marker-buffer beg))
+           (end (ical:text-region-end val)))
+      (with-temp-buffer
+        (insert-buffer-substring buf (marker-position beg) (marker-positio=
n end))
+        (ical:escape-text-in-region (point-min) (point-max))
+        (buffer-string)))))
+
+(defun ical:text-to-string (node)
+  "Return the value of an `icalendar-text' NODE as a string.
+The returned string is *not* escaped. For that, see `icalendar-print-text'=
."
+  (ical:with-node-value node nil
+    (if (stringp value) value
+      ;; Otherwise the value is a text region:
+      (let* ((beg (ical:text-region-begin value))
+             (buf (marker-buffer beg))
+             (end (ical:text-region-end value)))
+        (with-current-buffer buf
+          (buffer-substring (marker-position beg) (marker-position end))))=
)))
+
+;; TODO: would it be useful to add a third representation, namely a
+;; function or thunk? So that e.g. Org can pre-process its own syntax
+;; and return a plain text string to use in the description?
+(ical:define-type ical:text "TEXT"
+   "Type for Text values.
+
+Text values can be represented in Elisp in two ways: as strings,
+or as buffer regions. For values which aren't expected to change,
+such as property values in a text/calendar email attachment, use
+strings. For values which are user-editable and might change
+between parsing and serializing to iCalendar format, use a
+region. In that case, a text value contains two markers BEGIN and
+END which mark the bounds of the region. See
+`icalendar-make-text-region' to create such values, and
+`icalendar-text-region-begin' and `icalendar-text-region-end' to
+access the markers.
+
+Certain characters in text values are required to be escaped by
+the iCalendar standard. These characters should NOT be
+pre-escaped when inserting them into the parse tree. Instead,
+`icalendar-print-text' takes care of escaping text values, and
+`icalendar-read-text' takes care of unescaping them, when parsing and
+printing iCalendar data."
+  '(or string (satisfies ical:text-region-p))
+  (zero-or-more (or ical:text-safe-char ?: ?\" ical:escaped-char))
+  :reader ical:read-text
+  :printer ical:print-text
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11")
+
+;; 3.3.12 Time - Defined above
+
+;;;; 3.3.13 URI
+;; see https://www.rfc-editor.org/rfc/rfc3986#section-3
+(rx-define ical:uri-with-scheme
+  ;; Group 11: URI scheme; see icalendar-uri-schemes.el
+  ;; Group 12: rest of URI after ":"
+  ;; This regex mostly just scans for all characters allowed by RFC3986,
+  ;; except we make an effort to parse the scheme, because otherwise the
+  ;; regex is either too permissive (ical:binary, in particular, matches
+  ;; a subset of the characters allowed in a URI) or too complicated to
+  ;; be useful.
+  ;; TODO: use url-parse.el to parse to struct?
+  (seq (group-n 11 (any "a-zA-Z") (zero-or-more (any ?- ?+ ?. "A-Za-z0-9")=
))
+       ":"
+       (group-n 12
+         (one-or-more
+          (any "A-Za-z0-9" ?- ?. ?_ ?~             ; unreserved chars
+               ?: ?/ ?? ?# ?\[ ?\] ?@              ; gen-delims
+               ?! ?$ ?& ?' ?\( ?\) ?* ?+ ?, ?\; ?=3D ; sub-delims
+               ?%)))))                             ; for %-encoding
+
+(ical:define-type ical:uri "URI"
+   "Type for URI values.
+
+The parsed and printed representations are the same: a URI string."
+   '(satisfies ical:match-uri-value)
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.13")
+
+;;;; 3.3.3 Calendar User Address
+(ical:define-type ical:cal-address "CAL-ADDRESS"
+   "Type for Calendar User Address values.
+
+The parsed and printed representations are the same: a URI string.
+Typically, this should be a mailto: URI.
+
+RFC5545 says: '*When used to address an Internet email transport
+  address* for a calendar user, the value MUST be a mailto URI,
+  as defined by [RFC2368]'
+
+Since it is unclear whether there are Calendar User Address values
+which are not used to address email, this type does not enforce the use
+of the mailto: scheme, but be prepared for problems if you create
+values of this type with any other scheme."
+   '(and string (satisfies ical:match-cal-address-value))
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.3")
+
+;;;; 3.3.14 UTC Offset
+(defun ical:read-utc-offset (s)
+  "Read a UTC offset from a string.
+S should be a match against rx `icalendar-utc-offset'"
+  (let ((sign (if (equal (substring s 0 1) "-") -1 1))
+        (nhours (string-to-number (substring s 1 3)))
+        (nminutes (string-to-number (substring s 3 5)))
+        (nseconds (if (length=3D s 7)
+                      (string-to-number (substring s 5 7))
+                    0)))
+    (* sign (+ nseconds (* 60 nminutes) (* 60 60 nhours)))))
+
+(defun ical:print-utc-offset (utcoff)
+  "Serialize a UTC offset to a string."
+  (let* ((sign (if (< utcoff 0) "-" "+"))
+         (absoff (abs utcoff))
+         (nhours (/ absoff (* 60 60)))
+         (no-hours (mod absoff (* 60 60)))
+         (nminutes (/ no-hours 60))
+         (nseconds (mod no-hours 60)))
+    (if (zerop nseconds)
+        (format "%s%02d%02d" sign nhours nminutes)
+      (format "%s%02d%02d%02d" sign nhours nminutes nseconds))))
+
+(ical:define-type ical:utc-offset "UTC-OFFSET"
+  "Type for UTC Offset values.
+
+When printed, a sign followed by a string of digits, like +HHMM
+or -HHMMSS. When read, an integer representing the number of
+seconds offset from UTC. This representation is for compatibility
+with `decode-time' and related functions."
+  '(integer -999999 999999)
+  (seq (or ?+ ?-) ; + is not optional for positive values!
+       (=3D 4 digit) ; HHMM
+       (zero-or-one (=3D 2 digit))) ; SS
+  :reader ical:read-utc-offset
+  :printer ical:print-utc-offset
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.14")
+
+
+;;; Section 3.2: Property Parameters
+
+(defconst ical:param-types nil ;; populated by ical:define-param
+  "Alist mapping printed parameter names to type symbols")
+
+(defun ical:maybe-quote-param-value (s &optional always)
+  "Add quotes around param value string S if required.
+If ALWAYS is non-nil, add quotes to S regardless of its contents."
+  (if (or always
+          (not (string-match (rx ical:paramtext) s))
+          (< (match-end 0) (length s)))
+      (concat "\"" s "\"")
+    s))
+
+(defun ical:read-param-value (type s)
+  "Read a value for a parameter of type TYPE from a string S.
+S should have already been matched against the regex for TYPE and
+the match data should be available to this function. Returns a
+syntax node of type TYPE containing the read value.
+
+If TYPE accepts a list of values, S will be split on the list
+separator for TYPE and read individually."
+  (let* ((value-type (get type 'ical:value-type)) ; if nil, value is just =
a string
+         (value-regex (when (get type 'ical:value-rx)
+                         (rx-to-string (get type 'ical:value-rx))))
+         (list-sep (get type 'ical:list-sep))
+         (substitute-val (get type 'ical:substitute-value))
+         (unrecognized-val (match-string 5)) ; see :unrecognized in define=
-param
+         (raw-val (if unrecognized-val substitute-val s))
+         (one-val-reader (if (ical:value-type-symbol-p value-type)
+                             (lambda (s) (ical:read-value-node value-type =
s))
+                           #'identity)) ; value is just a string
+         ;; values may be quoted even if :quoted does not require it,
+         ;; so they need to be stripped of quotes. read-list-with does
+         ;; this by default; in the single value case, use string-trim
+         (read-val (if list-sep
+                       (ical:read-list-with one-val-reader raw-val
+                                            value-regex list-sep)
+                     (funcall one-val-reader
+                              (string-trim raw-val "\"" "\"")))))
+    (ical:make-ast-node type
+                        (list :value read-val
+                              :original-value unrecognized-val))))
+
+(defun ical:parse-param-value (type limit)
+  "Parse the value for a parameter of type TYPE from point up to LIMIT.
+TYPE should be a type symbol for an iCalendar parameter type.
+This function expects point to be at the start of the value
+string, after the parameter name and the equals sign. Returns a
+syntax node representing the parameter."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+    ;; By far the most common invalid data seem to be text values that
+    ;; contain unescaped characters (e.g. commas in addresses).  These
+    ;; are harmless as long as the parameter accepts arbitrary text and
+    ;; does not expect a list of values.  The only such parameter
+    ;; defined in RFC5545 is `ical:cnparam', so we treat this as a
+    ;; special case and loosen the official regexp to accept anything up
+    ;; to the start of the next param or property value:
+    (when (and (eq type 'ical:cnparam)
+               (not ical:parse-strictly))
+      (setq full-value-regex
+            (rx (group-n 2 (or ical:quoted-string
+                               (zero-or-more (not (any ?: ?\;))))))))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (ical:signal-parse-error
+       (format "Unable to parse `%s' value between %d and %d"
+               type (point) limit)))
+    (when (match-string 3)
+      (ical:signal-parse-error
+       (format "Invalid value for `%s' parameter: %s" type (match-string 3=
))))
+
+    (let ((value-begin (match-beginning 2))
+          (value-end (match-end 2))
+          (node (ical:read-param-value type (match-string 2))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; :begin must be set by parse-params
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end value-end)
+
+      node)))
+
+(defun ical:parse-params (limit)
+  "Parse the parameter string of the current property, up to LIMIT.
+Point should be at the \";\" at the start of the first parameter.
+Returns a list of parameters, which may be nil if none are present.
+After parsing, point is at the end of the parameter string and the
+start of the property value string."
+  (let (params param-node)
+    (rx-let ((ical:param-start (seq ";" (group-n 1 ical:param-name) "=3D")=
))
+      (while (re-search-forward (rx ical:param-start) limit t)
+        (when-let* ((begin (match-beginning 1))
+                    (param-name (match-string 1))
+                    (param-type (alist-get (upcase param-name)
+                                           ical:param-types
+                                           'ical:otherparam
+                                            nil #'equal)))
+          (condition-case err
+              (setq param-node (ical:parse-param-value param-type limit))
+            (ical:parse-error
+             (ical:handle-parse-error err (format "Skipping bad %s paramet=
er"
+                                                  param-name))
+             (setq param-node nil)))
+          (when param-node
+            (ical:ast-node-meta-set param-node :begin begin)
+            ;; store the original param name if we didn't recognize it:
+            (when (eq param-type 'ical:otherparam)
+              (ical:ast-node-meta-set param-node :original-name param-name=
))
+            (push param-node params))))
+    (nreverse params))))
+
+(defun ical:print-param-node (node)
+  "Serialize a parameter syntax node NODE to a string.
+NODE should be a syntax node whose type is an iCalendar
+parameter type."
+  (let* ((param-type (ical:ast-node-type node))
+         (param-name (car (rassq param-type ical:param-types)))
+         (name-str (or param-name
+                       ;; set by parse-params for unrecognized params:
+                       (ical:ast-node-meta-get :original-name node))))
+
+    (unless (and name-str (stringp name-str) (not (equal name-str "")))
+      (ical:signal-print-error "No printable parameter name" :node node))
+
+    (let* ((list-sep (get param-type 'ical:list-sep))
+           (val/s (ical:ast-node-value node))
+           (vals (if (and list-sep (listp val/s))
+                     val/s
+                   (list val/s)))
+           ;; any ical:print-error here propagates:
+           (printed (mapcar #'ical:default-value-printer vals))
+           ;; add quotes to each value as needed, even if :quoted
+           ;; does not require it:
+           (must-quote (get param-type 'ical:is-quoted))
+           (quoted (mapcar
+                    (lambda (v) (ical:maybe-quote-param-value v must-quote=
))
+                    printed))
+           (val-str (or (ical:ast-node-meta-get :original-value node)
+                        (string-join quoted list-sep)
+                        quoted)))
+
+      (unless (and (stringp val-str) (not (equal val-str "")))
+        (ical:signal-print-error "Unable to print parameter value" :node n=
ode))
+
+      (format ";%s=3D%s" name-str val-str))))
+
+(defun ical:print-params (param-nodes)
+  "Print the property parameter nodes in PARAM-NODES.
+Returns the printed parameter list as a string."
+  (let (param-strs)
+    (dolist (node param-nodes)
+      (condition-case err
+          (push (ical:print-param-node node) param-strs)
+        (ical:print-error
+         (ical:handle-print-error err))))
+    (apply #'concat (nreverse param-strs))))
+
+;; Parameter definitions in RFC5545:
+
+(ical:define-param ical:altrepparam "ALTREP"
+  "Alternate text representation (URI)"
+  ical:uri
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.1")
+
+(ical:define-param ical:cnparam "CN"
+  "Common Name"
+  ical:param-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.2")
+
+(ical:define-param ical:cutypeparam "CUTYPE"
+  "Calendar User Type"
+  (or "INDIVIDUAL"
+      "GROUP"
+      "RESOURCE"
+      "ROOM"
+      "UNKNOWN"
+      (group-n 5
+        (or ical:x-name ical:iana-token)))
+  :default "INDIVIDUAL"
+  ;; "Applications MUST treat x-name and iana-token values they
+  ;; don't recognize the same way as they would the UNKNOWN
+  ;; value":
+  :unrecognized "UNKNOWN"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.3")
+
+(ical:define-param ical:delfromparam "DELEGATED-FROM"
+  "Delegators.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have delegated their participation to the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.4")
+
+(ical:define-param ical:deltoparam "DELEGATED-TO"
+  "Delegatees.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have been delegated to participate by the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.5")
+
+(ical:define-param ical:dirparam "DIR"
+  "Directory Entry Reference.
+
+This parameter may be specified on properties with a
+`icalendar-cal-address' value type. It is a quoted URI which specifies
+a reference to a directory entry associated with the calendar
+user which is the value of the property."
+   ical:uri
+   :quoted t
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.6")
+
+(ical:define-param ical:encodingparam "ENCODING"
+  "Inline Encoding, either \"8BIT\" (text, default) or \"BASE64\" (binary).
+
+If \"BASE64\", the property value is base64-encoded binary data.
+This parameter must be specified if the `icalendar-valuetypeparam'
+is \"BINARY\"."
+  (or "8BIT" "BASE64")
+  :default "8BIT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.7")
+
+(rx-define ical:mimetype
+  (seq ical:mimetype-regname "/" ical:mimetype-regname))
+
+;; from https://www.rfc-editor.org/rfc/rfc4288#section-4.2:
+(rx-define ical:mimetype-regname
+  (** 1 127 (any "A-Za-z0-9" ?! ?# ?$ ?& ?. ?+ ?- ?^ ?_)))
+
+(ical:define-param ical:fmttypeparam "FMTTYPE"
+  "Format Type (Mimetype per RFC4288)
+
+Specifies the media type of the object referenced in the property value,
+for example \"text/plain\" or \"text/html\".
+Valid media types are defined in RFC4288; see
+URL `https://www.rfc-editor.org/rfc/rfc4288#section-4.2'"
+  ical:mimetype
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.8")
+
+(ical:define-param ical:fbtypeparam "FBTYPE"
+  "Free/Busy Time Type. Default is \"BUSY\".
+
+RFC5545 gives the following meanings to the values:
+
+FREE: the time interval is free for scheduling.
+BUSY: the time interval is busy because one or more events have
+  been scheduled for that interval.
+BUSY-UNAVAILABLE: the time interval is busy and the interval
+  can not be scheduled.
+BUSY-TENTATIVE: the time interval is busy because one or more
+  events have been tentatively scheduled for that interval.
+Other values are treated like BUSY."
+  (or "FREE"
+      "BUSY-UNAVAILABLE"
+      "BUSY-TENTATIVE"
+      "BUSY"
+      ical:x-name
+      ical:iana-token)
+  :default "BUSY"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.9")
+
+;; TODO: see https://www.rfc-editor.org/rfc/rfc5646#section-2.1
+(rx-define ical:rfc5646-lang
+  (one-or-more (any "A-Za-z0-9" ?-)))
+
+(ical:define-param ical:languageparam "LANGUAGE"
+  "Language tag (per RFC5646)
+
+This parameter specifies the language of the property value as a
+language tag, for example \"en-US\" for US English or \"no\" for
+Norwegian. Valid language tags are defined in RFC5646; see
+URL `https://www.rfc-editor.org/rfc/rfc5646'"
+  ical:rfc5646-lang
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.10")
+
+(ical:define-param ical:memberparam "MEMBER"
+  "Group or List Membership.
+
+This is a comma-separated list of quoted `icalendar-cal-address'
+values. These are addresses of groups or lists of which the user
+in the property value is a member."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.11")
+
+(ical:define-param ical:partstatparam "PARTSTAT"
+  "Participation status.
+
+The value specifies the participation status of the calendar user
+in the property value. They have different interpretations
+depending on whether they occur in a VEVENT, VTODO or VJOURNAL
+component. RFC5545 gives the values the following meanings:
+
+NEEDS-ACTION (all): needs action by the user
+ACCEPTED (all): accepted by the user
+DECLINED (all): declined by the user
+TENTATIVE (VEVENT, VTODO): tentatively accepted by the user
+DELEGATED (VEVENT, VTODO): delegated by the user
+COMPLETED (VTODO): completed at the `icalendar-date-time' in the
+  VTODO's `icalendar-completed' property
+IN-PROCESS (VTODO): in the process of being completed"
+  (or "NEEDS-ACTION"
+      "ACCEPTED"
+      "DECLINED"
+      "TENTATIVE"
+      "DELEGATED"
+      "COMPLETED"
+      "IN-PROCESS"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; NEEDS-ACTION value."
+  :default "NEEDS-ACTION"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.12")
+
+(ical:define-param ical:rangeparam "RANGE"
+  "Recurrence Identifier Range.
+
+Specifies the effective range of recurrence instances of the property's va=
lue.
+The value \"THISANDFUTURE\" is the only value compliant with RFC5545;
+legacy applications might also produce \"THISANDPRIOR\"."
+  "THISANDFUTURE"
+  :default "THISANDFUTURE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.13")
+
+(ical:define-param ical:trigrelparam "RELATED"
+  "Alarm Trigger Relationship.
+
+This parameter may be specified on properties whose values give
+an alarm trigger as an `icalendar-duration'. If the parameter
+value is \"START\" (the default), the alarm triggers relative to
+the start of the component; similarly for \"END\"."
+  (or "START" "END")
+  :default "START"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.14")
+
+(ical:define-param ical:reltypeparam "RELTYPE"
+  "Relationship type.
+
+This parameter specifies a hierarchical relationship between the
+calendar component referenced in a `icalendar-related-to'
+property and the calendar component in which it occurs.
+\"PARENT\" means the referenced component is superior to this
+one, \"CHILD\" that the referenced component is subordinate to
+this one, and \"SIBLING\" means they are peers."
+  (or "PARENT"
+      "CHILD"
+      "SIBLING"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values they don't
+  ;; recognize the same way as they would the PARENT value."
+  :default "PARENT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15")
+
+(ical:define-param ical:roleparam "ROLE"
+  "Participation role.
+
+This parameter specifies the participation role of the calendar
+user in the property value. RFC5545 gives the parameter values
+the following meanings:
+CHAIR: chair of the calendar entity
+REQ-PARTICIPANT (default): user's participation is required
+OPT-PARTICIPANT: user's participation is optional
+NON-PARTICIPANT: user is copied for information purposes only"
+  (or "CHAIR"
+      "REQ-PARTICIPANT"
+      "OPT-PARTICIPANT"
+      "NON-PARTICIPANT"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; REQ-PARTICIPANT value."
+  :default "REQ-PARTICIPANT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.16")
+
+(ical:define-param ical:rsvpparam "RSVP"
+  "RSVP expectation.
+
+This parameter is an `icalendar-boolean' which specifies whether
+the calendar user in the property value is expected to reply to
+the Organizer of a VEVENT or VTODO."
+  ical:boolean
+  :default "FALSE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.17")
+
+(ical:define-param ical:sentbyparam "SENT-BY"
+  "Sent by.
+
+This parameter specifies a calendar user that is acting on behalf
+of the user in the property value."
+  ;; "The parameter value MUST be a mailto URI as defined in [RFC2368]"
+  ;; Weirdly, this is the only place in the standard I've seen "mailto:"
+  ;; be *required* for a cal-address. We ignore this requirement because
+  ;; coding around the exception is not worth it: it requires working
+  ;; around the fact that two different types, the looser and the more
+  ;; stringent cal-address, would need to have the same print name.
+  ical:cal-address
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.18")
+
+(ical:define-param ical:tzidparam "TZID"
+  "Time Zone identifier.
+
+This parameter identifies the VTIMEZONE component in the calendar
+which should be used to interpret the time value given in the
+property. The value of this parameter must be equal to the value
+of the TZID property in that VTIMEZONE component; there must be
+exactly one such component for every unique value of this
+parameter in the calendar."
+  ;; TODO: "This parameter MUST be specified on the "DTSTART","DTEND",
+  ;; "DUE", "EXDATE", and "RDATE" properties when either a DATE-TIME
+  ;; or TIME value type is specified and when the value is neither a
+  ;; UTC or a "floating" time."
+  ;; TODO: "The "TZID" property parameter MUST NOT be applied to DATE
+  ;; properties and DATE-TIME or TIME properties whose time values are
+  ;; specified in UTC."
+  (seq (zero-or-one "/") ical:paramtext)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.19")
+
+(defun ical:read-value-type (s)
+  "Read a value type from string S.
+S should contain the printed representation of a value type in a \"VALUE=
=3D...\"
+property parameter. If S represents a known type in `icalendar-value-types=
',
+it is read as the associated type symbol. Otherwise S is returned unchange=
d."
+  (let ((type-assoc (assoc s ical:value-types)))
+    (if type-assoc
+        (cdr type-assoc)
+      s)))
+
+(defun ical:print-value-type (type)
+  "Print a value type TYPE.
+TYPE should be an iCalendar type symbol naming a known value type
+defined with `icalendar-define-type', or a string naming an
+unknown type. If it is a symbol, return the associated printed
+representation for the type from `icalendar-value-types'.
+Otherwise return TYPE."
+  (if (symbolp type)
+      (car (rassq type ical:value-types))
+    type))
+
+(ical:define-type ical:printed-value-type nil
+  "Type to represent values of the `icalendar-valuetypeparam' parameter.
+
+When read, if the type named by the parameter is a known value
+type in `icalendar-value-types', it is represented as a type
+symbol for that value type. If it is an unknown value type, it is
+represented as a string. When printed, a string is returned
+unchanged; a type symbol is printed as the associated name in
+`icalendar-value-types'.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing of the `icalendar-valuetypeparam' parameter."
+  '(or string (satisfies ical:printable-value-type-symbol-p))
+  (or "BINARY"
+      "BOOLEAN"
+      "CAL-ADDRESS"
+      "DATE-TIME"
+      "DATE"
+      "DURATION"
+      "FLOAT"
+      "INTEGER"
+      "PERIOD"
+      "RECUR"
+      "TEXT"
+      "TIME"
+      "URI"
+      "UTC-OFFSET"
+      ;; Note: "Applications MUST preserve the value data for x-name
+      ;; and iana-token values that they don't recognize without
+      ;; attempting to interpret or parse the value data." So in this
+      ;; case we don't specify :default or :unrecognized in the
+      ;; parameter definition, and we don't put the value in group 5;
+      ;; the reader will just preserve whatever string matches here.
+      ical:x-name
+      ical:iana-token)
+  :reader ical:read-value-type
+  :printer ical:print-value-type)
+
+(ical:define-param ical:valuetypeparam "VALUE"
+  "Property value data type.
+
+This parameter is used to specify the value type of the
+containing property's value, if it is not of the default value
+type."
+  ical:printed-value-type
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.20")
+
+(ical:define-param ical:otherparam nil ; don't add to ical:param-types
+  "Parameter with an unknown name.
+
+This is not a parameter type defined by RFC5545; it represents
+parameters with an unknown name (matching rx `icalendar-param-name')
+whose values must be parsed and preserved but not further
+interpreted."
+  ical:param-value)
+
+(rx-define ical:other-param-safe
+  ;; we use this rx to skip params when matching properties and
+  ;; their values. Thus we *don't* capture the param names and param values
+  ;; in numbered groups here, which would clobber the groups of the enclos=
ing
+  ;; expression.
+  (seq ";"
+       (or ical:iana-token ical:x-name)
+       "=3D"
+       (ical:comma-list ical:param-value)))
+
+
+;;; Properties:
+
+(defconst ical:property-types nil ;; populated by ical:define-property
+  "Alist mapping printed property names to type symbols")
+
+(defun ical:read-property-value (type s &optional params)
+  "Read a value for the property type TYPE from a string S.
+
+TYPE should be a type symbol for an iCalendar property type
+defined with `icalendar-define-property'. The property value is
+assumed to be of TYPE's default value type, unless an
+`icalendar-valuetypeparam' parameter appears in PARAMS, in which
+case a value of that type will be read. S should have already
+been matched against TYPE's value regex and the match data should
+be available to this function. Returns a property syntax node of
+type TYPE containing the read value and the list of PARAMS.
+
+If TYPE accepts lists of values, they will be split from S on the
+list separator and read separately."
+  (let* ((value-type (or (ical:value-type-from-params params)
+                         (get type 'ical:default-type)))
+         (list-sep (get type 'ical:list-sep))
+         (unrecognized-val (match-string 5))
+         (raw-val (if unrecognized-val
+                      (get type 'ical:substitute-value)
+                    s))
+         (value (if list-sep
+                    (ical:read-list-of value-type raw-val list-sep)
+                  (ical:read-value-node value-type raw-val))))
+    (ical:make-ast-node type
+                        (list :value value
+                              :original-value unrecognized-val)
+                        params)))
+
+(defun ical:parse-property-value (type limit &optional params)
+  "Parse a value for the property type TYPE from point up to LIMIT.
+This function expects point to be at the start of the value
+expression, after \"PROPERTY-NAME[PARAM...]:\". Returns a syntax
+node of type TYPE containing the parsed value and the list of
+PARAMS."
+  (let ((start (point))
+        (full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+
+    ;; By far the most common invalid data seem to be text values that
+    ;; contain unescaped characters (e.g. commas in addresses in
+    ;; LOCATION). These are harmless as long as the property accepts
+    ;; any text value, accepts no other types of values, and does not
+    ;; expect a list of values. So we treat this as a special case and
+    ;; loosen the regexp to accept any non-control character until eol:
+    (when (and (eq 'ical:text (get type 'ical:default-type))
+               (equal (rx-to-string 'ical:text t)
+                      (rx-to-string (get type 'ical:value-rx) t))
+               (null (get type 'ical:other-types))
+               (not (ical:expects-list-of-values-p type))
+               (not ical:parse-strictly))
+        (setq full-value-regex
+              (rx (group-n 2 (zero-or-more (not (any control))))
+                  line-end)))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (ical:signal-parse-error
+       (format "Unable to parse `%s' property value between %d and %d"
+               type start limit)
+       :restart-at (1+ limit)))
+
+    (when (match-string 3)
+      (ical:signal-parse-error
+       (format "Invalid value for `%s' property: %s" type (match-string 3))
+       :restart-at (1+ limit)))
+
+    (let* ((value-begin (match-beginning 2))
+           (value-end (match-end 2))
+           (end value-end)
+           (node (ical:read-property-value type (match-string 2) params)))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; 'begin must be set by parse-property
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-property-node (node)
+  "Serialize a property syntax node NODE to a string."
+  (setq node (ical:maybe-add-value-param node))
+  (let* ((type (ical:ast-node-type node))
+         (list-sep (get type 'ical:list-sep))
+         (property-name (car (rassq type ical:property-types)))
+         (name-str (or property-name
+                       (ical:ast-node-meta-get :original-name node)))
+         (params (ical:ast-node-children node))
+         (value (ical:ast-node-value node))
+         (value-str
+          (or (ical:ast-node-meta-get :original-value node)
+              ;; any ical:print-error here propagates:
+              (if list-sep
+                  (string-join (mapcar #'ical:default-value-printer value)
+                               list-sep)
+                (ical:default-value-printer value)))))
+
+    (unless (and (stringp name-str) (length> name-str 0))
+      (ical:signal-print-error
+       (format "Unknown property name for type `%s'" type)
+       :node node))
+
+    (concat name-str
+            (ical:print-params params)
+            ":"
+            value-str
+            "\n")))
+
+(defun ical:maybe-add-value-param (property-node)
+  "Add a VALUE parameter to PROPERTY-NODE if necessary.
+
+If the type of PROPERTY-NODE's value is not the same as its
+default-type, check that its parameter list contains an
+`icalendar-valuetypeparam' specifying that type as the type for
+the value. If not, add such a parameter to PROPERTY-NODE's list
+of parameters. Returns the possibly-modified PROPERTY-NODE.
+
+If the parameter list already contains a value type parameter for
+a type other than the property value's type, an
+`icalendar-validation-error' is signaled.
+
+If PROPERTY's value is a list, the type of the first element will
+be assumed to be the type for all the values in the list. If the
+list is empty, no change will be made to PROPERTY's parameters."
+  (catch 'no-value-type
+    (let* ((property-type (ical:ast-node-type property-node))
+           (value/s (ical:ast-node-value property-node))
+           (value (if (and (ical:expects-list-of-values-p property-type)
+                           (listp value/s))
+                      (car value/s)
+                    value/s))
+           (value-type (cond ((stringp value) 'ical:text)
+                             ((ical:ast-node-p value)
+                              (ical:ast-node-type value))
+                             ;; if we can't determine a type from the valu=
e, bail:
+                             (t (throw 'no-value-type property-node))))
+           (params (ical:ast-node-children property-node))
+           (expected-type (ical:value-type-from-params params)))
+
+      (when (not (eq value-type (get property-type 'ical:default-type)))
+        (if expected-type
+            (when (not (eq value-type expected-type))
+              (ical:signal-validation-error
+                (format (concat "Mismatching VALUE parameter. VALUE specif=
ies %s "
+                                "but property value has type %s")
+                        expected-type value-type)
+                :node property-node))
+          ;; the value isn't of the default type, but we didn't find a
+          ;; VALUE parameter, so add one now:
+          (let* ((valuetype-param
+                  (ical:make-ast-node 'ical:valuetypeparam
+                                      (list :value (ical:make-ast-node
+                                                    'ical:printed-value-ty=
pe
+                                                    (list :value value-typ=
e)))))
+                 (new-params (cons valuetype-param
+                                   (ical:ast-node-children property-node))=
))
+            (apply #'ical:ast-node-set-children property-node new-params))=
))
+
+      ;; Return the modified property node:
+      property-node)))
+
+(defun ical:value-type-from-params (params)
+  "Return the type symbol associated with any VALUE parameter in PARAMS.
+PARAMS should be a list of parameter nodes. The type symbol specified by
+the first `icalendar-valuetypeparam' in PARAMS, or nil, will be returned."
+  (catch 'found
+    (dolist (param params)
+      (when (ical:value-param-p param)
+        (let ((type (ical:ast-node-value
+                     (ical:ast-node-value param))))
+          (throw 'found type))))))
+
+(defun ical:parse-property (limit)
+  "Parse the current property, up to LIMIT.
+
+Point should be at the beginning of a property line; LIMIT should be the
+position at the end of the line.
+
+Returns a syntax node for the property. After parsing, point is at the
+beginning of the next content line."
+  (rx-let ((ical:property-start (seq line-start (group-n 1 ical:name))))
+    (let (line-begin line-end property-name property-type params node)
+      ;; Property name
+      (unless (re-search-forward (rx ical:property-start) limit t)
+        (ical:signal-parse-error
+         "Malformed property: could not match property name"
+         :restart-at (1+ limit)))
+
+      (setq property-name (match-string 1))
+      (setq line-begin (line-beginning-position))
+      (setq line-end (line-end-position))
+
+      ;; Parameters
+      (when (looking-at-p ";")
+        (setq params (ical:parse-params line-end)))
+
+      (unless (looking-at-p ":")
+        (ical:signal-parse-error
+         "Malformed property: parameters did not end at colon"
+         :restart-at (1+ limit)))
+      (forward-char)
+
+      ;; Value
+      (setq property-type (alist-get (upcase property-name)
+                                     ical:property-types
+                                     'ical:other-property
+                                     nil #'equal))
+      (setq node (ical:parse-property-value property-type limit params))
+
+      ;; sanity check, since e.g. invalid base64 data might not
+      ;; match all the way to the end of the line, as test
+      ;; rfc5545-sec3.1.3/2 initially revealed
+      (unless (eql (point) (line-end-position))
+        (ical:signal-parse-error
+         (format "%s property value did not consume line: %s"
+                 property-name
+                 (ical:default-value-printer (ical:ast-node-value node)))
+         :restart-at (1+ limit)))
+
+      ;; value, children are set in ical:read-property-value,
+      ;; value-begin, value-end, end in ical:parse-property-value.
+      ;; begin and original-name are only available here:
+      (ical:ast-node-meta-set node :begin line-begin)
+      (when (eq property-type 'ical:other-property)
+        (ical:ast-node-meta-set node :original-name property-name))
+
+      ;; Set point up for the next property parser.
+      (while (not (bolp))
+        (forward-char))
+
+      ;; Return the syntax node
+      node)))
+
+
+;;;; Section 3.7: Calendar Properties
+(ical:define-property ical:calscale "CALSCALE"
+  "Calendar scale.
+
+This property specifies the time scale of an
+`icalendar-vcalendar' object. The only scale defined by RFC5545
+is \"GREGORIAN\", which is the default."
+  ;; only allowed value:
+  "GREGORIAN"
+  :default "GREGORIAN"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.1")
+
+(ical:define-property ical:method "METHOD"
+  "Method for a scheduling request.
+
+When an `icalendar-vcalendar' is sent in a MIME message, this property
+specifies the semantics of the request in the message: e.g. it is
+a request to publish the calendar object, or a reply to an
+invitation. This property and the MIME message's \"method\"
+parameter value must be the same.
+
+RFC5545 does not define any methods, but RFC5546 does; see
+URL `https://www.rfc-editor.org/rfc/rfc5546.html#section-3.2'"
+  ;; TODO: implement methods in RFC5546?
+  ical:iana-token
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.2")
+
+(ical:define-property ical:prodid "PRODID"
+  "Product Identifier.
+
+This property identifies the program that created an
+`icalendar-vcalendar' object. It must be specified exactly once in a
+calendar object. Its value should be a globally unique identifier for
+the program. RFC5545 suggests using an ISO \"Formal Public Identifier\";
+see URL `https://en.wikipedia.org/wiki/Formal_Public_Identifier'."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.3")
+
+(ical:define-property ical:version "VERSION"
+  "Version (2.0 corresponds to RFC5545).
+
+This property specifies the version number of the iCalendar
+specification to which an `icalendar-vcalendar' object conforms,
+and must be specified exactly once in a calendar object. It is
+either the string \"2.0\" or a string like MIN;MAX specifying
+minimum and maximum versions of future revisions of the
+specification."
+  (or "2.0"
+      ;; minver ";" maxver
+      (seq ical:iana-token ?\; ical:iana-token))
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.4")
+
+
+;;;; Section 3.8:
+;;;;; Section 3.8.1: Descriptive Component Properties
+
+(ical:define-property ical:attach "ATTACH"
+  "Attachment.
+
+This property specifies a file attached to an iCalendar
+component, either via a URI, or as encoded binary data. In
+`icalendar-valarm' components, it is used to specify the
+notification sent by the alarm."
+  ;; Groups 11, 12 are used in ical:uri
+  (or (group-n 13 ical:uri)
+      (group-n 14 ical:binary))
+  :default-type ical:uri
+  :other-types (ical:binary)
+  :child-spec (:zero-or-one (ical:fmttypeparam
+                             ical:valuetypeparam
+                             ical:encodingparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:attach-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1")
+
+(defun ical:attach-validator (node)
+  "Additional validator for an `icalendar-attach' NODE.
+Checks that NODE has a correct `icalendar-encodingparam' and
+`icalendar-valuetypeparam' if its value is an `icalendar-binary'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+ATTACH nodes; it is not normally necessary to call it directly."
+  (let* ((value-node (ical:ast-node-value node))
+         (value-type (ical:ast-node-type value-node))
+         (valtypeparam (ical:ast-node-first-child-of 'ical:valuetypeparam =
node))
+         (encodingparam (ical:ast-node-first-child-of 'ical:encodingparam =
node)))
+
+    (when (eq value-type 'ical:binary)
+      (unless (and (ical:ast-node-p valtypeparam)
+                   (eq 'ical:binary
+                       (ical:ast-node-value ; unwrap inner printed-value-t=
ype
+                        (ical:ast-node-value valtypeparam))))
+        (ical:signal-validation-error
+         "`icalendar-binary' attachment requires 'VALUE=3DBINARY' paramete=
r"
+        :node node))
+      (unless (and (ical:ast-node-p encodingparam)
+                   (equal "BASE64" (ical:ast-node-value encodingparam)))
+        (ical:signal-validation-error
+         "`icalendar-binary' attachment requires 'ENCODING=3DBASE64' param=
eter"
+         :node node)))
+    ;; success:
+    node))
+
+(ical:define-property ical:categories "CATEGORIES"
+  "Categories.
+
+This property lists categories or subtypes of an iCalendar
+component for e.g. searching or filtering. The categories can be
+any `icalendar-text' value."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.2")
+
+(ical:define-property ical:class "CLASS"
+  "(Access) Classification.
+
+This property specifies the scope of access that the calendar
+owner intends for a given component, e.g. public or private."
+  (or "PUBLIC"
+      "PRIVATE"
+      "CONFIDENTIAL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "If not specified in a component that allows this property, the
+  ;; default value is PUBLIC. Applications MUST treat x-name and
+  ;; iana-token values they don't recognize the same way as they would
+  ;; the PRIVATE value."
+  :default "PUBLIC"
+  :unrecognized "PRIVATE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3")
+
+(ical:define-property ical:comment "COMMENT"
+  "Comment to calendar user.
+
+This property can be specified multiple times in calendar components,
+and can contain any `icalendar-text' value."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.4")
+
+(ical:define-property ical:description "DESCRIPTION"
+  "Description.
+
+This property should be a longer, more complete description of
+the calendar component than is contained in the
+`icalendar-summary' property. In a `icalendar-vjournal'
+component, it is used to capture a journal entry, and may be
+specified multiple times. Otherwise it may only be specified
+once. In an `icalendar-valarm' component, it contains the
+notification text for a DISPLAY or EMAIL alarm."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5")
+
+(defun ical:read-geo-coordinates (s)
+  "Read an `icalendar-geo-coordinates' value from string S."
+  (let ((vals (mapcar #'string-to-number (string-split s ";"))))
+    (cons (car vals) (cadr vals))))
+
+(defun ical:print-geo-coordinates (val)
+  "Serialize an `icalendar-geo-coordinates' value to a string."
+  (concat (number-to-string (car val)) ";" (number-to-string (cdr val))))
+
+(defun ical:geo-coordinates-p (val)
+  "Return non-nil if VAL is an `icalendar-geo-coordinates' value."
+  (and (floatp (car val)) (floatp (cdr val))))
+
+(ical:define-type ical:geo-coordinates nil ; don't add to ical:value-types
+  "Type for global positions.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-geo' property. When printed, it
+is represented as a pair of `icalendar-float' values separated by
+a semicolon, like LATITUDE;LONGITUDE. When read, it is a dotted
+pair of Elisp floats (LATITUDE . LONGITUDE)."
+  '(satisfies ical:geo-coordinates-p)
+  (seq ical:float ";" ical:float)
+  :reader ical:read-geo-coordinates
+  :printer ical:print-geo-coordinates)
+
+(ical:define-property ical:geo "GEO"
+  "Global position of a component as a pair LATITUDE;LONGITUDE.
+
+Both values are floats representing a number of degrees. The
+latitude value is north of the equator if positive, and south of
+the equator if negative. The longitude value is east of the prime
+meridian if positive, and west of it if negative."
+  ical:geo-coordinates
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.6")
+
+(ical:define-property ical:location "LOCATION"
+  "Location.
+
+This property describes the intended location or venue of a
+component, e.g. a particular room or building, with an
+`icalendar-text' value. RFC5545 suggests using the
+`icalendar-altrep' parameter on this property to provide more
+structured location information."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7")
+
+;; TODO: type for percentages?
+(ical:define-property ical:percent-complete "PERCENT-COMPLETE"
+  "Percent Complete.
+
+This property describes progress toward the completion of an
+`icalendar-vtodo' component. It can appear at most once in such a
+component. If this TODO is assigned to multiple people, the value
+represents the completion state for each person individually. The
+value should be between 0 and 100 (though this is not currently
+enforced here)."
+  ical:integer
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.8")
+
+;; TODO: type for priority values?
+(ical:define-property ical:priority "PRIORITY"
+  "Priority.
+
+This property describes the priority of a component. 0 means an
+undefined priority. Other values range from 1 (highest priority)
+to 9 (lowest priority). See RFC5545 for suggestions on how to
+represent other priority schemes with this property."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9")
+
+(ical:define-property ical:resources "RESOURCES"
+  "Resources for an activity.
+
+This property is a list of `icalendar-text' values that describe
+any resources required or foreseen for the activity represented
+by a component, e.g. a projector and screen for a meeting."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.10")
+
+(ical:define-type ical:status-keyword nil
+  "Keyword value of a STATUS property.
+
+This is not a real type defined by RFC5545; it is defined here to
+facilitate parsing that property."
+  '(and string (satisfies ical:match-status-keyword-value))
+  ;; Note that this type does NOT allow arbitrary text:
+  (or "TENTATIVE"
+      "CONFIRMED"
+      "CANCELLED"
+      "NEEDS-ACTION"
+      "COMPLETED"
+      "IN-PROCESS"
+      "DRAFT"
+      "FINAL"))
+
+(ical:define-property ical:status "STATUS"
+  "Overall status or confirmation.
+
+This property is a keyword used by an Organizer to inform
+Attendees about the status of a component, e.g. whether an
+`icalendar-vevent' has been cancelled, whether an
+`icalendar-vtodo' has been completed, or whether an
+`icalendar-vjournal' is still in draft form. It can be specified
+at most once on these components."
+  ical:status-keyword
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11")
+
+(ical:define-property ical:summary "SUMMARY"
+  "Short summary.
+
+This property provides a short, one-line description of a
+component for display purposes. In an EMAIL `icalendar-valarm',
+it is used as the subject of the email. A longer description of
+the component can be provided in the `icalendar-description'
+property."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12")
+
+;;;;; Section 3.8.2: Date and Time Component Properties
+
+(ical:define-property ical:completed "COMPLETED"
+  "Time completed.
+
+This property is a timestamp that records the date and time when
+an `icalendar-vtodo' was actually completed. The value must be an
+`icalendar-date-time' with a UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
+
+(ical:define-property ical:dtend "DTEND"
+  "End time of an event or free/busy block.
+
+This property's value specifies when an `icalendar-vevent' or
+`icalendar-freebusy' ends. Its value must be of the same type as
+the value of the component's corresponding `icalendar-dtstart'
+property. The value is a non-inclusive bound, i.e., the value of
+this property must be the first time or date *after* the end of
+the event or free/busy block."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2")
+
+(ical:define-property ical:due "DUE"
+  "Due date.
+
+This property specifies the date (and possibly time) by which an
+`icalendar-todo' item is expected to be completed, i.e., its
+deadline. If the component also has an `icalendar-dtstart'
+property, the two properties must have the same value type, and
+the value of the DTSTART property must be earlier than the value
+of this property."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.3")
+
+(ical:define-property ical:dtstart "DTSTART"
+  "Start time of a component.
+
+This property's value specifies when a component starts. In an
+`icalendar-vevent', it specifies the start of the event. In an
+`icalendar-vfreebusy', it specifies the start of the free/busy
+block. In `icalendar-standard' and `icalendar-daylight'
+sub-components, it defines the start time of a time zone
+specification.
+
+It is required in any component with an `icalendar-rrule'
+property, and in any `icalendar-vevent' component contained in a
+calendar that does not have a `icalendar-method' property.
+
+Its value must be of the same type as the value of the
+component's corresponding `icalendar-dtend' property. In an
+`icalendar-vtodo' component, it must also be of the same type as
+the value of an `icalendar-due' property (if present)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4")
+
+(ical:define-property ical:duration "DURATION"
+  "Duration.
+
+This property specifies a duration of time for a component.
+In an `icalendar-vevent', it can be used to implicitly specify
+the end of the event, instead of an explicit `icalendar-dtend'.
+In an `icalendar-vtodo', it can likewise be used to implicitly specify
+the due date, instead of an explicit `icalendar-due'.
+In an `icalendar-valarm', it used to specify the delay period
+before the alarm repeats.
+
+If a related `icalendar-dtstart' property has an `icalendar-date'
+value, then the duration must be given as a number of weeks or days."
+  ical:dur-value
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.5")
+
+(ical:define-property ical:freebusy "FREEBUSY"
+  "Free/Busy Times.
+
+This property specifies a list of periods of free or busy time in
+an `icalendar-vfreebusy' component. Whether it specifies free or
+busy times is determined by its `icalendar-fbtype' parameter. The
+times in each period must be in UTC format."
+  ical:period
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:fbtypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.6")
+
+(ical:define-property ical:transp "TRANSP"
+  "Time Transparency for free/busy searches.
+
+Note that this property only allows two values: \"TRANSPARENT\"
+or \"OPAQUE\". An OPAQUE value means that the component consumes
+time on a calendar. TRANSPARENT means it does not, and thus is
+invisible to free/busy time searches."
+  ;; Note that this does NOT allow arbitrary text:
+  (or "TRANSPARENT"
+      "OPAQUE")
+  :default "OPAQUE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7")
+
+;;;;; Section 3.8.3: Time Zone Component Properties
+
+(ical:define-property ical:tzid "TZID"
+  "Time Zone Identifier.
+
+This property specifies the unique identifier for a time zone in
+an `icalendar-vtimezone' component, and is a required property of
+that component. This is an identifier that `icalendar-tzidparam'
+parameters in other components may then refer to."
+  (seq (zero-or-one "/") ical:text)
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.1")
+
+(ical:define-property ical:tzname "TZNAME"
+  "Time Zone Name.
+
+This property specifies a customary name for a time zone in
+`icalendar-daylight' and `icalendar-standard' sub-components."
+  ical:text
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.2")
+
+(ical:define-property ical:tzoffsetfrom "TZOFFSETFROM"
+  "Time Zone Offset (prior to observance).
+
+This property specifies the time zone offset that is in use
+*prior to* this time zone observance. It is used to calculate the
+absolute time at which the observance takes place. It is a
+required property of an `icalendar-vtimezone' component. Positive
+numbers indicate time east of the prime meridian (ahead of UTC).
+Negative numbers indicate time west of the prime meridian (behind
+UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.3")
+
+(ical:define-property ical:tzoffsetto "TZOFFSETTO"
+  "Time Zone Offset (in this observance).
+
+This property specifies the time zone offset that is in use *in*
+this time zone observance. It is used to calculate the absolute
+time at which a new observance takes place. It is a required
+property of `icalendar-standard' and `icalendar-daylight'
+components. Positive numbers indicate time east of the prime
+meridian (ahead of UTC). Negative numbers indicate time west of
+the prime meridian (behind UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.4")
+
+(ical:define-property ical:tzurl "TZURL"
+  "Time Zone URL.
+
+This property specifies a URL where updated versions of an
+`icalendar-vtimezone' component are published."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.5")
+
+;;;;; Section 3.8.4: Relationship Component Properties
+
+(ical:define-property ical:attendee "ATTENDEE"
+  "Attendee.
+
+This property specfies a participant in a `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-valarm'. It is required when the
+containing component represents event, task, or notification for
+a *group* of people, but not for components that simply represent
+these items in a single user's calendar (in that case, it should
+not be specified). The property can be specified multiple times,
+once for each participant in the event or task. In an
+EMAIL-category VALARM component, this property specifies the
+address of the user(s) who should receive the notification email.
+
+The parameters `icalendar-roleparam', `icalendar-partstatparam',
+`icalendar-rsvpparam', `icalendar-delfromparam', and
+`icalendar-deltoparam' are especially relevant for further
+specifying the roles of each participant in the containing
+component."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cutypeparam
+                             ical:memberparam
+                             ical:roleparam
+                             ical:partstatparam
+                             ical:rsvpparam
+                             ical:deltoparam
+                             ical:delfromparam
+                             ical:sentbyparam
+                             ical:cnparam
+                             ical:dirparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1")
+
+(ical:define-property ical:contact "CONTACT"
+  "Contact.
+
+This property provides textual contact information relevant to an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy'."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.2")
+
+(ical:define-property ical:organizer "ORGANIZER"
+  "Organizer.
+
+This property specifies the organizer of a group-scheduled
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'.
+It is required in those components if they represent a calendar
+entity with multiple participants. In an `icalendar-vfreebusy'
+component, it used to specify the user requesting free or busy
+time, or the user who published the calendar that the free/busy
+information comes from."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cnparam
+                             ical:dirparam
+                             ical:sentbyparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3")
+
+(ical:define-property ical:recurrence-id "RECURRENCE-ID"
+  "Recurrence ID.
+
+This property is used together with the `icalendar-uid' and
+`icalendar-sequence' properties to identify a specific instance
+of a recurring `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' component. The property value is the
+original value of the `icalendar-dtstart' property of the
+recurrence instance. Its value must have the same type as that
+property's value, and both must specify times in the same way
+(either local or UTC)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam
+                             ical:tzidparam
+                             ical:rangeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.4")
+
+(ical:define-property ical:related-to "RELATED-TO"
+  "Related To (component UID).
+
+This property specifies the `icalendar-uid' value of a different,
+related calendar component. It can be specified on an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. An `icalendar-reltypeparam' can be used to specify the
+relationship type."
+  ical:text
+  :child-spec (:zero-or-one (ical:reltypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.5")
+
+(ical:define-property ical:url "URL"
+  "Uniform Resource Locator.
+
+This property specifies the URL associated with an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy' component."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6")
+
+;; TODO: UID should probably be its own type
+(ical:define-property ical:uid "UID"
+  "Unique Identifier.
+
+This property specifies a globally unique identifier for the
+containing component, and is required in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vfreebusy'
+component.
+
+RFC5545 requires that the program generating the UID guarantee
+that it be unique, and recommends generating it in a format which
+includes a timestamp on the left hand side of an '@' character,
+and the domain name or IP address of the host on the right-hand
+side."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.7")
+
+;;;;; Section 3.8.5: Recurrence Component Properties
+
+(ical:define-property ical:exdate "EXDATE"
+  "Exception Date-Times.
+
+This property defines a list of exceptions to a recurrence rule
+in an `icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component. Together
+with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-rdate' properties, it defines the recurrence set of
+the component."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1")
+
+(ical:define-property ical:rdate "RDATE"
+  "Recurrence Date-Times.
+
+This property defines a list of date-times or dates on which an
+`icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component recurs.
+Together with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-exdate' properties, it defines the recurrence set of
+the component."
+  (or ical:period
+      ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date ical:period)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.2")
+
+(ical:define-property ical:rrule "RRULE"
+  "Recurrence Rule.
+
+This property defines a rule or repeating pattern for the dates
+and times on which an `icalendar-vevent', `icalendar-todo',
+`icalendar-vjournal', `icalendar-standard', or
+`icalendar-daylight' component recurs. Together with the
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate'
+properties, it defines the recurrence set of the component."
+  ical:recur
+  ;; TODO: faces for subexpressions?
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3")
+
+;;;;; Section 3.8.6: Alarm Component Properties
+
+(ical:define-property ical:action "ACTION"
+  "Action (when alarm triggered).
+
+This property defines the action to be taken when the containing
+`icalendar-valarm' component is triggered. It is a required
+property in an alarm component."
+  (or "AUDIO"
+      "DISPLAY"
+      "EMAIL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "Applications MUST ignore alarms with x-name and iana-token values
+  ;; they don't recognize." This substitute is not defined in the
+  ;; standard but is the simplest way to parse such alarms:
+  :unrecognized "IGNORE"
+  :default-type ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.1")
+
+(ical:define-property ical:repeat "REPEAT"
+  "Repeat Count (after initial trigger).
+
+This property specifies the number of times an `icalendar-valarm'
+should repeat after it is initially triggered. This property,
+along with the `icalendar-duration' property, is required if the
+alarm triggers more than once."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.2")
+
+(ical:define-property ical:trigger "TRIGGER"
+  "Trigger.
+
+This property specifies when an `icalendar-valarm' should
+trigger. If the value is an `icalendar-dur-value', it represents
+a time of that duration relative to the start or end of a related
+`icalendar-vevent' or `icalendar-vtodo'. Whether the trigger
+applies to the start time or end time of the related component
+can be specified with the `icalendar-trigrelparam' parameter. A
+positive duration value triggers after the start or end of the
+related component; a negative duration value triggers before.
+
+If the value is an `icalendar-date-time', it must be in UTC
+format, and it triggers at the specified time."
+  (or ical:dur-value
+      ical:date-time)
+  :default-type ical:dur-value
+  :other-types (ical:date-time)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:trigrelparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:trigger-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.3")
+
+(defun ical:trigger-validator (node)
+  "Additional validator for an `icalendar-trigger' NODE.
+Checks that NODE has valid parameters depending on the type of its value.
+
+This function is called by `icalendar-ast-node-valid-p' for
+TRIGGER nodes; it is not normally necessary to call it directly."
+  (let* ((params (ical:ast-node-children node))
+         (value-node (ical:ast-node-value node))
+         (value-type (and value-node (ical:ast-node-type value-node))))
+    (when (eq value-type 'ical:date-time)
+      (let ((expl-type (ical:value-type-from-params params))
+            (dt-value (ical:ast-node-value value-node)))
+        (unless (eq expl-type 'ical:date-time)
+          (ical:signal-validation-error
+           (concat "Explicit `icalendar-valuetypeparam' required in "
+                   "`icalendar-trigger' with non-duration value")
+           :node node))
+        (when (ical:ast-node-first-child-of 'ical:trigrelparam node)
+          (ical:signal-validation-error
+           (concat "`icalendar-trigrelparam' not allowed in "
+                   "`icalendar-trigger' with non-duration value")
+           :node node))
+        (unless (ical:date-time-is-utc-p dt-value)
+          (ical:signal-validation-error
+           (concat "`icalendar-date-time' value of `icalendar-trigger' "
+                   "must be in UTC time")
+           :node node))))
+    ;; success:
+    node))
+
+;;;;; Section 3.8.7: Change Management Component Properties
+
+(ical:define-property ical:created "CREATED"
+  "Date-Time Created.
+
+This property specifies the date and time when the calendar user
+initially created an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' in the calendar database. The value must be
+in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
+
+(ical:define-property ical:dtstamp "DTSTAMP"
+  "Timestamp (of last revision or instance creation).
+
+In an `icalendar-vevent', `icalendar-vtodo',
+`icalendar-vjournal', or `icalendar-vfreebusy', this property
+specifies the date and time when the calendar user last revised
+the component's data in the calendar database. (In this case, it
+is equivalent to the `icalendar-last-modified' property.)
+
+If this property is specified on an `icalendar-vcalendar' object
+which contains an `icalendar-method' property, it specifies the
+date and time when that instance of the calendar object was
+created. In this case, it differs from the `icalendar-creation'
+and `icalendar-last-modified' properties: whereas those specify
+the time the underlying data was created and last modified in the
+calendar database, this property specifies when the calendar
+object *representing* that data was created.
+
+The value must be in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
+
+(ical:define-property ical:last-modified "LAST-MODIFIED"
+  "Last Modified timestamp.
+
+This property specifies when the data in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vtimezone'
+was last modified in the calendar database."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3")
+
+(ical:define-property ical:sequence "SEQUENCE"
+  "Revision Sequence Number.
+
+This property specifies the number of the current revision in a
+sequence of revisions in an `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-vjournal' component. It starts
+at 0 and should be incremented monotonically every time the
+Organizer makes a significant revision to the calendar data that
+component represents."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.4")
+
+;;;;; Section 3.8.8: Miscellaneous Component Properties
+;; IANA and X- properties should be parsed and printed but can be ignored:
+(ical:define-property ical:other-property nil ; don't add to ical:property=
-types
+  "IANA or X-name property.
+
+This property type corresponds to the IANA Properties and
+Non-Standard Properties defined in RFC5545; it represents
+properties with an unknown name (matching rx
+`icalendar-iana-token' or `icalendar-x-name') whose values must
+be parsed and preserved but not further interpreted. Its value
+may be set to any type with the `icalendar-valuetypeparam'
+parameter."
+  ical:value
+  :default-type ical:text
+  ;; "The default value type is TEXT. The value type can be set to any
+  ;; value type." TODO: should we specify :other-types? Without it, a
+  ;; VALUE param will be required to parse anything other than text,
+  ;; but that seems reasonable.
+  :child-spec (:allow-others t)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8")
+
+(defun ical:read-req-status-info (s)
+  "Read a request status value from S.
+S should have been previously matched against `icalendar-request-status-in=
fo'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((code (match-string 11))
+        (desc (match-string 12))
+        (exdata (match-string 13)))
+    (list code (ical:read-text desc) (when exdata (ical:read-text exdata))=
)))
+
+(defun ical:print-req-status-info (rsi)
+  "Serialize request status info value RSI to a string."
+  (let ((code (car rsi))
+        (desc (cadr rsi))
+        (exdata (caddr rsi)))
+    (if exdata
+        (format "%s;%s;%s" code (ical:print-text desc) (ical:print-text ex=
data))
+      (format "%s;%s" code (ical:print-text desc)))))
+
+(defun ical:req-status-info-p (val)
+  "Return non-nil if VAL is an `icalendar-request-status-info' value."
+  (and (listp val)
+       (length=3D val 3)
+       (stringp (car val))
+       (stringp (cadr val))
+       (cl-typep (caddr val) '(or string null))))
+
+(ical:define-type ical:req-status-info nil
+  "Type for REQUEST-STATUS property values.
+
+When read, a list (CODE DESCRIPTION EXCEPTION). CODE is a hierarchical
+numerical code, represented as a string, with the following meanings:
+  1.xx Preliminary success
+  2.xx Successful
+  3.xx Client Error
+  4.xx Scheduling Error
+DESCRIPTION is a longer description of the request status, also a string.
+EXCEPTION (which may be nil) is textual data describing an error.
+
+When printed, the three elements are separated by semicolons, like
+  CODE;DESCRIPTION;EXCEPTION
+or
+  CODE;DESCRIPTION
+if EXCEPTION is nil.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-request-status' property."
+  '(satisfies ical:req-status-info-p)
+  (seq
+   ;; statcode: hierarchical status code
+   (group-n 11
+     (seq (one-or-more digit)
+          (** 1 2 (seq ?. (one-or-more digit)))))
+   ?\;
+   ;; statdesc: status description
+   (group-n 12 ical:text)
+   ;; exdata: exception data
+   (zero-or-one (seq ?\; (group-n 13 ical:text))))
+  :reader ical:read-req-status-info
+  :printer ical:print-req-status-info
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+(ical:define-property ical:request-status "REQUEST-STATUS"
+  "Request status"
+  ical:req-status-info
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+
+;;; Section 3.6: Calendar Components
+
+(defconst ical:component-types nil ;; populated by ical:define-component
+  "Alist mapping printed component names to type symbols")
+
+(defun ical:parse-component (limit)
+  "Parse an iCalendar component from point up to LIMIT.
+Point should be at the start of the component, i.e., at the start
+of a line that looks like \"BEGIN:[COMPONENT-NAME]\". After parsing,
+point is at the beginning of the next line following the component
+(or end of the buffer). Returns a syntax node representing the component."
+  (let ((begin-pos nil)
+        (body-begin-pos nil)
+        (end-pos nil)
+        (body-end-pos nil)
+        (begin-regex (rx line-start "BEGIN:" (group-n 2 ical:name) line-en=
d)))
+
+    (unless (re-search-forward begin-regex limit t)
+      (ical:signal-parse-error "Not at start of a component"))
+
+    (setq begin-pos (match-beginning 0)
+          body-begin-pos (1+ (match-end 0))) ; start of next line
+
+    (let* ((component-name (match-string 2))
+           (known-type (alist-get (upcase component-name)
+                                  ical:component-types
+                                  nil nil #'equal))
+           (component-type (or known-type 'ical:other-component))
+           child children)
+
+      ;; Find end of component:
+      (save-excursion
+        (if (re-search-forward (concat "^END:" component-name "$") limit t)
+            (setq end-pos (match-end 0)
+                  body-end-pos (1- (match-beginning 0))) ; end of prev. li=
ne
+          (ical:signal-parse-error
+           (format  "Matching 'END:%s' not found between %d and %d"
+                    component-name begin-pos limit)
+           :restart-at (1+ limit))))
+
+      (while (not (bolp))
+        (forward-char))
+
+      ;; Parse the properties and subcomponents of this component:
+      (while (<=3D (point) body-end-pos)
+        (condition-case err
+            (setq child (ical:parse-property-or-component end-pos))
+          (ical:parse-error
+           (ical:handle-parse-error err)
+           (setq child nil)))
+        (when child (push child children)))
+
+      ;; Set point up for the next parser:
+      (goto-char end-pos)
+      (while (and (< (point) (point-max)) (not (bolp)))
+        (forward-char))
+
+      ;; Return the syntax node for the component:
+      (when children
+        (ical:make-ast-node component-type
+                            (list
+                             :original-name
+                             (when (eq component-type 'ical:other-componen=
t)
+                               component-name)
+                             :buffer (current-buffer)
+                             :begin begin-pos
+                             :end end-pos
+                             :value-begin body-begin-pos
+                             :value-end body-end-pos)
+                            (nreverse children))))))
+
+(defun ical:parse-property-or-component (limit)
+  "Parse a component or a property at point.
+Point should be at the beginning of a line which begins a
+component or contains a property."
+  (cond ((looking-at-p (rx line-start "BEGIN:" ical:name line-end))
+         (ical:parse-component limit))
+        ((looking-at-p (rx line-start ical:name))
+         (ical:parse-property (line-end-position)))
+        (t (ical:signal-parse-error
+            "Not at start of property or component"
+            :restart-at ; find start of next content line:
+            (save-excursion
+              (if (re-search-forward (rx line-start ical:name) nil t)
+                  (match-beginning 0)
+                (point-max)))))))
+
+(defun ical:print-component-node (node)
+  "Serialize a component syntax node NODE to a string."
+  (let* ((type (ical:ast-node-type node))
+         (name (or (ical:ast-node-meta-get :original-name node)
+                   (car (rassq type ical:component-types))))
+         (children (ical:ast-node-children node))
+         body)
+
+    (unless name
+      (ical:signal-print-error
+       (format "Unknown component name for type `%s'" type)
+       :node node))
+
+    (dolist (child children)
+      (condition-case err
+          (setq body
+                (concat body (ical:print-property-or-component child)))
+        (ical:print-error
+         (if (ical:ast-node-required-child-p child node)
+             (ical:signal-print-error
+              (format
+               "Unable to print required `%s' %s in `%s' component. Error =
was:\n%s"
+               (ical:ast-node-type child)
+               (if (ical:component-node-p child) "subcomponent" "property")
+               (ical:ast-node-type node)
+               (plist-get (cdr err) :message))
+              :node node)
+           (ical:handle-print-error err)))))
+    (concat
+     (format "BEGIN:%s\n" name)
+     body
+     (format "END:%s\n" name))))
+
+(defun ical:print-property-or-component (node)
+  "Serialize a property or component node NODE to a string."
+  (cond ((ical:property-node-p node)
+         (ical:print-property-node node))
+        ((ical:component-node-p node)
+         (ical:print-component-node node))
+        (t (ical:signal-print-error "Not a component or property node"
+                                    :node node))))
+
+(ical:define-component ical:vevent "VEVENT"
+  "Represents an event.
+
+This component contains properties which describe an event, such
+as its start and end time (`icalendar-dtstart' and
+`icalendar-dtend') and a summary (`icalendar-summary') and
+description (`icalendar-description'). It may also contain
+`icalendar-valarm' components as subcomponents which describe
+reminder notifications related to the event. Event components can
+only be direct children of an `icalendar-vcalendar'; they cannot
+be subcomponents of any other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:dtstart
+                             ;; TODO: dtstart required if METHOD not prese=
nt
+                             ;; in parent calendar
+                             ical:class
+                             ical:created
+                             ical:description
+                             ical:dtend
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:priority
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:transp
+                             ical:url
+                             ical:recurrence-id
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vevent-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1")
+
+(defun ical:rrule-validator (node)
+  "Validate that NODE has the properties required by a recurrence rule.
+
+NODE should represent an iCalendar component. When NODE has an
+`icalendar-rrule' property, this function validates that its
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate'
+properties satisfy the requirements imposed by this rule.
+
+This function is called by the additional validator functions for
+component nodes (e.g. `icalendar-vevent-validator'); it is not normally
+necessary to call it directly."
+  (let* ((rrule (ical:ast-node-first-child-of 'ical:rrule node))
+         (recval (when rrule (ical:ast-node-value rrule)))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node))
+         (start (when dtstart (ical:ast-node-value dtstart)))
+         (rdates (ical:ast-node-children-of 'ical:rdate node))
+         (included (when rdates
+                     (mapcar #'ical:ast-node-value
+                             (apply #'append
+                                    (mapcar #'ical:ast-node-value rdates))=
))))
+    (when rrule
+      (unless dtstart
+        (ical:signal-validation-error
+         "An `icalendar-rrule' requires an `icalendar-dtstart' property"
+         :node node))
+      (when included
+        ;; ""RDATE" in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with local time
+        ;; value, relative to the UTC offset specified in the
+        ;; "TZOFFSETFROM" property."
+        (when (and (memq (ical:ast-node-type node) '(ical:standard ical:da=
ylight)))
+          (unless (ical:list-of-p included 'ical:date-time)
+            (ical:signal-validation-error
+             (format
+              (concat "`icalendar-rdate' values must be `icalendar-date-ti=
me' "
+                      "values in %s components")
+              (ical:ast-node-type node))
+             :node node))
+          (when (seq-some #'decoded-time-zone included)
+            (ical:signal-validation-error
+             (format
+              (concat "`icalendar-rdate' values must be in local (\"floati=
ng\")"
+                      "time in %s components")
+              (ical:ast-node-type node))
+             :node node))))
+
+      (let* ((freq (car (alist-get 'FREQ recval)))
+             (until (car (alist-get 'UNTIL recval))))
+        (when (eq 'ical:date (ical:ast-node-type start))
+          (when (or (memq freq '(HOURLY MINUTELY SECONDLY))
+                    (assq 'BYSECOND recval)
+                    (assq 'BYMINUTE recval)
+                    (assq 'BYHOUR recval))
+            (ical:signal-validation-error
+             (concat "`icalendar-rrule' must not contain time-based "
+                     "rules when `icalendar-dtstart' is a plain date")
+             :node node)))
+        (when until
+          (unless (eq (ical:ast-node-type start)
+                      (ical:ast-node-type until))
+            (ical:signal-validation-error
+             (concat "`icalendar-rrule' UNTIL clause must agree with "
+                     "type of `icalendar-dtstart' property")
+             :node node))
+          (when (eq 'ical:date-time (ical:ast-node-type until))
+            (let ((until-zone
+                   (decoded-time-zone (ical:ast-node-value until)))
+                  (start-zone
+                   (decoded-time-zone (ical:ast-node-value start))))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with local time, then the UNTIL rule part MUST also
+              ;; be specified as a date with local time":
+              (when (and (null start-zone) (not (null until-zone)))
+                (ical:signal-validation-error
+                  (concat "`icalendar-rrule' UNTIL clause must be in "
+                          "local time if `icalendar-dtstart' is")
+                  :node node))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with UTC time or a date with local time and time zone
+              ;; reference, then the UNTIL rule part MUST be specified
+              ;; as a date with UTC time":
+              (when (and (integerp start-zone)
+                         (not (ical:date-time-is-utc-p until)))
+                (ical:signal-validation-error
+                  (concat "`icalendar-rrule' UNTIL clause must be in UTC t=
ime "
+                          "if `icalendar-dtstart' has a defined time zone")
+                  :node node))))
+          (when (memq (ical:ast-node-type node) '(ical:standard ical:dayli=
ght))
+            ;; "In the case of the "STANDARD" and "DAYLIGHT"
+            ;; sub-components the UNTIL rule part MUST always be
+            ;; specified as a date with UTC time":
+            (unless (ical:date-time-is-utc-p until)
+              (ical:signal-validation-error
+               (concat "`icalendar-rrule' UNTIL clause must be in UTC time=
 in "
+                       "`icalendar-standard' and `icalendar-daylight' comp=
onents")
+               :node node))))
+
+        ;; "DTSTART in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with a local
+        ;; time value."
+        (when (memq (ical:ast-node-type node) '(ical:standard ical:dayligh=
t))
+          (unless (eq 'ical:date-time (ical:ast-node-type start))
+            (ical:signal-validation-error
+              (concat "`icalendar-dtstart' must be an `icalendar-date-time=
' in "
+                      "`icalendar-standard' and `icalendar-daylight' compo=
nents")
+              :node node))
+
+          (when (decoded-time-zone (ical:ast-node-value start))
+            (ical:signal-validation-error
+             (concat "`icalendar-dtstart' must be in local (\"floating\") =
time in "
+                     "`icalendar-standard' and `icalendar-daylight' compon=
ents")
+             :node node)))))
+
+    ;; Success:
+    node))
+
+(defun ical:vevent-validator (node)
+  "Additional validator for an `icalendar-vevent' NODE.
+Checks that NODE has does not have both `icalendar-duration' and
+`icalendar-dtend' properties, and calls `icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VEVENT nodes; it is not normally necessary to call it directly."
+  (let* ((duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dur-value (when duration (ical:ast-node-value
+                                     (ical:ast-node-value duration))))
+         (dtend (ical:ast-node-first-child-of 'ical:dtend node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and dtend duration)
+      (ical:signal-validation-error
+       (concat "`icalendar-dtend' and `icalendar-duration' properties must=
 "
+               "not appear in the same `icalendar-vevent'")
+       :node node))
+    ;; don't allow time-based durations with dates
+    ;; TODO: check that the standard disallows this...?
+    (when (and dtstart duration
+               (eq 'ical:date (ical:ast-node-type dtstart))
+               (or (not (integerp dur-value))
+                   (decoded-time-hour dur-value)
+                   (decoded-time-minute dur-value)
+                   (decoded-time-second dur-value)))
+      (ical:signal-validation-error
+       (concat "Event with `icalendar-date' value in `icalendar-dtstart' "
+               "cannot have time units in `icalendar-duration'")
+       :node node))
+
+  (ical:rrule-validator node)
+  ;; success:
+  node))
+
+(ical:define-component ical:vtodo "VTODO"
+  "Represents a To-Do item or task.
+
+This component contains properties which describe a to-do item or
+task, such as its due date (`icalendar-due') and a summary
+(`icalendar-summary') and description (`icalendar-description').
+It may also contain `icalendar-valarm' components as
+subcomponents which describe reminder notifications related to
+the task. To-do components can only be direct children of an
+`icalendar-vcalendar'; they cannot be subcomponents of any other
+component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:completed
+                             ical:created
+                             ical:description
+                             ical:dtstart
+                             ical:due
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:percent-complete
+                             ical:priority
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vtodo-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.2")
+
+(defun ical:vtodo-validator (node)
+  "Additional validator for an `icalendar-vtodo' NODE.
+Checks that NODE has conformant `icalendar-due',
+`icalendar-duration', and `icalendar-dtstart' properties, and calls
+`icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTODO nodes; it is not normally necessary to call it directly."
+  (let* ((due (ical:ast-node-first-child-of 'ical:due node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and due duration)
+      (ical:signal-validation-error
+       (concat "`icalendar-due' and `icalendar-duration' properties "
+               "must not appear in the same `icalendar-vtodo'")
+       :node node))
+    (when (and duration (not dtstart))
+      (ical:signal-validation-error
+       (concat "`icalendar-duration' requires `icalendar-dtstart' "
+               "property in the same `icalendar-vtodo'")
+       :node node)))
+  (ical:rrule-validator node)
+  ;; success:
+  node)
+
+(ical:define-component ical:vjournal "VJOURNAL"
+  "Represents a journal entry.
+
+This component contains properties which describe a journal
+entry, which might be any longer-form data (e.g., meeting notes,
+a diary entry, or information needed to complete a task). It can
+be associated with an `icalendar-vevent' or `icalendar-vtodo' via
+the `icalendar-related-to' property. A journal entry does not
+take up time in a calendar, and plays no role in searches for
+free or busy time. Journal components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:created
+                             ical:dtstart
+                             ical:last-modified
+                             ical:organizer
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:description
+                              ical:exdate
+                              ical:related-to
+                              ical:rdate
+                              ical:request-status
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3")
+
+(ical:define-component ical:vfreebusy "VFREEBUSY"
+  "Represents a published set of free/busy time blocks, or a request
+or response for such blocks.
+
+The free/busy information is represented by the
+`icalendar-freebusy' property (which may be given more than once)
+and the related `icalendar-fbtype' parameter. Note that
+recurrence properties (`icalendar-rrule', `icalendar-rdate', and
+`icalendar-exdate') are NOT permitted in this component.
+
+When used to publish blocks of free/busy time in a user's
+schedule, the `icalendar-organizer' property specifies the user.
+
+When used to request free/busy time in a user's schedule, or to
+respond to such a request, the `icalendar-attendee' property
+specifies the user whose time is being requested, and the
+`icalendar-organizer' property specifies the user making the
+request.
+
+Free/busy components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component, and cannot contain subcomponents."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:contact
+                             ical:dtstart
+                             ical:dtend
+                             ical:organizer
+                             ical:url)
+               :zero-or-more (ical:attendee
+                              ical:comment
+                              ical:freebusy
+                              ical:request-status
+                              ical:other-property))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.4")
+
+;; TODO: RFC7808 defines additional properties that are relevant here:
+;; https://www.rfc-editor.org/rfc/rfc7808.html#section-7
+(ical:define-component ical:vtimezone "VTIMEZONE"
+  "Represents a time zone.
+
+A time zone is identified by an `icalendar-tzid' property, which
+is required in this component. Times in other calendar components
+can be specified in local time in this time zone with the
+`icalendar-tzidparam' parameter. An `icalendar-vcalendar' object
+must contain exactly one `icalendar-vtimezone' component for each
+unique time zone identifier used in the calendar.
+
+Besides the time zone identifier, a time zone component must
+contain at least one `icalendar-standard' or `icalendar-daylight'
+subcomponent, which describe the observance of standard or
+daylight time in the time zone, including the dates of the
+observance and the relevant offsets from UTC time."
+  :child-spec (:one (ical:tzid)
+               :zero-or-one (ical:last-modified
+                             ical:tzurl)
+               :zero-or-more (ical:standard
+                              ical:daylight
+                              ical:other-property))
+  :other-validator ical:vtimezone-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(defun ical:vtimezone-validator (node)
+  "Additional validator for an `icalendar-vtimezone' NODE.
+Checks that NODE has at least one `icalendar-standard' or
+`icalendar-daylight' child.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTIMEZONE nodes; it is not normally necessary to call it directly."
+  (let ((child-counts (ical:count-children-by-type node)))
+    (when (and (=3D 0 (alist-get 'ical:standard child-counts 0))
+               (=3D 0 (alist-get 'ical:daylight child-counts 0)))
+      (ical:signal-validation-error
+       (concat "`icalendar-vtimezone' must have at least one "
+               "`icalendar-standard' or `icalendar-daylight' child")
+       :node node)))
+
+  ;; success:
+  node)
+
+(ical:define-component ical:standard "STANDARD"
+  "Represents a Standard Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other explanatory
+comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:daylight "DAYLIGHT"
+  "Represents a Daylight Savings Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other
+explanatory comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:valarm "VALARM"
+  "Represents an alarm.
+
+An alarm is a notification or reminder for an event or task. The
+type of notification is determined by this component's
+`icalendar-action' property: it may be an AUDIO, DISPLAY, or
+EMAIL notification.
+If it is an audio alarm, it can include an
+`icalendar-attach' property specifying the audio to be rendered.
+If it is a DISPLAY alarm, it must include an `icalendar-description'
+property containing the text to be displayed.
+If it is an EMAIL alarm, it must include both an
+`icalendar-summary' and an `icalendar-description', which specify
+the subject and body of the email, and one or more
+`icalendar-attendee' properties, which specify the recipients.
+
+The required `icalendar-trigger' property specifies when the
+alarm triggers. If the alarm repeats, then `icalendar-duration'
+and `icalendar-repeat' properties are also both required.
+
+This component must occur as a direct child of an
+`icalendar-vevent' or `icalendar-vtodo' component, and cannot
+contain any subcomponents."
+  :child-spec (:one (ical:action ical:trigger)
+               :zero-or-one (ical:duration ical:repeat)
+               :zero-or-more (ical:summary
+                              ical:description
+                              ical:attendee
+                              ical:attach
+                              ical:other-property))
+  :other-validator ical:valarm-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.6")
+
+(defun ical:valarm-validator (node)
+  "Additional validator function for `icalendar-valarm' components.
+Checks that NODE has the right properties corresponding to its
+`icalendar-action' type, e.g., that an EMAIL alarm has a
+subject (`icalendar-summary') and recipients (`icalendar-attendee').
+
+This function is called by `icalendar-ast-node-valid-p' for
+VALARM nodes; it is not normally necessary to call it directly."
+  (let* ((action (ical:ast-node-first-child-of 'ical:action node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (repeat (ical:ast-node-first-child-of 'ical:repeat node))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when (and duration (not repeat))
+      (ical:signal-validation-error
+       (concat "`icalendar-valarm' node with `icalendar-duration' "
+               "must also have `icalendar-repeat' property")
+       :node node))
+
+    (when (and repeat (not duration))
+      (ical:signal-validation-error
+       (concat "`icalendar-valarm' node with `icalendar-repeat' "
+               "must also have `icalendar-duration' property")
+       :node node))
+
+    (let ((action-str (upcase (ical:text-to-string
+                               (ical:ast-node-value action)))))
+      (cond ((equal "AUDIO" action-str)
+             (unless (<=3D (alist-get 'ical:attach child-counts 0) 1)
+               (ical:signal-validation-error
+                (concat "AUDIO `icalendar-valarm' may not have "
+                        "more than one `icalendar-attach'")
+                :node node))
+             node)
+
+            ((equal "DISPLAY" action-str)
+             (unless (=3D 1 (alist-get 'ical:description child-counts 0))
+               (ical:signal-validation-error
+                (concat "DISPLAY `icalendar-valarm' must have "
+                        "exactly one `icalendar-description'")
+                :node node))
+             node)
+
+            ((equal "EMAIL" action-str)
+             (unless (=3D 1 (alist-get 'ical:summary child-counts 0))
+               (ical:signal-validation-error
+                (concat "EMAIL `icalendar-valarm' must have "
+                        "exactly one `icalendar-summary'")
+                :node node))
+             (unless (=3D 1 (alist-get 'ical:description child-counts 0))
+               (ical:signal-validation-error
+                (concat "EMAIL `icalendar-valarm' must have "
+                        "exactly one `icalendar-description'")
+                :node node))
+             (unless (<=3D 1 (alist-get 'ical:attendee child-counts 0))
+               (ical:signal-validation-error
+                (concat "EMAIL `icalendar-valarm' must have "
+                        "at least one `icalendar-attendee'")
+                :node node))
+             node)
+
+            (t
+             ;; "Applications MUST ignore alarms with x-name and iana-token
+             ;; values they don't recognize." So this is not a validation-=
error:
+             (ical:warn
+              (format "Unknown ACTION value in VALARM: %s" action-str)
+              :buffer (ical:ast-node-meta-get node :buffer)
+              :position (ical:ast-node-meta-get node :value-begin))
+             node)))))
+
+(ical:define-component ical:other-component nil
+  "Component type for unrecognized component names.
+
+This component type corresponds to the IANA and X-name components
+allowed by RFC5545 sec. 3.6; it represents components with an
+unknown name (matching rx `icalendar-iana-token' or
+`icalendar-x-name') which must be parsed and preserved but not
+further interpreted."
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6")
+
+;; Technically VCALENDAR is not a "component", but for the
+;; purposes of parsing and syntax highlighting, it looks just like
+;; one, so we define it as such here.
+;; (If this becomes a problem, modify `ical:component-node-p'
+;; to return nil for VCALENDAR components.)
+(ical:define-component ical:vcalendar "VCALENDAR"
+  "Calendar Object.
+
+This is the top-level data structure defined by RFC5545. A
+VCALENDAR must contain the calendar properties `icalendar-prodid'
+and `icalendar-version', and may contain the calendar properties
+`icalendar-method' and `icalendar-calscale'.
+
+It must also contain at least one VEVENT, VTODO, VJOURNAL,
+VFREEBUSY, or other component, and for every unique
+`icalendar-tzidparam' value appearing in a property within these
+components, the calendar object must contain an
+`icalendar-vtimezone' defining a time zone with that TZID."
+  :child-spec (:one (ical:prodid ical:version)
+               :zero-or-one (ical:calscale ical:method)
+               :zero-or-more (ical:other-property
+                              ical:vevent
+                              ical:vtodo
+                              ical:vjournal
+                              ical:vfreebusy
+                              ical:vtimezone
+                              ical:other-component))
+  :other-validator ical:vcalendar-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.4")
+
+(defun ical:all-tzidparams-in (node)
+  "Recursively find all `icalendar-tzidparam' values in NODE and its child=
ren."
+  (cond ((ical:tzid-param-p node)
+         (list (ical:ast-node-value node)))
+        ((ical:param-node-p node)
+         nil)
+        (t ;; TODO: could prune search here when properties don't allow tz=
idparam
+         (seq-uniq (mapcan #'ical:all-tzidparams-in
+                           (ical:ast-node-children node))))))
+
+(defun ical:vcalendar-validator (node)
+  "Additional validator for `icalendar-vcalendar' NODE.
+
+Checks that NODE has at least one component child and that all of the
+`ical-tzidparam' values appearing in subcomponents have a corresponding
+`icalendar-vtimezone' definition.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VCALENDAR nodes; it is not normally necessary to call it directly."
+  (let* ((children (ical:ast-node-children node))
+         (comp-children (seq-filter #'ical:component-node-p children))
+         (tz-children (seq-filter #'ical:vtimezone-component-p children))
+         (defined-tzs
+          (mapcar
+           (lambda (tz)
+             ;; ensure vtimezone component has a TZID property and
+             ;; extract its string value:
+             (when (ical:ast-node-valid-p tz)
+               (ical:with-component tz ((ical:tzid :value-node tzid-text))
+                 (ical:text-to-string tzid-text))))
+           tz-children))
+         (appearing-tzids (ical:all-tzidparams-in node)))
+    (unless comp-children
+      (ical:signal-validation-error
+       "`icalendar-vcalendar' must contain at least one component"
+       :node node))
+
+    (let ((seen nil))
+      (dolist (tzid appearing-tzids)
+        (unless (member tzid seen)
+          (unless (member tzid defined-tzs)
+            (ical:signal-validation-error
+             (format "No `icalendar-vtimezone' with TZID '%s' in calendar"=
 tzid)
+             :node node)))
+        (push tzid seen)))
+
+    ;; success:
+    node))
+
+(declare-function icr:tz-set-zones-in "icalendar-recur")
+
+(defun ical:contains-vcalendar-p (&optional buffer)
+  "Determine whether BUFFER contains \"BEGIN:VCALENDAR\".
+
+If so, then BUFFER is a candidate for parsing with, e.g.,
+`icalendar-parse-calendar'. BUFFER defaults to the current
+buffer. Returns the position where parsing should start, or nil."
+  (with-current-buffer (or buffer (current-buffer))
+    (save-excursion
+      (goto-char (point-min))
+      (when (re-search-forward "^BEGIN:VCALENDAR" nil t)
+        (beginning-of-line)
+        (point)))))
+
+;; `icalendar-parse-component' is sufficient to parse all the syntax in
+;; a calendar, but a calendar-level parsing function is needed to add
+;; support for time zones. This function ensures that every
+;; `icalendar-tzidparam' in the calendar has a corresponding
+;; `icalendar-vtimezone' component, and modifies the zone information of
+;; the parsed date-time according to the offset in that time zone.
+(defun ical:parse-calendar (limit)
+  "Parse an `icalendar-vcalendar' object from point up to LIMIT.
+Point should be at the start of the calendar object, i.e., at the start
+of a line that looks like \"BEGIN:VCALENDAR\". After parsing, point is
+at the beginning of the next line following the calendar (or end of the
+buffer). Returns a syntax node representing the calendar."
+  (require 'icalendar-recur) ; for icr:tz-set-zones-in; avoids circular re=
quire
+  (unless (looking-at-p "^BEGIN:VCALENDAR")
+    (ical:signal-parse-error "Not at start of VCALENDAR"))
+  (let ((cal-node (ical:parse-component limit)))
+      ;(when (ical:ast-node-valid-p cal-node t)
+      (ical:with-component cal-node
+          ((ical:vtimezone :all tzs))
+        ;; After parsing the whole calendar, set the zone and dst slots
+        ;; in all date-times which are relative to a time zone defined
+        ;; in the calendar:
+        ;; (TODO: if this proves too slow in general, we could instead
+        ;; do it lazily when individual components are queried somehow.
+        ;; But I'm not convinced that will actually save any time, because
+        ;; if we're parsing, we're probably already in the middle of a
+        ;; function that will immediately query all these times, e.g.
+        ;; `diary-icalendar-import-buffer'.)
+        (dolist (comp (ical:ast-node-children cal-node))
+          (unless (ical:vtimezone-component-p comp)
+            (icr:tz-set-zones-in tzs comp))));)
+      cal-node))
+
+;; TODO: should we do anything to *create* VTIMEZONE nodes in VCALENDAR
+;; when they're required but don't exist?
+(defun ical:print-calendar-node (vcalendar)
+  "Serialize an `icalendar-vcalendar' VCALENDAR to a string.
+
+If VCALENDAR is not a valid `icalendar-vcalendar', an
+`icalendar-validation-error' will be signaled. Any errors that arise
+during printing will be logged in the buffer returned by
+`icalendar-error-buffer'."
+  (when (ical:ast-node-valid-p vcalendar t)
+    (condition-case err
+        (ical:print-component-node vcalendar)
+      (ical:print-error
+       (ical:handle-print-error err)))))
+
+
+;;; High-level parsing and printing functions.
+(defun ical:parse (&optional buffer)
+  "Parse an `icalendar-vcalendar' object in BUFFER (default: current buffe=
r).
+
+An unfolded copy of BUFFER (see `icalendar-unfolded-buffer-from-buffer')
+will first be obtained if necessary. Parsing will begin at the first
+occurrence of \"BEGIN:VCALENDAR\" in the unfolded buffer.
+
+The buffer may be tidied up by user functions before parsing begins; see
+`icalendar-pre-unfolding-hook' and `icalendar-pre-parsing-hook'.
+
+If parsing is successful, the VCALENDAR object is returned. Otherwise,
+nil is returned, a warning is issued, and errors are logged in the
+buffer returned by `icalendar-error-buffer'."
+  (let* ((buf (or buffer (current-buffer)))
+         (unfolded (cond ((ical:unfolded-p buf) buf)
+                         ((buffer-file-name buf)
+                          (ical:unfolded-buffer-from-file (buffer-file-nam=
e buf)))
+                         (t (ical:unfolded-buffer-from-buffer buf)))))
+    (ical:init-error-buffer)
+    (with-current-buffer unfolded
+      (run-hooks 'ical:pre-parsing-hook)
+      (let ((cal-start (ical:contains-vcalendar-p))
+            vcalendar)
+        (unless cal-start
+          (ical:signal-parse-error "Buffer does not contain \"BEGIN:VCALEN=
DAR\""))
+        (save-excursion
+          (goto-char cal-start)
+          (ical:condition-case err
+              (setq vcalendar (ical:parse-calendar (point-max)))
+            (ical:parse-error
+             (ical:handle-parse-error err)
+             (warn "Errors while parsing %s; see buffer %s"
+                   buffer (buffer-name (ical:error-buffer))))))
+        vcalendar))))
+
+(defun ical:print (vcalendar &optional buffer pos)
+  "Insert VCALENDAR as a string at position POS in BUFFER.
+
+VCALENDAR should be an `icalendar-vcalendar'. BUFFER defaults to the
+current buffer and POS defaults to point.
+
+If printing is successful, VCALENDAR is returned. Otherwise, nil is
+returned, a warning is issued, and errors are logged in the buffer
+returned by `icalendar-error-buffer'."
+  ;; TODO: This is not really useful yet.
+  ;; Feels like it's needed for completeness but interface needs more thou=
ght.
+  ;; Should this instead be a generic function that prints any
+  ;; kind of node at point? at a given marker?
+  (with-current-buffer (or buffer (current-buffer))
+    (when pos (goto-char pos))
+    (condition-case err
+        (insert (ical:print-calendar-node vcalendar))
+      (ical:print-error
+       (ical:handle-print-error err)
+       (setq vcalendar nil) ; return
+       (warn "Errors while printing; see buffer %s"
+             (buffer-name (ical:error-buffer)))))
+    vcalendar))
+
+
+;;; Pre-parsing cleanup
+;;
+;; The following functions are based on observed syntax errors in
+;; real-world data and can help clean up such data before parsing.
+;; More functions can be added here based on user feedback.
+(defcustom ical:pre-parsing-hook nil
+  "Hook run by `icalendar-parse' before parsing iCalendar data.
+
+If you routinely receive iCalendar data in an incorrect format, you can
+add functions to this hook which clean up that data before parsing is
+attempted. The functions in this hook will be run after the iCalendar
+data has been \"unfolded\" but before parsing begins. (If you need to
+clean up data before unfolding happens, see
+`icalendar-pre-unfolding-hook'.)
+
+Each function should accept zero arguments and should perform its
+operation on the entire current buffer."
+  :version "32.1"
+  :type '(hook)
+  :options '(ical:fix-blank-lines
+             ical:fix-hyphenated-dates
+             ical:fix-missing-mailtos))
+
+(defun ical:fix-blank-lines ()
+  "Remove blank lines.
+This function is intended to be used from `icalendar-pre-parsing-hook',
+which see."
+  (goto-char (point-min))
+  (while (re-search-forward (rx "\n" (zero-or-more space) line-end)
+                            nil t)
+    (replace-match "" nil nil)))
+
+(defun ical:fix-hyphenated-dates ()
+  "Correct dates in \"YYYY-MM-DD...\" format to \"YYYYMMDD...\" format.
+This function is intended to be used from `icalendar-pre-parsing-hook',
+which see."
+  (goto-char (point-min))
+  (while (re-search-forward
+          (rx line-start
+              (or "COMPLETED" "DTEND" "DUE" "DTSTART" "RECURRENCE-ID"
+                  "EXDATE" "RDATE" "CREATED" "DTSTAMP" "LAST-MODIFIED")
+              (zero-or-more ical:other-param-safe)
+              ":")
+          nil t)
+    (unless (looking-at-p (rx (or ical:date ical:date-time)))
+      (while (re-search-forward ; exdate, rdate allow lists
+              (rx (group-n 1 (=3D 4 digit))
+                  "-"
+                  (group-n 2 (=3D 2 digit))
+                  "-"
+                  (group-n 3 (=3D 2 digit)))
+              (line-end-position) t)
+      (replace-match "\\1\\2\\3" nil nil)))))
+
+(defun ical:fix-missing-mailtos ()
+  "Insert \"mailto:\" when it is missing before email addresses.
+This function is intended to be used from `icalendar-pre-parsing-hook',
+which see."
+  ;; fix property values in properties that require an address:
+  (goto-char (point-min))
+  (while (re-search-forward
+          (rx line-start (or "ORGANIZER" "ATTENDEE")
+              (zero-or-more ical:other-param-safe) ":")
+          nil t)
+   (unless (looking-at-p (rx ical:cal-address))
+     (when (looking-at
+            (rx
+             ;; match local part of mail address: all the characters
+             ;; allowed after a URI scheme, *except*
+             ;; ?@ (so we can match that after) and
+             ;; ?: (in case we're looking at a non-"mailto:" scheme)
+             (group-n 1
+               (one-or-more
+                (any "A-Za-z0-9" ?- ?. ?_ ?~ ?/ ?? ?# ?\[ ?\] ?! ?$ ?& ?'
+                     ?\( ?\) ?* ?+ ?, ?\; ?=3D ?%)))
+             "@"))
+       (when (or (< (length (match-string 0)) 7)
+                 (not (equal "mailto:"
+                             (substring (downcase (match-string 0)) 0 7))))
+         (replace-match "mailto:\\1" nil nil nil 1)))))
+
+  ;; fix parameter values in parameters that require an address:
+  (goto-char (point-min))
+  (while (re-search-forward
+          (rx line-start ical:name
+              (zero-or-more icalendar-other-param-safe)
+              ";"
+              (or "DELEGATED-FROM" "DELEGATED-TO" "MEMBER" "SENT-BY")
+              "=3D")
+          nil t)
+    (unless (looking-at-p (rx ical:cal-address))
+      (while ; DELEGATED* params accept lists
+          (looking-at
+           (rx
+            ?\" ; values of these params must always be quoted
+            (group-n 1 ; matches local part of mail address as above
+              (one-or-more
+               (any "A-Za-z0-9" ?- ?. ?_ ?~ ?/ ?? ?# ?\[ ?\] ?! ?$ ?& ?'
+                    ?\( ?\) ?* ?+ ?, ?=3D ?%)))
+            "@"
+            (zero-or-more (not ?\"))
+            ?\"
+            (zero-or-one ",")))
+        (when (or (< (length (match-string 1)) 7)
+                  (not (equal "mailto:"
+                              (substring (downcase (match-string 1)) 0 7))=
))
+          (replace-match "mailto:\\1" nil nil nil 1))
+        (goto-char (match-end 0))))))
+
+
+;;; Caching and indexing parse trees
+;;
+;; The following functions provide a simple in-memory cache and index
+;; for faster access to parsed iCalendar data by date, UID, and other
+;; fields of interest. The index and parse tree are stored in a
+;; buffer-local variable of the parsed buffer and not recomputed if the
+;; buffer hasn't changed. Most users of the library should just call
+;; `icalendar-parse-and-index' to get both the parse tree and a
+;; reference to the index, and get objects of interest from them
+;; with `icalendar-index-get'.
+(defun ical:make-index ()
+  "Create an empty index of iCalendar components."
+  (list :bydate (make-hash-table :test #'equal) ;; date =3D> list of compo=
nents
+        :byuid (make-hash-table :test #'equal)  ;; UID =3D> component
+        :bytzid (make-hash-table :test #'equal) ;; tzid =3D> vtimezone
+        :recurring (list))) ;; list of components
+
+(defun ical:index-insert-tz (index vtimezone)
+  "Insert VTIMEZONE into INDEX."
+  (ical:with-component vtimezone
+      ((ical:tzid :value tzid))
+    (let ((tzid-index (plist-get index :bytzid)))
+      (puthash tzid vtimezone tzid-index)
+      ;; Update and return the index:
+      (plist-put index :bytzid tzid-index))))
+
+(declare-function icr:recurrences-to-count "icalendar-recur")
+
+(defun ical:index-insert (index component)
+  "Insert COMPONENT into INDEX."
+  (require 'icalendar-recur) ; avoid circular import
+  (ical:with-component component
+    ((ical:dtstart :first dtstart-node :value dtstart)
+     (ical:dtend :first dtend-node :value dtend)
+     (ical:due :value due)
+     (ical:duration :value duration)
+     (ical:rrule :value recur-value)
+     (ical:rdate :all rdate-nodes)
+     (ical:exdate :all exdate-nodes)
+     (ical:uid :value uid))
+    (let ((date-index (plist-get index :bydate))
+          (uid-index (plist-get index :byuid))
+          (tzid-index (plist-get index :bytzid))
+          (recurring (plist-get index :recurring))
+          (rdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append (mapcar #'ical:ast-node-value rdate-nod=
es))))
+          (exdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append (mapcar #'ical:ast-node-value exdate-no=
des))))
+          dates)
+      ;; Everything with a UID goes into the uid-index:
+      (when uid
+        (puthash uid component uid-index))
+      ;; For all top-level components, we gather a list of dates on which
+      ;; they recur for date-index, or put them in the recurring list:
+      (when dtstart
+        (cond
+         ;; If the component has an RRULE that specifies a fixed number
+         ;; of recurrences, compute them now and index them for each date
+         ;; in each recurrence:
+         ((and recur-value (ical:recur-count recur-value))
+          (let* ((tz (gethash (ical:with-param-of dtstart-node 'ical:tzidp=
aram)
+                              tzid-index))
+                 (recs (cons dtstart (icr:recurrences-to-count component t=
z))))
+            (dolist (rec recs)
+              (let ((end-time
+                     (when duration (ical:date/time-add-duration rec durat=
ion))))
+                (setq dates
+                      (append dates
+                              (if end-time (ical:dates-until rec end-time =
t)
+                                (list (ical:date/time-to-date
+                                       (ical:date/time-to-local rec)))))))=
)))
+         ;; Same with RDATEs when there's no RRULE:
+         ((and rdates (not recur-value))
+          (dolist (rec (cons dtstart rdates))
+            (unless (or (cl-typep rec 'ical:period) (member rec exdates))
+              (let ((end-time
+                     (when duration
+                       (ical:date/time-add-duration rec duration))))
+                (setq dates
+                      (append dates
+                              (if end-time (ical:dates-until rec end-time =
t)
+                                (list (ical:date/time-to-date
+                                       (ical:date/time-to-local rec))))))))
+            (when (cl-typep rec 'ical:period)
+              (let* ((start (ical:period-start rec))
+                     (end (or (ical:period-end rec)
+                              (ical:date/time-add-duration
+                               start (ical:period-dur-value rec)))))
+                (setq dates (append dates (ical:dates-until start end t)))=
))))
+         ;; A non-recurring event also gets an index entry for each date
+         ;; until its end time:
+         ((not recur-value)
+          (let ((end-time
+                 (or dtend due
+                     (when duration
+                       (ical:date/time-add-duration dtstart duration)))))
+            (setq dates (if end-time (ical:dates-until dtstart end-time t)
+                          (list
+                           (ical:date/time-to-date
+                            (ical:date/time-to-local dtstart)))))))
+         ;; Otherwise, we put off the computation of recurrences until que=
ried:
+         (t (push component recurring)))
+
+        (dolist (date (seq-uniq dates))
+          (let ((others (gethash date date-index)))
+            ;; TODO: wonder if we should normalize, and instead store UIDs
+            ;; in the date index, then look them up by UID when queried.
+            (puthash date (cons component others) date-index))))
+
+      ;; Return the updated index:
+      (setq index (plist-put index :byuid uid-index))
+      (setq index (plist-put index :bytzid tzid-index))
+      (setq index (plist-put index :bydate date-index))
+      (setq index (plist-put index :recurring recurring))
+      index)))
+
+(defun ical:index-populate-from-calendar (index vcalendar)
+  "Insert all components in VCALENDAR into INDEX."
+  (let* ((tzs (ical:ast-node-children-of 'ical:vtimezone vcalendar))
+         (vevents (ical:ast-node-children-of 'ical:vevent vcalendar))
+         (vjournals (ical:ast-node-children-of 'ical:vjournal vcalendar))
+         (vtodos (ical:ast-node-children-of 'ical:vtodo vcalendar))
+         ;; TODO: customizable selection? what about valarms?
+         (to-index (append vevents vjournals vtodos)))
+
+    ;; First insert the tzs, so that they're available when inserting
+    ;; the others by date:
+    (dolist (tz tzs)
+      (setq index (ical:index-insert-tz index tz)))
+
+    (dolist (component to-index)
+      (setq index (ical:index-insert index component)))
+    index))
+
+(declare-function icr:find-interval "icalendar-recur")
+(declare-function icr:recurrences-in-interval "icalendar-recur")
+
+(cl-defun ical:index-get (index &rest args &key date uid tzid)
+  "Get an iCalendar component from INDEX by date, UID, or TZID.
+
+INDEX should be a reference to a parse tree index as returned by
+`icalendar-parse-and-index', which see. The index can be queried by:
+
+:uid UID (string, see `icalendar-uid') - returns the component with that
+  UID.
+
+:tzid TZID (string, see `icalendar-tzid' and `icalendar-tzidparam') -
+  returns the `icalendar-vtimezone' component with that TZID.
+
+:date DT (an `icalendar-date', i.e. a list (M D Y)) - returns a list of
+  the components occurring (or recurring) on that date.
+
+Only one keyword argument can be queried at a time."
+  (require 'icalendar-recur) ; avoid circular import
+  (when (length> args 2)
+    (error "Only one keyword argument can be queried"))
+  (cond (uid (gethash uid (plist-get index :byuid)))
+        (tzid (gethash tzid (plist-get index :bytzid)))
+        (date
+         (let ((computed (gethash date (plist-get index :bydate)))
+               (recurring (plist-get index :recurring)))
+           (dolist (component recurring)
+             (ical:with-component component
+                 ((ical:dtstart :first dtstart-node :value dtstart)
+                  (ical:rrule :value recur-value)
+                  (ical:rdate :all rdate-nodes)
+                  (ical:duration :value duration))
+               (unless (ical:date/time<=3D date dtstart)
+                 (let* ((tz (ical:with-param-of dtstart-node 'ical:tzidpar=
am nil
+                              (gethash value (plist-get index :bytzid))))
+                        (int (icr:find-interval date dtstart recur-value t=
z))
+                        (recs (icr:recurrences-in-interval int component t=
z)))
+                   (catch 'found
+                     (dolist (rec recs)
+                       (let* ((local-rec (ical:date/time-to-local rec))
+                              (end
+                               (when duration
+                                 (ical:date/time-add-duration local-rec du=
ration)))
+                              (rec-dates
+                               (if end (ical:dates-until local-rec end t)
+                                 (list (ical:date/time-to-date local-rec))=
)))
+                         (when (member date rec-dates)
+                           (push component computed)
+                           (throw 'found nil))))
+                     (dolist (node rdate-nodes)
+                       ;; normal RDATE recurrences have already been
+                       ;; checked above, but we check whether `date'
+                       ;; occurs in any RDATE period values here:
+                       (when (eq 'ical:period
+                                 (ical:value-type-from-params
+                                  (ical:ast-node-children node)))
+                         (let* ((tz
+                                 (ical:with-param-of node 'ical:tzidparam =
nil
+                                   (gethash value (plist-get index :bytzid=
)))))
+                           (ical:with-property node nil
+                             (dolist (period values)
+                               (when (ical:date/time-in-period-p date peri=
od tz)
+                                 (push component computed)
+                                 (throw 'found nil))))))))))))
+           computed))
+        (t (error "At least one of :uid, :tzid, or :date is required"))))
+
+;; Buffer local variable to cache the index and parse tree.
+;; Format: (TICKS VCALENDAR INDEX)
+;; TICKS is the value of (buffer-modified-tick) at last parse
+(defvar-local ical:-parsed-calendar-and-index '(0 nil nil))
+
+(defun ical:parse-and-index (&optional buffer-or-file)
+  "Parse and index the first iCalendar VCALENDAR object in BUFFER-OR-FILE.
+
+Returns a list (VCALENDAR INDEX), where VCALENDAR is the parsed
+`icalendar-vcalendar' syntax tree. The index can then be queried to
+retrieve components from this calendar by UID, TZID, or date; see
+`icalendar-index-get'.
+
+BUFFER-OR-FILE may be a buffer or a string containing a filename; it
+defaults to the current buffer.  If it is a filename, an unfolded buffer
+containing its data will be found, or created if necessary (see
+`icalendar-unfolded-buffer-from-file').  The resulting buffer must
+contain an iCalendar VCALENDAR object, which will be parsed and indexed.
+
+The results of parsing and indexing are cached in buffer-local
+variables, and subsequent calls with the same BUFFER-OR-FILE will return
+the cached results as long as the buffer has not been modified in the
+meantime."
+  (let* ((buffer (cond ((null buffer-or-file) (current-buffer))
+                       ((bufferp buffer-or-file) buffer-or-file)
+                       ((and (stringp buffer-or-file)
+                             (file-exists-p buffer-or-file))
+                        (find-buffer-visiting buffer-or-file))))
+         (file-name (cond (buffer (buffer-file-name buffer))
+                          ((and (stringp buffer-or-file)
+                                (file-exists-p buffer-or-file))
+                           (expand-file-name buffer-or-file))))
+         (unfolded (cond ((and buffer (ical:unfolded-p buffer))
+                          buffer)
+                         (file-name
+                          (or (ical:find-unfolded-buffer-visiting file-nam=
e)
+                              (ical:unfolded-buffer-from-file file-name)))
+                         (buffer
+                          (ical:unfolded-buffer-from-buffer buffer))
+                         (t
+                          (error "Unable to get unfolded buffer for '%s'"
+                                 buffer-or-file)))))
+    (with-current-buffer unfolded
+      (when (ical:contains-vcalendar-p)
+        (if (eql (car ical:-parsed-calendar-and-index) (buffer-modified-ti=
ck))
+            (cdr ical:-parsed-calendar-and-index)
+          (message "Parsing and indexing iCalendar data in %s..." (buffer-=
name))
+          (let ((vcalendar (ical:parse)))
+            (when vcalendar
+              (setq ical:-parsed-calendar-and-index
+                    (list
+                     (buffer-modified-tick)
+                     vcalendar
+                     (ical:index-populate-from-calendar (ical:make-index)
+                                                         vcalendar)))
+              (message "Parsing and indexing iCalendar data in %s...Done."
+                       (buffer-name))
+              (cdr ical:-parsed-calendar-and-index))))))))
+
+
+
+;;; Documentation for all of the above via `describe-symbol':
+(defun ical:documented-symbol-p (sym)
+  "Return non-nil if SYM is a symbol with iCalendar documentation."
+  (or (get sym 'icalendar-type-documentation)
+      ;; grammatical categories defined with rx-define, but with no
+      ;; other special icalendar docs:
+      (and (get sym 'rx-definition)
+           (length> (symbol-name sym) 10)
+           (equal "icalendar-" (substring (symbol-name sym) 0 10)))))
+
+(defun ical:documentation (sym buf frame)
+  "iCalendar documentation backend for `describe-symbol-backends'."
+  (ignore buf frame) ; Silence the byte compiler
+  (with-help-window (help-buffer)
+    (with-current-buffer standard-output
+      (let* ((type-doc (get sym 'icalendar-type-documentation))
+             (link (get sym 'icalendar-link))
+             (rx-def (get sym 'rx-definition))
+             (rx-doc (when rx-def
+                       (with-output-to-string
+                         (pp rx-def))))
+             (value-rx-def (get sym 'ical:value-rx))
+             (value-rx-doc (when value-rx-def
+                             (with-output-to-string
+                               (pp value-rx-def))))
+             (values-rx-def (get sym 'ical:values-rx))
+             (values-rx-doc (when values-rx-def
+                             (with-output-to-string
+                               (pp values-rx-def))))
+
+             (full-doc
+              (concat
+               (when type-doc
+                 (format "`%s' is an iCalendar type:\n\n%s\n\n"
+                         sym type-doc))
+               (when link
+                 (format "For further information see\nURL `%s'\n\n" link))
+               ;; FIXME: this is probably better done in rx.el!
+               ;; TODO: could also generalize this to recursively
+               ;; search rx-def for any symbol that starts with "icalendar=
-"...
+               (when rx-def
+                 (format "`%s' is an iCalendar grammar category.
+Its `rx' definition is:\n\n%s%s%s"
+                         sym
+                         rx-doc
+                         (if value-rx-def
+                             (format "\nIndividual values must match:\n%s"
+                                      value-rx-doc)
+                           "")
+                         (if values-rx-def
+                             (format "\nLists of values must match:\n%s"
+                                      values-rx-doc)
+                           "")))
+               "\n")))
+
+        (insert full-doc)
+        full-doc))))
+
+
+(defconst ical:describe-symbol-backend
+  '(nil icalendar-documented-symbol-p icalendar-documentation)
+  "Entry for icalendar documentation in `describe-symbol-backends'")
+
+(push ical:describe-symbol-backend describe-symbol-backends)
+
+;; Unloading:
+(defun ical:parser-unload-function ()
+  "Unload function for `icalendar-parser'."
+  (mapatoms
+   (lambda (sym)
+     (when (string-match "^icalendar-" (symbol-name sym))
+       (makunbound sym)
+       (fmakunbound sym))))
+
+  (setq describe-symbol-backends
+        (remq ical:describe-symbol-backend describe-symbol-backends))
+  ;; Proceed with normal unloading:
+  nil)
+
+(provide 'icalendar-parser)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-=
recur-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/icalendar-recur.el b/lisp/calendar/icalendar-rec=
ur.el
new file mode 100644
index 00000000000..db7b8fc5c75
--- /dev/null
+++ b/lisp/calendar/icalendar-recur.el
@@ -0,0 +1,2074 @@
+;;; icalendar-recur.el --- Support for iCalendar recurrences and time zone=
s -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: December 2024
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This is a sub-library for working with recurrence rules and time
+;; zones, as defined by RFC5545 (see especially Secs. 3.3.10 and
+;; 3.8.5.3, which are required reading before you make any changes to
+;; the code below) and related standards (especially RFC8984 Sec. 4.3,
+;; also strongly recommended reading). Recurrence rules and time zones
+;; are mutually dependent: to calculate the date and time of future
+;; instances of a recurring event, you must be able to apply time zone
+;; rules; and to apply time zone rules, you must be able to calculate
+;; the date and time of recurring events, namely the shifts between
+;; observances of standard and daylight savings time. For example, an
+;; event that occurs "on the last Friday of every month at 11AM" in a
+;; given time zone should recur at 11AM daylight savings time in July,
+;; but 11AM standard time in January, for a typical time zone that
+;; shifts from standard to DST and back once each year. These shifts
+;; occur at, say, "the last Sunday in March at 2AM" and "the first
+;; Sunday in November at 2AM". So to calculate an absolute time for a
+;; given instance of the original event, you first have to calculate the
+;; nearest instance of the shift between standand and daylight savings
+;; time, which itself involves applying a recurrence rule of the same
+;; form.
+;;
+;; This mutual dependence between recurrence rules and time zones is not
+;; a *vicious* circle, because the shifts between time zone observances
+;; have fixed offsets from UTC time which are made explicit in iCalendar
+;; data. But it does make things complicated. RFC5545 focuses on making
+;; recurrence rules expressive enough to cover existing practices,
+;; including time zone observance shifts, rather than on being easy to
+;; implement.
+;;
+;; So be forewarned: here be dragons. The code here was difficult to get
+;; working, in part because this mutual dependence means it is difficult
+;; to implement anything less than the whole system, in part because
+;; recurrence rules are very flexible in order to cover as many
+;; practical uses as possible, in part because time zone practices are
+;; themselves complicated, and in part because there are a *lot* of edge
+;; cases to worry about. Much of it is tedious and repetitive but
+;; doesn't lend itself to further simplification or abstraction. If you
+;; need to make changes, make them slowly, and use the tests in
+;; test/lisp/calendar/icalendar-recur-tests.el to make sure they don't
+;; break anything.
+;;
+;; Notation: `date/time' with a slash in symbol names means "`date' or
+;; `date-time'", i.e., is a way of indicating that a function can
+;; accept either type of value, and `dt' is typically used for an
+;; argument of either type. `date-time' should always refer to *just*
+;; date-time values, not plain (calendar-style) dates.
+
+(require 'icalendar-ast)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'cl-lib)
+(require 'calendar)
+(require 'simple)
+(require 'seq)
+(eval-when-compile '(require 'icalendar-macs))
+
+;; FIXME: this function, or something similar, should probably be in
+;; calendar.el. It is the inverse of `calendar-day-number',
+;; extracted from `calendar-goto-day-of-year'.
+(defun ical:calendar-date-from-yearday-number (year dayno)
+  "Return the date of the DAYNO-th day in YEAR.
+DAYNO must be an integer between -366 and 366."
+  (calendar-gregorian-from-absolute
+   (+ (if (< dayno 0)
+          (+ 1 dayno (if (calendar-leap-year-p year) 366 365))
+        dayno)
+      (calendar-absolute-from-gregorian (list 12 31 (1- year))))))
+
+
+;; Recurrence Intervals
+;;
+;; Two important ideas in the following:
+;;
+;; 1) Because recurrence sets are potentially infinite, we always
+;; calculate recurrences within certain upper and lower bounds. These
+;; bounds might be determined by a user interface (e.g. the week or
+;; month displayed in a calendar) or might be derived from the logic of
+;; the recurrence rule itself. In the former case, where the bounds can
+;; be arbitrary, it's called a 'window' here (as in "window of
+;; time"). In the latter case, it's called an 'interval' here (after the
+;; "INTERVAL=3D..." clause in recurrence rules).
+;;
+;; Unlike a window, an interval must be synced up with the recurrence
+;; rule: its bounds must fall at successive integer multiples of the
+;; product of the recurrence rule's FREQ and INTERVAL values, relative
+;; to a starting date/time. For example, a recurrence rule with a
+;; MONTHLY frequency and INTERVAL=3D3 will have an interval that is three
+;; months long. If its start date is, e.g., in November, then the first
+;; interval runs from November to February, the next from February to
+;; May, and so on. Because intervals depend only on the starting
+;; date/time, the frequency, and the interval length, it is relatively
+;; straightforward to compute the bounds of the interval surrounding an
+;; arbitrary point in time (without enumerating them successively from
+;; the start time); see `icalendar-recur-find-interval', which calls
+;; this arbitrary point in time the 'target'.
+;;
+;; 2) An interval is the smallest unit of time for which we compute
+;; values of the recurrence set. This is because the "BYSETPOS=3D..."
+;; clause in a recurrence rule operates on the sequence of recurrences
+;; in a single interval. Since it selects recurrences by their index in
+;; this sequence, the sequence must have a determinate length and known
+;; bounds. The function `icalendar-recur-recurrences-in-interval' is the
+;; main function to compute recurrences in a given interval.
+;;
+;; The way to compute the recurrences in an arbitrary *window* is thus
+;; to find the interval bounds which are closest to the window's lower
+;; and upper bound, and then compute the recurrences for all the
+;; intervals in between, i.e., that "cover" the window. This is what the
+;; function `icalendar-recur-recurrences-in-window' does.
+;;
+;; Note that the recurrence set for a recurrence rule with a COUNT
+;; clause cannot be computed for an arbitrary interval (or window);
+;; instead, the set must be enumerated from the beginning, so that the
+;; enumeration can stop after a fixed number of recurrences. This is
+;; what the function `icalendar-recur-recurrences-to-count' does. But
+;; also in this case, recurrences are generated for one interval at a
+;; time, because a BYSETPOS clause might apply.
+;;
+;; An interval is represented as a list (LOW HIGH NEXT-LOW) of decoded
+;; times. The length of time between LOW and HIGH corresponds to the
+;; FREQ rule part: they are one year apart for a 'YEARLY rule, a month
+;; apart for a 'MONTHLY rule, etc. NEXT-LOW is the upper bound of the
+;; interval: it is equal to LOW in the subsequent interval. When the
+;; INTERVAL rule part is equal to 1 (the default), HIGH and NEXT-LOW are
+;; the same, but if it is > 1, NEXT-LOW is equal to LOW + INTERVAL *
+;; FREQ.  For example, in a 'MONTHLY rule where INTERVAL=3D3, which means
+;; "every three months", LOW and HIGH bound the first month, while HIGH
+;; and NEXT-LOW bound the following two months.
+;;
+;; The times between LOW and HIGH are candidates for recurrences.  LOW
+;; is an inclusive lower bound, and HIGH is an exclusive upper bound:
+;; LOW <=3D R < HIGH for each recurrence R in the interval. The times
+;; between HIGH and NEXT-LOW are not candidates for recurrences.
+;;
+;; The following functions deal with constructing intervals, given a
+;; target, a start date/time, and intervalsize, and optionally a time
+;; zone.  The main entry point is `icalendar-recur-find-interval'.
+
+;; Look, dragons already:
+(defun icr:find-absolute-interval (target dtstart intervalsize freqs
+                                   &optional vtimezone)
+  "Find a recurrence interval based on a fixed number of seconds.
+
+INTERVALSIZE should be the total size of the interval in seconds. FREQS
+should be the number of seconds between the lower bound of the interval
+and the upper bound for candidate recurrences; it is the number of
+seconds in the unit of time in a recurrence rule's FREQ part.  The
+returned interval looks like (LOW LOW+FREQS LOW+INTERVALSIZE).  See
+`icalendar-recur-find-interval' for other arguments' meanings."
+  ;; We assume here that the interval needs to be calculated using
+  ;; absolute times for SECONDLY, MINUTELY, and HOURLY rules.
+  ;; There are two reasons for this:
+  ;;
+  ;; 1) Time zone shifts. If we don't use absolute times, and instead
+  ;;    find interval boundaries using local clock times with e.g.
+  ;;    `ical:date/time-add' (as we do with time units of a day or
+  ;;    greater below), we have to adjust for clock time changes.  Using
+  ;;    absolute times is simpler.
+  ;; 2) More problematically, using local clock times, at least in its
+  ;;    most straightforward implementation, has pathological results
+  ;;    when `intervalsize' is relatively prime with 60 (for a SECONDLY
+  ;;    rule, similarly for the others): intervals generated by
+  ;;    successive enumeration from one target value will not in general
+  ;;    align with intervals generated from a different, but nearby,
+  ;;    target value.  (So going this route seems to mean giving up on
+  ;;    the idea that intervals can be calculated just from `target',
+  ;;    `dtstart' and `intervalsize', and instead always enumerating
+  ;;    them from the beginning.)
+  ;;
+  ;; In effect, we are deciding that a rule like "every 3 hours" always
+  ;; means every 3 * 60 * 60 =3D 10800 seconds after `dtstart', and not
+  ;; "every 10800 seconds, except when there's a time zone observance
+  ;; change".  People who want the latter have another option: use a
+  ;; DAILY rule and specify the (local) times for the hours they want in
+  ;; the BYHOUR clause, etc. (People who want it for a number of hours,
+  ;; e.g. 7, which does not divide 24, unfortunately do *not* have this
+  ;; option, but anyone who wants that but does not want to understand
+  ;; "7 hours" as a fixed number of seconds has a pathology that I
+  ;; cannot cure here.)
+  ;;
+  ;; RFC5545 does not seem to pronounce one way or the other on whether
+  ;; this decision is correct: there are no examples of SECONDLY rules
+  ;; to go on, and the few examples for MINUTELY and HOURLY rules only
+  ;; use "nice" values in the INTERVAL clause (real-life examples
+  ;; probably(?)  will too).  Our assumption has some possibly
+  ;; unintuitive consequences for `intervalsize' values that are not
+  ;; "nice" (basically, whenever intervalsize and either 60 or 24 are
+  ;; relatively prime), and for how interval boundaries behave at the
+  ;; shifts between time zone observances (since local clock times in
+  ;; the interval bounds will shift from what they would have been
+  ;; before the observance change -- arguably correct but possibly
+  ;; surprising, depending on the case). But the alternative seems
+  ;; worse, so until countervailing evidence emerges, this approach
+  ;; seems reasonable.
+  (let* ((given-start-zone (decoded-time-zone dtstart))
+         (start-w/zone (cond (given-start-zone dtstart)
+                             ((ical:vtimezone-component-p vtimezone)
+                              (ical:date-time-variant dtstart :tz vtimezon=
e))
+                             (t
+                              ;; "Floating" time should be interpreted in =
user's
+                              ;; current time zone; see RFC5545 Sec 3.3.5
+                              (ical:date-time-variant
+                               dtstart :zone (car (current-time-zone))))))
+         (start-abs (ignore-errors
+                      (time-convert (encode-time start-w/zone) 'integer)))
+         (given-target-zone (decoded-time-zone target))
+         (target-w/zone (cond (given-target-zone target)
+                              (vtimezone
+                               (ical:date-time-variant target :tz vtimezon=
e))
+                              (t
+                               (ical:date-time-variant
+                                target :zone (car (current-time-zone))))))
+         (target-abs (ignore-errors
+                         (time-convert (encode-time target-w/zone) 'intege=
r)))
+         low-abs low high next-low)
+
+    (unless (zerop (mod intervalsize freqs))
+      ;; Bad things will happen if intervalsize is not an integer
+      ;; multiple of freqs
+      (error "FREQS=3D%d does not divide INTERVALSIZE=3D%d" freqs interval=
size))
+    (unless (and start-abs target-abs)
+      (when (not start-abs)
+        (error "Could not determine an offset for DTSTART=3D%s" dtstart))
+      (when (not target-abs)
+        (error "Could not determine an offset for TARGET=3D%s" target)))
+
+    ;; Find the lower bound below target that is the closest integer
+    ;; multiple of intervalsize seconds from dtstart
+    (setq low-abs (- target-abs
+                     (mod (- target-abs start-abs) intervalsize)))
+
+    (if vtimezone
+        (setq low (icr:tz-decode-time low-abs vtimezone)
+              high (icr:tz-decode-time (+ low-abs freqs) vtimezone)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) vtimez=
one))
+      ;; best we can do is decode into target's zone:
+      (let ((offset (decoded-time-zone target-w/zone)))
+        (setq low (icr:tz-decode-time low-abs offset)
+              high (icr:tz-decode-time (+ low-abs freqs) offset)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) offset=
))))
+
+    (unless (and given-start-zone given-target-zone)
+      ;; but if we started with floating times, we should return floating =
times:
+      (setf (decoded-time-zone low) nil)
+      (setf (decoded-time-dst low) -1)
+      (setf (decoded-time-zone high) nil)
+      (setf (decoded-time-dst high) -1)
+      (setf (decoded-time-zone next-low) nil)
+      (setf (decoded-time-dst next-low) -1))
+
+    (list low high next-low)))
+
+(defun icr:find-secondly-interval (target dtstart intervalsize &optional v=
timezone)
+  "Find a SECONDLY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   dtstart
+   intervalsize
+   1
+   vtimezone))
+
+(defun icr:find-minutely-interval (target dtstart intervalsize &optional v=
timezone)
+  "Find a MINUTELY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; A MINUTELY interval always runs from the beginning of a minute to
+   ;; the beginning of the next minute:
+   (ical:date-time-variant dtstart :second 0 :tz 'preserve)
+   (* 60 intervalsize)
+   60
+   vtimezone))
+
+(defun icr:find-hourly-interval (target dtstart intervalsize &optional vti=
mezone)
+  "Find an HOURLY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; An HOURLY interval always runs from the beginning of an hour to
+   ;; the beginning of the next hour:
+   (ical:date-time-variant dtstart :minute 0 :second 0 :tz 'preserve)
+   (* 60 60 intervalsize)
+   (* 60 60)
+   vtimezone))
+
+(defun icr:find-daily-interval (target dtstart intervalsize &optional vtim=
ezone)
+  "Find a DAILY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-absdate (calendar-absolute-from-gregorian
+                         (ical:date/time-to-date dtstart)))
+         (target-absdate (calendar-absolute-from-gregorian
+                          (ical:date/time-to-date target)))
+         ;; low-absdate is the closest absolute date below target that
+         ;; is an integer multiple of intervalsize days from dtstart
+         (low-absdate (- target-absdate
+                         (mod (- target-absdate start-absdate) intervalsiz=
e)))
+         (high-absdate (1+ low-absdate))
+         (next-low-absdate (+ low-absdate intervalsize)))
+
+    (let* ((low-dt (ical:date-to-date-time
+                     (calendar-gregorian-from-absolute low-absdate)))
+           (high-dt (ical:date-to-date-time
+                      (calendar-gregorian-from-absolute high-absdate)))
+           (next-low-dt (ical:date-to-date-time
+                          (calendar-gregorian-from-absolute next-low-absda=
te))))
+
+      (when vtimezone
+        (icr:tz-set-zone low-dt vtimezone)
+        (icr:tz-set-zone high-dt vtimezone)
+        (icr:tz-set-zone next-low-dt vtimezone))
+
+      ;; Return the bounds:
+      (list low-dt high-dt next-low-dt))))
+
+(defun icr:find-weekly-interval (target dtstart intervalsize
+                                 &optional weekstart vtimezone)
+  "Find a WEEKLY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((target-date (ical:date/time-to-date target))
+         (start-date (ical:date/time-to-date dtstart))
+         ;; the absolute dates of the week start before target and
+         ;; dtstart; these are always a whole number of weeks apart:
+         (target-week-abs (calendar-nth-named-absday
+                           -1
+                           (or weekstart 1)
+                           (calendar-extract-month target-date)
+                           (calendar-extract-year target-date)
+                           (calendar-extract-day target-date)))
+         (start-abs (calendar-nth-named-absday
+                     -1
+                     (or weekstart 1)
+                     (calendar-extract-month start-date)
+                     (calendar-extract-year start-date)
+                     (calendar-extract-day start-date)))
+         (intsize-days (* 7 intervalsize))
+         ;; the absolute date of the week start before target which is
+         ;; an integer multiple of intervalsize weeks from dtstart:
+         (low-abs (- target-week-abs
+                  (mod (- target-week-abs start-abs) intsize-days)))
+         ;; then use this to find the interval bounds:
+         (low (ical:date-to-date-time
+               (calendar-gregorian-from-absolute low-abs)))
+         (high (ical:date-to-date-time
+               (calendar-gregorian-from-absolute (+ 7 low-abs))))
+         (next-low (ical:date-to-date-time
+                    (calendar-gregorian-from-absolute (+ intsize-days low-=
abs)))))
+
+    (when vtimezone
+      (icr:tz-set-zone low vtimezone)
+      (icr:tz-set-zone high vtimezone)
+      (icr:tz-set-zone next-low vtimezone))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-monthly-interval (target dtstart intervalsize &optional vt=
imezone)
+  "Find a MONTHLY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-month (ical:date/time-month dtstart))
+         (start-year (ical:date/time-year dtstart))
+         ;; we calculate in "absolute months", i.e., number of months
+         ;; since the beginning of the Gregorian calendar, to make
+         ;; finding the lower bound easier:
+         (start-abs-months (+ (* 12 (1- start-year)) (1- start-month)))
+         (target-month (ical:date/time-month target))
+         (target-year (ical:date/time-year target))
+         (target-abs-months (+ (* 12 (1- target-year)) (1- target-month)))
+         ;; number of "absolute months" between start of dtstart's month
+         ;; and start of target's month:
+         (nmonths (- target-abs-months start-abs-months))
+         ;; the number of months after dtstart that is the closest integer
+         ;; multiple of intervalsize months before target:
+         (lmonths (- nmonths (mod nmonths intervalsize)))
+         ;; convert these "absolute months" back to Gregorian month and ye=
ar:
+         (mod-month (mod (+ start-month lmonths) 12))
+         (low-month (if (zerop mod-month) 12 mod-month))
+         (low-year (+ (/ lmonths 12) start-year
+                      ;; iff we cross a year boundary moving forward in
+                      ;; time from start-month to target-month, we need
+                      ;; to add one to the year:
+                      (if (<=3D start-month target-month) 0 1)))
+         ;; and now we can use these to calculate the interval bounds:
+         (low (ical:make-date-time :year low-year :month low-month :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezo=
ne))
+         (high (ical:date/time-add low :month 1 vtimezone))
+         (next-low (ical:date/time-add low :month intervalsize vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-yearly-interval (target dtstart intervalsize &optional vti=
mezone)
+  "Find a YEARLY recurrence interval.
+See `icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-year (ical:date/time-year dtstart))
+         (target-year (ical:date/time-year target))
+         ;; The year before target that is the closest integer multiple
+         ;; of intervalsize years after dtstart:
+         (low-year (- target-year
+                      (mod (- target-year start-year) intervalsize)))
+         (low (ical:make-date-time :year low-year :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezo=
ne))
+         (high (ical:make-date-time :year (1+ low-year) :month 1 :day 1
+                                    :hour 0 :minute 0 :second 0 :tz vtimez=
one))
+         (next-low (ical:make-date-time :year (+ low-year intervalsize)
+                                        :month 1 :day 1 :hour 0 :minute 0 =
:second 0
+                                        :tz vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-interval (target dtstart recur-value &optional vtimezone)
+  "Return the recurrence interval around TARGET.
+
+TARGET and DTSTART should be `icalendar-date' or `icalendar-date-time'
+values. RECUR-VALUE should be an `icalendar-recur'.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which
+represents the lower and upper bounds of a recurrence interval around
+TARGET. For some N, LOW is equal to START + N*INTERVALSIZE units, HIGH
+is equal to START + (N+1)*INTERVALSIZE units, and LOW <=3D TARGET < HIGH.
+START here is a time derived from DTSTART depending on RECUR-VALUE's
+FREQ part: the first day of the year for a \\=3D'YEARLY rule, first day
+of the month for a \\=3D'MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\=3D'MONTHLY, etc.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (let ((freq (ical:recur-freq recur-value))
+        (intsize (ical:recur-interval-size recur-value))
+        (weekstart (ical:recur-weekstart recur-value)))
+    (cl-case freq
+      (SECONDLY (icr:find-secondly-interval target dtstart intsize vtimezo=
ne))
+      (MINUTELY (icr:find-minutely-interval target dtstart intsize vtimezo=
ne))
+      (HOURLY (icr:find-hourly-interval target dtstart intsize vtimezone))
+      (DAILY (icr:find-daily-interval target dtstart intsize vtimezone))
+      (WEEKLY (icr:find-weekly-interval target dtstart intsize
+                                        weekstart vtimezone))
+      (MONTHLY (icr:find-monthly-interval target dtstart intsize vtimezone=
))
+      (YEARLY (icr:find-yearly-interval target dtstart intsize vtimezone))=
)))
+
+(defun icr:nth-interval (n dtstart recur-value &optional vtimezone)
+  "Return the Nth recurrence interval after DTSTART.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which represent the Nth
+recurrence interval after DTSTART.  LOW is equal to START +
+N*INTERVALSIZE units, HIGH is equal to START + (N+1)*INTERVALSIZE units,
+and LOW <=3D TARGET < HIGH.  START here is a time derived from DTSTART
+depending on RECUR-VALUE's FREQ part: the first day of the year for a
+\\=3D'YEARLY rule, first day of the month for a \\=3D'MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\=3D'MONTHLY, etc.
+
+N should be a non-negative integer. Interval 0 is the interval
+containing DTSTART.  DTSTART should be an `icalendar-date' or
+`icalendar-date-time' value.  RECUR-VALUE should be an
+`icalendar-recur'.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (when (< n 0) (error "Recurrence interval undefined for negative N"))
+  (let* ((start-dt (if (cl-typep dtstart 'ical:date)
+                       (ical:date-to-date-time dtstart :tz vtimezone)
+                     dtstart))
+         (freq (ical:recur-freq recur-value))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (target (ical:date/time-add start-dt unit (* n intervalsize) vtim=
ezone)))
+    (icr:find-interval target dtstart recur-value vtimezone)))
+
+(defun icr:next-interval (interval recur-value &optional vtimezone)
+  "Return the next recurrence interval after INTERVAL.
+
+Given a recurrence interval (LOW HIGH NEXT), returns the next interval
+(NEXT HIGHER HIGHER-NEXT), where HIGHER and HIGHER-NEXT are determined
+by the frequency and interval sizes of RECUR-VALUE."
+  (let* ((new-low (caddr interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone))
+         (new-next (ical:date/time-add new-low unit intervalsize vtimezone=
)))
+
+    (when vtimezone
+      (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      ;; (icr:tz-set-zone new-next vtimezone)
+      )
+
+    (list new-low new-high new-next)))
+
+(defun icr:previous-interval (interval recur-value dtstart &optional vtime=
zone)
+  "Given a recurrence INTERVAL, return the previous interval.
+
+For an interval (LOW HIGH NEXT-LOW), the previous interval is
+(PREV-LOW PREV-HIGH LOW), where PREV-LOW and PREV-HIGH are determined by
+the frequency and interval sizes of RECUR-VALUE (see
+`icalendar-recur-find-interval').  If the resulting period of time
+between PREV-LOW and PREV-HIGH occurs entirely before DTSTART, then the
+interval does not exist; in this case nil is returned."
+  (let* ((upper (car interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-low (ical:date/time-add upper unit (* -1 intervalsize) vtime=
zone))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone)))
+
+    (when vtimezone
+      ;; (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      (icr:tz-set-zone upper vtimezone))
+
+    (unless (ical:date-time< new-high dtstart)
+      (list new-low new-high upper))))
+
+
+
+;; Refining intervals into subintervals
+;;
+;; For a given interval, the various BY*=3D... clauses in a recurrence
+;; rule specify the recurrences in that interval.
+;;
+;; RFC5545 unfortunately has an overly-complicated conceptual model for
+;; how recurrences are to be calculated which is based on "expanding" or
+;; "limiting" the recurrence set for each successive clause. This model
+;; is difficult to think about and implement, and the text of the
+;; standard is ambiguous. I did not succeed in producing a working
+;; implementation based on the description in the standard, and the
+;; existing implementations don't seem to agree on how it's to be
+;; implemented anyway.
+;;
+;; Fortunately, RFC8984 (JSCalendar) is a forthcoming standard which
+;; attempts to resolve the ambiguities while being semantically
+;; backward-compatible with RFC5545. It provides a much cleaner
+;; conceptual model: the recurrence set is generated by starting with a
+;; list of candidates, which consist of every second in (what is here
+;; called) an interval, and then filtering out any candidates which do
+;; not match the rule's clauses. The most straightforward implementation
+;; of this model, however, is unusably slow in typical cases. Consider
+;; for example the case of calculating the onset of daylight savings
+;; time in a given year: the interval is a year long, so it consists of
+;; over 31 million seconds. Although it's easy to generate Lisp
+;; timestamps for each of those seconds, filtering them through the
+;; various BY* clauses means decoding each of those timestamps, which
+;; means doing a fairly expensive computation over 31 million times, and
+;; then throwing away the result in all but one case. When I implemented
+;; this model, I was not patient enough to sit through the calculations
+;; for even MONTHLY rules (which on my laptop took minutes).
+;;
+;; So instead of implementing RFC8984's model directly, the strategy
+;; here is to do something equivalent but much more efficient: rather
+;; than thinking of an interval as consisting of a set of successive
+;; seconds, we think of it as described by its bounds; and for each BY*
+;; clause, we *refine* the interval into subintervals by computing the
+;; bounds of each subinterval corresponding to the value(s) in that
+;; clause. For example, in a YEARLY rule, the initial interval is one
+;; year long, say all of 2025. If it has a "BYMONTH=3D4,10" clause, then
+;; we refine this interval into two subintervals, each one month long:
+;; one for April 2025 and one for October 2025.  This is much more
+;; efficient in the typical case, because the number of bounds which
+;; describe the final set of subintervals is usually *much* smaller than
+;; the number of seconds in the original interval.
+;;
+;; The following functions are responsible for computing these
+;; refinements. The main entry point here is
+;; `icalendar-recur-refine-from-clauses', which takes care of
+;; successively refining the interval both by the explicit values in the
+;; rule's clauses and by the implicit values in DTSTART. (There, too,
+;; RFC8984 is helpful: it gives a much more explicit description of how
+;; the information in DTSTART interacts with the BY* clauses to further
+;; refine the subintervals.)
+
+(defun icr:refine-byyearday (interval yeardays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching YEARDAYS.
+
+YEARDAYS should be a list of values from a recurrence rule's
+BYYEARDAY=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-ydays (sort yeardays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 366 a)=
))
+                                            (pos-b (if (< 0 b) b (+ 366 b)=
)))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<=3D start-year end-year)
+      ;; For each year in the interval...
+      (dolist (n sorted-ydays)
+        ;; ...the subinterval is one day long on the nth yearday
+        (let* ((nthday (ical:calendar-date-from-yearday-number start-year =
n))
+               (low (ical:make-date-time :year start-year
+                                         :month (calendar-extract-month nt=
hday)
+                                         :day (calendar-extract-day nthday)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval.  Careful! This clipping can lead to high <=3D low,
+          ;; so need to check it is still the case that low < high
+          ;; before pushing the subinterval
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<=3D interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<=3D high interval-end))
+            (push (list low high) subintervals))))
+
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byweekno (interval weeknos &optional weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching WEEKNOS.
+
+WEEKNOS should be a list of values from a recurrence rule's
+BYWEEKNO=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-weeknos (sort weeknos
+                               :lessp (lambda (a b)
+                                        (let ((pos-a (if (< 0 a) a (+ 53 a=
)))
+                                              (pos-b (if (< 0 b) b (+ 53 b=
))))
+                                          (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<=3D start-year end-year)
+      ;; For each year in the interval...
+      (dolist (wn sorted-weeknos)
+        ;; ...the subinterval is one week long in the wn-th week
+        (let* ((nth-wstart (ical:start-of-weekno wn start-year weekstart))
+               (low (ical:make-date-time :year (calendar-extract-year nth-=
wstart)
+                                         :month (calendar-extract-month nt=
h-wstart)
+                                         :day (calendar-extract-day nth-ws=
tart)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 7 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval, as above. This can happen often here because week
+          ;; boundaries generally do not align with year boundaries.
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<=3D interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<=3D high interval-end))
+              (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonth (interval months &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHS.
+
+MONTHS should be a list of values from a recurrence rule's
+BYMONTH=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-months (sort months))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<=3D start-year end-year)
+      ;; For each year in the interval...
+      (dolist (m sorted-months)
+        ;; ...the subinterval is from the first day of the given month
+        ;; to the first day of the next
+        (let* ((low (ical:make-date-time :year start-year :month m :day 1
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :month 1 vtimezone)))
+
+          ;; Clip the subinterval bounds, as above
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<=3D interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<=3D high interval-end))
+            (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonthday (interval monthdays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHDAYS.
+
+MONTHDAYS should be a list of values from a recurrence rule's
+BYMONTHDAY=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-mdays (sort monthdays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 31 a)))
+                                            (pos-b (if (< 0 b) b (+ 31 b))=
))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<=3D interval-start interval-end)
+      ;; For each month in the interval...
+      (dolist (m sorted-mdays)
+        ;; ...the subinterval is one day long on the given monthday
+        (let* ((month (ical:date/time-month interval-start))
+               (year (ical:date/time-year interval-start))
+               (monthday (if (< 0 m) m
+                           (+ m 1 (calendar-last-day-of-month month year))=
))
+               (low (ical:date-time-variant interval-start :day monthday
+                                            :hour 0 :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+
+          (ignore-errors ; ignore invalid dates, e.g. 2025-02-29
+            ;; Clip subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<=3D high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start
+            (ical:date/time-add interval-start :month 1 vtimezone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byday (interval weekdays &optional in-month vtimezone)
+  "Refine INTERVAL to days matching the given WEEKDAYS.
+
+WEEKDAYS should be a list of values from a recurrence rule's
+BYDAY=3D... clause; see `icalendar-recur' for the possible values.
+
+If WEEKDAYS contains pairs (DOW . OFFSET), then IN-MONTH indicates
+whether OFFSET is relative to the month of the start of the interval. If
+it is nil, OFFSET will be relative to the year, rather than the month."
+  (let* ((sorted-weekdays (sort (seq-filter #'natnump weekdays)))
+         (with-offsets (sort (seq-filter #'consp weekdays)
+                             :lessp (lambda (w1 w2) (and (< (car w1) (car =
w2))))))
+         (interval-start (car interval))
+         (start-abs (calendar-absolute-from-gregorian
+                     (ical:date-time-to-date interval-start)))
+         (interval-end (cadr interval))
+         (end-abs (calendar-absolute-from-gregorian
+                   (ical:date-time-to-date interval-end)))
+         (subintervals nil))
+
+    ;; For days where an offset was given, the subinterval is a single
+    ;; weekday relative to the month or year of interval-start:
+    (dolist (wo with-offsets)
+      (let* ((dow (car wo))
+             (offset (cdr wo))
+             (low-date
+              (ical:nth-weekday-in offset dow
+                                   (ical:date/time-year interval-start)
+                                   (when in-month
+                                     (ical:date/time-month interval-start)=
)))
+             (low (ical:date-to-date-time low-date :tz vtimezone))
+             (high (ical:date/time-add low :day 1 vtimezone)))
+        (when (ical:date/time< low interval-start)
+          (setq low interval-start))
+        (when (ical:date/time< interval-end high)
+          (setq high interval-end))
+        (when vtimezone
+          (icr:tz-set-zone low vtimezone)
+          (icr:tz-set-zone high vtimezone))
+        (when (and (ical:date/time<=3D interval-start low)
+                   (ical:date/time<=3D high interval-end)
+                   (ical:date/time< low high))
+          (push (list low high) subintervals))))
+
+    ;; When no offset was given, for each day in the interval...
+    (while (and (<=3D start-abs end-abs)
+                sorted-weekdays)
+      ;; ...the subinterval is one day long on matching weekdays.
+      (let* ((gdate (calendar-gregorian-from-absolute start-abs)))
+        (when (memq (calendar-day-of-week gdate) sorted-weekdays)
+          (let* ((low (ical:date-to-date-time gdate))
+                 (high (ical:date/time-add low :day 1 vtimezone)))
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when vtimezone
+              (icr:tz-set-zone low vtimezone)
+              (icr:tz-set-zone high vtimezone))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time<=3D high interval-end)
+                       (ical:date/time< low high))
+              (push (list low high) subintervals)))))
+      (setq start-abs (1+ start-abs)))
+
+    ;; Finally, sort and return all subintervals:
+    (sort subintervals
+          :lessp (lambda (int1 int2)
+                   (ical:date-time< (car int1) (car int2)))
+          :in-place t)))
+
+(defun icr:refine-byhour (interval hours &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching HOURS.
+
+HOURS should be a list of values from a recurrence rule's
+BYHOUR=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-hours (sort hours))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<=3D interval-start interval-end)
+      ;; For each day in the interval...
+      (dolist (h sorted-hours)
+        ;; ...the subinterval is one hour long in the given hour
+        (let* ((low (ical:date-time-variant interval-start
+                                            :hour h :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :hour 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting ti=
mes
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<=3D high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start (ical:date/time-add interval-start :day 1 vtime=
zone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byminute (interval minutes &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MINUTES.
+
+MINUTES should be a list of values from a recurrence rule's
+BYMINUTE=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-minutes (sort minutes))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<=3D low-ts end-ts)
+      ;; For each hour in the interval...
+      (dolist (m sorted-minutes)
+        ;; ...the subinterval is one minute long in the given minute
+        (let* ((low (ical:date-time-variant interval-start :minute m :seco=
nd 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :minute 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting ti=
mes
+            ;; Clip the subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<=3D high interval-end))
+              (push (list low high) subintervals)))))
+      (setq low-ts (+ low-ts (* 60 60))
+            interval-start (if vtimezone (icr:tz-decode-time low-ts vtimez=
one)
+                             (ical:date/time-add interval-start :hour 1))))
+    (nreverse subintervals)))
+
+(defun icr:refine-bysecond (interval seconds &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching SECONDS.
+
+SECONDS should be a list of values from a recurrence rule's
+BYSECOND=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-seconds (sort seconds))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<=3D low-ts end-ts)
+      ;; For each minute in the interval...
+      (dolist (s sorted-seconds)
+        ;; ...the subinterval is one second long: the given second
+        (let* ((low (ical:date-time-variant interval-start :second s
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :second 1 vtimezone)))
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<=3D interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<=3D high interval-end))
+            (push (list low high) subintervals))))
+      (setq low-ts (+ low-ts 60)
+            interval-start (if vtimezone
+                               (icr:tz-decode-time low-ts vtimezone)
+                             (ical:date/time-add interval-start :minute 1)=
)))
+    (nreverse subintervals)))
+
+;; TODO: should this just become a generic function, with the above
+;; refine-by* functions becoming its methods?
+(defun icr:refine-by (unit interval values
+                      &optional byday-inmonth weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching VALUES for UNIT."
+  (cl-case unit
+    (BYYEARDAY (icr:refine-byyearday interval values vtimezone))
+    (BYWEEKNO (icr:refine-byweekno interval values weekstart vtimezone))
+    (BYMONTH (icr:refine-bymonth interval values vtimezone))
+    (BYMONTHDAY (icr:refine-bymonthday interval values vtimezone))
+    (BYDAY (icr:refine-byday interval values byday-inmonth vtimezone))
+    (BYHOUR (icr:refine-byhour interval values vtimezone))
+    (BYMINUTE (icr:refine-byminute interval values vtimezone))
+    (BYSECOND (icr:refine-bysecond interval values vtimezone))))
+
+(defun icr:make-bysetpos-filter (setpos)
+  "Return a filter on values for the indices in SETPOS.
+
+SETPOS should be a list of positive or negative integers between -366
+and 366, indicating a fixed index in a set of recurrences for *one
+interval* of a recurrence set, as found in the BYSETPOS=3D...  clause of
+an `icalendar-recur'. For example, in a YEARLY recurrence rule with an
+INTERVAL of 1, the SETPOS represent indices in the recurrence instances
+generated for a single year.
+
+The returned value is a closure which can be called on the list of
+recurrences for one interval to filter it by index."
+  (lambda (dts)
+    (let* ((len (length dts))
+           (keep-indices (mapcar
+                          (lambda (pos)
+                            ;; sequence indices are 0-based, POS's are 1-b=
ased:
+                            (if (< pos 0)
+                                (+ pos len)
+                              (1- pos)))
+                          setpos)))
+      (delq nil
+        (seq-map-indexed
+         (lambda (dt index)
+           (when (memq index keep-indices)
+                 dt))
+         dts)))))
+
+(defun icr:refine-from-clauses (interval recur-value dtstart
+                                &optional vtimezone)
+  "Resolve INTERVAL into subintervals based on the clauses in RECUR-VALUE.
+
+The resulting list of subintervals represents all times in INTERVAL
+which match the BY* clauses of RECUR-VALUE except BYSETPOS, as well as
+the constraints implicit in DTSTART. (For example, if there is no
+BYMINUTE clause, subintervals will have the same minute value as
+DTSTART.)
+
+If specified, VTIMEZONES should be a list of `icalendar-vtimezone'
+components and TZID should be the `icalendar-tzid' property value of one
+of those timezones. In this case, TZID states the time zone of DTSTART,
+and the offsets effective in that time zone on the dates and times of
+recurrences will be local to that time zone."
+  (let ((freq (ical:recur-freq recur-value))
+        (weekstart (ical:recur-weekstart recur-value))
+        (subintervals (list interval)))
+
+    (dolist (byunit (list 'BYMONTH 'BYWEEKNO
+                          'BYYEARDAY 'BYMONTHDAY 'BYDAY
+                          'BYHOUR 'BYMINUTE 'BYSECOND))
+      (let ((values (ical:recur-by* byunit recur-value))
+            (in-month nil))
+        ;; When there is no explicit BY* clause, use the value implicit
+        ;; in DTSTART. (These conditions are adapted from RFC8984:
+        ;;   https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.3.1-4=
.3.1
+        ;; Basically, the conditions are somewhat complicated because
+        ;; the meanings of various BY* clauses are not independent and
+        ;; so we have to be careful about the information we take to be
+        ;; implicit in DTSTART, especially with MONTHLY and YEARLY
+        ;; rules. For example, we *do* want to take the weekday of
+        ;; DTSTART as an implicit constraint if a BYWEEKNO clause is
+        ;; present, but not if an explicit BYDAY or BYMONTHDAY clause is
+        ;; also present, since they might contain conflicting
+        ;; constraints.)
+        (when (and (eq byunit 'BYSECOND)
+                   (not (eq freq 'SECONDLY))
+                   (not values))
+          (setq values (list (ical:date/time-second dtstart))))
+        (when (and (eq byunit 'BYMINUTE)
+                   (not (memq freq '(SECONDLY MINUTELY)))
+                   (not values))
+          (setq values (list (ical:date/time-minute dtstart))))
+        (when (and (eq byunit 'BYHOUR)
+                   (not (memq freq '(SECONDLY MINUTELY HOURLY)))
+                   (not values))
+          (setq values (list (ical:date/time-hour dtstart))))
+        (when (and (eq byunit 'BYDAY)
+                   (eq freq 'WEEKLY)
+                   (not values))
+          (setq values (list (ical:date/time-weekday dtstart))))
+        (when (and (eq byunit 'BYMONTHDAY)
+                   (eq freq 'MONTHLY)
+                   (not (ical:recur-by* 'BYDAY recur-value))
+                   (not values))
+          (setq values (list (ical:date/time-monthday dtstart))))
+        (when (and (eq freq 'YEARLY)
+                   (not (ical:recur-by* 'BYYEARDAY recur-value)))
+          (when (and (eq byunit 'BYMONTH)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (or (ical:recur-by* 'BYMONTHDAY recur-value)
+                         (not (ical:recur-by* 'BYDAY recur-value))))
+            (setq values (list (ical:date/time-month dtstart))))
+          (when (and (eq byunit 'BYMONTHDAY)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (not (ical:recur-by* 'BYDAY recur-value)))
+            (setq values (list (ical:date/time-monthday dtstart))))
+          (when (and (eq byunit 'BYDAY)
+                     (not values)
+                     (ical:recur-by* 'BYWEEKNO recur-value)
+                     (not (ical:recur-by* 'BYMONTHDAY recur-value)))
+            (setq values (list (ical:date/time-weekday dtstart)))))
+
+        ;; Handle offsets in a BYDAY clause:
+        ;; "If present, this [offset] indicates the nth occurrence of a
+        ;; specific day within the MONTHLY or YEARLY "RRULE".  For
+        ;; example, within a MONTHLY rule, +1MO (or simply 1MO)
+        ;; represents the first Monday within the month, whereas -1MO
+        ;; represents the last Monday of the month.  The numeric value
+        ;; in a BYDAY rule part with the FREQ rule part set to YEARLY
+        ;; corresponds to an offset within the month when the BYMONTH
+        ;; rule part is present"
+        (when (and (eq byunit 'BYDAY)
+                   (or (eq freq 'MONTHLY)
+                       (and (eq freq 'YEARLY)
+                            (ical:recur-by* 'BYMONTH recur-value))))
+          (setq in-month t))
+
+        ;; On each iteration of the loop, we refine the subintervals
+        ;; with these explicit or implicit values:
+        (when values
+          (setq subintervals
+                (delq nil
+                      (mapcan (lambda (in)
+                                (icr:refine-by byunit in values in-month
+                                               weekstart vtimezone))
+                              subintervals))))))
+
+    ;; Finally return the refined subintervals after we've looked at all
+    ;; clauses:
+    subintervals))
+
+;; Once we have refined an interval into a final set of subintervals, we
+;; need to convert those subintervals into a set of recurrences. For a
+;; recurrence set where DTSTART and the recurrences are date-times, the
+;; recurrence set (in this interval) consists of every date-time
+;; corresponding to each second of any subinterval. When DTSTART and the
+;; recurrences are plain dates, the recurrence set consists of each
+;; distinct date in any subinterval.
+(defun icr:subintervals-to-date-times (subintervals &optional vtimezone)
+  "Transform SUBINTERVALS into a list of `icalendar-date-time' recurrences.
+
+The returned list of recurrences contains one date-time value for each
+second of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (dt start)
+             ;; use absolute times for the loop in case the subinterval
+             ;; crosses the boundary between two observances
+             ;; TODO: what if we only have floating times?
+             (end (time-convert (encode-time (cadr int)) 'integer))
+             (tick (time-convert (encode-time start) 'integer)))
+        (while (time-less-p tick end)
+          (push dt recurrences)
+          (setq tick (1+ tick)
+                dt (if vtimezone (icr:tz-decode-time tick vtimezone)
+                     (ical:date/time-add dt :second 1))))))
+    (nreverse recurrences)))
+
+(defun icr:subintervals-to-dates (subintervals)
+  "Transform SUBINTERVALS into a list of `icalendar-date' recurrences.
+
+The returned list of recurrences contains one date value for each
+day of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (start-abs (calendar-absolute-from-gregorian
+                         (ical:date-time-to-date start)))
+             (end (cadr int))
+             (end-abs (calendar-absolute-from-gregorian
+                       (ical:date-time-to-date end)))
+             ;; end is an exclusive upper bound, but number-sequence
+             ;; needs an *inclusive* upper bound, so if end is at
+             ;; midnight, the bound is the previous day:
+             (bound (if (zerop (+ (decoded-time-hour end)
+                                  (decoded-time-minute end)
+                                  (decoded-time-second end)))
+                        (1- end-abs)
+                      end-abs)))
+        (setq recurrences
+              (append recurrences
+                      (mapcar #'calendar-gregorian-from-absolute
+                              (number-sequence start-abs bound))))))
+    recurrences))
+
+(defun icr:subintervals-to-recurrences (subintervals dtstart &optional vti=
mezone)
+  "Transform SUBINTERVALS into a list of recurrences.
+
+The returned list of recurrences contains all distinct values in each
+subinterval of the same type as DTSTART."
+  (if (cl-typep dtstart 'ical:date)
+      (icr:subintervals-to-dates subintervals)
+    (icr:subintervals-to-date-times subintervals vtimezone)))
+
+
+;; Calculating recurrences in a given interval or window
+;;
+;; We can now put all of the above together to compute the set of
+;; recurrences in a given interval (`icr:recurrences-in-interval'), and
+;; thereby in a given window (`icr:recurences-in-window'); or, if the
+;; rule describing the set has a COUNT clause, we can enumerate the
+;; recurrences in each interval starting from the beginning of the set
+;; (`icr:recurrences-to-count').
+(defun icr:recurrences-in-interval (interval component &optional vtimezone=
 nmax)
+  "Return a list of the recurrences of COMPONENT in INTERVAL.
+
+INTERVAL should be a list (LOW HIGH NEXT) of date-times which bound a
+single recurrence interval, as returned e.g. by
+`icalendar-recur-find-interval'. (To find the recurrences in an
+arbitrary window of time, rather than between interval boundaries, see
+`icalendar-recur-recurrences-in-window'.)
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and either an
+`icalendar-rrule' or `icalendar-rdate' property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone.
+
+If specified, NMAX should be a positive integer containing a maximum
+number of recurrences to return from this interval. In this case, if the
+interval contains more than NMAX recurrences, only the first NMAX
+recurrences will be returned; otherwise all recurrences in the interval
+are returned. (The NMAX argument mainly exists to support recurrence
+rules with a COUNT clause; see `icalendar-recur-recurrences-to-count'.)
+
+The returned list is a list of `icalendar-date' or `icalendar-date-time'
+values representing the start times of recurrences.  Note that any
+values of type `icalendar-period' in COMPONENT's `icalendar-rdate'
+property (or properties) will NOT be included in the list; it is the
+callee's responsibility to handle any such values separately.
+
+The computed recurrences for INTERVAL are cached in COMPONENT and
+retrieved on subsequent calls with the same arguments."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :all rdate-nodes) ;; TODO: these can also be ical:perio=
d values
+       (ical:exdate :all exdate-nodes))
+    (unless (or recur-value rdate-nodes)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylig=
ht))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (cl-labels ((get-interval (apply-partially #'icr:-set-get-interval com=
ponent))
+                (put-interval (apply-partially #'icr:-set-put-interval com=
ponent)))
+      (let ((cached (get-interval interval)))
+        (cond ((eq cached :none) nil)
+              (cached cached)
+              (t
+               (let* (;; Start by generating all the recurrences matching =
the
+                      ;; BY* clauses except for BYSETPOS:
+                      (subs (icr:refine-from-clauses interval recur-value =
dtstart
+                                                     vtimezone))
+                      (sub-recs (icr:subintervals-to-recurrences subs dtst=
art
+                                                                 vtimezone=
))
+                      ;; Apply any BYSETPOS clause to this set:
+                      (keep-indices (ical:recur-by* 'BYSETPOS recur-value))
+                      (pos-recs
+                       (if keep-indices
+                           (funcall (icr:make-bysetpos-filter keep-indices)
+                                    sub-recs)
+                         sub-recs))
+                      ;; Remove any recurrences before DTSTART or after UN=
TIL
+                      ;; (both of which are inclusive bounds):
+                      (until (ical:recur-until recur-value))
+                      (until-recs
+                       (seq-filter
+                        (lambda (rec) (and (ical:date/time<=3D dtstart rec)
+                                           (or (not until)
+                                               (ical:date/time<=3D rec unt=
il))))
+                        pos-recs))
+                      ;; Include any values in the interval from the
+                      ;; RDATE property:
+                      (low (car interval))
+                      (high (cadr interval))
+                      (rdates
+                       (mapcar #'ical:ast-node-value
+                               (apply #'append
+                                      (mapcar #'ical:ast-node-value rdate-=
nodes))))
+                      (interval-rdates
+                       (seq-filter
+                        (lambda (rec)
+                          ;; only include ical:date and ical:date-time
+                          ;; values from RDATE; callee is responsible
+                          ;; for handling ical:period values
+                          (unless (cl-typep rec 'ical:period)
+                             (and (ical:date/time<=3D low rec)
+                                  (ical:date/time< high rec))))
+                        rdates))
+                      (included-recs (append until-recs interval-rdates))
+                      ;; Exclude any values from the EXDATE property; this
+                      ;; gives us the complete set of recurrences in this =
interval:
+                      (exdates
+                       (mapcar #'ical:ast-node-value
+                               (append
+                                (mapcar #'ical:ast-node-value exdate-nodes=
))))
+                      (all-recs
+                       (if exdates
+                           (seq-filter
+                            (lambda (rec) (not (member rec exdates)))
+                            included-recs)
+                         included-recs))
+                      ;; Limit to the first NMAX recurrences if requested.
+                      ;; `icr:recurrences-to-count' provides NMAX so as no=
t to
+                      ;; store more recurrences in the final interval than=
 the
+                      ;; COUNT clause allows:
+                      (nmax-recs
+                       (if nmax (seq-take all-recs nmax)
+                         all-recs)))
+                 ;; Store and return the computed recurrences:
+                 (put-interval interval (or nmax-recs :none))
+                 nmax-recs)))))))
+
+(defun icr:recurrences-in-window (lower upper component &optional vtimezon=
e)
+  "Return the recurrences of COMPONENT in the window between LOWER and UPP=
ER.
+
+LOWER and UPPER may be arbitrary `icalendar-date' or
+`icalendar-date-time' values. COMPONENT should be an iCalendar component
+node representing a recurring event: it should contain at least an
+`icalendar-dtstart' and either an `icalendar-rrule' or `icalendar-rdate'
+property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :all rdate-nodes))
+    (unless (or recur-value rdate-nodes)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylig=
ht))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+
+    (let* (;; don't look for nonexistent intervals:
+           (low-start (if (ical:date/time< lower dtstart) dtstart lower))
+           (until (ical:recur-until recur-value))
+           (high-end (if (and until (ical:date/time< until upper)) until u=
pper))
+           (curr-interval (icr:find-interval low-start dtstart recur-value
+                                             vtimezone))
+           (high-interval (icr:find-interval high-end dtstart recur-value
+                                             vtimezone))
+           (high-intbound (cadr high-interval))
+           (recurrences nil))
+
+      (while (ical:date-time< (car curr-interval) high-intbound)
+        (setq recurrences
+              (append
+               (icr:recurrences-in-interval curr-interval component vtimez=
one)
+               recurrences))
+        (setq curr-interval (icr:next-interval curr-interval recur-value
+                                               vtimezone)))
+
+      ;; exclude any recurrences inside the first and last intervals but
+      ;; outside the window before returning:
+      (seq-filter
+       (lambda (dt)
+         (and (ical:date/time<=3D lower dt)
+              (ical:date/time< dt upper)))
+       recurrences))))
+
+(defun icr:recurrences-in-window-w/end-times
+    (lower upper component &optional vtimezone)
+  "Like `icalendar-recurrences-in-window', but returns end times.
+
+The return value is a list of (START END) pairs representing the start
+and end time of each recurrence of COMPONENT in the window defined by
+LOWER and UPPER.
+
+In the returned pairs, START and END are both `icalendar-date' or
+`icalendar-date-time' values of the same type as COMPONENT's
+`icalendar-dtstart'. Each END time is computed by adding COMPONENT's
+`icalendar-duration' value to START for each recurrence START between
+LOWER and UPPER. Or, if the recurrence is given by an `icalendar-period'
+value in an `icalendar-rdate' property, START and END are determined by
+the period."
+  (ical:with-component component
+    ((ical:duration :value duration)
+     (ical:rdate :all rdate-nodes))
+    ;; TODO: for higher-level applications showing a schedule, it might
+    ;; be useful to include recurrences which start outside the window,
+    ;; but end inside it. This would mean we can't simply use
+    ;; `recurrences-in-window' like this.
+    (let ((starts (icr:recurrences-in-window lower upper component vtimezo=
ne))
+          (periods (seq-filter
+                    (lambda (vnode)
+                      (when (eq 'ical:period (ical:ast-node-type vnode))
+                        (ical:ast-node-value vnode)))
+                    (append
+                     (mapcar #'ical:ast-node-value rdate-nodes)))))
+      (when (or starts periods)
+        (seq-uniq
+         (append (mapcar
+                  (lambda (dt) (list dt (ical:date/time-add-duration
+                                         dt duration vtimezone)))
+                  starts)
+                 (mapcar
+                  (lambda (p)
+                    (let ((start (ical:period-start p)))
+                      (list start
+                            (or (ical:period-end p)
+                                (ical:date/time-add-duration
+                                 start (ical:period-dur-value p) vtimezone=
)))))
+                  periods)))))))
+
+(defun icr:recurrences-to-count (component &optional vtimezone)
+  "Return all the recurrences in COMPONENT up to COUNT in its recurrence r=
ule.
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and an
+`icalendar-rrule', which must contain a COUNT=3D... clause.
+
+Warning: this function finds *all* the recurrences in COMPONENT's
+recurrence set. If the value of COUNT is large, this can be slow.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :all rdate-nodes))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylig=
ht))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (unless (or recur-value rdate-nodes)
+      (error "No recurrence data in component: %s" component))
+    (unless (ical:recur-count recur-value)
+      (error "Recurrence rule has no COUNT clause"))
+    (let ((count (ical:recur-count recur-value))
+          (int (icr:nth-interval 0 dtstart recur-value vtimezone))
+          recs)
+      (while (length< recs count)
+        (setq recs
+              (append recs (icr:recurrences-in-interval int component vtim=
ezone
+                                                        (- count (length r=
ecs)))))
+        (setq int (icr:next-interval int recur-value vtimezone)))
+      recs)))
+
+
+
+;; Recurrence set representation
+;;
+;; We represent a recurrence set as a map from intervals to the
+;; recurrences in that interval. The primary purpose of this
+;; representation is to memoize the computation of recurrences, since
+;; the computation is relatively expensive and the results are needed
+;; repeatedly, particularly for time zone observances. The map is stored
+;; in the `:recurrence-set' property of the iCalendar component which
+;; represents the recurring event.
+;;
+;; The macro `icalendar-recur-with-recurrence-set' makes it easy to work
+;; with these maps. Given a component representing a recurring event, it
+;; binds the values for all the recurrence-related properties in the
+;; component, as well as two functions to store and retrieve recurrences
+;; by interval.
+;;
+;; Internally, we use a hash table for the map, since the set can grow
+;; quite large. We use the start date-times of intervals as the keys,
+;; since these uniquely identify intervals within a given component; we
+;; ignore the weekday, zone and dst fields in the keys, mostly to avoid
+;; cache misses during time zone observance lookups, which must generate
+;; intervals with different zone values.
+;;
+;; In order to avoid repeating the computation of recurrences, we store
+;; the keyword `:none' as the value when there are no recurrences in a
+;; given interval. This distinguishes the value from nil, so that,
+;; whereas (gethash some-key the-map) =3D> nil means "We haven't computed
+;; recurrences yet for this interval", (gethash some-key the-map) =3D>
+;; :none means "We've computed that there are no recurrences in this
+;; interval", and can skip the computation of recurrences. See
+;; `icalendar-recur-recurrences-in-interval', which performs the check.
+
+(defun icr:-make-set ()
+  (make-hash-table :test #'equal))
+
+(defsubst icr:-key-from-interval (interval)
+  (take 6 (car interval))) ; (secs mins hours day month year)
+
+(defun icr:-set-get-interval (component interval)
+  (let ((set (ical:ast-node-meta-get :recurrence-set component))
+        (key (icr:-key-from-interval interval)))
+    (when (hash-table-p set)
+      (gethash key set))))
+
+(defun icr:-set-put-interval (component interval recurrences)
+  (let ((set (or (ical:ast-node-meta-get :recurrence-set component)
+                 (icr:-make-set)))
+        (key (icr:-key-from-interval interval)))
+    (setf (gethash key set) recurrences)
+    (ical:ast-node-meta-set component :recurrence-set set)))
+
+(defun icr:-set-complete-p (component)
+  (let* ((set (ical:ast-node-meta-get :recurrence-set component))
+         (recur-value (ical:with-property-of component 'ical:recur nil val=
ue))
+         (count (ical:recur-count recur-value))
+         (n 0)
+         (until (ical:recur-until recur-value))
+         (has-until nil))
+    (when (hash-table-p set)
+      (dolist (recs (hash-table-values set))
+        (cl-incf n (length recs))
+        ;; TODO: This isn't right; the set is only complete if it has
+        ;; recurrences for *all* intervals *up to* UNTIL. May not be
+        ;; worth computing this.
+        (when (and (listp recs) (member until recs))
+          (setq has-until t)))
+      (cond (count (=3D count n))
+            (until has-until)
+            (t nil)))))
+
+;; TODO: this needs more thought.
+;; The byte compiler doesn't like all these implicit bindings.
+;; Instead, with-component should offer the option to bind
+;; get-interval and put-interval; or these
+;; TODO: get/put-interval might need better names, and should be bound with
+;; cl-labels instead of let, so that they can be put directly in function =
position
+(defmacro icr:with-recurrence-set (component &rest body)
+  "Execute BODY with bindings for recurrence set properties in COMPONENT.
+
+This macro facilitates memoized computations of the values in
+COMPONENT's recurrence set.
+
+Within BODY, the following symbols are bound as follows:
+ dtstart - the value in COMPONENT's `icalendar-dtstart' property.
+   In `icalendar-standard' and `icalendar-daylight' components,
+   this value includes the UTC offset in the `icalendar-tzoffsetfrom' prop=
erty.
+ recur-value - the value in COMPONENT's `icalendar-rrule' property.
+ rdates - the values in COMPONENT's `icalendar-rdate' property.
+ exdates - the values in COMPONENT's `icalendar-exdate' property.
+ get-interval - a function which, given an interval, returns the
+   recurrences in that interval cached in COMPONENT's recurrence set.
+   It should be called like:
+   (funcall get-interval INTERVAL)
+ put-interval - a function which, given an interval and a list of recurren=
ces
+   in that interval, caches those recurrences in COMPONENT's recurrence se=
t.
+   It should be called like:
+   (funcall put-interval INTERVAL RECURRENCES)
+"
+  (let ((comp (gensym "icalendar-component"))
+        (offset (gensym "tzoffset"))
+        (recset (gensym "recurrence-set")))
+  `(let ((,comp ,component))
+     (ical:with-component ,comp
+       ((ical:dtstart :value dtstart :value-type dtstart-type)
+        (ical:tzoffsetfrom :value ,offset)
+        (ical:rrule :value recur-value)
+        (ical:rdate :all rdate-nodes))
+       (when (memq (ical:ast-node-type ,comp) '(ical:standard ical:dayligh=
t))
+         ;; in time zone observances, set the zone field in dtstart
+         ;; from the TZOFFSETFROM property:
+         (setq dtstart (ical:date-time-variant dtstart :zone ,offset
+                                               :dst (not (ical:daylight-co=
mponent-p ,comp)))))
+       (if (or recur-value rdate-nodes)
+         (let ((,recset (ical:ast-node-meta-get :recurrence-set ,comp)))
+           (unless ,recset
+             (setq ,recset (icr:-make-set))
+             (ical:ast-node-meta-set ,comp :recurrence-set ,recset))
+           (let ((get-interval (apply-partially #'icr:-set-get-interval ,c=
omp))
+                 (put-interval (apply-partially #'icr:-set-put-interval ,c=
omp)))
+             ;; TODO: further functions to test for membership, query by
+             ;; date, etc.?
+             ,@body))
+         ;; TODO: what's the most sensible thing when there's no RRULE or =
RDATE?
+         ;; Should we still execute body?
+         (error "No recurrence data in this component"))))))
+
+
+;; Timezones:
+
+(define-error 'ical:tz-nonexistent-time "Date-time does not exist" 'ical:e=
rror)
+
+(define-error 'ical:tz-no-observance "No observance found for date-time"
+              'ical:error)
+
+;; In RFC5545 Section 3.3.10, we read: "If the computed local start time
+;; of a recurrence instance does not exist ... the time of the
+;; recurrence instance is interpreted in the same manner as an explicit
+;; DATE-TIME value describing that date and time, as specified in
+;; Section 3.3.5." which in turn says:
+;; "If, based on the definition of the referenced time zone, the local
+;; time described occurs more than once (when changing from daylight to
+;; standard time), the DATE-TIME value refers to the first occurrence of
+;; the referenced time.  Thus, TZID=3DAmerica/New_York:20071104T013000
+;; indicates November 4, 2007 at 1:30 A.M. EDT (UTC-04:00).  If the
+;; local time described does not occur (when changing from standard to
+;; daylight time), the DATE-TIME value is interpreted using the UTC
+;; offset before the gap in local times. Thus,
+;; TZID=3DAmerica/New_York:20070311T023000 indicates March 11, 2007 at
+;; 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST (UTC-05:00)."
+
+;; TODO: verify that these functions are correct for time zones other
+;; than US Eastern.
+(defun icr:nonexistent-date-time-p (dt obs-onset observance)
+  "Return non-nil if DT does not exist in a given OBSERVANCE.
+
+Some local date-times do not exist in a given time zone.  When switching
+from standard to daylight savings time, the local clock time jumps over
+a certain range of times. This function tests whether DT is one of those
+non-existent local times.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted using the UTC offset in effect prior to the onset of
+OBSERVANCE. For example, at the switch from Standard to Daylight Savings
+time in US Eastern, the nonexistent time 2:30AM (Standard) must be
+re-interpreted as 3:30AM DST."
+  (when (ical:daylight-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (=3D (decoded-time-year dt) (decoded-time-year obs-onset))
+           (=3D (decoded-time-month dt) (decoded-time-month obs-onset))
+           (=3D (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (jumped (abs (- offset-from offset-to)))
+                  (after-jumped (+ onset-secs jumped)))
+             (and
+              (<=3D onset-secs dt-secs)
+              (< dt-secs after-jumped)))))))
+
+(defun icr:date-time-occurs-twice-p (dt obs-onset observance)
+  "Return non-nil if DT represents a local clock time that occurs twice in
+a given observance.
+
+Some local date-times occur twice in a given time zone.  When switching
+from daylight savings to standard time time, the local clock time is
+typically set back, so that a certain range of clock times occurs twice,
+once in daylight savings time and once in standard time. This function
+tests whether DT is one of those local times which occur twice.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the relevant onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted as the first occurrence of this clock time, i.e., in
+daylight savings time, prior to OBS-ONSET."
+  (when (ical:standard-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (=3D (decoded-time-year dt) (decoded-time-year obs-onset))
+           (=3D (decoded-time-month dt) (decoded-time-month obs-onset))
+           (=3D (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (repeated (abs (- offset-from offset-to)))
+                  (start-repeateds (- onset-secs repeated)))
+             (and
+              (<=3D start-repeateds dt-secs)
+              (< dt-secs onset-secs)))))))
+
+(defun icr:tz--get-updated-in (dt obs-onset observance)
+  "Determine how to update DT's zone and dst slots from OBSERVANCE.
+
+DT should be an `icalendar-date-time', OBSERVANCE an
+`icalendar-standard' or `icalendar-daylight', and OBS-ONSET the nearest
+onset of OBSERVANCE before DT. Returns an `icalendar-date-time' that can
+be used to update DT.
+
+In most cases, the return value will contain a zone offset equal to
+OBSERVANCE's `icalendar-tzoffsetto' value.
+
+However, when DT falls within a range of nonexistent times after
+OBS-ONSET, or a range of local times that occur twice (see
+`icalendar-recur-nonexistent-date-time-p' and
+`icalendar-recur-date-time-occurs-twice-p'), it needs to be interpreted
+with the UTC offset in effect prior to the OBS-ONSET of OBSERVANCE (see
+RFC5545 Sec. 3.3.5).  So e.g. at the switch from Standard to Daylight in
+US Eastern, 2:30AM EST (a nonexistent time) becomes 3:30AM EDT, and at
+the switch from Daylight to Standard, 1:30AM (which occurs twice)
+becomes 1:30AM EDT, the first occurence."
+  (ical:with-component observance
+      ((ical:tzoffsetfrom :value offset-from)
+       (ical:tzoffsetto :value offset-to))
+    (let* ((is-daylight (ical:daylight-component-p observance))
+           (to-dt (ical:date-time-variant dt :dst is-daylight :zone offset=
-to))
+           (from-dt (ical:date-time-variant dt :dst (not is-daylight)
+                                            :zone offset-from))
+          updated)
+      (cond ((icr:nonexistent-date-time-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we take the same
+             ;; point in absolute time as from-dt, but re-decode it into
+             ;; to-dt's zone:
+             (setq updated (decode-time (encode-time from-dt) offset-to))
+             (setf (decoded-time-dst updated) is-daylight))
+            ((icr:date-time-occurs-twice-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we interpret dt as
+             ;; from-dt, since that is the first occurrence of the clock
+             ;; time in the zone:
+             (setq updated from-dt))
+            (t
+             ;; Otherwise we interpret dt as to-dt, i.e., with the
+             ;; offset effective within the observance:
+             (setq updated to-dt)))
+      updated)))
+
+(defun icr:tz-for (tzid vtimezones)
+  "Return the `icalendar-vtimezone' for the TZID.
+
+VTIMEZONES should be a list of `icalendar-vtimezone' components.  TZID
+should be a time zone identifier, as found e.g. in an
+`icalendar-tzidparam' parameter. The first time zone in VTIMEZONES whose
+`icalendar-tzid' value matches this parameter's value is returned."
+  (catch 'found
+    (dolist (tz vtimezones)
+      (ical:with-component tz
+          ((ical:tzid :value tzidval))
+        (when (equal tzidval tzid)
+          (throw 'found tz))))))
+
+;; DRAGONS DRAGONS DRAGONS
+(defun icr:tz-observance-on (dt vtimezone &optional update nonexisting)
+  "Return the time zone observance in effect on DT in VTIMEZONE.
+
+If there is such an observance, the returned value is a list (OBSERVANCE
+ONSET). OBSERVANCE is an `icalendar-standard' or `icalendar-daylight'
+component node. ONSET is the recurrence of OBSERVANCE (an
+`icalendar-date-time') which occurs closest in time, but before, DT.
+
+If there is no such observance in VTIMEZONE, the returned value is nil.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node.
+
+DT may be an an `icalendar-date-time' or a Lisp timestamp. If it is a
+date-time, it represents a local time assumed to be in VTIMEZONE. Any
+existing offset in DT is ignored, and DT is compared with the local
+clock time at the start of each observance in VTIMEZONE to determine the
+correct observance and onset. (This is so that the correct observance
+can be found for clock times generated during recurrence rule
+calculations.)
+
+If UPDATE is non-nil, the observance found will be used to update the
+offset value in DT (as a side effect) before returning the observance
+and onset.
+
+If UPDATE is non-nil, NONEXISTING specifies how to handle clock times
+that do not exist in the observance (see
+`icalendar-recur-tz-nonexistent-date-time-p').  The keyword `:error'
+means to signal an \\=3D'icalendar-tz-nonexistent-time error, without
+modifying any of the fields in DT.  Otherwise, the default is to
+interpret DT using the offset from UTC before the onset of the found
+observance, and then reset the clock time in DT to the corresponding
+existing time after the onset of the observance.  For example, the
+nonexisting time 2:30AM in Standard time on the day of the switch to
+Daylight time in the US Eastern time zone will be reset to 3:30AM
+Eastern Daylight time.
+
+If DT is a Lisp timestamp, it represents an absolute time and
+comparisons with the onsets in VTIMEZONE are performed with absolute
+times. UPDATE and NONEXISTING have no meaning in this case and are
+ignored."
+  (ical:with-component vtimezone
+    ((ical:standard :all stds)
+     (ical:daylight :all dls))
+    (let (given-abs-time     ;; =3D `dt', if given a Lisp timestamp
+          given-clock-time   ;; =3D `dt', if given a decoded time
+          nearest-observance ;; the observance we're looking for
+          nearest-onset      ;; latest onset of this observance before `dt'
+          updated)           ;; stores how `dt's fields should be updated
+                             ;; in line with this observance, if requested
+
+      (if (cl-typep dt 'ical:date-time)
+          ;; We were passed a date-time with local clock time, not an
+          ;; absolute time; in this case, we must make local clock time
+          ;; comparisons with the observance onset start and recurrences
+          ;; (in order to determine the correct offset for it within the
+          ;; zone)
+          (setq given-clock-time dt
+                given-abs-time nil)
+        ;; We were passed an absolute time, not a date-time; in this
+        ;; case, we can make comparisons in absolute time with
+        ;; observance onset start and recurrences (in order to determine
+        ;; the correct offset for decoding it)
+        (setq given-abs-time dt
+              given-clock-time nil))
+
+      (dolist (obs (append stds dls))
+        (ical:with-component obs
+          ((ical:dtstart :value start)
+           (ical:rrule :value recur-value)
+           (ical:rdate :all rdate-nodes)
+           (ical:tzoffsetfrom :value offset-from))
+          ;; DTSTART of the observance must be given as local time, and is
+          ;; combined with TZOFFSETFROM to define the effective onset
+          ;; for the observance in absolute time.
+          (let* ((is-daylight (ical:daylight-component-p obs))
+                 (effective-start
+                  (ical:date-time-variant start :zone offset-from
+                                          :dst (not is-daylight)))
+                 (observance-might-apply
+                  (if given-clock-time
+                      (ical:date-time-locally<=3D effective-start given-cl=
ock-time)
+                    (ical:time<=3D (encode-time effective-start) given-abs=
-time))))
+
+            (when observance-might-apply
+              ;; Initialize our return values on the first iteration
+              ;; where an observance potentially applies:
+              (unless nearest-onset
+                (setq nearest-onset effective-start
+                      nearest-observance obs)
+                (when (and update given-clock-time)
+                  (setq updated
+                        (icr:tz--get-updated-in given-clock-time
+                                                effective-start obs))))
+
+              ;; We first check whether any RDATEs in the observance are
+              ;; the relevant onset:
+              (let ((rdates
+                     (mapcar #'ical:ast-node-value
+                             (apply #'append
+                                    (mapcar #'ical:ast-node-value rdate-no=
des)))))
+                (dolist (rd rdates)
+                  (let* ((effective-rd
+                          ;; N.B.: we don't have to worry about rd being
+                          ;; an ical:period or ical:date here because in
+                          ;; time zone observances, RDATE values are
+                          ;; *only* allowed to be local date-times; see
+                          ;; https://www.rfc-editor.org/rfc/rfc5545#sectio=
n-3.6.5
+                          ;; and `ical:rrule-validator'
+                          (ical:date-time-variant rd :zone offset-from
+                                                  :dst (not is-daylight)))
+                         (onset-applies
+                          (if given-clock-time
+                              (ical:date-time-locally<=3D effective-rd
+                                                        given-clock-time)
+                            (ical:time<=3D (encode-time effective-rd)
+                                         given-abs-time))))
+
+                    (when (and onset-applies nearest-onset
+                               (ical:date-time< nearest-onset effective-rd=
))
+                      (setq nearest-onset effective-rd
+                            nearest-observance obs)
+
+                      (when (and update given-clock-time)
+                        (setq updated
+                              (icr:tz--get-updated-in given-clock-time
+                                                      effective-rd obs))))=
)))
+
+              ;; If the observance has a recurrence value, it's the
+              ;; relevant observance if it:
+              ;; (1) has a recurrence which starts before dt
+              ;; (2) that recurrence is the nearest in the zone
+              ;;     which starts before dt
+              ;; Note that we intentionally do *not* pass `vtimezone'
+              ;; through here to find-interval, recurrences-in-interval,
+              ;; etc. so as not to cause infinite recursion. Instead we
+              ;; directly pass `offset-from' (the offset from UTC at the
+              ;; start of each observance onset), which
+              ;; `icr:tz-set-zone' knows to handle specially without
+              ;; calling this function.
+              (when recur-value
+                (let* ((target (or given-clock-time
+                                   (decode-time given-abs-time offset-from=
)))
+                       (int (icr:find-interval
+                             target effective-start recur-value offset-fro=
m))
+                       (int-recs (icr:recurrences-in-interval
+                                  int obs offset-from))
+                       ;; The closest observance onset before `dt' might
+                       ;; actually be in the previous interval, e.g.
+                       ;; if `dt' is in January after an annual change to
+                       ;; Standard Time in November. So check that as well.
+                       (prev-int (icr:previous-interval int recur-value
+                                                        effective-start
+                                                        offset-from))
+                       (prev-recs (when prev-int
+                                    (icr:recurrences-in-interval
+                                     prev-int obs offset-from)))
+                       (recs (append prev-recs int-recs))
+                       (keep-recs<=3Dgiven
+                        (if given-clock-time
+                            (lambda (rec)
+                              (ical:date-time-locally<=3D rec given-clock-=
time))
+                          (lambda (rec)
+                            (ical:time<=3D (encode-time rec) given-abs-tim=
e))))
+                       (srecs (sort (seq-filter ; (1)
+                                     keep-recs<=3Dgiven
+                                     recs)
+                                    :lessp #'ical:date-time<
+                                    :in-place t :reverse t))
+                       (latest-rec (car srecs)))
+
+                  (when (and latest-rec
+                             (ical:date-time< nearest-onset latest-rec)) ;=
 (2)
+                    (setf (decoded-time-dst latest-rec)
+                          ;; if obs is a DAYLIGHT observance, latest-rec
+                          ;; represents the last moment of standard time, =
and
+                          ;; vice versa
+                          (not is-daylight))
+                    (setq nearest-onset latest-rec
+                          nearest-observance obs)
+                    (when (and update given-clock-time)
+                      (setq updated
+                            (icr:tz--get-updated-in given-clock-time
+                                                    latest-rec obs))))))))=
))
+
+      ;; We've now found the nearest observance, if there was one.
+      ;; Update `dt' as a side effect if requested.  This saves
+      ;; repeating a lot of the above in a separate function.
+      (when (and update given-clock-time nearest-observance updated)
+        ;; signal an error when `dt' does not exist if requested, so the
+        ;; nonexistence can be handled further up the stack:
+        (when (and (eq :error nonexisting)
+                   (not (ical:date-time-locally-simultaneous-p dt updated)=
))
+          ;; TODO: write icr:signal-tz-nonexistent-time and use same error=
 format
+          (signal 'ical:tz-nonexistent-time
+                  (list (format "%d-%02d-%02d %02d:%02d:%02d does not exis=
t in %s"
+                                (decoded-time-year dt)
+                                (decoded-time-month dt)
+                                (decoded-time-day dt)
+                                (decoded-time-hour dt)
+                                (decoded-time-minute dt)
+                                (decoded-time-second dt)
+                                (or
+                                 (ical:with-property-of nearest-observance
+                                                        'ical:tzname nil v=
alue)
+                                 "time zone observance"))
+                        dt nearest-observance)))
+        ;; otherwise we copy `updated' over to `dt', which resets the
+        ;; clock time in `dt' if it did not exist:
+        (setf (decoded-time-zone dt) (decoded-time-zone updated))
+        (setf (decoded-time-dst dt) (decoded-time-dst updated))
+        (setf (decoded-time-second dt) (decoded-time-second updated))
+        (setf (decoded-time-minute dt) (decoded-time-minute updated))
+        (setf (decoded-time-hour dt) (decoded-time-hour updated))
+        (setf (decoded-time-day dt) (decoded-time-day updated))
+        (setf (decoded-time-month dt) (decoded-time-month updated))
+        (setf (decoded-time-year dt) (decoded-time-year updated))
+        (setf (decoded-time-weekday dt)
+              (calendar-day-of-week (ical:date-time-to-date updated))))
+
+      ;; Return the observance and onset if found, nil if not:
+      (when nearest-observance
+        (list nearest-observance nearest-onset)))))
+
+(defun icr:tz-offset-in (observance)
+  "Return the offset (in seconds) from UTC in effect during OBSERVANCE.
+
+OBSERVANCE should be an `icalendar-standard' or `icalendar-daylight'
+subcomponent of a particular `icalendar-vtimezone'. The returned value
+is the value of its `icalendar-tzoffsetto' property."
+  (ical:with-property-of observance 'ical:tzoffsetto nil value))
+
+(defun icr:tz-decode-time (ts vtimezone)
+  "Decode Lisp timestamp TS with the appropriate offset in VTIMEZONE.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node. The correct
+observance for TS will be looked up in VTIMEZONE, TS will be decoded
+with the UTC offset of that observance, and its dst slot will be set
+based on whether the observance is an `icalendar-standard' or
+`icalendar-daylight' component.  If VTIMEZONE does not have an
+observance that applies to TS, it is decoded into UTC time.
+
+VTIMEZONE may also be an `icalendar-utc-offset'. In this case TS is
+decoded directly into this UTC offset, and its dst slot is set to -1."
+  (let* ((observance (when (ical:vtimezone-component-p vtimezone)
+                       (car (icr:tz-observance-on ts vtimezone))))
+         (offset (cond (observance (icr:tz-offset-in observance))
+                       ((cl-typep vtimezone 'ical:utc-offset)
+                        vtimezone)
+                       (t 0))))
+
+    (ical:date-time-variant ; ensures weekday gets set, too
+     (decode-time ts offset)
+     :zone offset
+     :dst (if observance (ical:daylight-component-p observance)
+            -1))))
+
+(defun icr:tz-set-zone (dt vtimezone &optional nonexisting)
+  "Set the time zone offset and dst flag in DT based on VTIMEZONE.
+
+DT should be an `icalendar-date-time' and VTIMEZONE should be an
+`icalendar-vtimezone'. VTIMEZONE can also be an `icalendar-utc-offset',
+in which case this value is directly set in DT's zone field (without
+changing its dst flag). The updated DT is returned.
+
+This function generally sets only the zone and dst slots of DT, without
+changing the other slots; its main purpose is to adjust date-times
+generated from other date-times during recurrence rule calculations,
+where a different time zone observance may be in effect in the original
+date-time. It cannot be used to re-decode a fixed point in time into a
+different time zone; for that, see `icalendar-recur-tz-decode-time'.
+
+If given, NONEXISTING is a keyword that specifies what to do if DT
+represents a clock time that does not exist according to the relevant
+observance in VTIMEZONE. The value :error means to signal an
+\\=3D'icalendar-tz-nonexistent-time error, and nil means to reset the
+clock time in DT to an existing one; see
+`icalendar-recur-tz-observance-on'."
+  (if (cl-typep vtimezone 'ical:utc-offset)
+      ;; This is where the recurrence rule/time zone mutual dependence
+      ;; bottoms out; don't remove this conditional!
+      (setf (decoded-time-zone dt) vtimezone)
+
+    ;; Otherwise, if there's already zone information in dt, trust it
+    ;; without looking up the observance.  This is partly a performance
+    ;; optimization (because the lookup is expensive) and partly about
+    ;; avoiding problems: looking up the observance uses the clock time
+    ;; in dt without considering the zone information, and doing this
+    ;; when dt has already been adjusted to contain valid zone
+    ;; information can invalidate that information.
+    ;;
+    ;; It's reliable to skip the lookup when dt already contains zone
+    ;; information only because `icalendar-make-date-time',
+    ;; `icalendar-date/time-add', and in particular
+    ;; `icalendar-date-time-variant' are careful to remove the UTC
+    ;; offset and DST information in the date-times they construct,
+    ;; unless provided with enough information to fill those slots.
+    (unless (and (cl-typep dt 'ical:date-time)
+                 (decoded-time-zone dt)
+                 (booleanp (decoded-time-dst dt)))
+      ;; This updates the relevant slots in dt as a side effect:
+      ;; TODO: if no observance is found, is it ever sensible to signal an=
 error,
+      ;; instead of just leaving the zone slot unset?
+      (icr:tz-observance-on dt vtimezone t nonexisting)))
+    dt)
+
+(defun icr:tz-set-zones-in (vtimezones node)
+  "Recursively set time zone offset and dst flags in times in NODE.
+
+VTIMEZONES should be a list of the `icalendar-vtimezone' components in
+the calendar containing NODE. NODE can be any iCalendar syntax node. If
+NODE is a property node with an `icalendar-tzidparam' parameter and an
+`icalendar-date-time' or `icalendar-period' value, the appropriate time
+zone observance for its value is looked up in VTIMEZONES, and used to
+the set the zone and dst slots in its value. Otherwise, the function is
+called recursively on NODE's children."
+  (cond
+   ((ical:property-node-p node)
+    (ical:with-property node
+      ((ical:tzidparam :value tzid))
+      (when (and tzid (eq value-type 'ical:date-time))
+        (let* ((tz (icr:tz-for tzid vtimezones))
+               updated)
+          (cond ((eq value-type 'ical:date-time)
+                 (setq updated (icr:tz-set-zone value tz)))
+                ((eq value-type 'ical:period)
+                 (setq updated
+                       (ical:make-period
+                        (icr:tz-set-zone (ical:period-start value) tz)
+                        :end
+                        (if (ical:period--defined-end value)
+                            (icr:tz-set-zone (ical:period--defined-end val=
ue) tz)
+                          (ical:period-end value tz))
+                        :duration (ical:period-dur-value value)))))
+          (ical:ast-node-set-value value-node updated)))))
+   ((ical:component-node-p node) ; includes VCALENDAR nodes
+    (mapc (apply-partially #'icr:tz-set-zones-in vtimezones)
+          (ical:ast-node-children node)))
+   (t nil)))
+
+(defun icr:tzname-on (dt vtimezone)
+  "Return the name of the time zone observance in effect on DT in VTIMEZON=
E.
+
+DT should be an `icalendar-date' or `icalendar-date-time'. VTIMEZONE
+should be the `icalendar-vtimezone' component in which to interpret DT.
+
+The observance in effect on DT within VTIMEZONE is computed. The
+returned value is the value of the `icalendar-tzname' property of this
+observance."
+  (when-let* ((obs/onset (icr:tz-observance-on dt vtimezone))
+              (observance (car obs/onset)))
+    (ical:with-property-of observance 'ical:tzname)))
+
+
+
+(provide 'icalendar-recur)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-=
recur-"))
+;; End:
+;;; icalendar-recur.el ends here
diff --git a/lisp/calendar/icalendar-utils.el b/lisp/calendar/icalendar-uti=
ls.el
new file mode 100644
index 00000000000..27b23fbbd22
--- /dev/null
+++ b/lisp/calendar/icalendar-utils.el
@@ -0,0 +1,731 @@
+;;; icalendar-utils.el --- iCalendar utility functions  -*- lexical-bindin=
g: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: January 2025
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+(require 'cl-lib)
+(require 'calendar)
+(require 'icalendar-macs)
+(require 'icalendar-parser)
+
+;; Accessors for commonly used properties
+
+(defun ical:component-dtstart (component)
+  "Return the value of the `icalendar-dtstart' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtstart nil value))
+
+(defun ical:component-dtend (component)
+  "Return the value of the `icalendar-dtend' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtend nil value))
+
+(defun ical:component-rdate (component)
+  "Return the value of the `icalendar-rdate' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:rdate nil value))
+
+(defun ical:component-summary (component)
+  "Return the value of the `icalendar-summary' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:summary nil value))
+
+(defun ical:component-description (component)
+  "Return the value of the `icalendar-description' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:description nil value))
+
+(defun ical:component-tzname (component)
+  "Return the value of the `icalendar-tzname' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:tzname nil value))
+
+(defun ical:component-uid (component)
+  "Return the value of the `icalendar-uid' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:uid nil value))
+
+(defun ical:component-url (component)
+  "Return the value of the `icalendar-url' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:url nil value))
+
+(defun ical:property-tzid (property)
+  "Return the value of the `icalendar-tzid' parameter of PROPERTY."
+  (ical:with-param-of property 'ical:tzidparam nil value))
+
+;; Date/time
+
+;; N.B. Notation: "date/time" is used in function names when a function
+;; can accept either `icalendar-date' or `icalendar-date-time' values;
+;; in contrast, "date-time" means it accepts *only*
+;; `icalendar-date-time' values, not plain dates.
+;; TODO: turn all the 'date/time' functions into methods dispatched by
+;; type?
+
+(defun ical:date-time-to-date (dt)
+  "Convert an `icalendar-date-time' value DT to an `icalendar-date'."
+  (list (decoded-time-month dt)
+        (decoded-time-day dt)
+        (decoded-time-year dt)))
+
+(cl-defun ical:date-to-date-time (dt &key (hour 0) (minute 0) (second 0) (=
tz nil))
+  "Convert an `icalendar-date' value DT to an `icalendar-date-time'.
+
+The following keyword arguments are accepted:
+  :hour, :minute, :second - integers representing a local clock time on da=
te DT
+  :tz - an `icalendar-vtimezone' in which to interpret this clock time
+
+If these arguments are all unspecified, the hour, minute, and second
+slots of the returned date-time will be zero, and it will contain no
+time zone information. See `icalendar-make-date-time' for more on these
+arguments."
+  (ical:make-date-time
+   :year (calendar-extract-year dt)
+   :month (calendar-extract-month dt)
+   :day (calendar-extract-day dt)
+   :hour hour
+   :minute minute
+   :second second
+   :tz tz))
+
+(defun ical:date/time-to-date (dt)
+  "Extract a Gregorian date from DT.
+An `icalendar-date' value is returned unchanged.
+An `icalendar-date-time' value is converted to an `icalendar-date'."
+  (if (cl-typep dt 'ical:date)
+      dt
+    (ical:date-time-to-date dt)))
+
+;; Type-aware accessors for date/time slots that work for both
+;; ical:date and ical:date-time:
+;; NOTE: cl-typecase ONLY works here if dt is a valid decoded-time with al=
l slots!
+;; May need to adjust this if it's necessary to work with incomplete decod=
ed-times
+(defun ical:date/time-year (dt)
+  "Return DT's year slot.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (cl-typecase dt
+    (ical:date (calendar-extract-year dt))
+    (ical:date-time (decoded-time-year dt))))
+
+(defun ical:date/time-month (dt)
+  "Return DT's month slot.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (cl-typecase dt
+    (ical:date (calendar-extract-month dt))
+    (ical:date-time (decoded-time-month dt))))
+
+(defun ical:date/time-monthday (dt)
+  "Return DT's day of the month slot.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (cl-typecase dt
+    (ical:date (calendar-extract-day dt))
+    (ical:date-time (decoded-time-day dt))))
+
+(defun ical:date/time-weekno (dt &optional weekstart)
+  "Return DT's ISO week number.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  ;; TODO: Add support for weekstart.
+  ;; calendar-iso-from-absolute doesn't support this yet.
+  (when (and weekstart (not (=3D weekstart 1)))
+    (error "Support for WEEKSTART other than 1 (=3DMonday) not implemented=
 yet"))
+  (let* ((gdate (ical:date/time-to-date dt))
+         (isodate (calendar-iso-from-absolute
+                   (calendar-absolute-from-gregorian gdate)))
+         (weekno (car isodate)))
+    weekno))
+
+(defun ical:date/time-weekday (dt)
+  "Return DT's day of the week.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (cl-typecase dt
+    (ical:date (calendar-day-of-week dt))
+    (ical:date-time
+     (or (decoded-time-weekday dt)
+         ;; compensate for possibly-nil weekday slot if the date-time
+         ;; has been constructed by `make-decoded-time'; cf. comment
+         ;; in `icalendar--decoded-date-time-p':
+         (calendar-day-of-week (ical:date-time-to-date dt))))))
+
+(defun ical:date/time-hour (dt)
+  "Return DT's hour slot, or nil.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-hour dt)))
+
+(defun ical:date/time-minute (dt)
+  "Return DT's minute slot, or nil.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-minute dt)))
+
+(defun ical:date/time-second (dt)
+  "Return DT's second slot, or nil.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-second dt)))
+
+(defun ical:date/time-zone (dt)
+  "Return DT's time zone slot, or nil.
+DT may be either an `icalendar-date' or an `icalendar-date-time'."
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-zone dt)))
+
+;;; Date/time comparisons and arithmetic:
+(defun ical:date< (dt1 dt2)
+  "Return non-nil if date DT1 is strictly earlier than date DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YE=
AR)."
+  (< (calendar-absolute-from-gregorian dt1)
+     (calendar-absolute-from-gregorian dt2)))
+
+(defun ical:date<=3D (dt1 dt2)
+  "Return non-nil if date DT1 is earlier than or the same date as DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YE=
AR)."
+  (or (calendar-date-equal dt1 dt2) (ical:date< dt1 dt2)))
+
+(defun ical:date-time-locally-earlier (dt1 dt2 &optional or-equal)
+  "Return non-nil if date-time DT1 is locally earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information.
+
+If OR-EQUAL is non-nil, this function acts like `<=3D' rather than `<':
+it will return non-nil if DT1 and DT2 are locally the same time."
+  (let ((year1 (decoded-time-year dt1))
+        (year2 (decoded-time-year dt2))
+        (month1 (decoded-time-month dt1))
+        (month2 (decoded-time-month dt2))
+        (day1 (decoded-time-day dt1))
+        (day2 (decoded-time-day dt2))
+        (hour1 (decoded-time-hour dt1))
+        (hour2 (decoded-time-hour dt2))
+        (minute1 (decoded-time-minute dt1))
+        (minute2 (decoded-time-minute dt2))
+        (second1 (decoded-time-second dt1))
+        (second2 (decoded-time-second dt2)))
+    (or (< year1 year2)
+        (and (=3D year1 year2)
+             (or (< month1 month2)
+                 (and (=3D month1 month2)
+                      (or (< day1 day2)
+                          (and (=3D day1 day2)
+                               (or (< hour1 hour2)
+                                   (and (=3D hour1 hour2)
+                                        (or (< minute1 minute2)
+                                            (and (=3D minute1 minute2)
+                                                 (if or-equal
+                                                     (<=3D second1 second2)
+                                                   (< second1 second2)))))=
)))))))))
+
+(defun ical:date-time-locally< (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally strictly earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 nil))
+
+(defun ical:date-time-locally<=3D (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally earlier than, or equal to, D=
T2.
+
+Unlike `icalendar-date-time<=3D', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 t))
+
+(defun ical:date-time< (dt1 dt2)
+  "Return non-nil if date-time DT1 is strictly earlier than DT2.
+
+DT1 and DT2 must both be decoded times, and either both or neither
+should have time zone information.
+
+If one has a time zone offset and the other does not, the offset
+returned from `current-time-zone' is used as the missing offset; if
+`current-time-zone' cannot provide this information, an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-less-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (ical:date-time-locally< dt1 dt2))
+          (t
+           ;; Cf. RFC5545 Sec. 3.3.5:
+           ;; "The recipient of an iCalendar object with a property value
+           ;; consisting of a local time, without any relative time zone
+           ;; information, SHOULD interpret the value as being fixed to wh=
atever
+           ;; time zone the "ATTENDEE" is in at any given moment.  This me=
ans
+           ;; that two "Attendees", in different time zones, receiving the=
 same
+           ;; event definition as a floating time, may be participating in=
 the
+           ;; event at different actual times.  Floating time SHOULD only =
be
+           ;; used where that is the reasonable behavior."
+           ;; I'm interpreting this to mean that if we get here, where
+           ;; one date-time has zone information and the other doesn't,
+           ;; we should use the offset from (current-time-zone).
+           (let* ((user-tz (current-time-zone))
+                  (user-offset (car user-tz))
+                  (dt1z (ical:date-time-variant dt1 :zone (or zone1 user-o=
ffset)))
+                  (dt2z (ical:date-time-variant dt2 :zone (or zone2 user-o=
ffset))))
+             (if user-offset
+                 (time-less-p (encode-time dt1z) (encode-time dt2z))
+               (error "Too little zone information for comparison: %s %s"
+                      dt1 dt2)))))))
+
+;; Two different notions of equality are relevant to decoded times:
+;; strict equality (`icalendar-date-time=3D') of all slots, or
+;; simultaneity (`icalendar-date-time-simultaneous-p').
+;; Most tests probably want the strict notion, because it distinguishes
+;; between simultaneous events decoded into different time zones,
+;; whereas most user-facing functions (e.g. sorting events by date and tim=
e)
+;; probably want simultaneity.
+(defun ical:date-time=3D (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are decoded-times with identical slot val=
ues.
+
+Note that this function returns nil if DT1 and DT2 represent times in
+different time zones, even if they are simultaneous. For the latter, see
+`icalendar-date-time-simultaneous-p'."
+  (equal dt1 dt2))
+
+(defun ical:date-time-locally-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are locally simultaneous date-times.
+Note that this function ignores zone information in dt1 and dt2. It
+returns non-nil if DT1 and DT2 represent the same clock time in
+different time zones, even if they encode to different absolute times."
+  (and (eq (decoded-time-year dt1)   (decoded-time-year dt2))
+       (eq (decoded-time-month dt1)  (decoded-time-month dt2))
+       (eq (decoded-time-day dt1)    (decoded-time-day dt2))
+       (eq (decoded-time-hour dt1)   (decoded-time-hour dt2))
+       (eq (decoded-time-minute dt1) (decoded-time-minute dt2))
+       (eq (decoded-time-second dt1) (decoded-time-second dt2))))
+
+(defun ical:date-time-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are simultaneous date-times.
+
+This function returns non-nil if DT1 and DT2 encode to the same Lisp
+timestamp. Thus they can count as simultaneous even if they represent
+times in different timezones. If both date-times lack an offset from
+UTC, they are treated as simultaneous if they encode to the same
+timestamp in UTC.
+
+If only one date-time has an offset, they are treated as
+non-simultaneous if they represent different clock times according to
+`icalendar-date-time-locally-simultaneous-p'.  Otherwise an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-equal-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0))
+                         (encode-time (ical:date-time-variant dt2 :zone 0)=
)))
+          (t
+           ;; Best effort:
+           ;; TODO: I'm not convinced this is the right thing to do yet.
+           ;; Might want to be stricter here and fix the problem of compar=
ing
+           ;; times with and without zone information elsewhere.
+           (if (ical:date-time-locally-simultaneous-p dt1 dt2)
+               (error "Missing zone information: %s %s" dt1 dt2)
+             nil)))))
+
+(defun ical:date-time<=3D (dt1 dt2)
+  "Return non-nil if DT1 is earlier than, or simultaneous with, DT2.
+DT1 and DT2 must both be decoded times, and either both or neither must ha=
ve
+time zone information."
+  (or (ical:date-time< dt1 dt2)
+      (ical:date-time-simultaneous-p dt1 dt2)))
+
+(defun ical:date/time< (dt1 dt2)
+  "Return non-nil if DT1 is strictly earlier than DT2.
+DT1 and DT2 must be either `icalendar-date' or `icalendar-date-time'
+values. If they are not of the same type, only the date in the
+`icalendar-date-time' value will be considered."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date< dt1 dt2)
+       (ical:date< dt1 (ical:date-time-to-date dt2))))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time< dt1 dt2)
+       (ical:date< (ical:date-time-to-date dt1) dt2)))))
+
+(defun ical:date/time<=3D (dt1 dt2)
+  "Return non-nil if DT1 is earlier than or simultaneous to DT2.
+DT1 and DT2 must be either `icalendar-date' or `icalendar-date-time'
+values. If they are not of the same type, only the date in the
+`icalendar-date-time' value will be considered."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date<=3D dt1 dt2)
+       (ical:date<=3D dt1 (ical:date-time-to-date dt2))))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time<=3D dt1 dt2)
+       (ical:date<=3D (ical:date-time-to-date dt1) dt2)))))
+
+(defun ical:date/time-min (&rest dts)
+  "Return the earliest date or date-time among DTS.
+
+The DTS may be any `icalendar-date' or `icalendar-date-time' values, and
+will be ordered by `icalendar-date/time<=3D'."
+  (car (sort dts :lessp #'ical:date/time<=3D)))
+
+(defun ical:date/time-max (&rest dts)
+  "Return the latest date or date-time among DTS.
+
+The DTS may be any `icalendar-date' or `icalendar-date-time' values, and
+will be ordered by `icalendar-date/time<=3D'."
+  (car (sort dts :reverse t :lessp #'ical:date/time<=3D)))
+
+(defun ical:date-add (date unit n)
+  "Add N UNITs to DATE.
+
+UNIT should be `:year', `:month', `:week', or `:day'; time units will be
+ignored. N may be a positive or negative integer."
+  (if (memq unit '(:hour :minute :second))
+      date
+    (let* ((dt (ical:make-date-time :year (calendar-extract-year date)
+                                    :month (calendar-extract-month date)
+                                    :day (calendar-extract-day date)))
+           (delta (if (eq unit :week)
+                      (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n)))
+           (new-dt (decoded-time-add dt delta)))
+      (ical:date-time-to-date new-dt))))
+
+(declare-function icalendar-recur-tz-decode-time "icalendar-recur")
+
+(defun ical:date-time-add (dt delta &optional vtimezone)
+  "Like `decoded-time-add', but also updates weekday and time zone slots.
+
+DT and DELTA should be `icalendar-date-time' values (decoded times), as
+in `decoded-time-add'.  VTIMEZONE, if given, should be an
+`icalendar-vtimezone'. The resulting date-time will be given the offset
+determined by VTIMEZONE at the local time determined by adding DELTA to
+DT.
+
+This function assumes that time units in DELTA larger than an hour
+should not affect the local clock time in the result, even when crossing
+an observance boundary in VTIMEZONE. This means that e.g. if DT is at
+9AM daylight savings time on the day before the transition to standard
+time, then the result of adding a DELTA of two days will be at 9AM
+standard time, even though this is not exactly 48 hours later. Adding a
+DELTA of 48 hours, on the other hand, will result in a time exactly 48
+hours later, but at a different local time."
+  (require 'icalendar-recur) ; for icr:tz-decode-time; avoids circular req=
uires
+  (if (not vtimezone)
+      ;; the simple case: we have no time zone info, so just use
+      ;; `decoded-time-add':
+      (let ((sum (decoded-time-add dt delta)))
+        (ical:date-time-variant sum))
+    ;; `decoded-time-add' does not take time zone shifts into account,
+    ;; so we need to do the adjustment ourselves. We first add the units
+    ;; larger than an hour using `decoded-time-add', holding the clock
+    ;; time fixed, as described in the docstring. Then we add the time
+    ;; units as a fixed number of seconds and re-decode the resulting
+    ;; absolute time into the time zone.
+    (let* ((cal-delta (make-decoded-time :year (or (decoded-time-year delt=
a) 0)
+                                         :month (or (decoded-time-month de=
lta) 0)
+                                         :day (or (decoded-time-day delta)=
 0)))
+           (cal-sum (decoded-time-add dt cal-delta))
+           (dt-w/zone (ical:date-time-variant cal-sum
+                                              :tz vtimezone))
+           (secs-delta (+ (or (decoded-time-second delta) 0)
+                          (* 60 (or (decoded-time-minute delta) 0))
+                          (* 60 60 (or (decoded-time-hour delta) 0))))
+           (sum-ts (time-add (encode-time dt-w/zone) secs-delta)))
+      (icalendar-recur-tz-decode-time sum-ts vtimezone))))
+
+;; TODO: rework so that it's possible to add dur-values to plain dates.
+;; Perhaps rename this to "date/time-inc" or so, or use kwargs to allow
+;; multiple units, or...
+(defun ical:date/time-add (dt unit n &optional vtimezone)
+  "Add N UNITs to DT.
+
+DT should be an `icalendar-date' or `icalendar-date-time'. UNIT should
+be `:year', `:month', `:week', `:day', `:hour', `:minute', or `:second';
+time units will be ignored if DT is an `icalendar-date'. N may be a
+positive or negative integer."
+  (cl-typecase dt
+    (ical:date-time
+     (let ((delta (if (eq unit :week) (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n))))
+       (ical:date-time-add dt delta vtimezone)))
+    (ical:date (ical:date-add dt unit n))))
+
+(defun ical:date/time-add-duration (start duration &optional vtimezone)
+  "Return the end date(-time) which is a length of DURATION after START.
+
+START should be an `icalendar-date' or `icalendar-date-time'; the
+returned value will be of the same type as START. DURATION should be an
+`icalendar-dur-value'.  VTIMEZONE, if specified, should be the
+`icalendar-vtimezone' representing the time zone of START."
+  (if (integerp duration)
+      ;; number of weeks:
+      (setq duration (make-decoded-time :day (* 7 duration))))
+  (cl-typecase start
+    (ical:date
+     (ical:date-time-to-date
+      (ical:date-time-add (ical:date-to-date-time start) duration)))
+    (ical:date-time
+     (ical:date-time-add start duration vtimezone))))
+
+(defun ical:duration-between (start end)
+  "Return the duration between START and END.
+
+START should be an `icalendar-date' or `icalendar-date-time'; END must
+be of the same type as START. The returned value is an
+`icalendar-dur-value', i.e., a time delta in the sense of
+`decoded-time-add'."
+  (cl-typecase start
+    (ical:date
+     (make-decoded-time :day (- (calendar-absolute-from-gregorian end)
+                                (calendar-absolute-from-gregorian start))))
+    (ical:date-time
+     (let* ((start-abs (time-convert (encode-time start) 'integer))
+            (end-abs (time-convert (encode-time end) 'integer))
+            (dur-secs (- end-abs start-abs))
+            (days (/ dur-secs (* 60 60 24)))
+            (dur-nodays (mod dur-secs (* 60 60 24)))
+            (hours (/ dur-nodays (* 60 60)))
+            (dur-nohours (mod dur-nodays (* 60 60)))
+            (minutes (/ dur-nohours 60))
+            (seconds (mod dur-nohours 60)))
+       (make-decoded-time :day days
+                          :hour hours :minute minutes :second seconds)))))
+
+(defun ical:date/time-to-local (dt)
+  "Reinterpret DT in Emacs local time if necessary.
+If DT is an `icalendar-date-time', encode and re-decode it into Emacs
+local time. If DT is an `icalendar-date', return it unchanged."
+  (cl-typecase dt
+    (ical:date dt)
+    (ical:date-time
+     (ical:date-time-variant ; ensure weekday is present too
+      (decode-time (encode-time dt))))))
+
+(declare-function icalendar-recur-subintervals-to-dates "icalendar-recur")
+
+(defun ical:dates-until (start end &optional locally)
+  "Return a list of `icalendar-date' values between START and END.
+
+START and END may be either `icalendar-date' or `icalendar-date-time'
+values.  START is an inclusive lower bound, and END is an exclusive
+upper bound. (Note, however, that if END is a date-time and its time is
+after midnight, then its date will be included in the returned list.)
+
+If LOCALLY is non-nil and START and END are date-times, these will be
+interpreted into Emacs local time, so that the dates returned are valid
+for the local time zone."
+  (require 'icalendar-recur)
+  (when locally
+    (when (cl-typep start 'ical:date-time)
+      (setq start (ical:date/time-to-local start)))
+    (when (cl-typep end 'ical:date-time)
+      (setq end (ical:date/time-to-local end))))
+  (cl-typecase start
+    (ical:date
+     (cl-typecase end
+       (ical:date
+        (icalendar-recur-subintervals-to-dates
+         (list (list (ical:date-to-date-time start)
+                     (ical:date-to-date-time end)))))
+       (ical:date-time
+        (icalendar-recur-subintervals-to-dates
+         (list (list (ical:date-to-date-time start) end))))))
+    (ical:date-time
+     (cl-typecase end
+       (ical:date
+        (icalendar-recur-subintervals-to-dates
+         (list (list start (ical:date-to-date-time end)))))
+       (ical:date-time
+        (icalendar-recur-subintervals-to-dates (list (list start end))))))=
))
+
+
+(cl-defun ical:make-date-time (&key second minute hour day month year
+                                    (dst -1 given-dst) zone tz)
+  "Make an `icalendar-date-time' from the given keyword arguments.
+
+This function is like `make-decoded-time', except that it automatically
+sets the weekday slot set based on the date arguments, and it accepts an
+additional keyword argument: `:tz'. If provided, its value should be an
+`icalendar-vtimezone', and the `:zone' and `:dst' arguments should not
+be provided.  In this case, the zone and dst slots in the returned
+date-time will be adjusted to the correct values in the given time zone
+for the local time represented by the remaining arguments."
+  (when (and tz (or zone given-dst))
+    (error "Possibly conflicting time zone data in args"))
+  (apply #'ical:date-time-variant (make-decoded-time)
+         `(:second ,second :minute ,minute :hour ,hour
+           :day ,day :month ,month :year ,year
+           ;; Don't pass these keywords unless they were given explicitly.
+           ;; TODO: is there a cleaner way to write this?
+           ,@(when tz (list :tz tz))
+           ,@(when given-dst (list :dst dst))
+           ,@(when zone (list :zone zone)))))
+
+(declare-function icalendar-recur-tz-set-zone "icalendar-recur")
+
+(cl-defun ical:date-time-variant (dt &key second minute hour
+                                          day month year
+                                          (dst -1 given-dst)
+                                          (zone nil given-zone)
+                                          tz)
+  "Return a variant of DT with slots modified as in the given arguments.
+
+DT should be an `icalendar-date-time'; the keyword arguments have the
+same meanings as in `make-decoded-time'.  The returned variant will have
+slot values as specified by the arguments or copied from DT, except that
+the weekday slot will be updated if necessary, and the zone and dst
+fields will not be set unless given explicitly (because varying the date
+and clock time generally invalidates the time zone information in DT).
+
+One additional keyword argument is accepted: `:tz'. If provided, its
+value should be an `icalendar-vtimezone', an `icalendar-utc-offset', or
+the symbol \\=3D'preserve.  If it is a time zone component, the zone and
+dst slots in the returned variant will be adjusted to the correct
+values in the given time zone for the local time represented by the
+variant. If it is a UTC offset, the variant's zone slot will contain
+this value, but its dst slot will not be adjusted.  If it is the symbol
+\\=3D'preserve, then both the zone and dst fields are copied from DT into
+the variant."
+  (require 'icalendar-recur) ; for icr:tz-set-zone; avoids circular requir=
es
+  (let ((variant
+         (make-decoded-time :second (or second (decoded-time-second dt))
+                            :minute (or minute (decoded-time-minute dt))
+                            :hour (or hour (decoded-time-hour dt))
+                            :day (or day (decoded-time-day dt))
+                            :month (or month (decoded-time-month dt))
+                            :year (or year (decoded-time-year dt))
+                            ;; For zone and dst slots, trust the value
+                            ;; if explicitly specified or explicitly
+                            ;; requested to preserve, but not otherwise
+                            :dst (cond (given-dst dst)
+                                       ((eq 'preserve tz) (decoded-time-ds=
t dt))
+                                       (t -1))
+                            :zone (cond (given-zone zone)
+                                        ((eq 'preserve tz) (decoded-time-z=
one dt))
+                                        (t nil)))))
+    ;; update weekday slot when possible, since it depends on the date
+    ;; slots, which might have changed. (It's not always possible,
+    ;; because pure time values are also represented as decoded-times,
+    ;; with empty date slots.)
+    (unless (or (null (decoded-time-year variant))
+                (null (decoded-time-month variant))
+                (null (decoded-time-day variant)))
+      (setf (decoded-time-weekday variant)
+            (calendar-day-of-week (ical:date-time-to-date variant))))
+    ;; if given a time zone or UTC offset, update zone and dst slots,
+    ;; which also might have changed:
+    (when (and tz (not (eq 'preserve tz)))
+      (icalendar-recur-tz-set-zone variant tz))
+    variant))
+
+(defun ical:date/time-in-period-p (dt period &optional vtimezone)
+  "Return non-nil if DT occurs within PERIOD.
+
+DT can be an `icalendar-date' or `icalendar-date-time' value.  PERIOD
+should be an `icalendar-period' value.  VTIMEZONE, if given, is passed
+to `icalendar-period-end' to compute the end time of the period if it
+was not specified explicitly."
+  (and (ical:date/time<=3D (ical:period-start period) dt)
+       (ical:date/time< dt (ical:period-end period vtimezone))))
+
+;; TODO: surely this exists already?
+(defun ical:time<=3D (a b)
+  "Compare two lisp timestamps A and B: is A <=3D B?"
+  (or (time-equal-p a b)
+      (time-less-p a b)))
+
+(defun ical:number-of-weeks (year &optional weekstart)
+  "Return the number of weeks in (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (=3D 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (=3D Sunday)
+and 6 (=3D Saturday)."
+  ;; There are 53 weeks in a year if Jan 1 is the fourth day after
+  ;; WEEKSTART, e.g. if the week starts on Monday and Jan 1 is a
+  ;; Thursday, or in a leap year if Jan 1 is the third day after WEEKSTART
+  (let* ((jan1wd (calendar-day-of-week (list 1 1 year)))
+         (delta (mod (- jan1wd (or weekstart 1)) 7)))
+    (if (or (=3D 4 delta)
+            (and (=3D 3 delta) (calendar-leap-year-p year)))
+        53
+      52)))
+
+(defun ical:start-of-weekno (weekno year &optional weekstart)
+  "Return the start of the WEEKNOth week in the (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (=3D 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (=3D Sunday)
+and 6 (=3D Saturday). The returned value is an `icalendar-date'.
+
+If WEEKNO is negative, it refers to the WEEKNOth week before the end of
+the year: -1 is the last week of the year, -2 second to last, etc."
+  (calendar-gregorian-from-absolute
+   (+
+    (* 7 (if (< 0 weekno)
+             (1- weekno)
+           (+ 1 weekno (ical:number-of-weeks year weekstart))))
+    (calendar-dayname-on-or-before
+     (or weekstart 1)
+     ;; Three days after Jan 1. gives us the nearest occurrence;
+     ;; see `calendar-dayname-on-or-before'
+     (+ 3 (calendar-absolute-from-gregorian (list 1 1 year)))))))
+
+(defun ical:nth-weekday-in (n weekday year &optional month)
+  "Return the Nth WEEKDAY in YEAR or MONTH.
+
+If MONTH is specified, it refers to MONTH in YEAR, and N acts as an
+index for WEEKDAYs within the month. Otherwise, N acts as an index for
+WEEKDAYs within the entire YEAR.
+
+N should be an integer. If N<0, it counts from the end of the month or
+year: if N=3D-1, it refers to the last WEEKDAY in the month or year, if
+N=3D-2 the second to last, and so on."
+  (if month
+      (calendar-nth-named-day n weekday month year)
+    (let* ((jan1 (calendar-absolute-from-gregorian (list 1 1 year)))
+           (dec31 (calendar-absolute-from-gregorian (list 12 31 year))))
+      ;; Adapted from `calendar-nth-named-absday'.
+      ;; TODO: we could generalize that function to make month an optional
+      ;; argument, but that would mean changing its interface.
+      (calendar-gregorian-from-absolute
+       (if (> n 0)
+           (+ (* 7 (1- n))
+              (calendar-dayname-on-or-before
+               weekday
+               (+ 6 jan1)))
+         (+ (* 7 (1+ n))
+            (calendar-dayname-on-or-before
+             weekday
+             dec31)))))))
+
+(provide 'icalendar-utils)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-utils.el ends here
diff --git a/lisp/calendar/icalendar.el b/lisp/calendar/icalendar.el
index b3334e483c1..2b6bfa0184a 100644
--- a/lisp/calendar/icalendar.el
+++ b/lisp/calendar/icalendar.el
@@ -26,6 +26,11 @@
=20
 ;;; Commentary:
=20
+;; Most of the code in this file is now obsolete and has been marked as su=
ch.
+;; For the new implementation of diary import/export, see diary-icalendar.=
el.
+;; Error handling code, global variables, and user options relevant for the
+;; entire iCalendar library remain in this file.
+
 ;; This package is documented in the Emacs Manual.
=20
 ;;   Please note:
@@ -73,39 +78,10 @@
 ;;  0.01: (2003-03-21)
 ;;  - First published version.  Trial version.  Alpha version.
=20
-;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
-;; To Do:
-
-;;  * Import from ical to diary:
-;;    + Need more properties for icalendar-import-format
-;;      (added all that Mozilla Calendar uses)
-;;      From iCal specifications (RFC2445: 4.8.1), icalendar.el lacks
-;;      ATTACH, CATEGORIES, COMMENT, GEO, PERCENT-COMPLETE (VTODO),
-;;      PRIORITY, RESOURCES) not considering date/time and time-zone
-;;    + check vcalendar version
-;;    + check (unknown) elements
-;;    + recurring events!
-;;    + works for european style calendars only! Does it?
-;;    + alarm
-;;    + exceptions in recurring events
-;;    + the parser is too soft
-;;    + error log is incomplete
-;;    + nice to have: #include "webcal://foo.com/some-calendar.ics"
-;;    + timezones probably still need some improvements.
-
-;;  * Export from diary to ical
-;;    + diary-date, diary-float, and self-made sexp entries are not
-;;      understood
-
-;;  * Other things
-;;    + clean up all those date/time parsing functions
-;;    + Handle todo items?
-;;    + Check iso 8601 for datetime and period
-;;    + Which chars to (un)escape?
-
-
 ;;; Code:
=20
+(eval-when-compile (require 'compile))
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; Customizables
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
@@ -138,6 +114,12 @@ icalendar-import-format
           (function :tag "Function"))
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-summary
   "%s"
   "Format string defining how the summary element is formatted.
@@ -146,6 +128,12 @@ icalendar-import-format-summary
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-summary
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-description
   "\n Desc: %s"
   "Format string defining how the description element is formatted.
@@ -154,6 +142,12 @@ icalendar-import-format-description
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-description
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-location
   "\n Location: %s"
   "Format string defining how the location element is formatted.
@@ -162,6 +156,12 @@ icalendar-import-format-location
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-location
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-organizer
   "\n Organizer: %s"
   "Format string defining how the organizer element is formatted.
@@ -170,6 +170,12 @@ icalendar-import-format-organizer
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-organizer
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-url
   "\n URL: %s"
   "Format string defining how the URL element is formatted.
@@ -178,6 +184,12 @@ icalendar-import-format-url
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-url
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-uid
   "\n UID: %s"
   "Format string defining how the UID element is formatted.
@@ -187,6 +199,12 @@ icalendar-import-format-uid
   :version "24.3"
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-uid
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-status
   "\n Status: %s"
   "Format string defining how the status element is formatted.
@@ -195,6 +213,12 @@ icalendar-import-format-status
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-status
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-class
   "\n Class: %s"
   "Format string defining how the class element is formatted.
@@ -203,68 +227,65 @@ icalendar-import-format-class
   :type 'string
   :group 'icalendar)
=20
-(defcustom icalendar-recurring-start-year
-  2005
-  "Start year for recurring events.
-Some calendar browsers only propagate recurring events for
-several years beyond the start time.  Set this string to a year
-just before the start of your personal calendar."
-  :type 'integer
-  :group 'icalendar)
+(make-obsolete-variable
+ 'icalendar-import-format-class
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
=20
-(defcustom icalendar-export-hidden-diary-entries
-  t
-  "Determines whether hidden diary entries are exported.
-If non-nil hidden diary entries (starting with `&') get exported,
-if nil they are ignored."
-  :type 'boolean
-  :group 'icalendar)
+(define-obsolete-variable-alias
+ 'icalendar-recurring-start-year
+ 'diary-icalendar-recurring-start-year
+ "32.1")
+
+(define-obsolete-variable-alias
+ 'icalendar-export-hidden-diary-entries
+ 'diary-icalendar-export-nonmarking-entries
+ "32.1")
+
+(defcustom ical:uid-format
+  "%h"
+  "Format string for unique ID (UID) values for iCalendar components.
+
+This string is used by `icalendar-make-uid' to generate UID values when
+creating iCalendar components.
=20
-(defcustom icalendar-uid-format
-  "emacs%t%c"
-  "Format of unique ID code (UID) for each iCalendar object.
 The following specifiers are available:
 %c COUNTER, an integer value that is increased each time a uid is
    generated.  This may be necessary for systems which do not
    provide time-resolution finer than a second.
-%h HASH, a hash value of the diary entry,
-%s DTSTART, the start date (excluding time) of the diary entry,
+%h HASH, a hash value of the component's contents or system information,
 %t TIMESTAMP, a unique creation timestamp,
-%u USERNAME, the variable `user-login-name'.
+%u USERNAME, the value of `user-login-name'.
+%s (obsolete, ignored)
=20
-For example, a value of \"%s_%h@HIDDEN\" will generate a
-UID code for each entry composed of the time of the event, a hash
-code for the event, and your personal domain name."
+For example, a value of \"%h%t@HIDDEN\" will generate a UID code
+for each entry composed of a hash of the event data, a creation
+timestamp, and your personal domain name."
   :type 'string
   :group 'icalendar)
=20
-(defcustom icalendar-export-sexp-enumeration-days
-  14
-  "Number of days over which a sexp diary entry is enumerated.
-In general sexp entries cannot be translated to icalendar format.
-They are therefore enumerated, i.e. explicitly evaluated for a
-certain number of days, and then exported.  The enumeration starts
-on the current day and continues for the number of days given here.
-
-See `icalendar-export-sexp-enumerate-all' for a list of sexp
-entries which by default are NOT enumerated."
-  :version "25.1"
-  :type 'integer
+(defcustom ical:vcalendar-prodid
+  (format "-//gnu.org//GNU Emacs %s//EN" emacs-version)
+  "The value of the `icalendar-prodid' property for VCALENDAR objects
+produced by this Emacs."
+  :type 'string
   :group 'icalendar)
=20
-(defcustom icalendar-export-sexp-enumerate-all
-  nil
-  "Determines whether ALL sexp diary entries are enumerated.
-If non-nil all sexp diary entries are enumerated for
-`icalendar-export-sexp-enumeration-days' days instead of
-translating into an icalendar equivalent.  This affects the
-following sexp diary entries: `diary-anniversary',
-`diary-cyclic', `diary-date', `diary-float', `diary-block'.  All
-other sexp entries are enumerated in any case."
-  :version "25.1"
-  :type 'boolean
-  :group 'icalendar)
+(defconst ical:vcalendar-version "2.0"
+  "The current version of the VCALENDAR object, used in the
+`icalendar-version' property. \"2.0\" is the version corresponding to
+RFC5545.")
+
+(define-obsolete-variable-alias
+  'icalendar-export-sexp-enumeration-days
+  'diary-icalendar-export-sexp-enumeration-days
+  "32.1")
=20
+(define-obsolete-variable-alias
+  'icalendar-export-sexp-enumerate-all
+  'diary-icalendar-export-sexp-enumerate-all
+  "32.1")
=20
 (defcustom icalendar-export-alarms
   nil
@@ -286,16 +307,38 @@ icalendar-export-alarms
                                           (string :tag "Email"))))))
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-export-alarms
+ "please use the new format in `diary-icalendar-export-alarms' instead."
+ "32.1")
+
+(defcustom icalendar-debug-level 1
+  "Minimum severity for logging iCalendar error messages.
+A value of 2 only logs errors.
+A value of 1 also logs warnings.
+A value of 0 also logs debugging information."
+  :type 'integer
+  :group 'icalendar)
=20
 (defvar icalendar-debug nil
   "Enable icalendar debug messages.")
=20
+(make-obsolete-variable
+ 'icalendar-debug
+ 'icalendar-debug-level
+ "32.1")
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; NO USER SERVICEABLE PARTS BELOW THIS LINE
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
=20
 (defconst icalendar--weekday-array ["SU" "MO" "TU" "WE" "TH" "FR" "SA"])
=20
+(make-obsolete-variable
+ 'icalendar--weekday-array
+ 'icalendar-weekday-numbers
+ "32.1")
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; all the other libs we need
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
@@ -307,8 +350,221 @@ icalendar--weekday-array
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 (defun icalendar--dmsg (&rest args)
   "Print message ARGS if `icalendar-debug' is non-nil."
-  (if icalendar-debug
-      (apply 'message args)))
+  (declare (obsolete icalendar-warn "32.1"))
+  (if (or icalendar-debug (=3D 0 icalendar-debug-level))
+      (with-current-buffer (ical:error-buffer)
+        (goto-char (point-max))
+        (insert (apply #'format-message args))
+        (insert "\n"))))
+
+;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
+;; Error handling
+;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
+(define-error 'ical:error "iCalendar error")
+
+(defconst ical:error-buffer-name "*icalendar-errors*"
+  "Name of buffer in which errors are listed when processing iCalendar dat=
a.")
+
+(defun ical:error-buffer ()
+  "Return the iCalendar errors buffer, creating it if necessary.
+The buffer name is based on `icalendar-error-buffer-name'."
+  (get-buffer-create ical:error-buffer-name))
+
+(defvar ical:inhibit-error-erasure nil
+  "When non-nil, `icalendar-init-error-buffer' will not erase the errors
+buffer.")
+
+(defun ical:init-error-buffer (&optional err-buffer)
+  "Prepare ERR-BUFFER for iCalendar errors.
+ERR-BUFFER defaults to the buffer returned by `icalendar-error-buffer'.
+Erases ERR-BUFFER and places it in `icalendar-errors-mode'."
+  (with-current-buffer (or err-buffer (ical:error-buffer))
+    (unless ical:inhibit-error-erasure
+      (let ((inhibit-read-only t))
+        (erase-buffer)))
+    (if (not (eq major-mode 'icalendar-errors-mode))
+        (icalendar-errors-mode))))
+
+(defun ical:errors-p (&optional err-buffer)
+  "Return non-nil if iCalendar errors have been reported in ERR-BUFFER.
+ERR-BUFFER defaults to the buffer returned by `icalendar-error-buffer'."
+  (with-current-buffer (or err-buffer (ical:error-buffer))
+    (not (=3D (point-min) (point-max)))))
+
+(defun ical:warn (msg &rest err-plist)
+  "Write a warning to the `icalendar-error-buffer' without signaling an er=
ror."
+  (plist-put err-plist :message msg)
+  (unless (plist-get err-plist :severity)
+    (plist-put err-plist :severity 1))
+  (ical:handle-generic-error `(ical:warning . ,err-plist)))
+
+(defconst ical:error-regexp
+  (rx line-start
+      (zero-or-one
+       (group "("
+              (or (group-n 3 "ERROR") (group-n 4 "WARNING") (group-n 5 "IN=
FO"))
+              ")"))
+      (group-n 1 (zero-or-more (not ":"))) ":"
+      (zero-or-one (group-n 2 (one-or-more digit)))
+      ":")
+  "Regexp to match iCalendar errors.
+
+Group 1 contains the buffer name where the error originated.
+Group 2 contains the buffer position.
+Groups 3-5 match the severity:
+  3 matches ERROR
+  4 matches WARNING
+  5 matches INFO")
+
+(cl-defun ical:format-error (&rest error-plist
+                             &key (message "Unknown error")
+                                  severity
+                                  buffer
+                                  position
+                                  &allow-other-keys)
+  "Format iCalendar error data to a string.
+
+MESSAGE should be a string; it defaults to \"Unknown error\".
+BUFFER should be a buffer; POSITION should be a position in BUFFER.
+SEVERITY can be 0 for debug information, or 1 for a warning; otherwise
+a genuine error is reported.
+
+The returned error message looks like
+
+(LEVEL)BUFFER:POSITION: MESSAGE
+DEBUG-INFO...
+
+where LEVEL is derived from SEVERITY. DEBUG-INFO contains any additional
+data in ERROR-PLIST, if `icalendar-debug-level' is
+0. `icalendar-error-regexp' matches the fields in such messages."
+  (let ((name (copy-sequence (buffer-name buffer)))
+        (pos (if (integer-or-marker-p position)
+                 (format "%d" position)
+               ""))
+        (level (cond ((eq severity 0) "INFO")
+                     ((eq severity 1) "WARNING")
+                     (t "ERROR")))
+        (debug-info (if (not (=3D 0 icalendar-debug-level))
+                        ""
+                      (mapconcat ;; (:key val...) =3D> "Key: val\n..."
+                       (lambda (val)
+                         (if (keywordp val)
+                             (capitalize (substring (symbol-name val) 1))
+                           (format ": %s\n" val)))
+                       error-plist))))
+    ;; Make sure buffer name doesn't take too much space:
+    (when (< 8 (length name))
+      (put-text-property 9 (length name) 'display "..." name))
+    (format "(%s)%s:%s: %s\n%s" level name pos message debug-info)))
+
+(defun ical:handle-generic-error (err-data &optional err-buffer)
+  "Log error data to ERR-BUFFER (default: the iCalendar error buffer).
+ERR-DATA should be a list (ERROR-SYMBOL . SIGNAL-DATA) where
+SIGNAL-DATA is a plist of error data."
+  (let* ((signal-data (cdr err-data))
+         (err-plist (when (plistp signal-data) signal-data))
+         (err-symbol (car err-data))
+         (severity (or (plist-get err-plist :severity) 2))
+         (buf (current-buffer)))
+    (when (<=3D ical:debug-level severity)
+      (with-current-buffer (or err-buffer (ical:error-buffer))
+        (goto-char (point-max))
+        (let ((inhibit-read-only t))
+          (unless (bolp) (insert "\n"))
+          (insert (apply #'ical:format-error
+                         (or err-plist
+                             (list :buffer buf
+                                   :message
+                                   (format "Unhandled %s error: %s"
+                                           err-symbol signal-data))))))))))
+
+(defmacro ical:condition-case (var bodyform &rest handlers)
+  "Like `condition-case', but with default handler for unhandled iCalendar=
 errors.
+If none of HANDLERS handles an error, it will be handled by
+`icalendar-handle-generic-error'."
+  `(condition-case ,var
+       ,bodyform
+     ,@handlers
+     (ical:error (ical:handle-generic-error ,var))))
+
+;;; Mode based on compilation-mode for navigating error buffer:
+(defun ical:-buffer-from-error ()
+  (when-let* ((name (match-string 1)))
+    (or (get-buffer name)
+        (find-buffer-visiting name))))
+
+(defun ical:-filename-from-error ()
+  (when-let* ((buf (ical:-buffer-from-error)))
+    (buffer-file-name buf)))
+
+(defun ical:-lineno-from-error ()
+  (when-let* ((buf (ical:-buffer-from-error))
+              (posstr (match-string 2))
+              (pos (string-to-number posstr)))
+    (with-current-buffer buf
+      (line-number-at-pos pos))))
+
+(defconst ical:error-regexp-alist
+  (list (list icalendar-error-regexp
+              #'ical:-filename-from-error
+              #'ical:-lineno-from-error
+              nil
+              nil
+              nil
+              '(2 compilation-line-face)
+              '(3 compilation-error-face)
+              '(4 compilation-warning-face)
+              '(5 compilation-info-face)))
+  "Specifies how errors are parsed in `icalendar-errors-mode';
+see `compilation-error-regexp-alist'.")
+
+(define-compilation-mode ical:errors-mode "iCalendar Errors"
+  "Mode for listing and visiting errors when processing iCalendar data."
+  :group 'icalendar
+  (setq-local compilation-error-regexp-alist ical:error-regexp-alist))
+
+(defvar ical:-uid-count 0
+  "Internal counter for creating unique ids.")
+
+(defun ical:make-uid (&optional contents _)
+  "Construct a unique ID from `icalendar-uid-format'.
+
+CONTENTS can be any object which represents the contents of the
+iCalendar component for which the UID is generated.  If CONTENTS is a
+string with the text property \\=3D'uid, that property's value will be
+used as the returned UID.
+
+Otherwise, CONTENTS will be used to create the hash substituted for
+\\=3D'%h' in `icalendar-uid-format'. If CONTENTS is not given, the hash
+will be based on an internal counter, the system name, and the current
+time in nanoseconds.
+
+The second optional argument is for backward compatibility and is ignored."
+  (cl-incf icalendar--uid-count)
+  (let* ((uid icalendar-uid-format)
+         (timestamp (format-time-string "%s%N"))
+         (tohash (or contents
+                     (format "%d%s%s" ical:-uid-count (system-name) timest=
amp))))
+    (if (and (stringp contents) (get-text-property 0 'uid contents))
+        ;; "Allow other apps (such as org-mode) to create its own uid"
+        ;; FIXME: is this necessary? If caller already has a UID, why
+        ;; call this function at all?
+	(setq uid (get-text-property 0 'uid contents))
+      (progn
+        (setq uid (replace-regexp-in-string
+                   "%c" (format "%d" icalendar--uid-count) uid t t))
+        (setq uid (replace-regexp-in-string
+                   "%t" timestamp uid t t))
+        (setq uid (replace-regexp-in-string
+                   "%h" (format "%d" (abs (sxhash tohash))) uid t t))
+        (setq uid (replace-regexp-in-string
+                   "%u" (or user-login-name "UNKNOWN_USER") uid t t))
+        ;; `%s' no longer used, but allowed for backward compatibility:
+        (setq uid (replace-regexp-in-string "%s" "" uid t t))))
+    uid))
+
+;; Essentially everything beyond this point is obsoleted by the new
+;; implementation in diary-icalendar.el.
=20
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; Core functionality
@@ -321,6 +577,7 @@ icalendar--get-unfolded-buffer
 created buffer all occurrences of CR LF BLANK are replaced by the
 empty string.  Argument FOLDED-ICAL-BUFFER is the folded input
 buffer."
+  (declare (obsolete icalendar-unfolded-buffer-from-buffer "32.1"))
   (let ((unfolded-buffer (get-buffer-create " *icalendar-work*")))
     (save-current-buffer
       (set-buffer unfolded-buffer)
@@ -337,6 +594,7 @@ icalendar--clean-up-line-endings
 All occurrences of (CR LF) and (LF CF) are replaced with LF in
 the current buffer.  This is necessary in buffers which contain a
 mix of different line endings."
+  (declare (obsolete nil "32.1"))
   (save-excursion
     (goto-char (point-min))
     (while (re-search-forward "\r\n\\|\n\r" nil t)
@@ -352,6 +610,8 @@ icalendar--read-element
 This function calls itself recursively for each nested calendar element
 it finds.  The current buffer should be an unfolded buffer as returned
 from `icalendar--get-unfolded-buffer'."
+  (declare (obsolete "use `icalendar-parse' or one of `icalendar-parse-com=
ponent',
+`icalendar-parse-property', `icalendar-parse-params' instead." "32.1"))
   (let (element children line name params param param-name param-value
                 value
                 (continue t))
@@ -408,6 +668,7 @@ icalendar--read-element
=20
 (defun icalendar--get-event-property (event prop)
   "For the given EVENT return the value of the first occurrence of PROP."
+  (declare (obsolete icalendar-with-component "32.1"))
   (catch 'found
     (let ((props (car (cddr event))) pp)
       (while props
@@ -419,6 +680,7 @@ icalendar--get-event-property
=20
 (defun icalendar--get-event-property-attributes (event prop)
   "For the given EVENT return attributes of the first occurrence of PROP."
+  (declare (obsolete icalendar-with-component "32.1"))
   (catch 'found
     (let ((props (car (cddr event))) pp)
       (while props
@@ -430,6 +692,7 @@ icalendar--get-event-property-attributes
=20
 (defun icalendar--get-event-properties (event prop)
   "For the given EVENT return a list of all values of the property PROP."
+  (declare (obsolete icalendar-with-component "32.1"))
   (let ((props (car (cddr event))) pp result)
     (while props
       (setq pp (car props))
@@ -456,6 +719,7 @@ icalendar--get-children
   "Return all children of the given NODE which have a name NAME.
 For instance the VCALENDAR node can have VEVENT children as well as VTODO
 children."
+  (declare (obsolete icalendar-ast-node-children "32.1"))
   (let ((result nil)
         (children (cadr (cddr node))))
     (when (eq (car node) name)
@@ -476,6 +740,7 @@ icalendar--get-children
 ;; private
 (defun icalendar--all-events (icalendar)
   "Return the list of all existing events in the given ICALENDAR."
+  (declare (obsolete icalendar-with-component "32.1"))
   (let ((result '()))
     (mapc (lambda (elt)
 	    (setq result (append (icalendar--get-children elt 'VEVENT)
@@ -485,6 +750,7 @@ icalendar--all-events
=20
 (defun icalendar--split-value (value-string)
   "Split VALUE-STRING at `;=3D'."
+  (declare (obsolete nil "32.1"))
   (let ((result '())
         param-name param-value)
     (when value-string
@@ -509,6 +775,7 @@ icalendar--convert-tz-offset
 ALIST is an alist entry from a VTIMEZONE, like STANDARD.
 DST-P is non-nil if this is for daylight savings time.
 The strings are suitable for assembling into a TZ variable."
+  (declare (obsolete nil "32.1"))
   (let* ((offsetto (car (cddr (assq 'TZOFFSETTO alist))))
 	 (offsetfrom (car (cddr (assq 'TZOFFSETFROM alist))))
 	 (rrule-value (car (cddr (assq 'RRULE alist))))
@@ -561,6 +828,7 @@ icalendar--parse-vtimezone
   "Turn a VTIMEZONE ALIST into a cons (ID . TZ-STRING).
 Consider only the most recent date specification.
 Return nil if timezone cannot be parsed."
+  (declare (obsolete nil "32.1"))
   (let* ((tz-id (icalendar--convert-string-for-import
                  (icalendar--get-event-property alist 'TZID)))
 	 (daylight (cadr (cdar (icalendar--get-most-recent-observance alist 'DAYL=
IGHT))))
@@ -578,6 +846,7 @@ icalendar--get-most-recent-observance
   "Return the latest observance for SUB-COMP DAYLIGHT or STANDARD.
 ALIST is a VTIMEZONE potentially containing historical records."
 ;FIXME?: "most recent" should be relative to a given date
+  (declare (obsolete icalendar-recur-tz-observance-on "32.1"))
   (let ((components (icalendar--get-children alist sub-comp)))
     (list
      (car
@@ -600,6 +869,7 @@ icalendar--convert-all-timezones
   "Convert all timezones in the ICALENDAR into an alist.
 Each element of the alist is a cons (ID . TZ-STRING),
 like `icalendar--parse-vtimezone'."
+  (declare (obsolete nil "32.1"))
   (let (result)
     (dolist (zone (icalendar--get-children (car icalendar) 'VTIMEZONE))
       (setq zone (icalendar--parse-vtimezone zone))
@@ -610,6 +880,7 @@ icalendar--convert-all-timezones
 (defun icalendar--find-time-zone (prop-list zone-map)
   "Return a timezone string for the time zone in PROP-LIST, or nil if none.
 ZONE-MAP is a timezone alist as returned by `icalendar--convert-all-timezo=
nes'."
+  (declare (obsolete nil "32.1"))
   (let ((id (plist-get prop-list 'TZID)))
     (if id
 	(cdr (assoc id zone-map)))))
@@ -628,6 +899,7 @@ icalendar--decode-isodatetime
 RESULT-ZONE, if provided, is the timezone for encoding the result
 in any format understood by `decode-time'.
 FIXME: multiple comma-separated values should be allowed!"
+  (declare (obsolete icalendar-read-date-time "32.1"))
   (icalendar--dmsg isodatetimestring)
   (if isodatetimestring
       ;; day/month/year must be present
@@ -685,6 +957,7 @@ icalendar--decode-isoduration
=20
 FIXME: TZID-attributes are ignored....!
 FIXME: multiple comma-separated values should be allowed!"
+  (declare (obsolete icalendar-read-dur-value "32.1"))
   (if isodurationstring
       (save-match-data
         (string-match
@@ -740,6 +1013,7 @@ icalendar--add-decoded-times
   "Add TIME1 to TIME2.
 Both times must be given in decoded form.  One of these times must be
 valid (year > 1900 or something)."
+  (declare (obsolete icalendar-date-time-add "32.1"))
   ;; FIXME: does this function exist already?  Can we use decoded-time-add?
   (decode-time (encode-time
 		;; FIXME: Support subseconds.
@@ -761,6 +1035,8 @@ icalendar--datetime-to-american-date
 Optional argument SEPARATOR gives the separator between month,
 day, and year.  If nil a blank character is used as separator.
 American format: \"month day year\"."
+  (declare (obsolete "use `icalendar-date/time-to-date' and
+`diary-icalendar-format-date' instead." "32.1"))
   (if datetime
       (format "%d%s%d%s%d" (nth 4 datetime) ;month
               (or separator " ")
@@ -776,6 +1052,7 @@ icalendar--datetime-to-european-date
 day, and year.  If nil a blank character is used as separator.
 European format: (day month year).
 FIXME"
+  (declare (obsolete "use `icalendar-date/time-to-date' and `diary-icalend=
ar-format-date' instead." "32.1"))
   (if datetime
       (format "%d%s%d%s%d" (nth 3 datetime) ;day
               (or separator " ")
@@ -790,6 +1067,7 @@ icalendar--datetime-to-iso-date
 Optional argument SEPARATOR gives the separator between month,
 day, and year.  If nil a blank character is used as separator.
 ISO format: (year month day)."
+  (declare (obsolete "use `icalendar-date/time-to-date' and `diary-icalend=
ar-format-date' instead." "32.1"))
   (if datetime
       (format "%d%s%d%s%d" (nth 5 datetime) ;year
               (or separator " ")
@@ -805,6 +1083,7 @@ icalendar--datetime-to-diary-date
 day, and year.  If nil a blank character is used as separator.
 Call icalendar--datetime-to-*-date according to the current
 calendar date style."
+  (declare (obsolete "use `icalendar-date/time-to-date' and `diary-icalend=
ar-format-date' instead." "32.1"))
   (funcall (intern-soft (format "icalendar--datetime-to-%s-date"
                                 calendar-date-style))
            datetime separator))
@@ -812,10 +1091,12 @@ icalendar--datetime-to-diary-date
 (defun icalendar--datetime-to-colontime (datetime)
   "Extract the time part of a decoded DATETIME into 24-hour format.
 Note that this silently ignores seconds."
+  (declare (obsolete diary-icalendar-format-time "32.1"))
   (format "%02d:%02d" (nth 2 datetime) (nth 1 datetime)))
=20
 (defun icalendar--get-month-number (monthname)
   "Return the month number for the given MONTHNAME."
+  (declare (obsolete nil "32.1"))
   (catch 'found
     (let ((num 1)
           (m (downcase monthname)))
@@ -831,6 +1112,7 @@ icalendar--get-month-number
=20
 (defun icalendar--get-weekday-number (abbrevweekday)
   "Return the number for the ABBREVWEEKDAY."
+  (declare (obsolete "see `icalendar-weekday-numbers'" "32.1"))
   (if abbrevweekday
       (catch 'found
         (let ((num 0)
@@ -846,6 +1128,7 @@ icalendar--get-weekday-number
=20
 (defun icalendar--get-weekday-numbers (abbrevweekdays)
   "Return the list of numbers for the comma-separated ABBREVWEEKDAYS."
+  (declare (obsolete "see `icalendar-weekday-numbers'" "32.1"))
   (when abbrevweekdays
     (let* ((num -1)
            (weekday-alist (mapcar (lambda (day)
@@ -860,6 +1143,7 @@ icalendar--get-weekday-numbers
=20
 (defun icalendar--get-weekday-abbrev (weekday)
   "Return the abbreviated WEEKDAY."
+  (declare (obsolete "see `icalendar-weekday-numbers'" "32.1"))
   (catch 'found
     (let ((num 0)
           (w (downcase weekday)))
@@ -877,6 +1161,7 @@ icalendar--date-to-isodate
   "Convert DATE to iso-style date.
 DATE must be a list of the form (month day year).
 If DAY-SHIFT is non-nil, the result is shifted by DAY-SHIFT days."
+  (declare (obsolete icalendar-print-date "32.1"))
   (let ((mdy (calendar-gregorian-from-absolute
               (+ (calendar-absolute-from-gregorian date)
                  (or day-shift 0)))))
@@ -891,6 +1176,7 @@ icalendar--datestring-to-isodate
 must be either nil or an integer.  This function tries to figure
 the date style from DATESTRING itself.  If that is not possible
 it uses the current calendar date style."
+  (declare (obsolete "use `diary-icalendar-parse-date-form' and `icalendar=
-print-date' instead." "32.1"))
   (let ((day -1) month year)
     (save-match-data
       (cond ( ;; iso-style numeric date
@@ -981,6 +1267,7 @@ icalendar--diarytime-to-isotime
 AMPMSTRING would be \"pm\".  The minutes may be missing as long
 as the colon is missing as well, i.e. \"9\" is allowed as
 TIMESTRING and has the same result as \"9:00\"."
+  (declare (obsolete "use `diary-icalendar-parse-time' and `icalendar-prin=
t-date-time' instead." "32.1"))
   (if timestring
       (let* ((parts (save-match-data (split-string timestring ":")))
              (h (car parts))
@@ -1018,20 +1305,136 @@ icalendar-export-file
   "Export diary file to iCalendar format.
 All diary entries in the file DIARY-FILENAME are converted to iCalendar
 format.  The result is appended to the file ICAL-FILENAME."
+  (declare (obsolete diary-icalendar-export-file "32.1"))
   (interactive "FExport diary data from file: \n\
 Finto iCalendar file: ")
   (save-current-buffer
     (set-buffer (find-file diary-filename))
     (icalendar-export-region (point-min) (point-max) ical-filename)))
=20
-(defvar icalendar--uid-count 0
-  "Auxiliary counter for creating unique ids.")
+;;;###autoload
+(defun icalendar-export-region (min max ical-filename)
+  "Export region in diary file to iCalendar format.
+All diary entries in the region from MIN to MAX in the current buffer are
+converted to iCalendar format.  The result is appended to the file
+ICAL-FILENAME.
+This function attempts to return t if something goes wrong.  In this
+case an error string which describes all the errors and problems is
+written into the buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-export-region "32.1"))
+  (interactive "r
+FExport diary data into iCalendar file: ")
+  (let ((result "")
+        (entry-main "")
+        (entry-rest "")
+	(entry-full "")
+        (header "")
+        (contents)
+        (alarm)
+        (found-error nil)
+        (nonmarker (concat "^" (regexp-quote diary-nonmarking-symbol)
+                           "?"))
+        (other-elements nil)
+        (cns-cons-or-list nil))
+    ;; prepare buffer with error messages
+    (save-current-buffer
+      (set-buffer (get-buffer-create "*icalendar-errors*"))
+      (erase-buffer))
+
+    ;; here we go
+    (save-excursion
+      (goto-char min)
+      (while (re-search-forward
+              ;; possibly ignore hidden entries beginning with "&"
+              (if icalendar-export-hidden-diary-entries
+                  "^\\([^ \t\n#].+\\)\\(\\(\n[ \t].*\\)*\\)"
+                "^\\([^ \t\n&#].+\\)\\(\\(\n[ \t].*\\)*\\)")
+              max t)
+        (setq entry-main (match-string 1))
+        (if (match-beginning 2)
+            (setq entry-rest (match-string 2))
+          (setq entry-rest ""))
+	(setq entry-full (concat entry-main entry-rest))
+
+        (condition-case error-val
+            (progn
+              (setq cns-cons-or-list
+                    (icalendar--convert-to-ical nonmarker entry-main))
+              (setq other-elements (icalendar--parse-summary-and-rest
+				    entry-full))
+              (mapc (lambda (contents-n-summary)
+                      (setq contents (concat (car contents-n-summary)
+                                             "\nSUMMARY:"
+                                             (cdr contents-n-summary)))
+                      (let ((cla (cdr (assoc 'cla other-elements)))
+                            (des (cdr (assoc 'des other-elements)))
+                            (loc (cdr (assoc 'loc other-elements)))
+                            (org (cdr (assoc 'org other-elements)))
+                            (sta (cdr (assoc 'sta other-elements)))
+                            ;; (sum (cdr (assoc 'sum other-elements)))
+                            (url (cdr (assoc 'url other-elements)))
+                            (uid (cdr (assoc 'uid other-elements))))
+                        (if cla
+                            (setq contents (concat contents "\nCLASS:" cla=
)))
+                        (if des
+                            (setq contents (concat contents "\nDESCRIPTION=
:"
+                                                   des)))
+                        (if loc
+                            (setq contents (concat contents "\nLOCATION:" =
loc)))
+                        (if org
+                            (setq contents (concat contents "\nORGANIZER:"
+                                                   org)))
+                        (if sta
+                            (setq contents (concat contents "\nSTATUS:" st=
a)))
+                        ;;(if sum
+                        ;;    (setq contents (concat contents "\nSUMMARY:"=
 sum)))
+                        (if url
+                            (setq contents (concat contents "\nURL:" url)))
+
+                        (setq header (concat "\nBEGIN:VEVENT\nUID:"
+                                             (or uid
+                                                 (icalendar--create-uid
+                                                  entry-full contents))))
+                        (setq alarm (icalendar--create-ical-alarm
+                                     (cdr contents-n-summary))))
+                      (setq result (concat result header contents alarm
+                                           "\nEND:VEVENT")))
+                    (if (and (consp cns-cons-or-list)
+                             (not (listp (cdr cns-cons-or-list))))
+                        (list cns-cons-or-list)
+                      cns-cons-or-list)))
+          ;; handle errors
+          (error
+           (setq found-error t)
+           (save-current-buffer
+             (set-buffer (get-buffer-create "*icalendar-errors*"))
+             (insert (format-message "Error in line %d -- %s: `%s'\n"
+                                     (count-lines (point-min) (point))
+                                     error-val
+                                     entry-main))))))
+
+      ;; we're done, insert everything into the file
+      (save-current-buffer
+        (let ((coding-system-for-write 'utf-8))
+          (set-buffer (find-file ical-filename))
+          (goto-char (point-max))
+          (insert "BEGIN:VCALENDAR")
+          (insert "\nPRODID:-//Emacs//NONSGML icalendar.el//EN")
+          (insert "\nVERSION:2.0")
+          (insert result)
+          (insert "\nEND:VCALENDAR\n")
+          ;; save the diary file
+          (save-buffer)
+          (unless found-error
+            (bury-buffer)))))
+    found-error))
=20
 (defun icalendar--create-uid (entry-full contents)
   "Construct a unique iCalendar UID for a diary entry.
 ENTRY-FULL is the full diary entry string.  CONTENTS is the
 current iCalendar object, as a string.  Increase
 `icalendar--uid-count'.  Returns the UID string."
+  (declare (obsolete icalendar-make-uid "32.1"))
   (let ((uid icalendar-uid-format))
     (if
 	;; Allow other apps (such as org-mode) to create its own uid
@@ -1055,7 +1458,6 @@ icalendar--create-uid
                          (substring contents (match-beginning 1) (match-en=
d 1))
                        "DTSTART")))
         (setq uid (replace-regexp-in-string "%s" dtstart uid t t))))
-
     ;; Return the UID string
     uid))
=20
@@ -1068,6 +1470,7 @@ icalendar-export-region
 This function attempts to return t if something goes wrong.  In this
 case an error string which describes all the errors and problems is
 written into the buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-export-region "32.1"))
   (interactive "r
 FExport diary data into iCalendar file: ")
   (let ((result "")
@@ -1179,6 +1582,7 @@ icalendar--convert-to-ical
   "Convert a diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (or
    (unless icalendar-export-sexp-enumerate-all
      (or
@@ -1208,6 +1612,7 @@ icalendar--convert-to-ical
 (defun icalendar--parse-summary-and-rest (summary-and-rest)
   "Parse SUMMARY-AND-REST from a diary to fill iCalendar properties.
 Returns an alist."
+  (declare (obsolete diary-icalendar-parse-entry "32.1"))
   (save-match-data
     (if (functionp icalendar-import-format)
         ;; can't do anything
@@ -1322,6 +1727,7 @@ icalendar--parse-summary-and-rest
=20
 (defun icalendar--create-ical-alarm (summary)
   "Return VALARM blocks for the given SUMMARY."
+  (declare (obsolete diary-icalendar-add-valarms "32.1"))
   (when icalendar-export-alarms
     (let* ((advance-time (car icalendar-export-alarms))
            (alarm-specs (cadr icalendar-export-alarms))
@@ -1337,6 +1743,7 @@ icalendar--do-create-ical-alarm
 \(email (ADDRESS1 ...)), see `icalendar-export-alarms'.  Argument
 SUMMARY is a string which contains a short description for the
 alarm."
+  (declare (obsolete diary-icalendar-add-valarms "32.1"))
   (let* ((action (car alarm-spec))
          (act (format "\nACTION:%s"
                       (cdr (assoc action '((audio . "AUDIO")
@@ -1362,6 +1769,7 @@ icalendar--convert-ordinary-to-ical
   "Convert \"ordinary\" diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match
        (concat nonmarker
                "\\([^ /]+[ /]+[^ /]+[ /]+[^ ]+\\)\\s-*" ; date
@@ -1445,6 +1853,7 @@ icalendar--convert-ordinary-to-ical
 (defun icalendar-first-weekday-of-year (abbrevweekday year)
   "Find the first ABBREVWEEKDAY in a given YEAR.
 Returns day number."
+  (declare (obsolete icalendar-nth-weekday-in "32.1"))
   (let* ((day-of-week-jan01 (calendar-day-of-week (list 1 1 year)))
          (result (+ 1
                     (- (icalendar--get-weekday-number abbrevweekday)
@@ -1459,6 +1868,7 @@ icalendar--convert-weekly-to-ical
   "Convert weekly diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (and (string-match (concat nonmarker
                                  "\\([a-z]+\\)\\s-+"
                                  "\\(\\([0-9][0-9]?:[0-9][0-9]\\)"
@@ -1541,6 +1951,7 @@ icalendar--convert-yearly-to-ical
   "Convert yearly diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             (if (eq calendar-date-style 'european)
                                 "\\([0-9]+[0-9]?\\)\\s-+\\([a-z]+\\)\\s-+"
@@ -1626,6 +2037,7 @@ icalendar--convert-sexp-to-ical
=20
 Optional argument START determines the first day of the
 enumeration, given as a Lisp time value -- used for test purposes."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (cond ((string-match (concat nonmarker
                                "%%(and \\(([^)]+)\\))\\(\\s-*.*?\\) ?$")
                        entry-main)
@@ -1678,6 +2090,7 @@ icalendar--convert-block-to-ical
   "Convert block diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-block \\([^ /]+[ /]+[^ /]+[ /]+[^ ]+=
\\)"
                             " +\\([^ /]+[ /]+[^ /]+[ /]+[^ ]+\\))\\s-*"
@@ -1751,6 +2164,7 @@ icalendar--convert-block-to-ical
     nil))
=20
 (defun icalendar--convert-float-to-ical (nonmarker entry-main)
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   "Convert float diary entry to iCalendar format -- partially unsupported!
=20
   FIXME! DAY from `diary-float' yet unimplemented.
@@ -1817,6 +2231,7 @@ icalendar--convert-date-to-ical
=20
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-date \\([^)]+\\))\\s-*\\(.*?\\) ?$")
                     entry-main)
@@ -1830,6 +2245,7 @@ icalendar--convert-cyclic-to-ical
   "Convert `diary-cyclic' diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-cyclic \\([^ ]+\\) +"
                             "\\([^ /]+[ /]+[^ /]+[ /]+[^ ]+\\))\\s-*"
@@ -1904,6 +2320,7 @@ icalendar--convert-anniversary-to-ical
   "Convert `diary-anniversary' diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-anniversary \\([^)]+\\))\\s-*"
                             "\\(\\([0-9][0-9]?:[0-9][0-9]\\)\\([ap]m\\)?"
@@ -1986,6 +2403,7 @@ icalendar-import-file
 Argument DIARY-FILENAME input `diary-file'.
 Optional argument NON-MARKING determines whether events are created as
 non-marking or not."
+  (declare (obsolete diary-icalendar-import-file "32.1"))
   (interactive "fImport iCalendar data from file: \nFInto diary file: \nP")
   ;; clean up the diary file
   (save-current-buffer
@@ -2012,6 +2430,7 @@ icalendar-import-buffer
 Return code t means that importing worked well, return code nil
 means that an error has occurred.  Error messages will be in the
 buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-import-buffer "32.1"))
   (interactive)
   (save-current-buffer
     ;; prepare ical
@@ -2048,6 +2467,7 @@ icalendar-import-buffer
=20
 (defun icalendar--format-ical-event (event)
   "Create a string representation of an iCalendar EVENT."
+  (declare (obsolete diary-icalendar-format-entry "32.1"))
   (if (functionp icalendar-import-format)
       (funcall icalendar-import-format event)
     (let ((string icalendar-import-format)
@@ -2093,6 +2513,7 @@ icalendar--convert-ical-to-diary
 This function attempts to return t if something goes wrong.  In this
 case an error string which describes all the errors and problems is
 written into the buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-import-buffer "32.1"))
   (let* ((ev (icalendar--all-events ical-list))
          (error-string "")
          (event-ok t)
@@ -2255,6 +2676,7 @@ icalendar--convert-recurring-to-diary
 DTSTART-DEC is the DTSTART property of E.
 START-T is the event's start time in diary format.
 END-T is the event's end time in diary format."
+  (declare (obsolete diary-icalendar-format-entry "32.1"))
   (icalendar--dmsg "recurring event")
   (let* ((rrule        (icalendar--get-event-property e 'RRULE))
          (rrule-props  (icalendar--split-value rrule))
@@ -2492,6 +2914,7 @@ icalendar--convert-non-recurring-all-day-to-diary
 DTSTART is the decoded DTSTART property of E.
 Argument START-D gives the first day.
 Argument END-D gives the last day."
+  (declare (obsolete diary-icalendar-format-time-range "32.1"))
   (icalendar--dmsg "non-recurring all-day event")
   (format "%%%%(and (diary-block %s %s))" start-d end-d))
=20
@@ -2503,6 +2926,7 @@ icalendar--convert-non-recurring-not-all-day-to-diary
 DTSTART-DEC is the decoded DTSTART property of E.
 START-T is the event's start time in diary format.
 END-T is the event's end time in diary format."
+  (declare (obsolete diary-icalendar-format-time-range "32.1"))
   (icalendar--dmsg "not all day event")
   (cond (end-t
          (format "%s %s-%s"
@@ -2523,6 +2947,8 @@ icalendar--add-diary-entry
 SUMMARY is not nil it must be a string that gives the summary of the
 entry.  In this case the user will be asked whether he wants to insert
 the entry."
+  (declare (obsolete "see `diary-icalendar-post-entry-format-hook' and
+`diary-icalendar--entry-import'" "32.1"))
   (when (or (not summary)
             (y-or-n-p (format-message "Add appointment for `%s' to diary? "
                                       summary)))
@@ -2541,6 +2967,7 @@ icalendar--add-diary-entry
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 (defun icalendar-import-format-sample (event)
   "Example function for formatting an iCalendar EVENT."
+  (declare (obsolete "see `diary-icalendar-vevent-skeleton'" "32.1"))
   (format (concat "SUMMARY=3D'%s' DESCRIPTION=3D'%s' LOCATION=3D'%s' ORGAN=
IZER=3D'%s' "
                   "STATUS=3D'%s' URL=3D'%s' CLASS=3D'%s'")
           (or (icalendar--get-event-property event 'SUMMARY) "")
@@ -2558,4 +2985,7 @@ icalendar-version
=20
 (provide 'icalendar)
=20
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
 ;;; icalendar.el ends here
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
diary-american b/test/lisp/calendar/diary-icalendar-resources/import-bug-11=
473.diary-american
new file mode 100644
index 00000000000..c65def0c8d9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.diary-a=
merican
@@ -0,0 +1,9 @@
+&5/15/2012 15:00-15:30 Query
+ Location: phone
+ Status: confirmed
+ Organizer: A. Luser <MAILTO:a.luser@HIDDEN>=20
+ Attendee: Luser, Other <MAILTO:other.luser@HIDDEN> (needs-action)
+ Access: public
+ UID: 040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000=
000010000000575268034ECDB649A15349B1BF240F15
+ Description: Whassup?
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
diary-european b/test/lisp/calendar/diary-icalendar-resources/import-bug-11=
473.diary-european
new file mode 100644
index 00000000000..b68cff22084
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.diary-e=
uropean
@@ -0,0 +1,9 @@
+&15/5/2012 15:00-15:30 Query
+ Location: phone
+ Status: confirmed
+ Organizer: A. Luser <MAILTO:a.luser@HIDDEN>=20
+ Attendee: Luser, Other <MAILTO:other.luser@HIDDEN> (needs-action)
+ Access: public
+ UID: 040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000=
000010000000575268034ECDB649A15349B1BF240F15
+ Description: Whassup?
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.d=
iary-iso
new file mode 100644
index 00000000000..f891fe78189
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.diary-i=
so
@@ -0,0 +1,9 @@
+&2012/5/15 15:00-15:30 Query
+ Location: phone
+ Status: confirmed
+ Organizer: A. Luser <MAILTO:a.luser@HIDDEN>=20
+ Attendee: Luser, Other <MAILTO:other.luser@HIDDEN> (needs-action)
+ Access: public
+ UID: 040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000=
000010000000575268034ECDB649A15349B1BF240F15
+ Description: Whassup?
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.ics
new file mode 100644
index 00000000000..bc3a6c69fb7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.ics
@@ -0,0 +1,54 @@
+BEGIN:VCALENDAR
+METHOD:REQUEST
+PRODID:Microsoft Exchange Server 2007
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna
+BEGIN:STANDARD
+DTSTART:16010101T030000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D10
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:16010101T020000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D3
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+ORGANIZER;CN=3D"A. Luser":MAILTO:a.luser@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Luser, Oth
+ er":MAILTO:other.luser@HIDDEN
+DESCRIPTION;LANGUAGE=3Den-US:\nWhassup?\n\n
+SUMMARY;LANGUAGE=3Den-US:Query
+DTSTART;TZID=3D"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vien=
na"
+ :20120515T150000
+DTEND;TZID=3D"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna=
":2
+ 0120515T153000
+UID:040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000000
+ 010000000575268034ECDB649A15349B1BF240F15
+RECURRENCE-ID;TZID=3D"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm=
, V
+ ienna":20120515T170000
+CLASS:PUBLIC
+PRIORITY:5
+DTSTAMP:20120514T153645Z
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+SEQUENCE:15
+LOCATION;LANGUAGE=3Den-US:phone
+X-MICROSOFT-CDO-APPT-SEQUENCE:15
+X-MICROSOFT-CDO-OWNERAPPTID:1907632092
+X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
+X-MICROSOFT-CDO-IMPORTANCE:1
+X-MICROSOFT-CDO-INSTTYPE:3
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:REMINDER
+TRIGGER;RELATED=3DSTART:-PT15M
+END:VALARM
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
diary-american b/test/lisp/calendar/diary-icalendar-resources/import-bug-22=
092.diary-american
new file mode 100644
index 00000000000..392345fe0a2
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.diary-a=
merican
@@ -0,0 +1,6 @@
+&12/8/2014 18:30-22:55 Norwegian til Tromsoe-Langnes -=20
+ Desc: Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; 8. de=
s 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til Troms=
&#248; 8. des 2014 21:00, DY390
+ Location: Stavanger-Sola
+ Organizer: noreply@HIDDEN
+ Class: PUBLIC
+ UID: RFCALITEM1
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
diary-european b/test/lisp/calendar/diary-icalendar-resources/import-bug-22=
092.diary-european
new file mode 100644
index 00000000000..6a64cf6a8e9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.diary-e=
uropean
@@ -0,0 +1,6 @@
+&8/12/2014 18:30-22:55 Norwegian til Tromsoe-Langnes -=20
+ Desc: Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; 8. de=
s 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til Troms=
&#248; 8. des 2014 21:00, DY390
+ Location: Stavanger-Sola
+ Organizer: noreply@HIDDEN
+ Class: PUBLIC
+ UID: RFCALITEM1
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.d=
iary-iso
new file mode 100644
index 00000000000..e0fadbf94dc
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.diary-i=
so
@@ -0,0 +1,6 @@
+&2014/12/8 18:30-22:55 Norwegian til Tromsoe-Langnes -=20
+ Desc: Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; 8. de=
s 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til Troms=
&#248; 8. des 2014 21:00, DY390
+ Location: Stavanger-Sola
+ Organizer: noreply@HIDDEN
+ Class: PUBLIC
+ UID: RFCALITEM1
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.ics
new file mode 100644
index 00000000000..4a4c679da9c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.ics
@@ -0,0 +1,30 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//www.norwegian.no//iCalendar MIMEDIR//EN=0D
+VERSION:2.0=0D
+METHOD:REQUEST=0D
+BEGIN:VEVENT=0D
+UID:RFCALITEM1=0D
+SEQUENCE:1512040950=0D
+DTSTAMP:20141204T095043Z=0D
+ORGANIZER:noreply@HIDDEN=0D
+DTSTART:20141208T173000Z=0D
+
+DTEND:20141208T215500Z=0D
+
+LOCATION:Stavanger-Sola=0D
+
+DESCRIPTION:Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; =
8. des 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til =
Troms&#248; 8. des 2014 21:00, DY390=0D
+
+X-ALT-DESC;FMTTYPE=3Dtext/html:<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2=
//EN"><html><head><META NAME=3D"Generator" CONTENT=3D"MS Exchange Server ve=
rsion 08.00.0681.000"><title></title></head><body><b><font face=3D"Calibri"=
 size=3D"3">Reisereferanse</p></body></html>
+SUMMARY:Norwegian til Tromsoe-Langnes - =0D
+
+CATEGORIES:Appointment=0D
+
+
+PRIORITY:5=0D
+
+CLASS:PUBLIC=0D
+
+TRANSP:OPAQUE=0D
+END:VEVENT=0D
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.=
diary-all b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.d=
iary-all
new file mode 100644
index 00000000000..036762dc37e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-a=
ll
@@ -0,0 +1,12 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (BYDAY ((3 . 1))) (INTERVAL 1))
+	     :exclude
+	     '((0 46 11 6 1 2016 3 -1 0) (0 46 11 3 2 2016 3 -1 0)
+	       (0 46 11 2 3 2016 3 -1 0) (0 46 10 4 5 2016 3 -1 0)
+	       (0 46 10 1 6 2016 3 -1 0))
+	     :start '(0 46 12 2 12 2015 3 -1 nil) :duration
+	     '(0 14 3 0 nil nil nil -1 nil)) Summary
+ Location: Loc
+ Access: private
+ UID: 9188710a-08a7-4061-bae3-d4cf4972599a
+ Description: Desc
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.ics
new file mode 100644
index 00000000000..8851de7b80c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.ics
@@ -0,0 +1,25 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:Summary=0D
+DESCRIPTION:Desc=0D
+LOCATION:Loc=0D
+DTSTART:20151202T124600=0D
+DTEND:20151202T160000=0D
+RRULE:FREQ=3DMONTHLY;BYDAY=3D1WE;INTERVAL=3D1=0D
+EXDATE:20160106T114600Z=0D
+EXDATE:20160203T114600Z=0D
+EXDATE:20160302T114600Z=0D
+EXDATE:20160504T104600Z=0D
+EXDATE:20160601T104600Z=0D
+CLASS:DEFAULT=0D
+TRANSP:OPAQUE=0D
+BEGIN:VALARM=0D
+ACTION:DISPLAY=0D
+TRIGGER;VALUE=3DDURATION:-PT3H=0D
+END:VALARM=0D
+LAST-MODIFIED:20160805T191040Z=0D
+UID:9188710a-08a7-4061-bae3-d4cf4972599a=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
diary-american b/test/lisp/calendar/diary-icalendar-resources/import-bug-33=
277.diary-american
new file mode 100644
index 00000000000..5d1f4286d5f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.diary-a=
merican
@@ -0,0 +1,2 @@
+&11/5/2018 21:00 event with same start/end time
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
diary-european b/test/lisp/calendar/diary-icalendar-resources/import-bug-33=
277.diary-european
new file mode 100644
index 00000000000..02ec8ee17b9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.diary-e=
uropean
@@ -0,0 +1,2 @@
+&5/11/2018 21:00 event with same start/end time
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.d=
iary-iso
new file mode 100644
index 00000000000..d2256e859a3
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.diary-i=
so
@@ -0,0 +1,2 @@
+&2018/11/5 21:00 event with same start/end time
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.ics
new file mode 100644
index 00000000000..f3cb3a0c3e5
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.ics
@@ -0,0 +1,15 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART:20181105T200000Z=0D
+DTSTAMP:20181105T181652Z=0D
+DESCRIPTION:=0D
+LAST-MODIFIED:20181105T181646Z=0D
+LOCATION:=0D
+SEQUENCE:0=0D
+SUMMARY:event with same start/end time=0D
+TRANSP:OPAQUE=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.d=
iary-all b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.dia=
ry-all
new file mode 100644
index 00000000000..19b2df6e424
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.diary-all
@@ -0,0 +1,13 @@
+&%%(diary-rrule :rule '((FREQ WEEKLY) (INTERVAL 1) (BYDAY (1 3 4 5)))
+	     :start '(0 30 11 21 4 2010 3 -1 nil) :duration
+	     '(0 30 0 0 nil nil nil -1 nil)) Scrum
+ Status: confirmed
+ Access: public
+ UID: 8814e3f9-7482-408f-996c-3bfe486a1262
+
+&%%(diary-rrule :rule '((FREQ WEEKLY) (INTERVAL 1) (BYDAY (2 4))) :start
+	     '(4 22 2010) :duration
+	     '(nil nil nil 1 nil nil nil -1 nil)) Tues + Thurs thinking
+ Access: public
+ UID: 8814e3f9-7482-408f-996c-3bfe486a1263
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.i=
cs b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.ics
new file mode 100644
index 00000000000..72502ff05c4
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.ics
@@ -0,0 +1,28 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+CLASS:PUBLIC=0D
+DTEND;TZID=3DAmerica/New_York:20100421T120000=0D
+DTSTAMP:20100525T141214Z=0D
+DTSTART;TZID=3DAmerica/New_York:20100421T113000=0D
+RRULE:FREQ=3DWEEKLY;INTERVAL=3D1;BYDAY=3DMO,WE,TH,FR=0D
+SEQUENCE:1=0D
+STATUS:CONFIRMED=0D
+SUMMARY:Scrum=0D
+TRANSP:OPAQUE=0D
+UID:8814e3f9-7482-408f-996c-3bfe486a1262=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+CLASS:PUBLIC=0D
+DTSTAMP:20100525T141214Z=0D
+DTSTART;VALUE=3DDATE:20100422=0D
+DTEND;VALUE=3DDATE:20100423=0D
+RRULE:FREQ=3DWEEKLY;INTERVAL=3D1;BYDAY=3DTU,TH=0D
+SEQUENCE:1=0D
+SUMMARY:Tues + Thurs thinking=0D
+TRANSP:OPAQUE=0D
+UID:8814e3f9-7482-408f-996c-3bfe486a1263=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration-2=
.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-duration-2=
.diary-all
new file mode 100644
index 00000000000..f914a57a369
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration-2.diary-=
all
@@ -0,0 +1,6 @@
+&%%(diary-rrule :rule
+	     '((FREQ DAILY) (UNTIL (12 29 2001)) (INTERVAL 1) (WKST 0))
+	     :start '(12 21 2001)) Urlaub
+ Access: public
+ UID: 20041127T183329Z-18215-1001-4536-49109@andromeda
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration-2=
.ics b/test/lisp/calendar/diary-icalendar-resources/import-duration-2.ics
new file mode 100644
index 00000000000..1d59da60599
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration-2.ics
@@ -0,0 +1,17 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:20041127T183329Z-18215-1001-4536-49109@andromeda=0D
+DTSTAMP:20041127T183315Z=0D
+LAST-MODIFIED:20041127T183329=0D
+SUMMARY:Urlaub=0D
+DTSTART;VALUE=3DDATE:20011221=0D
+DTEND;VALUE=3DDATE:20011221=0D
+RRULE:FREQ=3DDAILY;UNTIL=3D20011229;INTERVAL=3D1;WKST=3DSU=0D
+CLASS:PUBLIC=0D
+SEQUENCE:1=0D
+CREATED:20041127T183329=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.d=
iary-american b/test/lisp/calendar/diary-icalendar-resources/import-duratio=
n.diary-american
new file mode 100644
index 00000000000..81b81c30237
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.diary-am=
erican
@@ -0,0 +1,2 @@
+&%%(diary-block 2 17 2005 2 23 2005) duration
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.d=
iary-european b/test/lisp/calendar/diary-icalendar-resources/import-duratio=
n.diary-european
new file mode 100644
index 00000000000..ce906fcda13
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.diary-eu=
ropean
@@ -0,0 +1,2 @@
+&%%(diary-block 17 2 2005 23 2 2005) duration
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.d=
iary-iso b/test/lisp/calendar/diary-icalendar-resources/import-duration.dia=
ry-iso
new file mode 100644
index 00000000000..feec177ff9e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.diary-iso
@@ -0,0 +1,2 @@
+&%%(diary-block 2005 2 17 2005 2 23) duration
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.i=
cs b/test/lisp/calendar/diary-icalendar-resources/import-duration.ics
new file mode 100644
index 00000000000..0fa41b709a0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20050217=0D
+SUMMARY:duration=0D
+DURATION:P7D=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-fun=
ction.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-legac=
y-function.diary-all
new file mode 100644
index 00000000000..e0d27f4d1b0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-function.d=
iary-all
@@ -0,0 +1,10 @@
+ SUMMARY: Testing legacy `icalendar-import-format' function
+ DESCRIPTION: described
+ CLASS: private
+ LOCATION: somewhere
+ ORGANIZER: mailto:baz@HIDDEN
+ STATUS: CONFIRMED
+ URL: http://example.com/foo/baz
+ UID: some-unique-id-here
+ DTSTART: 20250919T090000
+ DTEND: 20250919T113000
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-fun=
ction.ics b/test/lisp/calendar/diary-icalendar-resources/import-legacy-func=
tion.ics
new file mode 100644
index 00000000000..760131b8192
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-function.i=
cs
@@ -0,0 +1,16 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:Testing legacy `icalendar-import-format' function=0D
+DESCRIPTION:described=0D
+CLASS:private=0D
+LOCATION:somewhere=0D
+ORGANIZER;CN=3D"Baz Foo":mailto:baz@HIDDEN=0D
+STATUS:CONFIRMED=0D
+URL:http://example.com/foo/baz=0D
+UID:some-unique-id-here=0D
+DTSTART;VALUE=3DDATE-TIME:20250919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20250919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.diary-american b/test/lisp/calendar/diary-icalendar-resources/import-lega=
cy-vars.diary-american
new file mode 100644
index 00000000000..42076a32138
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.diary=
-american
@@ -0,0 +1,8 @@
+9/19/2025 09:00-11:30 Testing legacy `icalendar-import-format*' vars
+ CLASS=3Dprivate
+ DESCRIPTION=3Ddescribed
+ LOCATION=3Dsomewhere
+ ORGANIZER=3Dmailto:baz@HIDDEN
+ STATUS=3Dconfirmed
+ URL=3Dhttp://example.com/foo/baz
+ UID=3Dsome-unique-id-here
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.diary-european b/test/lisp/calendar/diary-icalendar-resources/import-lega=
cy-vars.diary-european
new file mode 100644
index 00000000000..699c627e2f9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.diary=
-european
@@ -0,0 +1,8 @@
+19/9/2025 09:00-11:30 Testing legacy `icalendar-import-format*' vars
+ CLASS=3Dprivate
+ DESCRIPTION=3Ddescribed
+ LOCATION=3Dsomewhere
+ ORGANIZER=3Dmailto:baz@HIDDEN
+ STATUS=3Dconfirmed
+ URL=3Dhttp://example.com/foo/baz
+ UID=3Dsome-unique-id-here
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-legacy-va=
rs.diary-iso
new file mode 100644
index 00000000000..f6d69805c19
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.diary=
-iso
@@ -0,0 +1,8 @@
+2025/9/19 09:00-11:30 Testing legacy `icalendar-import-format*' vars
+ CLASS=3Dprivate
+ DESCRIPTION=3Ddescribed
+ LOCATION=3Dsomewhere
+ ORGANIZER=3Dmailto:baz@HIDDEN
+ STATUS=3Dconfirmed
+ URL=3Dhttp://example.com/foo/baz
+ UID=3Dsome-unique-id-here
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.ics b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.ics
new file mode 100644
index 00000000000..cc69f1a3b97
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.ics
@@ -0,0 +1,17 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:Testing legacy `icalendar-import-format*' vars=0D
+DESCRIPTION:described=0D
+CLASS:private=0D
+LOCATION:somewhere=0D
+ORGANIZER;CN=3D"Baz Foo":mailto:baz@HIDDEN=0D
+STATUS:CONFIRMED=0D
+URL:http://example.com/foo/baz=0D
+UID:some-unique-id-here=0D
+DTSTART;VALUE=3DDATE-TIME:20250919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20250919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.diary-american b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-multiple-vcalendars.diary-american
new file mode 100644
index 00000000000..5961a36fed8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.diary-american
@@ -0,0 +1,8 @@
+&7/23/2011 event-1
+
+&7/24/2011 event-2
+
+&7/25/2011 event-3a
+
+&7/25/2011 event-3b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.diary-european b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-multiple-vcalendars.diary-european
new file mode 100644
index 00000000000..169833eb55e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.diary-european
@@ -0,0 +1,8 @@
+&23/7/2011 event-1
+
+&24/7/2011 event-2
+
+&25/7/2011 event-3a
+
+&25/7/2011 event-3b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-m=
ultiple-vcalendars.diary-iso
new file mode 100644
index 00000000000..0dda3401bf8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.diary-iso
@@ -0,0 +1,8 @@
+&2011/7/23 event-1
+
+&2011/7/24 event-2
+
+&2011/7/25 event-3a
+
+&2011/7/25 event-3b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.ics b/test/lisp/calendar/diary-icalendar-resources/import-multipl=
e-vcalendars.ics
new file mode 100644
index 00000000000..f6e2febec39
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.ics
@@ -0,0 +1,21 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110723=0D
+SUMMARY:event-1=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110724=0D
+SUMMARY:event-2=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110725=0D
+SUMMARY:event-3a=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110725=0D
+SUMMARY:event-3b=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.diary-american b/test/lisp/calendar/diary-icalendar-resources/import-=
non-recurring-1.diary-american
new file mode 100644
index 00000000000..ece825f089f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.d=
iary-american
@@ -0,0 +1,2 @@
+&9/19/2003 09:00-11:30 non-recurring
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.diary-european b/test/lisp/calendar/diary-icalendar-resources/import-=
non-recurring-1.diary-european
new file mode 100644
index 00000000000..9b4ce920bc7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.d=
iary-european
@@ -0,0 +1,2 @@
+&19/9/2003 09:00-11:30 non-recurring
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-non-r=
ecurring-1.diary-iso
new file mode 100644
index 00000000000..278d72cb1a8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.d=
iary-iso
@@ -0,0 +1,2 @@
+&2003/9/19 09:00-11:30 non-recurring
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.ics b/test/lisp/calendar/diary-icalendar-resources/import-non-recurri=
ng-1.ics
new file mode 100644
index 00000000000..37855fbce6e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.i=
cs
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:non-recurring=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-non-recurring-all-day.diary-american
new file mode 100644
index 00000000000..7d8c6d82455
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.diary-american
@@ -0,0 +1,2 @@
+&9/19/2003 non-recurring allday
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-non-recurring-all-day.diary-european
new file mode 100644
index 00000000000..4ac8d9aab05
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.diary-european
@@ -0,0 +1,2 @@
+&19/9/2003 non-recurring allday
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import=
-non-recurring-all-day.diary-iso
new file mode 100644
index 00000000000..98f42084be9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.diary-iso
@@ -0,0 +1,2 @@
+&2003/9/19 non-recurring allday
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.ics b/test/lisp/calendar/diary-icalendar-resources/import-non-r=
ecurring-all-day.ics
new file mode 100644
index 00000000000..5992fe678f9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.ics
@@ -0,0 +1,9 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:non-recurring allday=0D
+DTSTART;VALUE=3DDATE:20030919=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.diary-american b/test/lisp/calendar/diary-icalendar-res=
ources/import-non-recurring-another-example.diary-american
new file mode 100644
index 00000000000..a0e91b9ddaa
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.diary-american
@@ -0,0 +1,5 @@
+&11/23/2004 14:45-15:45 another example
+ Status: tentative
+ Access: private
+ UID: 6161a312-3902-11d9-b512-f764153bb28b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.diary-european b/test/lisp/calendar/diary-icalendar-res=
ources/import-non-recurring-another-example.diary-european
new file mode 100644
index 00000000000..e6d0f81f5da
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.diary-european
@@ -0,0 +1,5 @@
+&23/11/2004 14:45-15:45 another example
+ Status: tentative
+ Access: private
+ UID: 6161a312-3902-11d9-b512-f764153bb28b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.diary-iso b/test/lisp/calendar/diary-icalendar-resource=
s/import-non-recurring-another-example.diary-iso
new file mode 100644
index 00000000000..6aa2014fa99
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.diary-iso
@@ -0,0 +1,5 @@
+&2004/11/23 14:45-15:45 another example
+ Status: tentative
+ Access: private
+ UID: 6161a312-3902-11d9-b512-f764153bb28b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.ics b/test/lisp/calendar/diary-icalendar-resources/impo=
rt-non-recurring-another-example.ics
new file mode 100644
index 00000000000..5e3e09e8689
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.ics
@@ -0,0 +1,23 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID=0D
+ :6161a312-3902-11d9-b512-f764153bb28b=0D
+SUMMARY=0D
+ :another example=0D
+STATUS=0D
+ :TENTATIVE=0D
+CLASS=0D
+ :PRIVATE=0D
+X-MOZILLA-ALARM-DEFAULT-LENGTH=0D
+ :0=0D
+DTSTART=0D
+ :20041123T144500=0D
+DTEND=0D
+ :20041123T154500=0D
+DTSTAMP=0D
+ :20041118T013641Z=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.diary-american b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-non-recurring-block.diary-american
new file mode 100644
index 00000000000..c665ab22f84
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.diary-american
@@ -0,0 +1,5 @@
+&%%(diary-block 7 19 2004 8 27 2004) Sommerferien
+ Status: tentative
+ Access: private
+ UID: 748f2da0-0d9b-11d8-97af-b4ec8686ea61
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.diary-european b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-non-recurring-block.diary-european
new file mode 100644
index 00000000000..96373d6ebb1
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.diary-european
@@ -0,0 +1,5 @@
+&%%(diary-block 19 7 2004 27 8 2004) Sommerferien
+ Status: tentative
+ Access: private
+ UID: 748f2da0-0d9b-11d8-97af-b4ec8686ea61
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-n=
on-recurring-block.diary-iso
new file mode 100644
index 00000000000..3d07106344e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.diary-iso
@@ -0,0 +1,5 @@
+&%%(diary-block 2004 7 19 2004 8 27) Sommerferien
+ Status: tentative
+ Access: private
+ UID: 748f2da0-0d9b-11d8-97af-b4ec8686ea61
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.ics b/test/lisp/calendar/diary-icalendar-resources/import-non-rec=
urring-block.ics
new file mode 100644
index 00000000000..c5c777844cf
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.ics
@@ -0,0 +1,16 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:748f2da0-0d9b-11d8-97af-b4ec8686ea61=0D
+SUMMARY:Sommerferien=0D
+STATUS:TENTATIVE=0D
+CLASS:PRIVATE=0D
+X-MOZILLA-ALARM-DEFAULT-UNITS:Minuten=0D
+X-MOZILLA-RECUR-DEFAULT-INTERVAL:0=0D
+DTSTART;VALUE=3DDATE:20040719=0D
+DTEND;VALUE=3DDATE:20040828=0D
+DTSTAMP:20031103T011641Z=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.diary-american b/test/lisp/calendar/diary-icalendar-reso=
urces/import-non-recurring-folded-summary.diary-american
new file mode 100644
index 00000000000..ad40baa4de9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.diary-american
@@ -0,0 +1,5 @@
+&11/23/2004 14:00-14:30 folded summary
+ Status: tentative
+ Access: private
+ UID: 04979712-3902-11d9-93dd-8f9f4afe08da
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.diary-european b/test/lisp/calendar/diary-icalendar-reso=
urces/import-non-recurring-folded-summary.diary-european
new file mode 100644
index 00000000000..38f937e8151
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.diary-european
@@ -0,0 +1,5 @@
+&23/11/2004 14:00-14:30 folded summary
+ Status: tentative
+ Access: private
+ UID: 04979712-3902-11d9-93dd-8f9f4afe08da
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.diary-iso b/test/lisp/calendar/diary-icalendar-resources=
/import-non-recurring-folded-summary.diary-iso
new file mode 100644
index 00000000000..39b7ca3ac93
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.diary-iso
@@ -0,0 +1,5 @@
+&2004/11/23 14:00-14:30 folded summary
+ Status: tentative
+ Access: private
+ UID: 04979712-3902-11d9-93dd-8f9f4afe08da
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.ics b/test/lisp/calendar/diary-icalendar-resources/impor=
t-non-recurring-folded-summary.ics
new file mode 100644
index 00000000000..26348a8909b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.ics
@@ -0,0 +1,25 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID=0D
+ :04979712-3902-11d9-93dd-8f9f4afe08da=0D
+SUMMARY=0D
+ :folded summary=0D
+STATUS=0D
+ :TENTATIVE=0D
+CLASS=0D
+ :PRIVATE=0D
+X-MOZILLA-ALARM-DEFAULT-LENGTH=0D
+ :0=0D
+DTSTART=0D
+ :20041123T140000=0D
+DTEND=0D
+ :20041123T143000=0D
+DTSTAMP=0D
+ :20041118T013430Z=0D
+LAST-MODIFIED=0D
+ :20041118T013640Z=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.diary-american b/test/lisp/calendar/diary-icalendar-resour=
ces/import-non-recurring-long-summary.diary-american
new file mode 100644
index 00000000000..8adae619438
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.diary-american
@@ -0,0 +1,2 @@
+&9/19/2003 long summary
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.diary-european b/test/lisp/calendar/diary-icalendar-resour=
ces/import-non-recurring-long-summary.diary-european
new file mode 100644
index 00000000000..2e764fb1b2f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.diary-european
@@ -0,0 +1,2 @@
+&19/9/2003 long summary
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.diary-iso b/test/lisp/calendar/diary-icalendar-resources/i=
mport-non-recurring-long-summary.diary-iso
new file mode 100644
index 00000000000..e1eb4074ca0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.diary-iso
@@ -0,0 +1,2 @@
+&2003/9/19 long summary
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.ics b/test/lisp/calendar/diary-icalendar-resources/import-=
non-recurring-long-summary.ics
new file mode 100644
index 00000000000..c024498fb68
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.ics
@@ -0,0 +1,9 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:long summary=0D
+DTSTART;VALUE=3DDATE:20030919=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-05-29.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2003-05-29.diary-american
new file mode 100644
index 00000000000..e6c8712d254
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
5-29.diary-american
@@ -0,0 +1,6 @@
+&5/9/2003 07:00-12:00 On-Site Interview
+ Desc: 10:30am - Blah
+ Location: Cccc
+ Organizer: MAILTO:aaaaaaa@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E0080000000080B6DE661216C301000000000000=
000010000000DB823520692542408ED02D7023F9DFF9
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-05-29.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2003-05-29.diary-european
new file mode 100644
index 00000000000..cecca070a51
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
5-29.diary-european
@@ -0,0 +1,6 @@
+&9/5/2003 07:00-12:00 On-Site Interview
+ Desc: 10:30am - Blah
+ Location: Cccc
+ Organizer: MAILTO:aaaaaaa@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E0080000000080B6DE661216C301000000000000=
000010000000DB823520692542408ED02D7023F9DFF9
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-05-29.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2003-05-29.ics
new file mode 100644
index 00000000000..a0a5583dd15
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
5-29.ics
@@ -0,0 +1,54 @@
+BEGIN:VCALENDAR=0D
+METHOD:REQUEST=0D
+PRODID:Microsoft CDO for Microsoft Exchange=0D
+VERSION:2.0=0D
+BEGIN:VTIMEZONE=0D
+TZID:Kolkata, Chennai, Mumbai, New Delhi=0D
+X-MICROSOFT-CDO-TZID:23=0D
+BEGIN:STANDARD=0D
+DTSTART:16010101T000000=0D
+TZOFFSETFROM:+0530=0D
+TZOFFSETTO:+0530=0D
+END:STANDARD=0D
+BEGIN:DAYLIGHT=0D
+DTSTART:16010101T000000=0D
+TZOFFSETFROM:+0530=0D
+TZOFFSETTO:+0530=0D
+END:DAYLIGHT=0D
+END:VTIMEZONE=0D
+BEGIN:VEVENT=0D
+DTSTAMP:20030509T043439Z=0D
+DTSTART;TZID=3D"Kolkata, Chennai, Mumbai, New Delhi":20030509T103000=0D
+SUMMARY:On-Site Interview=0D
+UID:040000008200E00074C5B7101A82E0080000000080B6DE661216C30100000000000000=
0=0D
+ 010000000DB823520692542408ED02D7023F9DFF9=0D
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Xxxxx=0D
+ xxx Xxxxxxxxxxxx":MAILTO:xxxxxxxx@HIDDEN=0D
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Yyyyyyy Y=0D
+ yyyy":MAILTO:yyyyyyy@HIDDEN=0D
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Zzzz Zzzz=0D
+ zz":MAILTO:zzzzzz@HIDDEN=0D
+ORGANIZER;CN=3D"Aaaaaa Aaaaa":MAILTO:aaaaaaa@HIDDEN=0D
+LOCATION:Cccc=0D
+DTEND;TZID=3D"Kolkata, Chennai, Mumbai, New Delhi":20030509T153000=0D
+DESCRIPTION:10:30am - Blah=0D
+SEQUENCE:0=0D
+PRIORITY:5=0D
+CLASS:=0D
+CREATED:20030509T043439Z=0D
+LAST-MODIFIED:20030509T043459Z=0D
+STATUS:CONFIRMED=0D
+TRANSP:OPAQUE=0D
+X-MICROSOFT-CDO-BUSYSTATUS:BUSY=0D
+X-MICROSOFT-CDO-INSTTYPE:0=0D
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY=0D
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE=0D
+X-MICROSOFT-CDO-IMPORTANCE:1=0D
+X-MICROSOFT-CDO-OWNERAPPTID:126441427=0D
+BEGIN:VALARM=0D
+ACTION:DISPLAY=0D
+DESCRIPTION:REMINDER=0D
+TRIGGER;RELATED=3DSTART:-PT00H15M00S=0D
+END:VALARM=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18a.diary-american b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18a.diary-american
new file mode 100644
index 00000000000..f2c914184e7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18a.diary-american
@@ -0,0 +1,6 @@
+&6/23/2003 11:00-12:00 Dress Rehearsal for XXXX-XXXX
+ Desc: 753 Zeichen hier radiert
+ Location: 555 or TN 555-5555 ID 5555 & NochWas (see below)
+ Organizer: MAILTO:xxx@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18a.diary-european b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18a.diary-european
new file mode 100644
index 00000000000..89cff58af42
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18a.diary-european
@@ -0,0 +1,6 @@
+&23/6/2003 11:00-12:00 Dress Rehearsal for XXXX-XXXX
+ Desc: 753 Zeichen hier radiert
+ Location: 555 or TN 555-5555 ID 5555 & NochWas (see below)
+ Organizer: MAILTO:xxx@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18a.ics b/test/lisp/calendar/diary-icalendar-resources/import-real=
-world-2003-06-18a.ics
new file mode 100644
index 00000000000..6bb5b05af17
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18a.ics
@@ -0,0 +1,36 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20030618T195512Z
+DTSTART;TZID=3D"Mountain Time (US & Canada)":20030623T110000
+SUMMARY:Dress Rehearsal for XXXX-XXXX
+UID:040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000000
+ 0100000007C3A6D65EE726E40B7F3D69A23BD567E
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
AAAAA,AAA
+ AA (A-AAAAAAA,ex1)":MAILTO:aaaaa_aaaaa@HIDDEN
+ORGANIZER;CN=3D"ABCD,TECHTRAINING
+ (A-Americas,exgen1)":MAILTO:xxx@HIDDEN
+LOCATION:555 or TN 555-5555 ID 5555 & NochWas (see below)
+DTEND;TZID=3D"Mountain Time (US & Canada)":20030623T120000
+DESCRIPTION:753 Zeichen hier radiert
+SEQUENCE:0
+PRIORITY:5
+CLASS:
+CREATED:20030618T195518Z
+LAST-MODIFIED:20030618T195527Z
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+X-MICROSOFT-CDO-BUSYSTATUS:BUSY
+X-MICROSOFT-CDO-INSTTYPE:0
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
+X-MICROSOFT-CDO-IMPORTANCE:1
+X-MICROSOFT-CDO-OWNERAPPTID:1022519251
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:REMINDER
+TRIGGER;RELATED=3DSTART:-PT00H15M00S
+END:VALARM
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18b.diary-american b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18b.diary-american
new file mode 100644
index 00000000000..2c0774cdd83
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18b.diary-american
@@ -0,0 +1,6 @@
+&6/23/2003 17:00-18:00 Updated: Dress Rehearsal for ABC01-15
+ Desc: Viele Zeichen standen hier fr=C3=BCher
+ Location: 123 or TN 123-1234 ID abcd & SonstWo (see below)
+ Organizer: MAILTO:bbb@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18b.diary-european b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18b.diary-european
new file mode 100644
index 00000000000..95aac168699
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18b.diary-european
@@ -0,0 +1,6 @@
+&23/6/2003 17:00-18:00 Updated: Dress Rehearsal for ABC01-15
+ Desc: Viele Zeichen standen hier fr=C3=BCher
+ Location: 123 or TN 123-1234 ID abcd & SonstWo (see below)
+ Organizer: MAILTO:bbb@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18b.ics b/test/lisp/calendar/diary-icalendar-resources/import-real=
-world-2003-06-18b.ics
new file mode 100644
index 00000000000..1523135adf3
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18b.ics
@@ -0,0 +1,55 @@
+BEGIN:VCALENDAR
+METHOD:REQUEST
+PRODID:Microsoft CDO for Microsoft Exchange
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:Mountain Time (US & Canada)
+X-MICROSOFT-CDO-TZID:12
+BEGIN:STANDARD
+DTSTART:16010101T020000
+TZOFFSETFROM:-0600
+TZOFFSETTO:-0700
+RRULE:FREQ=3DYEARLY;WKST=3DMO;INTERVAL=3D1;BYMONTH=3D10;BYDAY=3D-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:16010101T020000
+TZOFFSETFROM:-0700
+TZOFFSETTO:-0600
+RRULE:FREQ=3DYEARLY;WKST=3DMO;INTERVAL=3D1;BYMONTH=3D4;BYDAY=3D1SU
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:20030618T230323Z
+DTSTART;TZID=3D"Mountain Time (US & Canada)":20030623T090000
+SUMMARY:Updated: Dress Rehearsal for ABC01-15
+UID:040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000000
+ 0100000007C3A6D65EE726E40B7F3D69A23BD567E
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;X-REPLYTIME=3D2003=
0618T20
+ 0700Z;RSVP=3DTRUE;CN=3D"AAAAA,AAAAAA
+\(A-AAAAAAA,ex1)":MAILTO:aaaaaa_aaaaa@aaaaa
+ .com
+ORGANIZER;CN=3D"ABCD,TECHTRAINING
+\(A-Americas,exgen1)":MAILTO:bbb@HIDDEN
+LOCATION:123 or TN 123-1234 ID abcd & SonstWo (see below)
+DTEND;TZID=3D"Mountain Time (US & Canada)":20030623T100000
+DESCRIPTION:Viele Zeichen standen hier fr=C3=BCher
+SEQUENCE:0
+PRIORITY:5
+CLASS:
+CREATED:20030618T230326Z
+LAST-MODIFIED:20030618T230335Z
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+X-MICROSOFT-CDO-BUSYSTATUS:BUSY
+X-MICROSOFT-CDO-INSTTYPE:0
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
+X-MICROSOFT-CDO-IMPORTANCE:1
+X-MICROSOFT-CDO-OWNERAPPTID:1022519251
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:REMINDER
+TRIGGER;RELATED=3DSTART:-PT00H15M00S
+END:VALARM
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2004-11-19.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2004-11-19.diary-american
new file mode 100644
index 00000000000..a986f700ba2
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2004-1=
1-19.diary-american
@@ -0,0 +1,19 @@
+&11/23/2004 14:00-14:30 Jjjjj & Wwwww
+ Status: TENTATIVE
+ Class: PRIVATE
+&11/23/2004 14:45-15:45 BB Aaaaaaaa Bbbbb
+ Status: TENTATIVE
+ Class: PRIVATE
+&11/23/2004 11:00-12:00 Hhhhhhhh
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 14 11 12 2004)) 14:00-18:30 MMM Aaaaaaaaa
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-block 11 19 2004 11 19 2004)) Rrrr/Cccccc ii Aaaaaaaa
+ Desc: Vvvvv Rrrr aaa Cccccc
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 7 11 1 2004)) Wwww aa hhhh
+ Status: TENTATIVE
+ Class: PRIVATE
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2004-11-19.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2004-11-19.diary-european
new file mode 100644
index 00000000000..cbfe99eb8e3
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2004-1=
1-19.diary-european
@@ -0,0 +1,19 @@
+&23/11/2004 14:00-14:30 Jjjjj & Wwwww
+ Status: TENTATIVE
+ Class: PRIVATE
+&23/11/2004 14:45-15:45 BB Aaaaaaaa Bbbbb
+ Status: TENTATIVE
+ Class: PRIVATE
+&23/11/2004 11:00-12:00 Hhhhhhhh
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 14 12 11 2004)) 14:00-18:30 MMM Aaaaaaaaa
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-block 19 11 2004 19 11 2004)) Rrrr/Cccccc ii Aaaaaaaa
+ Desc: Vvvvv Rrrr aaa Cccccc
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 7 1 11 2004)) Wwww aa hhhh
+ Status: TENTATIVE
+ Class: PRIVATE
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2004-11-19.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2004-11-19.ics
new file mode 100644
index 00000000000..9edb682fcad
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2004-1=
1-19.ics
@@ -0,0 +1,120 @@
+BEGIN:VCALENDAR
+VERSION
+ :2.0
+PRODID
+ :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN
+BEGIN:VEVENT
+SUMMARY
+ :Jjjjj & Wwwww
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ :20041123T140000
+DTEND
+ :20041123T143000
+DTSTAMP
+ :20041118T013430Z
+LAST-MODIFIED
+ :20041118T013640Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :BB Aaaaaaaa Bbbbb
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ :20041123T144500
+DTEND
+ :20041123T154500
+DTSTAMP
+ :20041118T013641Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :Hhhhhhhh
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ :20041123T110000
+DTEND
+ :20041123T120000
+DTSTAMP
+ :20041118T013831Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :MMM Aaaaaaaaa
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+X-MOZILLA-RECUR-DEFAULT-INTERVAL
+ :2
+RRULE
+ :FREQ=3DWEEKLY;INTERVAL=3D2;BYDAY=3DFR
+DTSTART
+ :20041112T140000
+DTEND
+ :20041112T183000
+DTSTAMP
+ :20041118T014117Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :Rrrr/Cccccc ii Aaaaaaaa
+DESCRIPTION
+ :Vvvvv Rrrr aaa Cccccc
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ ;VALUE=3DDATE
+ :20041119
+DTEND
+ ;VALUE=3DDATE
+ :20041120
+DTSTAMP
+ :20041118T013107Z
+LAST-MODIFIED
+ :20041118T014203Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :Wwww aa hhhh
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+RRULE
+ :FREQ=3DWEEKLY;INTERVAL=3D1;BYDAY=3DMO
+DTSTART
+ ;VALUE=3DDATE
+ :20041101
+DTEND
+ ;VALUE=3DDATE
+ :20041102
+DTSTAMP
+ :20041118T014045Z
+LAST-MODIFIED
+ :20041118T023846Z
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-02-07.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-02-07.diary-american
new file mode 100644
index 00000000000..ce7d835d96b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
2-07.diary-american
@@ -0,0 +1,5 @@
+&%%(and (diary-block 2 6 2005 2 6 2005)) Waitangi Day
+ Desc: abcdef
+ Status: CONFIRMED
+ Class: PRIVATE
+ UID: b60d398e-1dd1-11b2-a159-cf8cb05139f4
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-02-07.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-02-07.diary-european
new file mode 100644
index 00000000000..3a52b0ab271
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
2-07.diary-european
@@ -0,0 +1,5 @@
+&%%(and (diary-block 6 2 2005 6 2 2005)) Waitangi Day
+ Desc: abcdef
+ Status: CONFIRMED
+ Class: PRIVATE
+ UID: b60d398e-1dd1-11b2-a159-cf8cb05139f4
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-02-07.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2005-02-07.ics
new file mode 100644
index 00000000000..9eec71fe751
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
2-07.ics
@@ -0,0 +1,26 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+UID
+ :b60d398e-1dd1-11b2-a159-cf8cb05139f4
+SUMMARY
+ :Waitangi Day
+DESCRIPTION
+ :abcdef
+CATEGORIES
+ :Public Holiday
+STATUS
+ :CONFIRMED
+CLASS
+ :PRIVATE
+DTSTART
+ ;VALUE=3DDATE
+ :20050206
+DTEND
+ ;VALUE=3DDATE
+ :20050207
+DTSTAMP
+ :20050128T011209Z
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-03-01.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-03-01.diary-american
new file mode 100644
index 00000000000..23c93d45d9a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
3-01.diary-american
@@ -0,0 +1,2 @@
+&%%(and (diary-block 2 17 2005 2 23 2005)) Hhhhhh Aaaaa ii Aaaaaaaa
+ UID: 6AFA7558-6994-11D9-8A3A-000A95A0E830-RID
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-03-01.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-03-01.diary-european
new file mode 100644
index 00000000000..106e9f3cdd0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
3-01.diary-european
@@ -0,0 +1,2 @@
+&%%(and (diary-block 17 2 2005 23 2 2005)) Hhhhhh Aaaaa ii Aaaaaaaa
+ UID: 6AFA7558-6994-11D9-8A3A-000A95A0E830-RID
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-03-01.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2005-03-01.ics
new file mode 100644
index 00000000000..ed9faa9b0bd
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
3-01.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTART;VALUE=3DDATE:20050217
+SUMMARY:Hhhhhh Aaaaa ii Aaaaaaaa
+UID:6AFA7558-6994-11D9-8A3A-000A95A0E830-RID
+DTSTAMP:20050118T210335Z
+DURATION:P7D
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-no-dst.diary-american b/test/lisp/calendar/diary-icalendar-resources/impor=
t-real-world-no-dst.diary-american
new file mode 100644
index 00000000000..290edb88760
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-no-dst=
.diary-american
@@ -0,0 +1,4 @@
+&11/16/2014 04:30-05:30 NoDST
+ Desc: Test event from timezone without DST
+ Location: Everywhere
+ UID: 20141116T171439Z-678877132@HIDDEN
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-no-dst.diary-european b/test/lisp/calendar/diary-icalendar-resources/impor=
t-real-world-no-dst.diary-european
new file mode 100644
index 00000000000..c56b7a6547a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-no-dst=
.diary-european
@@ -0,0 +1,4 @@
+&16/11/2014 04:30-05:30 NoDST
+ Desc: Test event from timezone without DST
+ Location: Everywhere
+ UID: 20141116T171439Z-678877132@HIDDEN
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-no-dst.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-worl=
d-no-dst.ics
new file mode 100644
index 00000000000..5f147af4f37
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-no-dst=
.ics
@@ -0,0 +1,26 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//www.marudot.com//iCal Event Maker
+X-WR-CALNAME:Test
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Asia/Tehran
+TZURL:http://tzurl.org/zoneinfo-outlook/Asia/Tehran
+X-LIC-LOCATION:Asia/Tehran
+BEGIN:STANDARD
+TZOFFSETFROM:+0330
+TZOFFSETTO:+0330
+TZNAME:IRST
+DTSTART:19700101T000000
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:20141116T171439Z
+UID:20141116T171439Z-678877132@HIDDEN
+DTSTART;TZID=3D"Asia/Tehran":20141116T070000
+DTEND;TZID=3D"Asia/Tehran":20141116T080000
+SUMMARY:NoDST
+DESCRIPTION:Test event from timezone without DST
+LOCATION:Everywhere
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-anni=
versary.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rru=
le-anniversary.diary-all
new file mode 100644
index 00000000000..ee270024dfd
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-anniversary=
.diary-all
@@ -0,0 +1,2 @@
+&%%(diary-rrule :rule '((FREQ YEARLY)) :start '(8 15 2004)) Maria Himmelfa=
hrt
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-anni=
versary.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-ann=
iversary.ics
new file mode 100644
index 00000000000..de402a29d26
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-anniversary=
.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20040815=0D
+SUMMARY:Maria Himmelfahrt=0D
+RRULE:FREQ=3DYEARLY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-rrule-count-bi-weekly.diary-american
new file mode 100644
index 00000000000..84b6d109953
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.diary-american
@@ -0,0 +1 @@
+&%%(and (diary-cyclic 14 9 19 2003) (diary-block 9 19 2003 10 31 2003)) 09=
:00-11:30 rrule count bi-weekly 3 times
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-rrule-count-bi-weekly.diary-european
new file mode 100644
index 00000000000..0bebdf8872f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.diary-european
@@ -0,0 +1 @@
+&%%(and (diary-cyclic 14 19 9 2003) (diary-block 19 9 2003 31 10 2003)) 09=
:00-11:30 rrule count bi-weekly 3 times
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import=
-rrule-count-bi-weekly.diary-iso
new file mode 100644
index 00000000000..11429081abe
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.diary-iso
@@ -0,0 +1 @@
+&%%(and (diary-cyclic 14 2003 9 19) (diary-block 2003 9 19 2003 10 31)) 09=
:00-11:30 rrule count bi-weekly 3 times
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule=
-count-bi-weekly.ics
new file mode 100644
index 00000000000..888b85bb331
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count bi-weekly 3 times
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DWEEKLY;COUNT=3D3;INTERVAL=3D2
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-long.diary-all b/test/lisp/calendar/diary-icalendar-resources/impor=
t-rrule-count-daily-long.diary-all
new file mode 100644
index 00000000000..7bc7bd9e45f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-long.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (COUNT 14) (INTERVAL 1)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule count daily long
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-long.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrul=
e-count-daily-long.ics
new file mode 100644
index 00000000000..73df19a8196
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-long.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count daily long
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DDAILY;COUNT=3D14;INTERVAL=3D1
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-short.diary-all b/test/lisp/calendar/diary-icalendar-resources/impo=
rt-rrule-count-daily-short.diary-all
new file mode 100644
index 00000000000..62bf30b02fa
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-short.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (COUNT 1) (INTERVAL 1)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule count daily short
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-short.ics b/test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-short.ics
new file mode 100644
index 00000000000..92ffe8be654
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-short.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count daily short
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DDAILY;COUNT=3D1;INTERVAL=3D1
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-month.diary-all b/test/lisp/calendar/diary-icalendar-resourc=
es/import-rrule-count-every-second-month.diary-all
new file mode 100644
index 00000000000..20f8e22aa0a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-month.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (INTERVAL 2) (COUNT 5)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule count every second month
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-month.ics b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-rrule-count-every-second-month.ics
new file mode 100644
index 00000000000..3b27b665498
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-month.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count every second month
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-year.diary-all b/test/lisp/calendar/diary-icalendar-resource=
s/import-rrule-count-every-second-year.diary-all
new file mode 100644
index 00000000000..cff588297e4
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-year.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ YEARLY) (INTERVAL 2) (COUNT 5)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule count every second year
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-year.ics b/test/lisp/calendar/diary-icalendar-resources/impo=
rt-rrule-count-every-second-year.ics
new file mode 100644
index 00000000000..ce21c34d09a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-year.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count every second year
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D2;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-monthly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-r=
rule-count-monthly.diary-all
new file mode 100644
index 00000000000..c22a4ac3013
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-month=
ly.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (INTERVAL 1) (COUNT 5)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule count monthly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-monthly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-c=
ount-monthly.ics
new file mode 100644
index 00000000000..3391ca24252
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-month=
ly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count monthly
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DMONTHLY;INTERVAL=3D1;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-yearly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rr=
ule-count-yearly.diary-all
new file mode 100644
index 00000000000..6f7caedcfb5
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-yearl=
y.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ YEARLY) (INTERVAL 1) (COUNT 5)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule count yearly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-yearly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-co=
unt-yearly.ics
new file mode 100644
index 00000000000..d8569933e0c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-yearl=
y.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count yearly
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-two-day.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-r=
rule-daily-two-day.diary-all
new file mode 100644
index 00000000000..6a8296f2e04
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-two-d=
ay.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (INTERVAL 2)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule daily
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-two-day.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-d=
aily-two-day.ics
new file mode 100644
index 00000000000..8c9cb3b2845
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-two-d=
ay.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule daily
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DDAILY;INTERVAL=3D2
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-with-exceptions.diary-all b/test/lisp/calendar/diary-icalendar-resources/=
import-rrule-daily-with-exceptions.diary-all
new file mode 100644
index 00000000000..c473bb60f6d
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-with-=
exceptions.diary-all
@@ -0,0 +1,5 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (INTERVAL 2)) :exclude
+	     '((9 21 2003) (9 25 2003)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule daily with exceptions
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-with-exceptions.ics b/test/lisp/calendar/diary-icalendar-resources/import=
-rrule-daily-with-exceptions.ics
new file mode 100644
index 00000000000..a07c99ce6a7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-with-=
exceptions.ics
@@ -0,0 +1,12 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule daily with exceptions=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DDAILY;INTERVAL=3D2=0D
+EXDATE;VALUE=3DDATE:20030921,20030925=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rrule-dai=
ly.diary-all
new file mode 100644
index 00000000000..dc830c87d4b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily.diary=
-all
@@ -0,0 +1,3 @@
+&%%(diary-rrule :rule '((FREQ DAILY)) :start '(0 0 9 19 9 2003 5 -1 nil)
+	     :duration '(0 30 2 0 nil nil nil -1 nil)) rrule daily
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily.ics
new file mode 100644
index 00000000000..93ed08065bc
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule daily=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DDAILY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-no-end.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-=
rrule-monthly-no-end.diary-all
new file mode 100644
index 00000000000..f5f672ecd4b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-no-=
end.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule monthly no end
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-no-end.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-=
monthly-no-end.ics
new file mode 100644
index 00000000000..9448ca058f8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-no-=
end.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule monthly no end=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DMONTHLY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-with-end.diary-all b/test/lisp/calendar/diary-icalendar-resources/impor=
t-rrule-monthly-with-end.diary-all
new file mode 100644
index 00000000000..a6a68e0a2f8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-wit=
h-end.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (UNTIL (8 19 2005))) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule monthly with end
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-with-end.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrul=
e-monthly-with-end.ics
new file mode 100644
index 00000000000..8f611f4c6a4
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-wit=
h-end.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule monthly with end=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DMONTHLY;UNTIL=3D20050819=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-week=
ly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rrule-we=
ekly.diary-all
new file mode 100644
index 00000000000..99f8eff66a5
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-weekly.diar=
y-all
@@ -0,0 +1,3 @@
+&%%(diary-rrule :rule '((FREQ WEEKLY)) :start '(0 0 9 19 9 2003 5 -1 nil)
+	     :duration '(0 30 2 0 nil nil nil -1 nil)) rrule weekly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-week=
ly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-weekly.i=
cs
new file mode 100644
index 00000000000..44b6f44e2e0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-weekly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule weekly=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DWEEKLY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-year=
ly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rrule-ye=
arly.diary-all
new file mode 100644
index 00000000000..45319077c37
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-yearly.diar=
y-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ YEARLY) (INTERVAL 2)) :start
+	     '(0 0 9 19 9 2003 5 -1 nil) :duration
+	     '(0 30 2 0 nil nil nil -1 nil)) rrule yearly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-year=
ly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-yearly.i=
cs
new file mode 100644
index 00000000000..21cca097f7e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-yearly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule yearly
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D2
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-time-forma=
t-12hr-blank.diary-iso b/test/lisp/calendar/diary-icalendar-resources/impor=
t-time-format-12hr-blank.diary-iso
new file mode 100644
index 00000000000..cf9a1bee9ce
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-time-format-12hr-=
blank.diary-iso
@@ -0,0 +1,2 @@
+&2003/9/19  9.00h-11.30h 12hr blank-padded
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-time-forma=
t-12hr-blank.ics b/test/lisp/calendar/diary-icalendar-resources/import-time=
-format-12hr-blank.ics
new file mode 100644
index 00000000000..7f436df5391
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-time-format-12hr-=
blank.ics
@@ -0,0 +1,9 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:12hr blank-padded=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-attac=
hment.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-with-=
attachment.diary-iso
new file mode 100644
index 00000000000..9584bcb5453
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-attachment.d=
iary-iso
@@ -0,0 +1,4 @@
+&2003/9/19 09:00 Has an attachment
+ Attachment: R3Jl.plain
+ UID: f9fee9a0-1231-4984-9078-f1357db352db
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-attac=
hment.ics b/test/lisp/calendar/diary-icalendar-resources/import-with-attach=
ment.ics
new file mode 100644
index 00000000000..338e291d407
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-attachment.i=
cs
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:f9fee9a0-1231-4984-9078-f1357db352db=0D
+SUMMARY:Has an attachment=0D
+ATTACH;VALUE=3DBINARY;FMTTYPE=3Dtext/plain;ENCODING=3DBASE64:R3JlZXRpbmdzI=
SBJIGFtIGEgYmFzZTY0LWVuY29kZWQgZmlsZQ=3D=3D=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-timez=
one.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-with-ti=
mezone.diary-iso
new file mode 100644
index 00000000000..1739bb8180c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-timezone.dia=
ry-iso
@@ -0,0 +1,4 @@
+&2012/1/15 15:00-15:30 standardtime
+
+&2012/12/15 11:00-11:30 daylightsavingtime
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-timez=
one.ics b/test/lisp/calendar/diary-icalendar-resources/import-with-timezone=
.ics
new file mode 100644
index 00000000000..0db619e4f0a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-timezone.ics
@@ -0,0 +1,27 @@
+BEGIN:VCALENDAR=0D
+BEGIN:VTIMEZONE=0D
+TZID:fictional=0D
+BEGIN:STANDARD=0D
+DTSTART:20100101T000000=0D
+TZOFFSETFROM:+0200=0D
+TZOFFSETTO:-0200=0D
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D1SU;BYMONTH=3D01=0D
+END:STANDARD=0D
+BEGIN:DAYLIGHT=0D
+DTSTART:20101201T000000=0D
+TZOFFSETFROM:-0200=0D
+TZOFFSETTO:+0200=0D
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D1SU;BYMONTH=3D11=0D
+END:DAYLIGHT=0D
+END:VTIMEZONE=0D
+BEGIN:VEVENT=0D
+SUMMARY:standardtime=0D
+DTSTART;TZID=3Dfictional:20120115T120000=0D
+DTEND;TZID=3Dfictional:20120115T123000=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+SUMMARY:daylightsavingtime=0D
+DTSTART;TZID=3Dfictional:20121215T120000=0D
+DTEND;TZID=3Dfictional:20121215T123000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.d=
iary-american b/test/lisp/calendar/diary-icalendar-resources/import-with-ui=
d.diary-american
new file mode 100644
index 00000000000..99ca57d83a1
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.diary-am=
erican
@@ -0,0 +1,3 @@
+&9/19/2003 09:00-11:30 non-recurring
+ UID: 1234567890uid
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.d=
iary-european b/test/lisp/calendar/diary-icalendar-resources/import-with-ui=
d.diary-european
new file mode 100644
index 00000000000..cfb507eb1ea
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.diary-eu=
ropean
@@ -0,0 +1,3 @@
+&19/9/2003 09:00-11:30 non-recurring
+ UID: 1234567890uid
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.d=
iary-iso b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.dia=
ry-iso
new file mode 100644
index 00000000000..0e782405520
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.diary-iso
@@ -0,0 +1,3 @@
+&2003/9/19 09:00-11:30 non-recurring
+ UID: 1234567890uid
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.i=
cs b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.ics
new file mode 100644
index 00000000000..53a34325c83
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:1234567890uid=0D
+SUMMARY:non-recurring=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-tests.el b/test/lisp/calend=
ar/diary-icalendar-tests.el
new file mode 100644
index 00000000000..a1f2c2ea83a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-tests.el
@@ -0,0 +1,1206 @@
+;;; diary-icalendar-tests.el --- Tests for diary-icalendar -*- lexical-bin=
ding: t; -*-
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(eval-when-compile (require 'cl-lib))
+(require 'diary-icalendar)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'icalendar)
+(require 'ert)
+(require 'ert-x)
+(require 'seq)
+
+
+;; Tests for diary import functions
+(defun dit:file-contents (filename)
+  "Return contents of file in test data directory named FILENAME."
+  (with-temp-buffer
+    (let ((coding-system-for-read 'raw-text)
+          (inhibit-eol-conversion t))
+      (insert-file-contents-literally
+       (ert-resource-file filename))
+      (buffer-string))))
+
+(defun dit:import-file (filename)
+  "Test diary import of FILENAME.
+
+FILENAME names a .ics file in diary-icalendar-resources directory.
+The calendar in FILENAME is parsed and imported in ISO, European, and
+American date styles. The output of each import is compared against the
+contents of the diary files with the same base name as FILENAME and
+extensions \".diary-iso\", \".diary-european\", and \".diary-american\"."
+  (let* ((basename (file-name-base filename))
+         (ics-file (ert-resource-file filename))
+         (import-buffer (icalendar-unfolded-buffer-from-file ics-file))
+         (all-file (ert-resource-file (concat basename ".diary-all")))
+         (iso-file (ert-resource-file (concat basename ".diary-iso")))
+         (european-file (ert-resource-file (concat basename ".diary-europe=
an")))
+         (american-file (ert-resource-file (concat basename ".diary-americ=
an"))))
+    (with-current-buffer import-buffer
+      (when (file-exists-p all-file)
+        (calendar-set-date-style 'american) ; because it's the default
+        (dit:-do-test-import all-file))
+      (when (file-exists-p iso-file)
+        (calendar-set-date-style 'iso)
+        (dit:-do-test-import iso-file))
+      (when (file-exists-p european-file)
+        (calendar-set-date-style 'european)
+        (dit:-do-test-import european-file))
+      (when (file-exists-p american-file)
+        (calendar-set-date-style 'american)
+        (dit:-do-test-import american-file))
+      (set-buffer-modified-p nil)) ; so we can kill it without being asked
+    (kill-buffer import-buffer)))
+
+(defun dit:-do-test-import (diary-filename)
+  "Perform import of current iCalendar buffer and compare the result with
+the contents of DIARY-FILENAME."
+  (ert-with-temp-file temp-file
+    :suffix "icalendar-test-diary"
+    (di:import-buffer temp-file t t)
+    (save-excursion
+      (find-file temp-file)
+      (let ((result (buffer-substring-no-properties (point-min) (point-max=
)))
+            (expected (dit:file-contents diary-filename)))
+        (should (equal result expected))
+        ;; This is useful for debugging differences when tests are failing:
+        ;; (let ((result-buf (current-buffer))
+        ;;       (diary-buf (find-file diary-filename)))
+        ;;   (ediff-buffers result-buf ; actual output
+        ;;                  diary-buf)
+        ;;   (switch-to-buffer-other-frame "*Ediff Control Panel*"))
+        ;; (error "Unexpected result; see ediff"))
+          ))
+    (kill-buffer (find-buffer-visiting temp-file))))
+
+(ert-deftest dit:import-non-recurring ()
+  "Import tests for standard, non-recurring events."
+  (dit:import-file "import-non-recurring-1.ics")
+  (dit:import-file "import-non-recurring-all-day.ics")
+  (dit:import-file "import-non-recurring-long-summary.ics")
+  (dit:import-file "import-non-recurring-block.ics")
+  (dit:import-file "import-non-recurring-folded-summary.ics")
+  (dit:import-file "import-non-recurring-another-example.ics"))
+
+(ert-deftest dit:import-w/legacy-vars ()
+  "Import tests using legacy import variables"
+  (let ((icalendar-import-format "%s%c%d%l%o%t%u%U")
+        (icalendar-import-format-summary "%s")
+        (icalendar-import-format-class "\n CLASS=3D%s")
+        (icalendar-import-format-description "\n DESCRIPTION=3D%s")
+        (icalendar-import-format-location "\n LOCATION=3D%s")
+        (icalendar-import-format-organizer "\n ORGANIZER=3D%s")
+        (icalendar-import-format-status "\n STATUS=3D%s")
+        (icalendar-import-format-url "\n URL=3D%s")
+        (icalendar-import-format-uid "\n UID=3D%s"))
+    (dit:import-file "import-legacy-vars.ics")))
+
+(defun dit:legacy-import-function (vevent)
+  "Example function value for `icalendar-import-format'"
+  (let ((props (nth 2 (car vevent))))
+    (mapconcat
+     (lambda (prop)
+       (format " %s: %s\n"
+               (symbol-name (nth 0 prop))
+               (nth 2 prop)))
+     props)))
+
+(ert-deftest dit:import-w/legacy-function ()
+  "Import tests using legacy import variables"
+  (let ((icalendar-import-format 'dit:legacy-import-function))
+    (dit:import-file "import-legacy-function.ics")))
+
+(ert-deftest dit:import-w/time-format ()
+  "Import tests for customized `diary-icalendar-time-format'"
+  (let ((diary-icalendar-time-format "%l.%Mh"))
+    (dit:import-file "import-time-format-12hr-blank.ics")))
+
+(ert-deftest dit:import-rrule ()
+  "Import tests for recurring events."
+  (dit:import-file "import-rrule-daily.ics")
+  (dit:import-file "import-rrule-daily-two-day.ics")
+  (dit:import-file "import-rrule-daily-with-exceptions.ics")
+  (dit:import-file "import-rrule-weekly.ics")
+  (dit:import-file "import-rrule-monthly-no-end.ics")
+  (dit:import-file "import-rrule-monthly-with-end.ics")
+  (dit:import-file "import-rrule-anniversary.ics")
+  (dit:import-file "import-rrule-yearly.ics")
+  (dit:import-file "import-rrule-count-daily-short.ics")
+  (dit:import-file "import-rrule-count-daily-long.ics")
+  (dit:import-file "import-rrule-count-monthly.ics")
+  (dit:import-file "import-rrule-count-every-second-month.ics")
+  (dit:import-file "import-rrule-count-yearly.ics")
+  (dit:import-file "import-rrule-count-every-second-year.ics"))
+
+(ert-deftest dit:import-duration ()
+  (dit:import-file "import-duration.ics")
+  ;; duration-2: this is actually an rrule test
+  (dit:import-file "import-duration-2.ics"))
+
+(ert-deftest dit:import-multiple-vcalendars ()
+  (dit:import-file "import-multiple-vcalendars.ics"))
+
+(ert-deftest dit:import-with-uid ()
+  "Perform import test with uid."
+  (dit:import-file "import-with-uid.ics"))
+
+(ert-deftest dit:import-with-attachment ()
+  "Test importing an attached file to `icalendar-attachment-directory'"
+  (ert-with-temp-directory temp-dir
+    (let ((di:attachment-directory temp-dir)
+          (uid-dir (file-name-concat temp-dir
+                                     ;; Event's UID:
+                                     "f9fee9a0-1231-4984-9078-f1357db352db=
")))
+      (dit:import-file "import-with-attachment.ics")
+      (should (file-directory-p uid-dir))
+      (let ((files (directory-files uid-dir t
+                                    ;; First 4 chars of base64-string:
+                                    "R3Jl")))
+        (should (length=3D files 1))
+        (with-temp-buffer
+          (insert-file-contents (car files))
+          (should (equal "Greetings! I am a base64-encoded file"
+                         (buffer-string))))))))
+
+(ert-deftest dit:import-with-timezone ()
+  ;; "standardtime" begins first sunday in january and is 4 hours behind C=
ET
+  ;; "daylightsavingtime" begins first sunday in november and is 1 hour be=
fore CET
+  (dit:import-file "import-with-timezone.ics"))
+
+(ert-deftest dit:import-bug-6766 ()
+  ;;bug#6766 -- multiple byday values in a weekly rrule
+  (dit:import-file "import-bug-6766.ics"))
+
+(ert-deftest dit:import-bug-24199 ()
+  ;;bug#24199 -- monthly rule with byday-clause
+  (dit:import-file "import-bug-24199.ics"))
+
+(ert-deftest dit:import-bug-33277 ()
+  ;;bug#33277 -- start time equals end time
+  (dit:import-file "import-bug-33277.ics"))
+
+(ert-deftest dit:import-bug-11473 ()
+  ;; bug#11473 -- illegal tzid
+  (dit:import-file "import-bug-11473.ics"))
+
+
+;; Tests for diary export functions
+(cl-defmacro dit:parse-test (entry &key parser type number
+                                   bindings tests
+                                   source)
+  "Create a test which parses data from ENTRY.
+
+PARSER should be a zero-argument function which parses data of TYPE in a
+buffer containing ENTRY.  The defined test passes if PARSER returns a
+list of NUMBER objects which satisfy TYPE. If NUMBER is nil, the return
+value of parser must be a single value satisfying TYPE.
+
+BINDINGS, if given, will be evaluated and made available in the lexical
+environment where PARSER is called; this can be used to temporarily set
+variables that affect parsing.
+
+TESTS, if given, is an additional test form that will be evaluated after
+the main tests. The variable `parsed' will be bound to the return value
+of PARSER when TESTS are evaluated.
+
+SOURCE, if given, should be a symbol; it is used to name the test."
+  (let ((parser-form `(funcall (function ,parser))))
+    `(ert-deftest
+         ,(intern (concat "diary-icalendar-test-"
+                          (string-replace "diary-icalendar-" ""
+                                          (symbol-name parser))
+                          (if source (concat "/" (symbol-name source)) "")=
))
+         ()
+       ,(format "Does `%s' correctly parse `%s' in diary entries?" parser =
type)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (unparsed ,entry))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let* (,@bindings
+               (parsed ,parser-form))
+           (when ,number
+               (should (length=3D parsed ,number))
+               (should (seq-every-p (lambda (val) (cl-typep val ,type))
+                                    parsed)))
+           (unless ,number
+             (should (cl-typep parsed ,type)))
+           ,tests)))))
+
+(dit:parse-test
+ "2025-04-01 A basic entry
+    Other data"
+:parser di:parse-entry-type
+:type 'symbol
+:source vevent
+:tests (should (eq parsed 'ical:vevent)))
+
+(dit:parse-test
+ "&2025-04-01 A nonmarking journal entry
+     Other data"
+:parser di:parse-entry-type
+:bindings ((di:export-nonmarking-as-vjournal t))
+:type 'symbol
+:source vjournal
+:tests (should (eq parsed 'ical:vjournal)))
+
+(dit:parse-test
+ "2025-04-01 Due: some task
+     Other data"
+:parser di:parse-entry-type
+:bindings ((di:todo-regexp "Due: "))
+:type 'symbol
+:source vtodo
+:tests (should (eq parsed 'ical:vtodo)))
+
+(defun dit:parse-vevent-transparency ()
+  "Call `di:parse-transparency' with \\=3D'icalendar-vevent"
+  (di:parse-transparency 'ical:vevent))
+
+(dit:parse-test
+ "&%%(diary-anniversary 7 28 1985) A transparent anniversary"
+ :parser dit:parse-vevent-transparency
+ :type 'ical:transp
+ :number 1
+ :source nonmarking
+ :tests
+ (ical:with-property (car parsed) nil
+   (should (equal value "TRANSPARENT"))))
+
+(dit:parse-test
+ "2025-04-01 Team Meeting
+   Some data
+   Organizer: Mr. Foo <foo@HIDDEN>
+   Attendees: Baz Bar <baz@HIDDEN>
+              Alice Unternehmer <alice@HIDDEN> (some other data)
+   Other data"
+:parser di:parse-attendees-and-organizer
+:number 3
+:type '(or ical:attendee ical:organizer)
+:tests
+(dolist (p parsed)
+  (ical:with-property p
+    ((ical:cnparam :value name))
+    (cond ((equal value "mailto:foo@HIDDEN")
+           (should (equal name "Mr. Foo"))
+           (should (ical:organizer-property-p p)))
+          ((equal value "mailto:baz@HIDDEN")
+           (should (equal name "Baz Bar"))
+           (should (ical:attendee-property-p p)))
+          ((equal value "mailto:alice@HIDDEN")
+           (should (equal name "Alice Unternehmer"))
+           (should (ical:attendee-property-p p)))
+          (t (error "Incorrectly parsed attendee address: %s" value))))))
+
+(dit:parse-test
+ "2025-04-01 An event with a UID
+    Some data
+    UID: emacs174560213714413195191
+    Other data"
+:parser di:parse-uid
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:uid
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "emacs174560213714413195191" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with a different style of UID
+    Some data
+    UID: 197846d7-51be-4d8e-837f-7e132286e7cf
+    Other data"
+:parser di:parse-uid
+:source with-org-id-uuid
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:uid
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "197846d7-51be-4d8e-837f-7e132286e7cf" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with a status
+    Some data
+    Status: confirmed
+    Other data"
+:parser di:parse-status
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:status
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "CONFIRMED" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with an access classification
+    Some data
+    Class: private
+    Other data"
+:parser di:parse-class
+:source private
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:class
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "PRIVATE" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with an access classification
+    Some data
+    Access: public
+    Other data"
+:parser di:parse-class
+:source public
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:class
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "PUBLIC" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with a location
+    Some data
+    Location: Sesamstra=C3=9Fe 13
+    Other data"
+:parser di:parse-location
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:location
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "Sesamstra=C3=9Fe 13" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with an URL
+    Some data
+    URL: http://example.com/foo/bar?q=3Dbaz
+    Other data"
+:parser di:parse-url
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:url
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "http://example.com/foo/bar?q=3Dbaz" value))))
+
+
+;; N.B. There is no date at the start of the entry in the following two
+;; tests because di:parse-summary-and-description assumes that the date
+;; parsing functions have already moved the start of the restriction
+;; beyond it
+(dit:parse-test
+ "Event summary
+    Some data
+    Other data"
+:parser di:parse-summary-and-description
+:number 2
+:type '(or ical:summary ical:description)
+:bindings ((diary-date-forms diary-iso-date-forms))
+:tests
+(ical:with-property (car parsed) nil (should (equal "Event summary" value)=
)))
+
+(dit:parse-test
+ "Some data
+    Summary: Event summary
+    Other data"
+:parser di:parse-summary-and-description
+:number 2
+:bindings ((di:summary-regexp "^[[:space:]]+Summary: \\(.*\\)$"))
+:type '(or ical:summary ical:description)
+:bindings ((diary-date-forms diary-iso-date-forms))
+:source with-summary-regexp
+:tests
+(ical:with-property (car parsed) nil (should (equal "Event summary" value)=
)))
+
+(dit:parse-test
+ "2025/04/01 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-iso-date-forms))
+ :source iso-date
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "2025-04-01 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-iso-date-forms))
+ :source iso-date-dashes
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "1/4/2025 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source european-date
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "4/1/2025 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source american-date
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "4/1 April Fool's"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source generic-year-american
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "1/5 Tag der Arbeit"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source generic-year-european
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 5 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "1/*/2025 Rent due"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source generic-month
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "*/2/2025 Every day in February: go running"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source generic-day
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 2 (calendar-extract-month parsed)))
+   (should (eq t (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "Friday
+    Lab meeting
+    Backup data"
+ :parser di:parse-weekday-name
+ :type 'integer
+ :tests
+ (should (=3D 5 parsed)))
+
+;;; Examples from the Emacs manual:
+(dit:parse-test
+ "12/22/2015  Twentieth wedding anniversary!"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/1
+ :tests
+ (progn
+   (should (=3D 2015 (calendar-extract-year parsed)))
+   (should (=3D 12 (calendar-extract-month parsed)))
+   (should (=3D 22 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date via unspecified year:
+ "10/22       Ruth's birthday."
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/2
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 10 (calendar-extract-month parsed)))
+   (should (=3D 22 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date via unspecified year:
+ "4/30  Results for April are due"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/3
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 30 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date with asterisks:
+ "* 21, *:    Payday"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/3
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 21 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date with asterisks:
+ "*/25  Monthly cycle finishes"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/4
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 25 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Weekday name:
+ "Tuesday--weekly meeting with grad students at 10am
+           Supowit, Shen, Bitner, and Kapoor to attend."
+ :parser di:parse-weekday-name
+ :type 'integer
+ :source emacs-manual-sec33.10.1/4
+ :tests
+ (should (=3D 2 parsed)))
+
+(dit:parse-test
+ ;; Weekday name:
+ "Friday  Don't leave without backing up files"
+ :parser di:parse-weekday-name
+ :type 'integer
+ :source emacs-manual-sec33.10.3/5
+ :tests
+ (should (=3D 5 parsed)))
+
+(dit:parse-test
+ ;; Date with two-digit year:
+ "1/13/89     Friday the thirteenth!!"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/5
+ :tests
+ (progn
+   (should (=3D 1989 (calendar-extract-year parsed)))
+   (should (=3D 1 (calendar-extract-month parsed)))
+   (should (=3D 13 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Date with two-digit year:
+ "4/20/12  Switch-over to new tabulation system"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/1
+ :tests
+ (progn
+   (should (=3D 2012 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 20 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Abbreviated weekday name:
+ "thu 4pm     squash game with Lloyd."
+ :parser di:parse-weekday-name
+ :type 'integer
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/6
+ :tests
+ (should (=3D 4 parsed)))
+
+(dit:parse-test
+ ;; Abbreviated month name:
+ "mar 16      Dad's birthday"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/7
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 3 (calendar-extract-month parsed)))
+   (should (=3D 16 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Abbreviated month name with following period:
+ "apr. 25  Start tabulating annual results"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/2
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 25 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Long form date:
+ "April 15, 2016 Income tax due."
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/8
+ :tests
+ (progn
+   (should (=3D 2016 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 15 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic monthly date:
+ "* 15        time cards due."
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/9
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 15 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "%%(diary-anniversary 5 28 1995) A birthday"
+ :parser di:parse-sexp
+ :type 'list
+ :tests (should (eq 'diary-anniversary (car parsed))))
+
+(dit:parse-test
+ "%%(diary-time-block :start (0 0 13 2 4 2025 6 t 7200)
+                      :end (0 0 11 4 4 2025 6 t 7200))
+    A multiday event with different start and end times"
+ :parser di:parse-sexp
+ :type 'list
+ :source multiline-sexp
+ :tests (should (eq 'diary-time-block (car parsed))))
+
+(defun dit:entry-parser ()
+  "Call `di:parse-entry' on the full test buffer"
+  (let ((tz
+         (cond
+          ((eq 'local di:time-zone-export-strategy)
+           (di:current-tz-to-vtimezone))
+          ((listp di:time-zone-export-strategy)
+           (di:current-tz-to-vtimezone di:time-zone-export-strategy)))))
+
+    (di:parse-entry (point-min) (point-max) tz)))
+
+(dit:parse-test
+ ;; Weekly event, abbreviated weekday name:
+ "thu 4pm     squash game with Lloyd."
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/6
+ :tests
+ (ical:with-component (car parsed)
+   ((ical:dtstart :value dtstart)
+    (ical:rrule :value rrule)
+    (ical:summary :value summary))
+   (should (equal summary "squash game with Lloyd."))
+   (should (equal (ical:date-time-to-date dtstart)
+                  (calendar-nth-named-day 1 4 1 di:recurring-start-year)))
+   (should (=3D 16 (decoded-time-hour dtstart)))
+   (should (eq (ical:recur-freq rrule) 'WEEKLY))
+   (should (equal (ical:recur-by* 'BYDAY rrule) (list 4)))))
+
+(dit:parse-test
+ ;; Multiline entry, parsed as one event:
+ "2025-05-03
+    9AM Lab meeting
+      Gunther to present on new assay
+    12:30-1:30PM Lunch with Phil
+    16:00 Experiment A finishes; move to freezer"
+ :parser dit:entry-parser
+ :source multiline-single
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-iso-date-forms)))
+
+(dit:parse-test
+ ;; Multiline entry, parsed linewise as three events:
+ "2025-05-03
+    9AM Lab meeting
+      Gunther to present on new assay
+    12:30-1:30PM Lunch with Phil
+    16:00 Experiment A finishes; move to freezer"
+ :parser dit:entry-parser
+ :source multiline-linewise
+ :type 'ical:vevent
+ :number 3
+ :bindings ((diary-date-forms diary-iso-date-forms)
+            (diary-icalendar-export-linewise t))
+ :tests
+ (progn
+   (dolist (event parsed)
+     (ical:with-component event
+       ((ical:dtstart :value-type start-type :value dtstart)
+        (ical:dtend :value-type end-type :value dtend)
+        (ical:summary :value summary))
+       (should (eq start-type 'ical:date-time))
+       (should (=3D 2025 (decoded-time-year dtstart)))
+       (should (=3D 5 (decoded-time-month dtstart)))
+       (should (=3D 3 (decoded-time-day dtstart)))
+       (when dtend
+         (should (eq end-type 'ical:date-time))
+         (should (=3D 2025 (decoded-time-year dtend)))
+         (should (=3D 5 (decoded-time-month dtend)))
+         (should (=3D 3 (decoded-time-day dtend))))
+       (cond ((equal summary "Lab meeting")
+              (should (=3D 9 (decoded-time-hour dtstart))))
+             ((equal summary "Lunch with Phil")
+              (should (=3D 12 (decoded-time-hour dtstart)))
+              (should (=3D 30 (decoded-time-minute dtstart)))
+              (should (=3D 13 (decoded-time-hour dtend)))
+              (should (=3D 30 (decoded-time-minute dtend))))
+             ((equal summary "Experiment A finishes; move to freezer")
+              (should (=3D 16 (decoded-time-hour dtstart))))
+             (t (error "Unknown event: %s" summary)))))))
+
+(dit:parse-test
+ ;; Multiline entry from the manual, parsed linewise:
+ ;; TODO: I've left the times verbatim in the example
+ ;; and in the tests, even though "2:30", "5:30" and "8:00"
+ ;; would most naturally be understood as PM times.
+ ;; Should probably fix the manual, then revise here.
+ "02/11/2012
+           Bill B. visits Princeton today
+           2pm Cognitive Studies Committee meeting
+           2:30-5:30 Liz at Lawrenceville
+           4:00pm Dentist appt
+           7:30pm Dinner at George's
+           8:00-10:00pm concert"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 6
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (diary-icalendar-export-linewise t))
+ :source emacs-manual-sec33.10.1/10
+ :tests
+ (progn
+   (dolist (event parsed)
+     (ical:with-component event
+       ((ical:dtstart :value-type start-type :value dtstart)
+        (ical:dtend :value-type end-type :value dtend)
+        (ical:summary :value summary))
+       (when (eq start-type 'ical:date)
+         (should (=3D 2012 (calendar-extract-year dtstart)))
+         (should (=3D 2 (calendar-extract-month dtstart)))
+         (should (=3D 11 (calendar-extract-day dtstart))))
+       (when (eq start-type 'ical:date-time)
+         (should (=3D 2012 (decoded-time-year dtstart)))
+         (should (=3D 2 (decoded-time-month dtstart)))
+         (should (=3D 11 (decoded-time-day dtstart))))
+       (when dtend
+         (should (eq end-type 'ical:date-time))
+         (should (=3D 2012 (decoded-time-year dtend)))
+         (should (=3D 2 (decoded-time-month dtend)))
+         (should (=3D 11 (decoded-time-day dtend))))
+       (cond ((equal summary "Bill B. visits Princeton today")
+              (should (eq start-type 'ical:date)))
+             ((equal summary "Cognitive Studies Committee meeting")
+              (should (=3D 14 (decoded-time-hour dtstart)))
+              (should (=3D 0 (decoded-time-minute dtstart))))
+             ((equal summary "Liz at Lawrenceville")
+              (should (=3D 2 (decoded-time-hour dtstart)))
+              (should (=3D 30 (decoded-time-minute dtstart)))
+              (should (=3D 5 (decoded-time-hour dtend)))
+              (should (=3D 30 (decoded-time-minute dtend))))
+             ((equal summary "Dentist appt")
+              (should (=3D 16 (decoded-time-hour dtstart)))
+              (should (=3D 0 (decoded-time-minute dtstart))))
+             ((equal summary "Dinner at George's")
+              (should (=3D 19 (decoded-time-hour dtstart)))
+              (should (=3D 30 (decoded-time-minute dtstart))))
+             ((equal summary "concert")
+              (should (=3D 8 (decoded-time-hour dtstart)))
+              (should (=3D 0 (decoded-time-minute dtstart)))
+              (should (=3D 22 (decoded-time-hour dtend)))
+              (should (=3D 0 (decoded-time-minute dtend))))
+             (t (error "Unknown event: %s" summary)))))))
+
+(dit:parse-test
+ ;; Same as the last, but with ignored data on the same line as the date
+ "02/11/2012 Ignored
+             2pm Cognitive Studies Committee meeting
+             2:30-5:30 Liz at Lawrenceville
+             4:00pm Dentist appt
+             7:30pm Dinner at George's
+             8:00-10:00pm concert"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 5
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (diary-icalendar-export-linewise t))
+ :source emacs-manual-sec33.10.1/10-first-line)
+
+(dit:parse-test
+ "%%(diary-anniversary 5 28 1995) H's birthday"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (calendar-date-style 'american))
+ :source diary-anniversary-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart '(5 28 1995)))
+   (should (eq (ical:recur-freq recur-value) 'YEARLY))
+   (should (equal summary "H's birthday"))))
+
+(dit:parse-test
+ "%%(diary-block 6 24 2012 7 10 2012) Vacation"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-block-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart '(6 24 2012)))
+   (should (equal (ical:recur-freq recur-value) 'DAILY))
+   (should (equal (ical:recur-until recur-value) '(7 10 2012)))
+   (should (equal summary "Vacation"))))
+
+(dit:parse-test
+ "%%(diary-cyclic 50 3 1 2012) Renew medication"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-cyclic-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart '(3 1 2012)))
+   (should (eq (ical:recur-freq recur-value) 'DAILY))
+   (should (eq (ical:recur-interval-size recur-value) 50))
+   (should (equal summary "Renew medication"))))
+
+(dit:parse-test
+ "%%(diary-float 11 4 4) American Thanksgiving"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-float-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart
+                  (calendar-nth-named-day 4 4 11 di:recurring-start-year)))
+   (should (eq (ical:recur-freq recur-value) 'MONTHLY))
+   (should (equal (ical:recur-by* 'BYMONTH recur-value) (list 11)))
+   (should (equal (ical:recur-by* 'BYDAY recur-value) (list '(4 . 4))))
+   (should (equal summary "American Thanksgiving"))))
+
+(dit:parse-test
+ "%%(diary-offset '(diary-float t 3 4) 2) Monthly committee meeting"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-offset-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart
+                  (calendar-nth-named-day 4 5 1 di:recurring-start-year)))
+   (should (eq (ical:recur-freq recur-value) 'MONTHLY))
+   ;; day 3 is Wednesday, so offset of 2 means Friday (=3D5):
+   (should (equal (ical:recur-by* 'BYDAY recur-value) (list '(5 . 4))))
+   (should (equal summary "Monthly committee meeting"))))
+
+(dit:parse-test
+ "%%(diary-rrule :start '(11 11 2024)
+                 :rule '((FREQ WEEKLY))
+                 :exclude '((12 23 2024) (12 30 2024))
+     ) Reading group"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-rrule-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:exdate :values exdates)
+      (ical:summary :value summary))
+   (should (equal dtstart '(11 11 2024)))
+   (should (eq (ical:recur-freq recur-value) 'WEEKLY))
+   (should (equal exdates '((12 23 2024) (12 30 2024))))
+   (should (equal summary "Reading group"))))
+
+(dit:parse-test
+ "%%(diary-date '(10 11 12) 22 t) Rake leaves"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-date-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart (list 10 22 di:recurring-start-year)))
+   (should (eq (ical:recur-freq recur-value) 'YEARLY))
+   (should (equal (ical:recur-by* 'BYMONTH recur-value) (list 10 11 12)))
+   (should (equal (ical:recur-by* 'BYMONTHDAY recur-value) (list 22)))
+   (should (equal summary "Rake leaves"))))
+
+(dit:parse-test
+ ;; From the manual: "Suppose you get paid on the 21st of the month if
+ ;; it is a weekday, and on the Friday before if the 21st is on a
+ ;; weekend..."
+ "%%(let ((dayname (calendar-day-of-week date))
+          (day (cadr date)))
+          (or (and (=3D day 21) (memq dayname '(1 2 3 4 5)))
+              (and (memq day '(19 20)) (=3D dayname 5)))
+              ) Pay check deposited"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (di:export-sexp-enumeration-days 366))
+ :source emacs-manual-33.13.10.7
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rdate :values rdates)
+      (ical:summary :value summary))
+   (should (equal summary "Pay check deposited"))
+   (mapc
+    (lambda (date)
+      (should (or (and (=3D 21 (calendar-extract-day date))
+                       (memq (calendar-day-of-week date) (list 1 2 3 4 5)))
+                  (and (memq (calendar-extract-day date) (list 19 20))
+                       (=3D 5 (calendar-day-of-week date))))))
+    (cons dtstart rdates))))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with 'local strategy"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((tz (getenv "TZ"))
+            ;; Refresh output from `calendar-current-time-zone':
+            (calendar-current-time-zone-cache nil)
+	    ;; Assume Eastern European Time (UTC+2, UTC+3 daylight saving)
+            (_ (setenv "TZ" "EET-2EEST,M3.5.0/3,M10.5.0/4"))
+            ;; ...and use this TZ when exporting:
+            (diary-icalendar-time-zone-export-strategy 'local)
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-local
+ :tests
+ (unwind-protect
+     (let ((vtimezone (di:current-tz-to-vtimezone)))
+       (ical:with-component vtimezone
+         ((ical:standard :first std)
+          (ical:daylight :first dst))
+         (should (=3D (* 2 60 60) (ical:with-property-of std 'ical:tzoffse=
tto)))
+         (should (=3D (* 3 60 60) (ical:with-property-of dst 'ical:tzoffse=
tto))))
+       (ical:with-component (car parsed)
+         ((ical:dtstart :first start-node :value start))
+         (should (=3D (* 2 60 60) (decoded-time-zone start)))
+         (should (=3D 16 (decoded-time-hour start)))
+         (should (ical:with-param-of start-node 'ical:tzidparam))))
+   ;; restore time zone
+   (setenv "TZ" tz)))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with 'to-utc strategy"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((tz (getenv "TZ"))
+	    ;; Assume Eastern European Time (UTC+2, UTC+3 daylight saving)
+            (_ (setenv "TZ" "EET-2EEST,M3.5.0/3,M10.5.0/4"))
+            ;; ...and convert times to UTC on export:
+            (diary-icalendar-time-zone-export-strategy 'to-utc)
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-to-utc
+ :tests
+ (unwind-protect
+     (ical:with-component (car parsed)
+       ((ical:dtstart :first start-node :value start))
+       (should (=3D 0 (decoded-time-zone start)))
+       (should (=3D (- 16 2) (decoded-time-hour start)))
+       (should-not (ical:with-param-of start-node 'ical:tzidparam)))
+   ;; restore time zone
+   (setenv "TZ" tz)))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with 'floating strategy"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((tz (getenv "TZ"))
+	    ;; Assume Eastern European Time (UTC+2, UTC+3 daylight saving)
+            (_ (setenv "TZ" "EET-2EEST,M3.5.0/3,M10.5.0/4"))
+            ;; ...but use floating times:
+            (diary-icalendar-time-zone-export-strategy 'floating)
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-floating
+ :tests
+ (unwind-protect
+     (ical:with-component (car parsed)
+         ((ical:dtstart :first start-node :value start))
+       (should (null (decoded-time-zone start)))
+       (should (=3D 16 (decoded-time-hour start)))
+       (should-not (ical:with-param-of start-node 'ical:tzidparam)))
+
+     ;; restore time zone
+     (setenv "TZ" tz)))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with tz info list"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings (;; Encode Eastern European Time (UTC+2, UTC+3 daylight saving)
+            ;; directly in the variable:
+            (diary-icalendar-time-zone-export-strategy
+             '(120 60 "EET" "EEST"
+               (calendar-nth-named-day -1 0 3 year) ; last Sunday of March
+               (calendar-nth-named-day -1 0 10 year) ; last Sunday of Octo=
ber
+               240 180))
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-sexp
+ :tests
+ (let ((vtimezone (di:current-tz-to-vtimezone
+                   diary-icalendar-time-zone-export-strategy
+                   "EET")))
+   (ical:with-component vtimezone
+       ((ical:standard :first std)
+        (ical:daylight :first dst))
+     (should (=3D (* 2 60 60) (ical:with-property-of std 'ical:tzoffsetto)=
))
+     (should (=3D (* 3 60 60) (ical:with-property-of dst 'ical:tzoffsetto)=
)))
+   (ical:with-component (car parsed)
+       ((ical:dtstart :first start-node :value start))
+     (should (=3D 7200 (decoded-time-zone start)))
+     (should (=3D 16 (decoded-time-hour start)))
+     (should (ical:with-param-of start-node 'ical:tzidparam)))))
+
+(defun dit:parse-@-location ()
+  "Example user function for parsing additional properties.
+Parses anything following \"@\" to end of line as the entry's LOCATION."
+  (goto-char (point-min))
+  (when (re-search-forward "@\\([^\n]+\\)" nil t)
+    (list (ical:make-property ical:location
+                              (string-trim (match-string 1))))))
+
+(dit:parse-test
+ "2025/08/02 BBQ @ John's"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-icalendar-other-properties-parser #'dit:parse-@-locatio=
n)
+            (diary-date-forms diary-iso-date-forms))
+ :source other-properties-parser
+ :tests
+ (ical:with-component (car parsed)
+   ((ical:location :value location))
+   (should (equal location "John's"))))
+
+(dit:parse-test
+ "2025/05/15 11AM Department meeting
+  Attendee: <mydept@HIDDEN>"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-icalendar-export-alarms
+             '((audio 10)
+               (display 20 "In %t minutes: %s")
+               (email 60 "In %t minutes: %s" ("myemail@HIDDEN" from-e=
ntry))))
+            (diary-date-forms diary-iso-date-forms))
+ :source alarms-export
+ :tests
+ (ical:with-component (car parsed)
+   ((ical:valarm :all valarms))
+   (should (length=3D valarms 3))
+   (dolist (valarm valarms)
+     (ical:with-component valarm
+       ((ical:action :value action)
+        (ical:trigger :value trigger)
+        (ical:summary :value summary)
+        (ical:attendee :all attendee-nodes))
+       (cond ((equal action "AUDIO")
+              (should (eql -10 (decoded-time-minute trigger))))
+             ((equal action "DISPLAY")
+              (should (eql -20 (decoded-time-minute trigger)))
+              (should (equal summary "In 20 minutes: Department meeting")))
+             ((equal action "EMAIL")
+              (should (eql -60 (decoded-time-minute trigger)))
+              (should (equal summary "In 60 minutes: Department meeting"))
+              (should (length=3D attendee-nodes 2))
+              (let ((addrs (mapcar (lambda (n) (ical:with-node-value n))
+                                   attendee-nodes)))
+                (should (member "mailto:myemail@HIDDEN" addrs))
+                (should (member "mailto:mydept@HIDDEN" addrs))))
+             (t (error "Unknown alarm action %s" action)))))))
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("dit:" . "diary-icalendar-test-") ("di:" . "d=
iary-icalendar-") ("ical:" . "icalendar-"))
+;; End:
+;;; tests/icalendar-recur.el ends here
diff --git a/test/lisp/calendar/icalendar-parser-tests.el b/test/lisp/calen=
dar/icalendar-parser-tests.el
new file mode 100644
index 00000000000..09df808dc91
--- /dev/null
+++ b/test/lisp/calendar/icalendar-parser-tests.el
@@ -0,0 +1,2030 @@
+;;; tests/icalendar-parser.el --- Tests for icalendar-parser  -*- lexical-=
binding: t; -*-
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(eval-when-compile (require 'cl-lib))
+(require 'ert)
+(require 'ert-x)
+(require 'icalendar-parser)
+
+(cl-defmacro ipt:parse/print-test (string &key expected parser type printe=
r source)
+  "Create a test which parses STRING, prints the resulting parse
+tree, and compares the printed version with STRING (or with
+EXPECTED, if given). If they are the same, the test passes.
+PARSER and PRINTER should be the parser and printer functions
+appropriate to STRING. TYPE, if given, should be the type of
+object PARSER is expected to parse; it will be passed as PARSER's
+first argument. SOURCE should be a symbol; it is used to name the
+test."
+  (let ((parser-form
+         (if type
+             `(funcall (function ,parser) (quote ,type) (point-max))
+           `(funcall (function ,parser) (point-max)))))
+    `(ert-deftest ,(intern (concat "ipt:parse/print-" (symbol-name source)=
)) ()
+       ,(format "Parse and reprint example from `%s'; pass if they match" =
source)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (print-buf (get-buffer-create "*iCalendar Print Test*"))
+              (unparsed ,string)
+              (expected (or ,expected unparsed))
+              (printed nil))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let ((parsed ,parser-form))
+           (should (icalendar-ast-node-valid-p parsed))
+           (set-buffer print-buf)
+           (erase-buffer)
+           (insert (funcall (function ,printer) parsed))
+           ;; this may need adjusting if printers become coding-system awa=
re:
+           (decode-coding-region (point-min) (point-max) 'utf-8-dos)
+           (setq printed (buffer-substring-no-properties (point-min) (poin=
t-max)))
+           (should (equal expected printed)))))))
+
+(ipt:parse/print-test
+"ATTENDEE;RSVP=3DTRUE;ROLE=3DREQ-PARTICIPANT:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/1)
+
+(ipt:parse/print-test
+"RDATE;VALUE=3DDATE:19970304,19970504,19970704,19970904\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/2)
+
+(ipt:parse/print-test
+"ATTACH:http://example.com/public/quarterly-report.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/1)
+
+(ipt:parse/print-test
+;; Corrected. The original contains invalid base64 data; it was
+;; missing the final "=3D", as noted in errata ID 5602.
+;; The decoded string should read:
+;; The quick brown fox jumps over the lazy dog.
+"ATTACH;FMTTYPE=3Dtext/plain;ENCODING=3DBASE64;VALUE=3DBINARY:VGhlIHF1aWNr=
IGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4=3D\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/2)
+
+(ipt:parse/print-test
+"DESCRIPTION;ALTREP=3D\"cid:part1.0001@HIDDEN\":The Fall'98 Wild Wiza=
rds Conference - - Las Vegas\\, NV\\, USA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2/1)
+
+(ipt:parse/print-test
+"DESCRIPTION;ALTREP=3D\"CID:part3.msg.970415T083000@HIDDEN\": Project=
 XYZ Review Meeting will include the following agenda items: (a) Market Ove=
rview\\, (b) Finances\\, (c) Project Management\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.1/1)
+
+(ipt:parse/print-test
+"ORGANIZER;CN=3D\"John Smith\":mailto:jsmith@HIDDEN\n"
+;; CN param value does not require quotes, so they're missing when
+;; re-printed:
+:expected "ORGANIZER;CN=3DJohn Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.2/1)
+
+(ipt:parse/print-test
+"ATTENDEE;CUTYPE=3DGROUP:mailto:ietf-calsch@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.3/1)
+
+(ipt:parse/print-test
+"ATTENDEE;DELEGATED-FROM=3D\"mailto:jsmith@HIDDEN\":mailto:jdoe@examp=
le.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.4/1)
+
+(ipt:parse/print-test
+"ATTENDEE;DELEGATED-TO=3D\"mailto:jdoe@HIDDEN\",\"mailto:jqpublic@exa=
mple.com\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.5/1)
+
+(ipt:parse/print-test
+"ORGANIZER;DIR=3D\"ldap://example.com:6666/o=3DABC%20Industries,c=3DUS???(=
cn=3DJim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.6/1)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dtext/plain;ENCODING=3DBASE64;VALUE=3DBINARY:TG9yZW0gaXBz=
dW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2ljaW5nIGVsaXQsIHNlZCBkbyB=
laXVzbW9kIHRlbXBvciBpbmNpZGlkdW50IHV0IGxhYm9yZSBldCBkb2xvcmUgbWFnbmEgYWxpcX=
VhLiBVdCBlbmltIGFkIG1pbmltIHZlbmlhbSwgcXVpcyBub3N0cnVkIGV4ZXJjaXRhdGlvbiB1b=
GxhbWNvIGxhYm9yaXMgbmlzaSB1dCBhbGlxdWlwIGV4IGVhIGNvbW1vZG8gY29uc2VxdWF0LiBE=
dWlzIGF1dGUgaXJ1cmUgZG9sb3IgaW4gcmVwcmVoZW5kZXJpdCBpbiB2b2x1cHRhdGUgdmVsaXQ=
gZXNzZSBjaWxsdW0gZG9sb3JlIGV1IGZ1Z2lhdCBudWxsYSBwYXJpYXR1ci4gRXhjZXB0ZXVyIH=
NpbnQgb2NjYWVjYXQgY3VwaWRhdGF0IG5vbiBwcm9pZGVudCwgc3VudCBpbiBjdWxwYSBxdWkgb=
2ZmaWNpYSBkZXNlcnVudCBtb2xsaXQgYW5pbSBpZCBlc3QgbGFib3J1bS4=3D\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.7/1)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dapplication/msword:ftp://example.com/pub/docs/agenda.doc=
\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.8/1)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DBUSY:19980415T133000Z/19980415T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.9/1)
+
+(ipt:parse/print-test
+"SUMMARY;LANGUAGE=3Den-US:Company Holiday Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/1)
+
+(ipt:parse/print-test
+"LOCATION;LANGUAGE=3Den:Germany\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/2)
+
+(ipt:parse/print-test
+"LOCATION;LANGUAGE=3Dno:Tyskland\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/3)
+
+(ipt:parse/print-test
+"ATTENDEE;MEMBER=3D\"mailto:ietf-calsch@HIDDEN\":mailto:jsmith@exampl=
e.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/1)
+
+(ipt:parse/print-test
+"ATTENDEE;MEMBER=3D\"mailto:projectA@HIDDEN\",\"mailto:projectB@examp=
le.com\":mailto:janedoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/2)
+
+(ipt:parse/print-test
+"ATTENDEE;PARTSTAT=3DDECLINED:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.12/1)
+
+(ipt:parse/print-test
+"RECURRENCE-ID;RANGE=3DTHISANDFUTURE:19980401T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.13/1)
+
+(ipt:parse/print-test
+"TRIGGER;RELATED=3DEND:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.14/1)
+
+(ipt:parse/print-test
+"RELATED-TO;RELTYPE=3DSIBLING:19960401-080045-4000F192713@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.15/1)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DCHAIR:mailto:mrbig@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.16/1)
+
+(ipt:parse/print-test
+"ATTENDEE;RSVP=3DTRUE:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.17/1)
+
+(ipt:parse/print-test
+"ORGANIZER;SENT-BY=3D\"mailto:sray@HIDDEN\":mailto:jsmith@HIDDEN=
\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.18/1)
+
+(ipt:parse/print-test
+"DTSTART;TZID=3DAmerica/New_York:19980119T020000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/1)
+
+(ipt:parse/print-test
+"DTEND;TZID=3DAmerica/New_York:19980119T030000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/2)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dimage/vnd.microsoft.icon;ENCODING=3DBASE64;VALUE=3DBINAR=
Y:AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAACAAAAAgIAAAICAgADAwMAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAAAAABNEMQAAAAAAAkQgAAAAA=
AJEREQgAAACECQ0QgEgAAQxQzM0E0AABERCRCREQAADRDJEJEQwAAAhA0QwEQAAAAAEREAAAAAA=
AAREQAAAAAAAAkQgAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.1/1)
+
+(ipt:parse/print-test
+"TRUE"
+:type icalendar-boolean
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.2/1)
+
+(ipt:parse/print-test
+"mailto:jane_doe@HIDDEN"
+:type icalendar-cal-address
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.3/1)
+
+(ipt:parse/print-test
+"19970714"
+:type icalendar-date
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.4/1)
+
+(ipt:parse/print-test
+;; 'Floating' time:
+"19980118T230000"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/1)
+
+(ipt:parse/print-test
+;; UTC time:
+"19980119T070000Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/2)
+
+(ipt:parse/print-test
+;; Leap second (seconds =3D 60)
+"19970630T235960Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/3)
+
+(ipt:parse/print-test
+;; Local time:
+"DTSTART:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/4)
+
+(ipt:parse/print-test
+;; UTC time:
+"DTSTART:19970714T173000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/5)
+
+(ipt:parse/print-test
+;; Local time with TZ identifier:
+"DTSTART;TZID=3DAmerica/New_York:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/6)
+
+(ipt:parse/print-test
+"P15DT5H0M20S"
+:expected "P15DT5H20S"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/1)
+
+(ipt:parse/print-test
+"P7W"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/2)
+
+(ipt:parse/print-test
+"1000000.0000001"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/1)
+
+(ipt:parse/print-test
+"1.333"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/2)
+
+(ipt:parse/print-test
+"-3.14"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/3)
+
+(ipt:parse/print-test
+"1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/1)
+
+(ipt:parse/print-test
+"-1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/2)
+
+(ipt:parse/print-test
+"+1234567890"
+;; "+" sign isn't required, so it's not re-printed:
+:expected "1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/3)
+
+(ipt:parse/print-test
+"432109876"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/4)
+
+(ipt:parse/print-test
+"19970101T180000Z/19970102T070000Z"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/1)
+
+(ipt:parse/print-test
+"19970101T180000Z/PT5H30M"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/2)
+
+(ipt:parse/print-test
+"FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-1"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/1)
+
+(ipt:parse/print-test
+"FREQ=3DYEARLY;INTERVAL=3D2;BYMONTH=3D1;BYDAY=3DSU;BYHOUR=3D8,9;BYMINUTE=
=3D30"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/2)
+
+(ipt:parse/print-test
+"FREQ=3DDAILY;COUNT=3D10;INTERVAL=3D2"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/3)
+
+(ipt:parse/print-test
+"Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared."
+:type icalendar-text
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.11/1)
+
+(ipt:parse/print-test
+;; Local time:
+"230000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/1)
+
+(ipt:parse/print-test
+;; UTC time:
+"070000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/2)
+
+(ipt:parse/print-test
+;; Local time:
+"083000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/3)
+
+(ipt:parse/print-test
+;; UTC time:
+"133000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/4)
+
+(ipt:parse/print-test
+;; Local time with TZ identifier:
+"SOMETIMEPROP;TZID=3DAmerica/New_York;VALUE=3DTIME:083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.12/5)
+
+(ipt:parse/print-test
+"http://example.com/my-report.txt"
+:type icalendar-uri
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.13/1)
+
+(ipt:parse/print-test
+"-0500"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.14/1)
+
+(ipt:parse/print-test
+"+0100"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc55453.3.14/1)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@HIDDEN
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec3.4/1)
+
+(ipt:parse/print-test
+"DTSTART:19960415T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.5/1)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123401@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970903T163000Z
+DTEND:19970903T190000Z
+SUMMARY:Annual Employee Review
+CLASS:PRIVATE
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/1)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123402@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970401T163000Z
+DTEND:19970402T010000Z
+SUMMARY:Laurel is in sensitivity awareness class.
+CLASS:PUBLIC
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/2)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123403@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=3DDATE:19971102
+SUMMARY:Our Blissful Anniversary
+TRANSP:TRANSPARENT
+CLASS:CONFIDENTIAL
+CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION
+RRULE:FREQ=3DYEARLY
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/3)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:20070423T123432Z-541111@HIDDEN
+DTSTAMP:20070423T123432Z
+DTSTART;VALUE=3DDATE:20070628
+DTEND;VALUE=3DDATE:20070709
+SUMMARY:Festival International de Jazz de Montreal
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/4)
+
+(ipt:parse/print-test
+"BEGIN:VTODO
+UID:20070313T123432Z-456553@HIDDEN
+DTSTAMP:20070313T123432Z
+DUE;VALUE=3DDATE:20070501
+SUMMARY:Submit Quebec Income Tax Return for 2006
+CLASS:CONFIDENTIAL
+CATEGORIES:FAMILY,FINANCE
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/1)
+
+(ipt:parse/print-test
+"BEGIN:VTODO
+UID:20070514T103211Z-123404@HIDDEN
+DTSTAMP:20070514T103211Z
+DTSTART:20070514T110000Z
+DUE:20070709T130000Z
+COMPLETED:20070707T100000Z
+SUMMARY:Submit Revised Internet-Draft
+PRIORITY:1
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/2)
+
+(ipt:parse/print-test
+"BEGIN:VJOURNAL
+UID:19970901T130000Z-123405@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=3DDATE:19970317
+SUMMARY:Staff meeting minutes
+DESCRIPTION:1. Staff meeting: Participants include Joe\\,Lisa\\, and Bob. =
Aurora project plans were reviewed. There is currently no budget reserves f=
or this project. Lisa will escalate to management. Next meeting on Tuesday.=
\\n 2. Telephone Conference: ABC Corp. sales representative called to discu=
ss new printer. Promised to get us a demo by Friday.\\n3. Henry Miller (Han=
dsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 55=
5-2323 (tel).
+END:VJOURNAL
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.3/1)
+
+(ipt:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T082949Z-FA43EF@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTART:19971015T050000Z
+DTEND:19971016T050000Z
+DTSTAMP:19970901T083000Z
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/1)
+
+(ipt:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T095957Z-76A912@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTAMP:19970901T100000Z
+FREEBUSY:19971015T050000Z/PT8H30M,19971015T160000Z/PT5H30M,19971015T223000=
Z/PT6H30M
+URL:http://example.com/pub/busy/jpublic-01.ifb
+COMMENT:This iCalendar file contains busy time information for the next th=
ree months.
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/2)
+
+(ipt:parse/print-test
+;; Corrected. Original has invalid value in ORGANIZER
+"BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/3)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU;UNTIL=3D20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D1SU;UNTIL=3D20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/1)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:STANDARD
+DTSTART:20071104T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/2)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+TZURL:http://zones.example.com/tz/America-New_York.ics
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/3)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D-1SU;BYMONTH=3D10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D1SU;BYMONTH=3D4;UNTIL=3D19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/4)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D-1SU;BYMONTH=3D10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D1SU;BYMONTH=3D4;UNTIL=3D19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19990424T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D-1SU;BYMONTH=3D4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/5)
+
+(ipt:parse/print-test
+"BEGIN:VALARM
+TRIGGER;VALUE=3DDATE-TIME:19970317T133000Z
+REPEAT:4
+DURATION:PT15M
+ACTION:AUDIO
+ATTACH;FMTTYPE=3Daudio/basic:ftp://example.com/pub/sounds/bell-01.aud
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/1)
+
+(ipt:parse/print-test
+"BEGIN:VALARM
+TRIGGER:-PT30M
+REPEAT:2
+DURATION:PT15M
+ACTION:DISPLAY
+DESCRIPTION:Breakfast meeting with executive\\nteam at 8:30 AM EST.
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/2)
+
+(ipt:parse/print-test
+"BEGIN:VALARM
+TRIGGER;RELATED=3DEND:-P2D
+ACTION:EMAIL
+ATTENDEE:mailto:john_doe@HIDDEN
+SUMMARY:*** REMINDER: SEND AGENDA FOR WEEKLY STAFF MEETING ***
+DESCRIPTION:A draft agenda needs to be sent out to the attendees to the we=
ekly managers meeting (MGR-LIST). Attached is a pointer the document templa=
te for the agenda file.
+ATTACH;FMTTYPE=3Dapplication/msword:http://example.com/templates/agenda.doc
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/3)
+
+(ipt:parse/print-test
+"CALSCALE:GREGORIAN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.1/1)
+
+(ipt:parse/print-test
+"METHOD:REQUEST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.2/1)
+
+(ipt:parse/print-test
+"PRODID:-//ABC Corporation//NONSGML My Product//EN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.3/1)
+
+(ipt:parse/print-test
+"VERSION:2.0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7./1)
+
+(ipt:parse/print-test
+"ATTACH:CID:jsmith.part3.960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/1)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dapplication/postscript:ftp://example.com/pub/reports/r-9=
60812.ps\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/2)
+
+(ipt:parse/print-test
+"CATEGORIES:APPOINTMENT,EDUCATION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/1)
+
+(ipt:parse/print-test
+"CATEGORIES:MEETING\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/2)
+
+(ipt:parse/print-test
+"CLASS:PUBLIC\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.3/1)
+
+(ipt:parse/print-test
+"COMMENT:The meeting really needs to include both ourselves and the custom=
er. We can't hold this meeting without them. As a matter of fact\\, the ven=
ue for the meeting ought to be at their site. - - John\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.4/1)
+
+(ipt:parse/print-test
+"DESCRIPTION:Meeting to provide technical review for \"Phoenix\" design.\\=
nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\=
\nRSVP to team leader.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.5/1)
+
+(ipt:parse/print-test
+"GEO:37.386013;-122.082932\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.6/1)
+
+(ipt:parse/print-test
+"LOCATION:Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/1)
+
+(ipt:parse/print-test
+"LOCATION;ALTREP=3D\"http://xyzcorp.com/conf-rooms/f123.vcf\":Conference R=
oom - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/2)
+
+(ipt:parse/print-test
+"PERCENT-COMPLETE:39\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.8/1)
+
+(ipt:parse/print-test
+"PRIORITY:1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/1)
+
+(ipt:parse/print-test
+"PRIORITY:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/2)
+
+(ipt:parse/print-test
+"PRIORITY:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/3)
+
+(ipt:parse/print-test
+"RESOURCES:EASEL,PROJECTOR,VCR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/1)
+
+(ipt:parse/print-test
+"RESOURCES;LANGUAGE=3Dfr:Nettoyeur haute pression\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/2)
+
+(ipt:parse/print-test
+"STATUS:TENTATIVE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/1)
+
+(ipt:parse/print-test
+"STATUS:NEEDS-ACTION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/2)
+
+(ipt:parse/print-test
+"STATUS:DRAFT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/3)
+
+(ipt:parse/print-test
+"SUMMARY:Department Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.12/1)
+
+(ipt:parse/print-test
+"COMPLETED:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.1/1)
+
+(ipt:parse/print-test
+"DTEND:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/1)
+
+(ipt:parse/print-test
+"DTEND;VALUE=3DDATE:19980704\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/2)
+
+(ipt:parse/print-test
+"DUE:19980430T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.3/1)
+
+(ipt:parse/print-test
+"DTSTART:19980118T073000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.4/1)
+
+(ipt:parse/print-test
+"DURATION:PT1H0M0S\n"
+;; 0M and 0S are not re-printed because they don't contribute to the durat=
ion:
+:expected "DURATION:PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/1)
+
+(ipt:parse/print-test
+"DURATION:PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/2)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DBUSY-UNAVAILABLE:19970308T160000Z/PT8H30M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/1)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DFREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/2)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DFREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,199703=
08T230000Z/19970309T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/3)
+
+(ipt:parse/print-test
+"TRANSP:TRANSPARENT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/1)
+
+(ipt:parse/print-test
+"TRANSP:OPAQUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/2)
+
+(ipt:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/1)
+
+(ipt:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/2)
+
+(ipt:parse/print-test
+"TZID:/example.org/America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/3)
+
+(ipt:parse/print-test
+"TZNAME:EST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/1)
+
+(ipt:parse/print-test
+"TZNAME;LANGUAGE=3Dfr-CA:HNE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/2)
+
+(ipt:parse/print-test
+"TZOFFSETFROM:-0500\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/1)
+
+(ipt:parse/print-test
+"TZOFFSETFROM:+1345\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/2)
+
+(ipt:parse/print-test
+"TZOFFSETTO:-0400\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/1)
+
+(ipt:parse/print-test
+"TZOFFSETTO:+1245\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/2)
+
+(ipt:parse/print-test
+"TZURL:http://timezones.example.org/tz/America-Los_Angeles.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.5/1)
+
+(ipt:parse/print-test
+"ATTENDEE;MEMBER=3D\"mailto:DEV-GROUP@HIDDEN\":mailto:joecool@example=
.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/1)
+
+(ipt:parse/print-test
+"ATTENDEE;DELEGATED-FROM=3D\"mailto:immud@HIDDEN\":mailto:ildoit@exam=
ple.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/2)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DTENTATIVE;CN=3DHenry Cabot:mai=
lto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/3)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;DELEGATED-FROM=3D\"mailto:bob@HIDDEN=
\";PARTSTAT=3DACCEPTED;CN=3DJane Doe:mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/4)
+
+(ipt:parse/print-test
+"ATTENDEE;CN=3DJohn Smith;DIR=3D\"ldap://example.com:6666/o=3DABC%20Indust=
ries,c=3DUS???(cn=3DJim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/5)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DTENTATIVE;DELEGATED-FROM=3D\"m=
ailto:iamboss@HIDDEN\";CN=3DHenry Cabot:mailto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/6)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DNON-PARTICIPANT;PARTSTAT=3DDELEGATED;DELEGATED-TO=3D\"mai=
lto:hcabot@HIDDEN\";CN=3DThe Big Cheese:mailto:iamboss@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/7)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;CN=3DJane Doe:mailto:=
jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/8)
+
+(ipt:parse/print-test
+;; Corrected. Original lacks quotes around SENT-BY address.
+"ATTENDEE;SENT-BY=3D\"mailto:jan_doe@HIDDEN\";CN=3DJohn Smith:mailto:=
jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/9)
+
+(ipt:parse/print-test
+"CONTACT:Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/1)
+
+(ipt:parse/print-test
+;; Corrected. Original contained unallowed backslash in ldap: URI
+"CONTACT;ALTREP=3D\"ldap://example.com:6666/o=3DABC%20Industries,c=3DUS???=
(cn=3DJim%20Dolittle)\":Jim Dolittle\\, ABC Industries\\,+1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/2)
+
+(ipt:parse/print-test
+"CONTACT;ALTREP=3D\"CID:part3.msg970930T083000SILVER@HIDDEN\":Jim Dol=
ittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/3)
+
+(ipt:parse/print-test
+"CONTACT;ALTREP=3D\"http://example.com/pdi/jdoe.vcf\":Jim Dolittle\\, ABC =
Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/4)
+
+(ipt:parse/print-test
+"ORGANIZER;CN=3DJohn Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/1)
+
+(ipt:parse/print-test
+"ORGANIZER;CN=3DJohnSmith;DIR=3D\"ldap://example.com:6666/o=3DDC%20Associa=
tes,c=3DUS???(cn=3DJohn%20Smith)\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/2)
+
+(ipt:parse/print-test
+"ORGANIZER;SENT-BY=3D\"mailto:jane_doe@HIDDEN\":mailto:jsmith@example=
.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/3)
+
+(ipt:parse/print-test
+"RECURRENCE-ID;VALUE=3DDATE:19960401\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/1)
+
+(ipt:parse/print-test
+"RECURRENCE-ID;RANGE=3DTHISANDFUTURE:19960120T120000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/2)
+
+(ipt:parse/print-test
+"RELATED-TO:jsmith.part7.19960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/1)
+
+(ipt:parse/print-test
+"RELATED-TO:19960401-080045-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/2)
+
+(ipt:parse/print-test
+"URL:http://example.com/pub/calendars/jsmith/mytime.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.6/1)
+
+(ipt:parse/print-test
+"UID:19960401T080045Z-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.7/1)
+
+(ipt:parse/print-test
+"EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.1/1)
+
+(ipt:parse/print-test
+"RDATE:19970714T123000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/1)
+
+(ipt:parse/print-test
+"RDATE;TZID=3DAmerica/New_York:19970714T083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/2)
+
+(ipt:parse/print-test
+"RDATE;VALUE=3DPERIOD:19960403T020000Z/19960403T040000Z,19960404T010000Z/P=
T3H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/3)
+
+(ipt:parse/print-test
+"RDATE;VALUE=3DDATE:19970101,19970120,19970217,19970421,19970526,19970704,=
19970901,19971014,19971128,19971129,19971225\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/4)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;COUNT=3D10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/1)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;UNTIL=3D19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/2)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;INTERVAL=3D2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/3)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;INTERVAL=3D10;COUNT=3D5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/4)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;UNTIL=3D20000131T140000Z;BYMONTH=3D1;BYDAY=3DSU,MO,TU=
,WE,TH,FR,SA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/5)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;UNTIL=3D20000131T140000Z;BYMONTH=3D1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/6)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;COUNT=3D10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/7)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;UNTIL=3D19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/8)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;WKST=3DSU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/9)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;UNTIL=3D19971007T000000Z;WKST=3DSU;BYDAY=3DTU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/10)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;COUNT=3D10;WKST=3DSU;BYDAY=3DTU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/11)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;UNTIL=3D19971224T000000Z;WKST=3DSU;BYDAY=
=3DMO,WE,FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/12)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D8;WKST=3DSU;BYDAY=3DTU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/13)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYDAY=3D1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/14)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;UNTIL=3D19971224T000000Z;BYDAY=3D1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/15)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;COUNT=3D10;BYDAY=3D1SU,-1SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/16)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D6;BYDAY=3D-2MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/17)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D-3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/18)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D2,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/19)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D1,-1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/20)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;INTERVAL=3D18;COUNT=3D10;BYMONTHDAY=3D10,11,12,13,14=
,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/21)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;BYDAY=3DTU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/22)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;COUNT=3D10;BYMONTH=3D6,7\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/23)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D2;COUNT=3D10;BYMONTH=3D1,2,3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/24)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D3;COUNT=3D10;BYYEARDAY=3D1,100,200\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/25)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYDAY=3D20MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/26)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYWEEKNO=3D20;BYDAY=3DMO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/27)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3DTH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/28)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYDAY=3DTH;BYMONTH=3D6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/29)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYDAY=3DFR;BYMONTHDAY=3D13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/30)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYDAY=3DSA;BYMONTHDAY=3D7,8,9,10,11,12,13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/31)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D4;BYMONTH=3D11;BYDAY=3DTU;BYMONTHDAY=3D2,3=
,4,5,6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/32)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D3;BYDAY=3DTU,WE,TH;BYSETPOS=3D3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/33)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/34)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DHOURLY;INTERVAL=3D3;UNTIL=3D19970902T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/35)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMINUTELY;INTERVAL=3D15;COUNT=3D6\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/36)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMINUTELY;INTERVAL=3D90;COUNT=3D4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/37)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;BYHOUR=3D9,10,11,12,13,14,15,16;BYMINUTE=3D0,20,40\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/38)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMINUTELY;INTERVAL=3D20;BYHOUR=3D9,10,11,12,13,14,15,16\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/39)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DMO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/40)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DSU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/41)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D15,30;COUNT=3D5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/42)
+
+(ipt:parse/print-test
+"ACTION:AUDIO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/1)
+
+(ipt:parse/print-test
+"ACTION:DISPLAY\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/2)
+
+(ipt:parse/print-test
+"REPEAT:4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.2/1)
+
+(ipt:parse/print-test
+"TRIGGER:-PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/1)
+
+(ipt:parse/print-test
+"TRIGGER;RELATED=3DEND:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/2)
+
+(ipt:parse/print-test
+"TRIGGER;VALUE=3DDATE-TIME:19980101T050000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/3)
+
+(ipt:parse/print-test
+"CREATED:19960329T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.1/1)
+
+(ipt:parse/print-test
+"DTSTAMP:19971210T080000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.2/1)
+
+(ipt:parse/print-test
+"LAST-MODIFIED:19960817T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.3/1)
+
+(ipt:parse/print-test
+"SEQUENCE:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/1)
+
+(ipt:parse/print-test
+"SEQUENCE:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/2)
+
+(ipt:parse/print-test
+"DRESSCODE:CASUAL\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/1)
+
+(ipt:parse/print-test
+"NON-SMOKING;VALUE=3DBOOLEAN:TRUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/2)
+
+(ipt:parse/print-test
+"X-ABC-MMSUBJ;VALUE=3DURI;FMTTYPE=3Daudio/basic:http://www.example.org/mys=
ubj.au\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.2/1)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:2.0;Success\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/1)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/2)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:2.8; Success\\, repeating event ignored. Scheduled as a si=
ngle event.;RRULE:FREQ=3DWEEKLY\\;INTERVAL=3D2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/3)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:4.1;Event conflict.  Date-time is busy.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/4)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:3.7;Invalid calendar user;ATTENDEE:mailto:jsmith@HIDDEN=
om\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/5)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congre=
ss Center\\nAtlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/1)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//RDU Software//NONSGML HandCal//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:19981025T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19990404T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:19980309T231000Z
+UID:guid-1.example.com
+ORGANIZER:mailto:mrbig@HIDDEN
+ATTENDEE;RSVP=3DTRUE;ROLE=3DREQ-PARTICIPANT;CUTYPE=3DGROUP:mailto:employee=
-A@HIDDEN
+DESCRIPTION:Project XYZ Review Meeting
+CATEGORIES:MEETING
+CLASS:PUBLIC
+CREATED:19980309T130000Z
+SUMMARY:XYZ Project Review
+DTSTART;TZID=3DAmerica/New_York:19980312T083000
+DTEND;TZID=3DAmerica/New_York:19980312T093000
+LOCATION:1CP Conference Room 4350
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/2)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@HIDDEN
+ORGANIZER:mailto:jdoe@HIDDEN
+ATTENDEE;RSVP=3DTRUE:mailto:jsmith@HIDDEN
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar=
 and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=3Dapplication/postscript:ftp://example.com/pub/conf/bkgrnd.=
ps
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/3)
+
+(ipt:parse/print-test
+;; Corrected. The TRIGGER property originally did not specify
+;; VALUE=3DDATE-TIME, which is required since it is not the default type.
+;; See https://www.rfc-editor.org/errata/eid2039
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VTODO
+DTSTAMP:19980130T134500Z
+SEQUENCE:2
+UID:uid4@HIDDEN
+ORGANIZER:mailto:unclesam@HIDDEN
+ATTENDEE;PARTSTAT=3DACCEPTED:mailto:jqpublic@HIDDEN
+DUE:19980415T000000
+STATUS:NEEDS-ACTION
+SUMMARY:Submit Income Taxes
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER;VALUE=3DDATE-TIME:19980403T120000Z
+ATTACH;FMTTYPE=3Daudio/basic:http://example.com/pub/audio-files/ssbanner.a=
ud
+REPEAT:4
+DURATION:PT1H
+END:VALARM
+END:VTODO
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/4)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VJOURNAL
+DTSTAMP:19970324T120000Z
+UID:uid5@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+STATUS:DRAFT
+CLASS:PUBLIC
+CATEGORIES:Project Report,XYZ,Weekly Meeting
+DESCRIPTION:Project xyz Review Meeting Minutes\\nAgenda\\n1. Review of pro=
ject version 1.0 requirements.\\n2.Definitionof project processes.\\n3. Rev=
iew of project schedule.\\nParticipants: John Smith\\, Jane Doe\\, Jim Dand=
y\\n-It was decided that the requirements need to be signed off byproduct m=
arketing.\\n-P roject processes were accepted.\\n-Project schedule needs to=
 account for scheduled holidaysand employee vacation time. Check with HR fo=
r specificdates.\\n-New schedule will be distributed by Friday.\\n-Next wee=
ks meeting is cancelled. No meeting until 3/23.
+END:VJOURNAL
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/5)
+
+(ipt:parse/print-test
+;; Corrected. Original text in the standard is missing UID and DTSTAMP.
+;; See https://www.rfc-editor.org/errata/eid4149
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//RDU Software//NONSGML HandCal//EN
+BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/6)
+
+
+;; Tests from real world data:
+(ert-deftest ipt:bad-organizer-params ()
+  "Real example: bad ORGANIZER property with params introduced by colon"
+  (let ((bad "ORGANIZER:CN=3DORGANIZER:mailto:anonymized@HIDDEN\n")
+        (ok  "ORGANIZER;CN=3DORGANIZER:mailto:anonymized@HIDDEN\n"=
))
+    (should-error (ical:parse-from-string 'ical:organizer bad))
+    (should (ical:ast-node-p (ical:parse-from-string 'ical:organizer ok)))=
))
+
+(ert-deftest ipt:bad-attendee ()
+  "Real example: bad ATTENDEE property missing mailto: prefix"
+  (let ((bad "ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELLER:anonymized@do=
main.example\n")
+        (ok  "ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELLER:mailto:anonym=
ized@HIDDEN\n"))
+    (should-error (ical:parse-from-string 'ical:attendee bad))
+    (should (ical:ast-node-p (ical:parse-from-string 'ical:attendee ok)))))
+
+(ert-deftest ipt:bad-attach ()
+  "Real example: bad ATTACH property containing broken URI"
+  (let ((bad "ATTACH;VALUE=3DURI:Glass\n")
+        (ok  "ATTACH;VALUE=3DURI:https://example.com\n"))
+    (should-error (ical:parse-from-string 'ical:attach bad))
+    (should (ical:ast-node-p (ical:parse-from-string 'ical:attach ok)))))
+
+(ert-deftest ipt:bad-cnparam ()
+  "Real example: bad unquoted CN parameter containing a comma"
+  (let ((bad "ORGANIZER;CN=3DHartlauer Gesch=C3=A4ft Wien, Taborstr. 18:ma=
ilto:anonymized@HIDDEN\n")
+        (ok  "ORGANIZER;CN=3D\"Hartlauer Gesch=C3=A4ft Wien, Taborstr. 18\=
":mailto:anonymized@HIDDEN\n"))
+    ;; strict parser should reject bad but accept ok:
+    (let ((ical:parse-strictly t))
+      (should (ical:ast-node-p (ical:parse-from-string 'ical:organizer ok)=
))
+      (should-error (ical:parse-from-string 'ical:organizer bad)))
+    ;; relaxed parser should accept bad:
+    (let ((ical:parse-strictly nil))
+      (should (ical:ast-node-p (ical:parse-from-string 'ical:organizer bad=
))))))
+
+(ert-deftest ipt:fix-bad-description ()
+  "Real example: bad DESCRIPTION property containing blank lines,
+fixed by `icalendar-fix-blank-lines'."
+  (let ((bad "BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:45dd7698-5c53-47e3-9280-19c5dff62571
+PRIORITY:1
+DTSTART:20210721T175200
+DTEND:20210721T192400
+LOCATION:Verona Porta Nuova
+DESCRIPTION:Verona Porta Nuova-Firenze S. M. Novella;Train: Frecciarossa 8=
527, departing from Verona Porta Nuova Hours: 17:52; arriving at Firenze S.=
 M. Novella Hours: 19:24 Coach 8, Position 7A; pnr code CLS345
+
+
+SUMMARY:Trip Verona Porta Nuova-Firenze S. M. Novella, Train Frecciarossa =
8527, Coach 8, Position 7A, PNR CLS345,
+ORGANIZER;CN=3DORGANIZER:mailto:anonymized@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DBUYER:mailto:anonymized@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELLER:mailto:anonymized@HIDDEN=
ample
+END:VEVENT
+END:VCALENDAR
+"))
+    ;; The default parser should produce an error on the blank lines in
+    ;; DESCRIPTION:
+    (let ((ical:pre-parsing-hook nil))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (ical:parse)
+        ;; Parsing should produce error at the bad description property:
+        (should (ical:errors-p))))
+    ;; cleaning up the blank lines before parsing should correct this:
+    (let ((ical:pre-parsing-hook '(ical:fix-blank-lines)))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse)))
+          (should (not (ical:errors-p)))
+          (ical:with-component vcal
+              ((ical:vevent vevent))
+            (ical:with-component vevent
+                ((ical:description :value description))
+              (let* ((expected "CLS345")
+                     (end (length description))
+                     (start (- end (length expected))))
+              (should (equal expected
+                             (substring description start end)))))))))))
+
+(ert-deftest ipt:bad-hyphenated-dates ()
+  "Real example: bad date values containing hyphens, fixed by
+`icalendar-fix-hyphenated-dates'."
+  (let ((bad "BEGIN:VCALENDAR
+X-LOTUS-CHARSET:UTF-8
+VERSION:2.0
+PRODID:http://www.bahn.de
+METHOD:PUBLISH
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D3
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D10
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:bahn2023-08-29141400
+CLASS:PUBLIC
+SUMMARY:Frankfurt(Main)Hbf -> Hamburg Hbf
+DTSTART;TZID=3DEurope/Berlin:2023-08-29T141400
+DTEND;TZID=3DEurope/Berlin:2023-08-29T183600
+DTSTAMP:2023-07-30T194700Z
+END:VEVENT
+END:VCALENDAR
+"))
+    ;; default parser should skip the invalid DTSTART, DTEND, and DTSTAMP =
values:
+    (let ((ical:pre-parsing-hook nil))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse)))
+          ;; Parsing should produce errors as the bad properties are
+          ;; skipped:
+          (should (ical:errors-p))
+          ;; The resulting calendar is invalid because the VEVENT
+          ;; contains no DTSTAMP:
+          (should-error (ical:ast-node-valid-p vcal t)))))
+    ;; cleaning up the hyphenated dates before parsing should correct
+    ;; these problems:
+    (let ((ical:pre-parsing-hook '(ical:fix-hyphenated-dates)))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse))
+              (expected-dtstamp
+               (ical:make-date-time :year 2023 :month 7 :day 30
+                                    :hour 19 :minute 47 :second 0
+                                    :zone 0)))
+          (should (not (ical:errors-p)))
+          (should (ical:ast-node-valid-p vcal t))
+          (ical:with-component vcal
+              ((ical:vevent vevent))
+            (ical:with-component vevent
+                ((ical:dtstamp :value dtstamp))
+              (should (equal dtstamp expected-dtstamp)))))))))
+
+(ert-deftest ipt:bad-user-addresses ()
+  "Real example: bad calendar user addresses missing \"mailto:\", fixed by
+`icalendar-fix-missing-mailtos'."
+  (let ((bad "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:missing
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:45dd7698-5c53-47e3-9280-19c5dff62571
+PRIORITY:1
+DTSTART:20210721T175200
+DTEND:20210721T192400
+LOCATION:Verona Porta Nuova
+SUMMARY:Trip Verona Porta Nuova-Firenze S. M. Novella
+ORGANIZER;SENT-BY=3D\"other@HIDDEN\":anonymized@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELER:traveler@HIDDEN
+END:VEVENT
+END:VCALENDAR
+"))
+    (let ((ical:pre-parsing-hook nil))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (ical:parse)
+        ;; Parsing should produce errors as the bad properties are
+        ;; skipped:
+        (should (ical:errors-p))))
+    ;; cleaning up the addresses before parsing should correct
+    ;; these problems:
+    (let ((ical:pre-parsing-hook '(ical:fix-missing-mailtos)))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse))
+              (expected-attendee "mailto:traveler@HIDDEN")
+              (expected-organizer "mailto:anonymized@HIDDEN")
+              (expected-sender "mailto:other@HIDDEN"))
+          (should (not (ical:errors-p)))
+          (ical:with-component vcal
+              ((ical:vevent vevent))
+            (ical:with-component vevent
+                ((ical:attendee :value attendee)
+                 (ical:organizer :value organizer))
+              (should (equal attendee expected-attendee))
+              (should (equal organizer expected-organizer))
+              (ical:with-property organizer
+                  ((ical:sentbyparam :value sent-by))
+                  (should (equal sent-by expected-sender))))))))))
+
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ipt:" . "icalendar-parser-test-") ("ical:" .=
 "icalendar-"))
+;; End:
+;;; tests/icalendar-parser.el ends here
diff --git a/test/lisp/calendar/icalendar-recur-tests.el b/test/lisp/calend=
ar/icalendar-recur-tests.el
new file mode 100644
index 00000000000..90c257faefd
--- /dev/null
+++ b/test/lisp/calendar/icalendar-recur-tests.el
@@ -0,0 +1,2867 @@
+;;; icalendar-recur-tests.el --- Tests for icalendar-recur  -*- lexical-bi=
nding: t; -*-
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ert)
+(require 'icalendar-recur)
+(require 'icalendar-utils)
+(require 'icalendar-parser)
+(require 'icalendar-ast)
+
+;; Some constants for tests that use time zones:
+(defconst ict:tz-eastern
+  (ical:parse-from-string 'ical:vtimezone
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU;UNTIL=3D20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D1SU;UNTIL=3D20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+")
+"`icalendar-vtimezone' representing America/New_York (Eastern) time.")
+
+(defconst ict:est-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:standard :all stds))
+    (seq-find (lambda (obs)
+                (ical:date-time=3D
+                 (ical:make-date-time :year 2007 :month 11 :day 4
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              stds))
+  "The observance of Eastern Standard Time which began 2007-11-04")
+
+(defconst ict:edt-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:daylight :all dls))
+    (seq-find (lambda (obs)
+                (ical:date-time=3D
+                 (ical:make-date-time :year 2007 :month 3 :day 11
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              dls))
+  "The observance of Eastern Daylight Time which began 2007-03-11")
+
+(defconst ict:est -18000  ;; =3D -0500
+  "UTC offset for Eastern Standard Time")
+
+(defconst ict:edt -14400 ;; =3D -0400
+  "UTC offset for Eastern Daylight Time")
+
+
+;; Tests for basic functions:
+
+(ert-deftest ict:recur-bysetpos-filter ()
+  "Test that `icr:make-bysetpos-filter' filters correctly by position"
+  (let* ((t1 (list 1 1 2024))
+         (t2 (list 2 1 2024))
+         (t3 (list 12 30 2024))
+         (dts (list t1 t2 t3))
+         (filter (icr:make-bysetpos-filter (list 1 -1)))
+         (filtered (funcall filter dts)))
+    (should (member t1 filtered))
+    (should (member t3 filtered))
+    (should-not (member t2 filtered))))
+
+(ert-deftest ict:recur-yearday-number ()
+  "Test that `icr:calendar-date-from-yearday-number' finds correct dates"
+  (let* ((year 2025)
+         (daynos (list '(1 . (1 1 2025))
+                       '(8 . (1 8 2025))
+                       '(-1 . (12 31 2025))
+                       '(363 . (12 29 2025)))))
+    (dolist (d daynos)
+      (let ((dayno (car d))
+            (date (cdr d)))
+        (should
+         (equal date (ical:calendar-date-from-yearday-number year dayno)))=
))))
+
+(ert-deftest ict:date-time-add ()
+  "Does `ical:date-time-add' correctly handle time zone transitions?"
+  ;; A sum that does not use a time zone at all:
+  (let* ((dt (ical:make-date-time :year 2007 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3)))
+    (should (equal expected (ical:date-time-add dt delta))))
+
+  ;; A sum that does not cross an observance boundary:
+  (let* ((dt (ical:make-date-time :year 2007 :month 2 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3 :tz 'preserve)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should preserve clock ti=
me:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 12 :zone ict:edt :dst t=
)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should be exactly 48 hou=
rs later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 12 :hour 13
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should preserve clock ti=
me:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 5 :zone ict:est :dst ni=
l)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should be exactly 48 hou=
rs later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 5 :hour 11
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the Std->DST boundary and should result
+  ;; in a clock time one hour later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 11 :hour 3
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the DST->Std boundary and should result
+  ;; in a clock time one hour earlier:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 4 :hour 1
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern)))=
))
+
+(ert-deftest ict:recur-nonexistent-date-time-p ()
+  "Does `icr:nonexistent-date-time-p' correctly identify nonexistent times=
?"
+  (let*  ((dst-onset (ical:make-date-time :year 2025 :month 3 :day 9
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:est :dst nil))
+          ;; 2:30 AM falls into the gap when shifting from 2AM EST to 3AM =
EDT:
+          (nonexistent1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                             :hour 2 :minute 30 :second 0
+                                             :zone ict:est :dst nil))
+          (nonexistent2 (ical:date-time-variant nonexistent1
+                                                :zone ict:edt :dst t))
+          (std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:30AM around the shift back to EST exists twice (once in
+          ;; EDT, once in EST) and should not be nonexistent:
+          (existent1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:edt :dst t))
+          (existent2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:est :dst nil)))
+    (should (icr:nonexistent-date-time-p nonexistent1 dst-onset ict:edt-la=
test))
+    (should (icr:nonexistent-date-time-p nonexistent2 dst-onset ict:edt-la=
test))
+    (should-not
+     (icr:nonexistent-date-time-p existent1 std-onset ict:est-latest))
+    (should-not
+     (icr:nonexistent-date-time-p existent2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-date-time-occurs-twice-p ()
+  "Does `icr:date-time-occurs-twice-p' correctly identify times that occur=
 twice?"
+  (let*  ((std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:00, 1:30 AM occur twice when shifting from 2AM EDT to 1AM E=
ST:
+          (twice1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 0 :second 0))
+          (twice2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 30 :second 0))
+          ;; 12:59 AM, 2AM should not occur twice:
+          (once1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 0 :minute 59 :second 0
+                                      :zone ict:edt :dst t))
+          (once2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 2 :minute 0 :second 0
+                                      :zone ict:est :dst nil)))
+    (should (icr:date-time-occurs-twice-p twice1 std-onset ict:est-latest))
+    (should (icr:date-time-occurs-twice-p twice2 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once1 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-find-secondly-interval ()
+  "Does `icr:find-secondly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0 :second 0
+                                       ;; Use UTC for the tests with no
+                                       ;; time zone, so that the results
+                                       ;; don't depend on system's local t=
ime
+                                       :zone 0))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :z=
one 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 0 :tz 'preserve)
+             (ical:date-time-variant target :second 1 :tz 'preserve)
+             (ical:date-time-variant target :second 10 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :=
zone 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 10 :tz 'preserve)
+             (ical:date-time-variant target :second 11 :tz 'preserve)
+             (ical:date-time-variant target :second 20 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :second 20
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 20 :tz 'preserve)
+             (ical:date-time-variant target :second 21 :tz 'preserve)
+             (ical:date-time-variant target :second 30 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :second 30
+                                           :zone ict:edt :dst t))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 30 :tz 'preserve)
+             (ical:date-time-variant target :second 31 :tz 'preserve)
+             (ical:date-time-variant target :second 40 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-east=
ern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observan=
ce]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :second 1
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :second 10
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-east=
ern))))
+
+    ;; With timezone, with a "pathological" interval size of 59 seconds.
+    ;; There should be no problem with this case, because the interval
+    ;; bounds calculation is done in absolute time, but it's annoying to
+    ;; calculate the expected interval by hand:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2031 :month 4 :day 15
+                                           :hour 12 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+           (intsize 59)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 59 :second 16
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 11 :minute 59 :second 17
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 15
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-minutely-interval ()
+  "Does `icr:find-minutely-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0
+                                       ;; make sure intervals are
+                                       ;; bounded on whole minutes:
+                                       :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just a fixed offset, around a target that doesn't fall=
 on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :minute 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 0 :second 0)
+             (ical:date-time-variant target :minute 1 :second 0)
+             (ical:date-time-variant target :minute 10 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :minute 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 10 :second 0)
+             (ical:date-time-variant target :minute 11 :second 0)
+             (ical:date-time-variant target :minute 20 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :minute 20
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 20 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 21 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :minute 30
+                                           :zone ict:edt :dst t))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observan=
ce]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-hourly-interval ()
+  "Does `icr:find-hourly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole hours:
+                                       :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :hour 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 1 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0))=
))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :hour 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 20 :minute 0 :second 0))=
))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :hour 10
+                                           :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift. Note that
+    ;; because of our decision to calculate with absolute times in
+    ;; SECONDLY/MINUTELY/HOURLY rules (see `icr:find-secondly-recurrence-r=
ule')
+    ;; the interval clock times shift an hour here:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :hour 12
+                                           :zone ict:edt :dst t))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 13 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observan=
ce]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:make-date-time :year 2030 :month 3 :day 10
+                                        :hour 2 :minute 30 :second 30
+                                        :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 4 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 5 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date ()
+  "Does `icr:find-daily-interval' find correct date intervals?"
+  (let* ((dtstart (list 1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target (list 1 9 2026))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 7
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 8
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 14
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-daily-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date-time ()
+  "Does `icr:find-daily-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wedn=
esday
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart
+                                              :year 2026 :month 1 :day 9))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 8 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 14
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :da=
y 6))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 6 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 13 :hour 0 :minute 0 :sec=
ond 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz :year 2028 :month 2 =
:day 2
+                                           :zone ict:est :dst nil))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :seco=
nd 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 3 :hour 0 :minute 0 :seco=
nd 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :seco=
nd 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize ict:tz-ea=
stern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :day 28
+                                           :zone ict:edt :dst t))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :sec=
ond 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 24 :hour 0 :minute 0 :sec=
ond 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 30 :hour 0 :minute 0 :sec=
ond 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize
+                                       ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date ()
+  "Does `icr:find-weekly-interval' find correct date intervals?"
+  (let* ((dtstart '(1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target '(1 9 2026))
+           (intsize 2)
+           (expected-int-mon
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 5
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 12
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 19
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int-mon
+                     (icr:find-weekly-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date-time ()
+  "Does `icr:find-weekly-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wedn=
esday
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :month 1 :da=
y 9))
+           (intsize 2)
+           (weds 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :day 5 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 12 :hour 0 :minute 0 :sec=
ond 0)
+             (ical:date-time-variant target :day 19 :hour 0 :minute 0 :sec=
ond 0)))
+           ;; expected interval for Wednesday week start:
+           (expected-int-wed
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 14 :hour 0 :minute 0 :sec=
ond 0)
+             (ical:date-time-variant target :day 21 :hour 0 :minute 0 :sec=
ond 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize)))
+      (should
+       (equal expected-int-wed
+              (icr:find-weekly-interval target dtstart intsize weds))))
+
+    ;; Around a target that does fall on an interval boundary, Monday week=
 start:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :da=
y 4))
+           (intsize 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :year 2026 :month 12 :day 21
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :year 2026 :month 12 :day 28
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 11
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize))))
+
+    ;; Around a target that does fall on an interval boundary, Sunday week=
 start:
+    (let* ((target (ical:date-time-variant dtstart :year 2028 :month 1 :da=
y 2))
+           (intsize 3)
+           (sun 0)
+           ;; expected interval for Sunday week start:
+           (expected-int-sun
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :sec=
ond 0))))
+      (should
+       (equal expected-int-sun
+              (icr:find-weekly-interval target dtstart intsize sun))))))
+
+(ert-deftest ict:recur-find-monthly-interval ()
+  "Does `icr:find-monthly-interval' find correct intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+           (intsize 5)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2025 :month 6 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 7 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 11 :day 1
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that doesn't fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2026 :month 3 :day =
9))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :day 1 :hour 0 :minute 0 :second=
 0)
+           (ical:date-time-variant target :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2027 :month 5 :day =
1))
+         (intsize 7)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2027 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does not fall on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2028 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11))
+         (target (ical:date-time-variant dtstart
+                                         :year 2029 :month 4 :day 15))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2029 :month 3 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart
+                                         :year 2030 :month 5 :day 1))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2030 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 7 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month =3D target month
+  (let* ((dtstart (ical:make-date-time :year 2031 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart :year 2032 :month 11 :day=
 11))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2032 :month 11 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2032 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2033 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize)))))
+
+(ert-deftest ict:recur-find-yearly-interval ()
+  "Does `icr:find-yearly-interval' find correct date intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2025 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target not on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2026 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2026 :month 7 :day 28
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 3)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2027 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2028 :month 1 :day 1
+                                      :hour 0 :minute 0 :second 0))
+         (intsize 4)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2028 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2031 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target earlier than dtstart but in the same year;
+  ;; it's important that this works when looking up recurrences of
+  ;; time zone observance onsets
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 5 :day 28
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2029 :month 2 :day 14
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 1)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize)))))
+
+;; Subintervals:
+
+(ert-deftest ict:recur-refine-byyearday ()
+  "Does `icr:refine-byyearday' correctly refine by yeardays?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (yeardays (list 2 -7))
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 25)
+                     (ical:date-time-variant low :month 12 :day 26)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byyearday interval yeardays)))))
+
+(ert-deftest ict:recur-refine-bymonth ()
+  "Does `icr:refine-bymonth' correctly refine by months?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (months (list 9 2))
+         (sub1 (list (ical:date-time-variant low :month 2 :day 1)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (sub2 (list (ical:date-time-variant low :month 9 :day 1)
+                     (ical:date-time-variant low :month 10 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonth interval months)))))
+
+(ert-deftest ict:recur-refine-bymonthday ()
+  "Does `icr:refine-bymonthday' correctly refine by days of the month?"
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (monthdays (list -1 2 29))
+         ;; N.B. we should get no subinterval for Feb. 29, 2025
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :day 28)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonthday interval monthdays)))))
+
+(ert-deftest ict:recur-refine-byday ()
+  "Does `icr:refine-byday' correctly refine by days of the week?"
+  ;; The simple case: just day names
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 3 ; a Monday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 7))
+         (interval (list low high high))
+         (days (list 0 6)) ; just the weekend, please!
+         (sub1 (list (ical:date-time-variant low :day 8)
+                     (ical:date-time-variant low :day 9)))
+         (sub2 (list (ical:date-time-variant low :day 9)
+                     (ical:date-time-variant low :day 10)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days))))
+
+  ;; Day names with offsets within the month
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 1 ; a Saturday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (days (list '(1 . 2) '(1 . -1)))  ; second and last Monday
+         (sub1 (list (ical:date-time-variant low :day 10)
+                     (ical:date-time-variant low :day 11)))
+         (sub2 (list (ical:date-time-variant low :day 31)
+                     (ical:date-time-variant low :month 4 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days t))))
+
+  ;; Day names with offsets within the year
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (days (list '(5 . 1) '(5 . -1)))  ; first and last Friday
+         (sub1 (list (ical:date-time-variant low :day 3)
+                     (ical:date-time-variant low :day 4)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 26)
+                     (ical:date-time-variant low :month 12 :day 27)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days nil)))))
+
+(ert-deftest ict:recur-refine-byhour ()
+  "Does `icr:refine-byhour' correctly refine by hours?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 1))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2)
+                     (ical:date-time-variant low :hour 3)))
+         (sub2 (list (ical:date-time-variant low :hour 19)
+                     (ical:date-time-variant low :hour 20)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :day 1 ict:tz-eastern))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2 :tz 'preserve)
+                     (ical:date-time-variant low :hour 3 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :hour 19 :tz 'preserve)
+                     (ical:date-time-variant low :hour 20 :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours ict:tz-eastern)))))
+
+(ert-deftest ict:recur-refine-byminute ()
+  "Does `icr:refine-byminute' correctly refine by minutes?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 0 :second 0))
+         (high (ical:date/time-add low :hour 1))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7)
+                     (ical:date-time-variant low :minute 8)))
+         (sub2 (list (ical:date-time-variant low :minute 59)
+                     (ical:date-time-variant low :hour 14 :minute 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :hour 1 ict:tz-eastern))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7 :tz 'preserve)
+                     (ical:date-time-variant low :minute 8 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :minute 59 :tz 'preserve)
+                     (ical:date-time-variant low :hour 14 :minute 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes ict:tz-eastern)))=
))
+
+(ert-deftest ict:recur-refine-bysecond ()
+  "Does `icr:refine-bysecond' correctly refine by seconds?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 59 :second 0))
+         (high (ical:date/time-add low :minute 1))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24)
+                     (ical:date-time-variant low :second 25)))
+         (sub2 (list (ical:date-time-variant low :second 59)
+                     (ical:date-time-variant low :hour 14 :minute 0 :secon=
d 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 19 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :minute 1 ict:tz-eastern))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24 :tz 'preserve)
+                     (ical:date-time-variant low :second 25 :tz 'preserve)=
))
+         (sub2 (list (ical:date-time-variant low :second 59 :tz 'preserve)
+                     (ical:date-time-variant low :minute 20 :second 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds ict:tz-eastern)))=
))
+
+(ert-deftest ict:recur-subintervals-to-dates ()
+  "Does `icr:subintervals-to-dates' correctly generate recurrences?"
+  ;; Two subintervals, the first three days long, the second less than a s=
ingle day
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :day 3))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 31
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :hour 3)) ; later but on the same=
 day
+         (sub2 (list low2 high2))
+         (low-date1 (ical:date-time-to-date low1))
+         (low-date2 (ical:date-time-to-date low2))
+         (expected-recs (list low-date1
+                              (ical:date/time-add low-date1 :day 1)
+                              (ical:date/time-add low-date1 :day 2)
+                              (ical:date/time-add low-date1 :day 3)
+                              low-date2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-dates (list sub1 sub2))))))
+
+(ert-deftest ict:recur-subintervals-to-date-times ()
+  "Does `icr:subintervals-to-date-times' correctly generate recurrences?"
+  ;; Two subintervals, each one second long, no time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :second 1))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 2
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :second 1))
+         (sub2 (list low2 high2))
+         (expected-recs (list low1 low2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1 sub2)))))
+
+  ;; A subinterval five seconds long, with time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 6 :day 1
+                                    :hour 13 :minute 59 :second 0
+                                    :zone ict:edt :dst t))
+         (high1 (ical:date/time-add low1 :second 5 ict:tz-eastern))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date/time-add low1 :second 1 ict:tz-eastern)
+                (ical:date/time-add low1 :second 2 ict:tz-eastern)
+                (ical:date/time-add low1 :second 3 ict:tz-eastern)
+                (ical:date/time-add low1 :second 4 ict:tz-eastern))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-east=
ern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set forward an hour; these seconds should therefore be in =
EDT:
+  (let* ((low1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:est :dst nil))
+         (high1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 3 :minute 0 :second 3
+                                     :zone ict:edt :dst t))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve)
+                (ical:date-time-variant high1 :second 2 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-east=
ern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set back an hour; these seconds should therefore be in
+  ;; EST:
+  (let* ((low1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:edt :dst t))
+         (high1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                     :hour 1 :minute 0 :second 2
+                                     :zone ict:est :dst nil))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-east=
ern)))))
+
+;; Tests for time zone functions:
+
+(ert-deftest ict:recur-tz-observance-on/nonexistent ()
+  "Does `icr:tz-observance-on' correctly interpret nonexistent times?"
+  (let* ((onset-start (ical:make-date-time :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:est :dst nil))
+         (start-shifted (ical:date-time-variant onset-start :hour 3
+                                                :zone ict:edt :dst t))
+         ;; 2:30AM falls into the gap when the clock jumps from 2AM to 3AM:
+         (nonexistent (ical:date-time-variant onset-start :minute 30
+                                              :zone ict:est :dst nil))
+         (nonexistent-shifted (ical:date-time-variant nonexistent :hour 3
+                                                      :zone ict:edt :dst t=
)))
+    (icr:tz-observance-on onset-start ict:tz-eastern t) ;; updates the tim=
e to EDT
+    (icr:tz-observance-on nonexistent ict:tz-eastern t) ;; updates the tim=
e to EDT
+    (should (equal onset-start start-shifted))
+    (should (equal nonexistent nonexistent-shifted))))
+
+(ert-deftest ict:recur-tz-observance-on/occurs-twice ()
+  "Does `icr:tz-observance-on' correctly interpret times that occur twice?"
+  (let* ((onset-start (ical:make-date-time :year 2025 :month 11 :day 2
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+         ;; 1:30AM occurs twice when the clock is set back from 2AM to 1AM:
+         (no-zone (ical:date-time-variant onset-start :hour 1 :minute 30))
+         (first (ical:date-time-variant onset-start :hour 1 :minute 30
+                                        :zone ict:edt :dst t))
+         (second (ical:date-time-variant first :zone ict:est :dst nil))
+         (first+1h (ical:date/time-add first :hour 1 ict:tz-eastern)))
+    (icr:tz-observance-on no-zone ict:tz-eastern t) ;; sets zone
+    (should (equal first no-zone))
+    (should (equal second first+1h))))
+
+(ert-deftest ict:recur-tz-observance-on ()
+  "Does `icr:tz-observance-on' correctly find observances?"
+
+  ;; A date before the start of all observances in the timezone.
+  ;; In this case, there is no matching observance, so we should get nil.
+  (let* ((dt (ical:make-date-time :year 1900 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (ts (encode-time dt)))
+    (should (null (icr:tz-observance-on dt ict:tz-eastern)))
+    (should (null (icr:tz-observance-on ts ict:tz-eastern))))
+
+  ;; A date matching the start of one of the STANDARD observances:
+  (let* ((dt (ical:make-date-time :year 1967 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the start of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1967 :month 4 :day 30
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an RDATE of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1975 :month 2 :day 23
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a STANDARD observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 6 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a DAYLIGHT observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 7 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset in the middle of a DAYLIGHT observance
+  ;; which has ended:
+  (let* ((dt (ical:make-date-time :year 1980 :month 4 :day 27
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (end (ical:make-date-time :year 1986 :month 4 :day 27
+                                   :hour 7 :minute 0 :second 0
+                                   :zone 0)) ; UNTIL is in UTC
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal end (ical:recur-until
+                        (ical:with-property-of obs 'ical:rrule nil value))=
))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset of the DAYLIGHT observance which is
+  ;; ongoing:
+  (let* ((dt (ical:make-date-time :year 2025 :month 3 :day 9
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the DAYLIGHT observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:est :dst nil))
+         (dt (ical:make-date-time :year 2025 :month 5 :day 28
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the STANDARD observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; The following two tests were useful in detecting a broken optimizatio=
n:
+  (let* ((start (ical:make-date-time :year 2006 :month 10 :day 29
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2006 :month 11 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  (let* ((start (ical:make-date-time :year 2007 :month 11 :day 4
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2008 :month 2 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+
+  ;; A date in the middle of the STANDARD observance which is ongoing;
+  ;; test that the update flag correctly sets the zone information:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  ;; no zone information
+                                  ))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern t))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))))
+
+
+;; Tests for recurrence rule interpretation:
+(cl-defmacro ict:rrule-test (recur-string doc
+                             &key dtstart
+                             (low dtstart)
+                             high
+                             tz
+                             rdates
+                             exdates
+                             members
+                             nonmembers
+                             size
+                             source)
+
+  "Create a test which parses RECUR-STRING to an `icalendar-recur',
+creates an event with a recurrence set from this value, and checks
+various properties of the recurrence set.
+
+DTSTART should be an `icalendar-date' or `icalendar-date-time'
+  value appropriate to the RECUR-STRING. The value will be
+  bound to the symbol `dtstart'; this symbol can thus be used inside
+  the expressions for MEMBERS and NONMEMBERS.
+LOW and HIGH should be the bounds of the window in which to compute
+  recurrences. LOW defaults to DTSTART.
+TZ, if present, should be an `icalendar-vtimezone'.
+  Date-times in the recurrence set will be calculated relative to this
+  time zone.
+RDATES, if present, should be a list of additional
+  `icalendar-date' or `icalendar-date-time' values to be added to
+  the recurrence set *in addition to* those generated by the
+  recurrence rule (see `icalendar-rdate').
+EXDATES, if present, should be a list of `icalendar-date' or
+  `icalendar-date-time' values to be excluded from the recurrence
+  set, *even if* they are in RDATES or generated by the
+  recurrence rule (see `icalendar-exdate').
+MEMBERS, if present, should be a list of values that are expected
+  to be present in the recurrence set.
+NONMEMBERS, if present, should be a list of values that are expected
+  to be excluded from the recurrence set.
+SIZE, if present, should be a positive integer representing the
+  expected size of the recurrence set. Defaults to the value of the
+  COUNT clause in the recurrence rule, if any.
+SOURCE should be a symbol; it is used to name the test."
+  `(ert-deftest ,(intern (concat "ict:rrule-test-" (symbol-name source))) =
()
+     ,(format "Parse and evaluate recur-value example from `%s':\n%s"
+              source doc)
+     (let* ((parsed (ical:parse-from-string 'ical:recur ,recur-string))
+            (recvalue (ical:ast-node-value parsed))
+            (until (ical:recur-until recvalue))
+            (count (ical:recur-count recvalue))
+            (dtstart ,dtstart)
+            (recset-size (or ,size count))
+            (vevent
+             (ical:make-vevent
+              (ical:uid (concat "uid-test-" ,(symbol-name source)))
+              (ical:dtstart ,dtstart (ical:tzidparam "America/New_York"))
+              (ical:rrule parsed)
+              (ical:rdate ,rdates)
+              (ical:exdate ,exdates)))
+            ;; default for HIGH: UNTIL or DTSTART+3*INTERVAL
+            (win-high
+             (or ,high
+                 until
+                 (cadr
+                  (icr:nth-interval 2 ,dtstart recvalue))))
+            (recs
+             (if count
+                 (icr:recurrences-to-count vevent ,tz)
+               (icr:recurrences-in-window ,low win-high vevent ,tz))))
+       (should (ical:ast-node-valid-p parsed))
+       (when ,members
+         (dolist (dt ,members)
+           (should (member dt recs))))
+       (when ,nonmembers
+         (dolist (dt ,nonmembers)
+           (should-not (member dt recs))))
+       (when recset-size
+         (should (length=3D recs recset-size))))))
+
+(ict:rrule-test
+ "FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-1"
+ "Last non-weekend day of the month"
+ :dtstart '(3 31 2025)
+ :high '(6 1 2025)
+ :members '((3 31 2025) (4 30 2025) (5 30 2025))
+ :nonmembers '((5 31 2025)) ;; 5/31/2025 is a Saturday
+ :source rfc5545-sec3.3.10/1)
+
+(ict:rrule-test
+ "FREQ=3DYEARLY;INTERVAL=3D2;BYMONTH=3D1;BYDAY=3DSU;BYHOUR=3D8,9;BYMINUTE=
=3D30"
+ "Every Sunday in January at 8:30AM and 9:30AM, every other year"
+ :dtstart (ical:read-date-time "20250105T083000")
+ :high (ical:read-date-time "20271231T000000")
+ :members
+ (let ((jan3-27 (ical:make-date-time :year 2027 :month 1 :day 3
+                                     :hour 8 :minute 30 :second 0)))
+   (list dtstart
+         ;; 2025: Jan 5, 12, 19, 26
+         (ical:date-time-variant dtstart :hour 9)
+         (ical:date-time-variant dtstart :day 12)
+         (ical:date-time-variant dtstart :day 12 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 26)
+         (ical:date-time-variant dtstart :day 26 :hour 9)
+         ;; 2027: Jan 3, 10, 17, 24, 31
+         (ical:date-time-variant jan3-27 :hour 9)
+         (ical:date-time-variant jan3-27 :day 10)
+         (ical:date-time-variant jan3-27 :day 10 :hour 9)
+         (ical:date-time-variant jan3-27 :day 17)
+         (ical:date-time-variant jan3-27 :day 17 :hour 9)
+         (ical:date-time-variant jan3-27 :day 24)
+         (ical:date-time-variant jan3-27 :day 24 :hour 9)
+         (ical:date-time-variant jan3-27 :day 31)
+         (ical:date-time-variant jan3-27 :day 31 :hour 9)))
+ :nonmembers
+ (list
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 8 :minute 30 :second 0)
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 9 :minute 30 :second 0))
+ :source rfc5545-sec3.3.10/2)
+
+(ict:rrule-test
+ "FREQ=3DYEARLY;BYMONTH=3D2;BYMONTHDAY=3D-1"
+ "Every year on the last day in February"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 28 2025) (2 28 2026) (2 28 2027) (2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/1)
+
+(ict:rrule-test
+ "FREQ=3DYEARLY;INTERVAL=3D4;BYMONTH=3D2;BYMONTHDAY=3D29"
+ "Every four years on February 29"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/2)
+
+(ict:rrule-test
+"FREQ=3DDAILY;COUNT=3D10"
+"Daily for 10 occurrences"
+:dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                              :hour 9 :minute 0 :second 0)
+:members
+;; (1997 9:00 AM EDT) September 2-11
+(mapcar
+ (lambda (day) (ical:date-time-variant dtstart :day day))
+ (number-sequence 2 11))
+:source rfc5545-sec3.3.10/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY"
+ "Every year on a specific date, e.g. an anniversary"
+ :dtstart '(11 11 2024)
+ :high '(10 1 2030)
+ :members '((11 11 2024)
+            (11 11 2025)
+            (11 11 2026)
+            (11 11 2027)
+            (11 11 2028)
+            (11 11 2029))
+ :nonmembers '((11 11 2030))
+ :source rfc5545-sec3.6.1/3)
+
+;; Time zone tests
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19730429T070000Z"
+ "Every year on the last Sunday of April (through 1973-04-29) at 2AM.
+(Onset of US Eastern Daylight Time.)"
+ :tz ict:tz-eastern
+ ;; DTSTART and all the times below are at *3*AM EDT, because 2AM EST
+ ;; (the onset of the observance) does not exist as a local time:
+ :dtstart (ical:make-date-time :year 1967 :month 4 :day 30
+                               :hour 3 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 1973 :month 4 :day 30
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1968 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1969 :day 27 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1970 :day 26 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1971 :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1972 :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1973 :day 29 :tz 'preserve))
+ :source rfc5545-sec3.6.5/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU"
+ "Every year on the first Sunday of November at 2AM.
+(Onset of Eastern Standard Time)."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 11 :day 4
+                               :hour 2 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 2010 :month 11 :day 8
+                               :zone ict:est :dst nil)
+ :members
+ ;; all the times below are at *1*AM EST, because 2AM EDT (the onset of
+ ;; the observance) is when clocks get set back:
+ (list (ical:date-time-variant dtstart
+                               :year 2008 :month 11 :day 2
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2009 :month 11 :day 1
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2010 :month 11 :day 7
+                               :zone ict:est :dst nil))
+ :source rfc5545-sec3.6.5/3.1)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D3;BYDAY=3D1SU"
+ "Every three months on the first Sunday of the month."
+ :dtstart '(1 5 2025)
+ :high '(1 1 2026)
+ :members (list '(4 6 2025)
+                '(7 6 2025)
+                '(10 5 2025))
+ :nonmembers (list '(1 12 2025) ;; second Sun.
+                   '(2 2 2025) ;; first Sun. in Feb.
+                   '(4 5 2025)) ;; Sat.
+ :source monthly/interval)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;COUNT=3D10\n"
+ "Daily for 10 occurrences"
+ :dtstart (ical:read-date-time "19970902T090000")
+ :members
+ (mapcar
+  (lambda (day) (ical:date-time-variant dtstart :day day))
+  (number-sequence 2 11))
+ :nonmembers (list (ical:date-time-variant dtstart :day 12))
+ :high (ical:read-date-time "19970912T090000")
+ :source rfc5545-sec3.8.5.3/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;UNTIL=3D19971224T000000Z\n"
+ "Daily at 9AM until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+   ;; EDT:
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 2 30)) ;; Sept. 2--30
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :month 10 :day day
+                                         :tz 'preserve))
+   (number-sequence 1 25)) ;; Oct. 1--25
+  ;; EST:
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :zone ict:est :dst=
 nil))
+   (number-sequence 26 31))) ;; Oct. 26--31
+ :source rfc5545-sec3.8.5.3/2)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;INTERVAL=3D2\n"
+ "Every other day - forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 12 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,4,6,8...24,26,28,30;
+  ;;                    October 2,4,6...20,22,24
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 12))
+  ;; (1997 9:00 AM EST) October 26,28,30;
+  ;;                    November 1,3,5,7...25,27,29;
+  ;;                    December 1,3,...
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n)
+                             :zone ict:est :dst nil))
+   (number-sequence 13 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 11 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 12 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 2)))
+
+ :nonmembers
+ (list
+  ;; e.g.
+  (ical:make-date-time :year 1997 :month 10 :day 27
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;INTERVAL=3D10;COUNT=3D5\n"
+ "Every ten days for five recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members ;; (1997 9:00 AM EDT) September 2,12,22; October 2,12
+ (list
+  dtstart
+  (ical:make-date-time :year 1997 :month 9 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 9 :day 22
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 2
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t))
+ :source rfc5545-sec3.8.5.3/4)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;UNTIL=3D20000131T140000Z;BYMONTH=3D1;BYDAY=3DSU,MO,T=
U,WE,TH,FR,SA\n"
+ "Every day in January, for three years (weekdays explicit)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/5)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;UNTIL=3D20000131T140000Z;BYMONTH=3D1\n"
+ "Every day in January, for three years (weekdays implicit)"
+ ;; TODO: as things are currently implemented, this way of expressing
+ ;; the rule is quite expensive, since we end up computing intervals and
+ ;; recurrences for every day of the year, even though the only relevant
+ ;; days are in January and there are no recurrences on the other days.
+ ;; We could try to optimize e.g. icr:refine-from-clauses to deal with such
+ ;; cases.
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/6)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;COUNT=3D10\n"
+ "Weekly for ten occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :tz 'preserve))
+   (list 7 14 21))
+  ;; (1997 9:00 AM EST) October 28;November 4
+  (list
+   (ical:make-date-time :year 1997 :month 10 :day 28
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)
+   (ical:make-date-time :year 1997 :month 11 :day 4
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)))
+ :source rfc5545-sec3.8.5.3/7)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;UNTIL=3D19971224T000000Z\n"
+ "Every week until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (let ((oct97 (ical:date-time-variant dtstart :month 10
+                                      :zone ict:edt :dst t))
+       (nov97 (ical:date-time-variant dtstart :month 11
+                                      :zone ict:est :dst nil))
+       (dec97 (ical:date-time-variant dtstart :month 12
+                                      :zone ict:est :dst nil)))
+   (append
+    ;; (1997 9:00 AM EDT) September 2,9,16,23,30;
+    ;;                    October 7,14,21
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dtstart :day day :tz 'preserve))
+     (list 2 9 16 23 30))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant oct97 :day day :tz 'preserve))
+     (list 7 14 21))
+    ;; (1997 9:00 AM EST) October 28;
+    ;;                    November 4,11,18,25;
+    ;;                    December 2,9,16,23
+    (list (ical:date-time-variant oct97 :day 28 :zone ict:est :dst nil))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant nov97 :day day :tz 'preserve))
+     (list 4 11 18 25))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dec97 :day day :tz 'preserve))
+     (list 2 9 16 23))))
+ :source rfc5545-sec3.8.5.3/8)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;WKST=3DSU\n"
+ "Every other week - forever; Weekstart on Sunday"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,16,30;
+  ;;                        October 14
+  dtstart
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+  ;;    (1997 9:00 AM EST) October 28;
+  ;;                       November 11,25;
+  ;;                       December 9,23
+  (ical:date-time-variant dtstart :month 10 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  ;;    (1998 9:00 AM EST) January 6,20;
+  ;;                       February 3, 17
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 17
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/9)
+
+(ict:rrule-test
+"RRULE:FREQ=3DWEEKLY;UNTIL=3D19971007T000000Z;WKST=3DSU;BYDAY=3DTU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using UNTIL"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/10)
+
+(ict:rrule-test
+"RRULE:FREQ=3DWEEKLY;COUNT=3D10;WKST=3DSU;BYDAY=3DTU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using COUNT"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/11)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;UNTIL=3D19971224T000000Z;WKST=3DSU;BYDA=
Y=3DMO,WE,FR\n"
+ "Every other week on Monday, Wednesday, and Friday until December 24,
+1997, starting on Monday, September 1, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  dtstart
+  ;; =3D=3D> (1997 9:00 AM EDT) September 1,3,5,15,17,19,29;
+  ;;                        October 1,3,13,15,17
+  (ical:date-time-variant dtstart :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 5 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 29 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 13 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 17 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 27,29,31;
+  ;;                        November 10,12,14,24,26,28;
+  ;;                        December 8,10,12,22
+  (ical:date-time-variant dtstart :month 10 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 29 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 31 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 14 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 26 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 8  :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22 :zone ict:est :dst nil=
))
+ :nonmembers
+ (list
+  ;; These match the rule, but are just past the UNTIL date:
+  (ical:date-time-variant dtstart :month 12 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 26 :zone ict:est :dst nil=
))
+ :source rfc5545-sec3.8.5.3/12)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D8;WKST=3DSU;BYDAY=3DTU,TH\n"
+ "Every other week on Tuesday and Thursday, for 8 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ ;; =3D=3D> (1997 9:00 AM EDT) September 2,4,16,18,30;
+ ;;                        October 2,14,16
+ (list
+   dtstart
+   (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 16 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/13)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYDAY=3D1FR\n"
+ "Monthly on the first Friday for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 5;October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2;February 6;March 6;April 3
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 3
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 1;June 5
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 5 :tz 'preserve=
))
+  :source rfc5545-sec3.8.5.3/14)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;UNTIL=3D19971224T000000Z;BYDAY=3D1FR\n"
+ "Monthly on the first Friday until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 5; October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/15)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;COUNT=3D10;BYDAY=3D1SU,-1SU\n"
+ "Every other month on the first and last Sunday of the month for 10 occur=
rences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 7
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 7,28
+  dtstart
+  (ical:date-time-variant dtstart :day 28 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,30
+  (ical:date-time-variant dtstart :month 11 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 4,25;March 1,29
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 25
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 3,31
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 31 :tz 'preserv=
e))
+ :source rfc5545-sec3.8.5.3/16)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D6;BYDAY=3D-2MO\n"
+ "Monthly on the second-to-last Monday of the month for 6 months"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 22
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 22;October 20
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 20 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 17;December 22
+  (ical:date-time-variant dtstart :month 11 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 19;February 16
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 19
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 16
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/17)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D-3\n"
+ "Monthly on the third-to-the-last day of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 28
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 28
+  dtstart
+  ;;     (1997 9:00 AM EST) October 29;November 28;December 29
+  (ical:date-time-variant dtstart :month 10 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/18)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D2,15\n"
+ "Monthly on the 2nd and 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,15;October 2,15
+  dtstart
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,15;December 2,15
+  (ical:date-time-variant dtstart :month 11 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 15
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 15
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2,15
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 15
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/19)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D1,-1\n"
+ "Monthly on the first and last day of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 30
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 30;October 1
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 31;November 1,30;December 1,31
+  (ical:date-time-variant dtstart :month 10 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 31
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 1,31;February 1
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 1
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/20)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D18;COUNT=3D10;BYMONTHDAY=3D10,11,12,13,1=
4,15\n"
+ "Every 18 months on the 10th thru 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  (list
+   ;; =3D=3D> (1997 9:00 AM EDT) September 10,11,12,13,14,15
+   dtstart
+   (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 12 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 13 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 15 :tz 'preserve))
+
+  ;;     (1999 9:00 AM EST) March 10,11,12,13
+  (let ((mar99 (ical:make-date-time :year 1999 :month 3 :day 10
+                                    :hour 9 :minute 0 :second 0
+                                    :zone ict:est :dst nil)))
+    (list
+     mar99
+     (ical:date-time-variant mar99 :day 11 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 12 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 13 :tz 'preserve))))
+ :nonmembers
+ (list
+  ;; These match the rule but are excluded by the COUNT clause:
+  (ical:make-date-time :year 1999 :month 3 :day 14
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil)
+  (ical:make-date-time :year 1999 :month 3 :day 15
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/21)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;BYDAY=3DTU\n"
+ "Every Tuesday, every other month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,9,16,23,30
+  dtstart
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 4,11,18,25
+  (ical:date-time-variant dtstart :month 11 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 6,13,20,27;March 3,10,17,24,31
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 27
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 24
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 31
+                          :zone ict:est :dst nil))
+ :nonmembers
+ ;; e.g. Tuesdays in December 1997:
+ (list
+  (ical:date-time-variant dtstart :month 12 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 9 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 16 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil=
))
+ :source rfc5545-sec3.8.5.3/22)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;COUNT=3D10;BYMONTH=3D6,7\n"
+ "Yearly in June and July for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ ;; Note: Since none of the BYDAY, BYMONTHDAY, or BYYEARDAY
+ ;; clauses are specified, the month day is gotten from "DTSTART"
+ :members
+ ;; =3D=3D> (1997 9:00 AM EDT) June 10;July 10
+ ;;     (1998 9:00 AM EDT) June 10;July 10
+ ;;     (1999 9:00 AM EDT) June 10;July 10
+ ;;     (2000 9:00 AM EDT) June 10;July 10
+ ;;     (2001 9:00 AM EDT) June 10;July 10
+ (mapcan
+  (lambda (y)
+    (list
+     (ical:date-time-variant dtstart :year y :month 6 :tz 'preserve)
+     (ical:date-time-variant dtstart :year y :month 7 :tz 'preserve)))
+  (number-sequence 1997 2001))
+ :source rfc5545-sec3.8.5.3/23)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;INTERVAL=3D2;COUNT=3D10;BYMONTH=3D1,2,3\n"
+ "Every other year on January, February, and March for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ ;; =3D=3D> (1997 9:00 AM EST) March 10
+ ;;     (1999 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2001 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2003 9:00 AM EST) January 10;February 10;March 10
+ (cons
+  dtstart
+  (mapcan
+   (lambda (y)
+     (list
+      (ical:date-time-variant dtstart :year y :month 1 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 2 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 3 :tz 'preserve)))
+   (list 1999 2001 2003)))
+ :source rfc5545-sec3.8.5.3/24)
+
+(ict:rrule-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D3;COUNT=3D10;BYYEARDAY=3D1,100,200\n"
+"Every third year on the 1st, 100th, and 200th day for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EST) January 1
+  dtstart
+  ;;     (1997 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :month 4 :day 10 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 7 :day 19 :zone ict:edt :dst t)
+  ;;     (2000 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2000 :tz 'preserve)
+  ;;     (2000 9:00 AM EDT) April 9;July 18
+  (ical:date-time-variant dtstart :year 2000 :month 4 :day 9 :zone ict:edt=
 :dst t)
+  (ical:date-time-variant dtstart :year 2000 :month 7 :day 18 :zone ict:ed=
t :dst t)
+  ;;     (2003 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2003 :tz 'preserve)
+  ;;     (2003 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :year 2003 :month 4 :day 10 :zone ict:ed=
t :dst t)
+  (ical:date-time-variant dtstart :year 2003 :month 7 :day 19 :zone ict:ed=
t :dst t)
+  ;;     (2006 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2006 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/25)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYDAY=3D20MO\n"
+ "Every 20th Monday of the year, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 5 :day 19
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) May 19
+  ;;     (1998 9:00 AM EDT) May 18
+  ;;     (1999 9:00 AM EDT) May 17
+  ;;     ...
+  dtstart
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/26)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYWEEKNO=3D20;BYDAY=3DMO\n"
+ "Every year on Monday in Week 20 (where the week starts Monday), forever"
+ :tz ict:tz-eastern
+ :dtstart
+ (ical:make-date-time :year 1997 :month 5 :day 12
+                      :hour 9 :minute 0 :second 0
+                      :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 12 :tz 'preserve) ; a Tu=
esday
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)) ; wro=
ng weekno
+ :source rfc5545-sec3.8.5.3/27)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3DTH\n"
+ "Every Thursday in March, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ (append
+  ;; =3D=3D> (1997 9:00 AM EST) March 13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 13 20 27))
+  ;;     (1998 9:00 AM EST) March 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 5 12 19 26))
+  ;;     (1999 9:00 AM EST) March 4,11,18,25
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 4 11 18 25)))
+ :source rfc5545-sec3.8.5.3/28)
+
+(ict:rrule-test
+"RRULE:FREQ=3DYEARLY;BYDAY=3DTH;BYMONTH=3D6,7,8\n"
+"Every Thursday, but only during June, July, and August, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; =3D=3D> (1997 9:00 AM EDT) June 5,12,19,26;July 3,10,17,24,31;
+  ;;                        August 7,14,21,28
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 5 12 19 26))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 7 :day d :tz 'preserve))
+   (list 3 10 17 24 31))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 8 :day d :tz 'preserve))
+   (list 7 14 21 28))
+  ;;     (1998 9:00 AM EDT) June 4,11,18,25;July 2,9,16,23,30;
+  ;;                        August 6,13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 4 11 18 25))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 7 :day d :tz 'prese=
rve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 8 :day d :tz 'prese=
rve))
+   (list 6 13 20 27))
+  ;;     (1999 9:00 AM EDT) June 3,10,17,24;July 1,8,15,22,29;
+  ;;                        August 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 3 10 17 24))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 7 :day d :tz 'prese=
rve))
+   (list 1 8 15 22 29))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 8 :day d :tz 'prese=
rve))
+   (list 5 12 19 26)))
+ :source rfc5545-sec3.8.5.3/29)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYDAY=3DFR;BYMONTHDAY=3D13\n"
+ "Every Friday the 13th, forever, *excluding* DTSTART "
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 2000 :month 10 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :exdates (list dtstart)
+ :members
+ (list
+  ;; =3D=3D> (1998 9:00 AM EST) February 13;March 13;November 13
+  ;;     (1999 9:00 AM EDT) August 13
+  ;;     (2000 9:00 AM EDT) October 13
+  ;;     ...
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 11 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1999 :month 8 :day 13 :tz 'preserv=
e)
+  (ical:date-time-variant dtstart :year 2000 :month 10 :day 13 :tz 'preser=
ve))
+ :source rfc5545-sec3.8.5.3/30)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYDAY=3DSA;BYMONTHDAY=3D7,8,9,10,11,12,13\n"
+ "The first Saturday that follows the first Sunday of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 6 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 13;October 11
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 11 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 8;December 13
+  (ical:date-time-variant dtstart :month 11 :day 8 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 13 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 10;February 7;March 7
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 7
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 7
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) April 11;May 9;June 13...
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 11 :tz 'preserv=
e)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 13 :tz 'preserv=
e))
+ :source rfc5545-sec3.8.5.3/31)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;INTERVAL=3D4;BYMONTH=3D11;BYDAY=3DTU;BYMONTHDAY=3D2,=
3,4,5,6,7,8\n"
+ "Every 4 years, the first Tuesday after a Monday in November, forever
+(U.S. Presidential Election day)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1996 :month 11 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; =3D=3D> (1996 9:00 AM EST) November 5
+  dtstart
+  ;;     (2000 9:00 AM EST) November 7
+  (ical:date-time-variant dtstart :year 2000 :day 7 :tz 'preserve)
+  ;;     (2004 9:00 AM EST) November 2
+  (ical:date-time-variant dtstart :year 2004 :day 2 :tz 'preserve))
+  :source rfc5545-sec3.8.5.3/32)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D3;BYDAY=3DTU,WE,TH;BYSETPOS=3D3\n"
+ "The third instance into the month of one of Tuesday, Wednesday, or
+Thursday, for the next 3 months"
+ ;; TODO: Yikes, why is this so slow??
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 4
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 4;October 7
+  ;;     (1997 9:00 AM EST) November 6
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 7 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 11 :day 6 :zone ict:est :dst nil))
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-2\n"
+ "The second-to-last weekday of the month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 29
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 29
+  dtstart
+  ;;     (1997 9:00 AM EST) October 30;November 27;December 30
+  (ical:date-time-variant dtstart :month 10 :day 30 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26;March 30
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 30
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/34)
+
+(ict:rrule-test
+ ;; corrected, see Errata ID 3883: https://www.rfc-editor.org/errata/eid38=
83
+ "RRULE:FREQ=3DHOURLY;INTERVAL=3D3;UNTIL=3D19970902T210000Z\n"
+ "Every 3 hours from 9:00 AM to 5:00 PM on a specific day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; =3D=3D> (September 2, 1997 EDT) 09:00,12:00,15:00
+  dtstart
+  (ical:date-time-variant dtstart :hour 12 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/35)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMINUTELY;INTERVAL=3D15;COUNT=3D6\n"
+ "Every 15 minutes for 6 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; =3D=3D> (September 2, 1997 EDT) 09:00,09:15,09:30,09:45,10:00,10:15
+  dtstart
+  (ical:date-time-variant dtstart :minute 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 45 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/36)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMINUTELY;INTERVAL=3D90;COUNT=3D4\n"
+ "Every hour and a half for 4 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (September 2, 1997 EDT) 09:00,10:30;12:00;13:30
+  dtstart
+  (ical:date-time-variant dtstart :hour 10 :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 12 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 13 :minute 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/37)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;BYHOUR=3D9,10,11,12,13,14,15,16;BYMINUTE=3D0,20,40\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; =3D=3D> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preser=
ve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'prese=
rve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'prese=
rve)))
+   (number-sequence 9 16)))
+ :source rfc5545-sec3.8.5.3/38)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMINUTELY;INTERVAL=3D20;BYHOUR=3D9,10,11,12,13,14,15,16\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day
+(Alternative rule for the previous example)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; =3D=3D> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preser=
ve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'prese=
rve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'prese=
rve)))
+   (number-sequence 9 16)))
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DMO\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Monday, for four recurr=
ences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 EDT) August 5,10,19,24
+  dtstart
+  (ical:date-time-variant dtstart :day 10 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 24 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/40)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DSU\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Sunday, for four recurr=
ences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 EDT) August 5,17,19,31
+  dtstart
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 31 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/41)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D15,30;COUNT=3D5\n"
+ "An example where an invalid date (i.e., February 30) is ignored."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 1 :day 15
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2007 :month 4 :day 1
+                               :hour 0 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (2007 EST) January 15,30
+  ;;     (2007 EST) February 15
+  ;;     (2007 EDT) March 15,30
+  dtstart
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 3 :day 15 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 3 :day 30 :zone ict:edt :dst t))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :month 2 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/42)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-") ("icr:" . "icalen=
dar-recur-") ("ical:" . "icalendar-"))
+;; End:
+;;; tests/icalendar-recur.el ends here
diff --git a/test/lisp/calendar/icalendar-tests.el b/test/lisp/calendar/ica=
lendar-tests.el
index 5e745c05d0a..a409c97a815 100644
--- a/test/lisp/calendar/icalendar-tests.el
+++ b/test/lisp/calendar/icalendar-tests.el
@@ -35,6 +35,17 @@
 (require 'ert-x)
 (require 'icalendar)
=20
+;; Some variables in the icalendar-* namespace have now been aliased to
+;; diary-icalendar-*:
+(require 'diary-icalendar)
+
+;; These values were used by icalendar.el when tests were written; this
+;; is a quick fix for failing tests:
+(setq icalendar-recurring-start-year 2005
+      icalendar-vcalendar-prodid "-//Emacs//NONSGML icalendar.el//EN"
+      icalendar-uid-format "emacs%t%c"
+      icalendar-export-hidden-diary-entries t)
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; Helpers
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
--=20
2.39.5


--=-=-=--




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 13 Nov 2025 13:13:08 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Thu Nov 13 08:13:08 2025
Received: from localhost ([127.0.0.1]:54760 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vJX8O-0002Zo-2o
	for submit <at> debbugs.gnu.org; Thu, 13 Nov 2025 08:13:08 -0500
Received: from fout-a8-smtp.messagingengine.com ([103.168.172.151]:56809)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1vJX8L-0002ZN-SX
 for 74994 <at> debbugs.gnu.org; Thu, 13 Nov 2025 08:13:06 -0500
Received: from phl-compute-01.internal (phl-compute-01.internal [10.202.2.41])
 by mailfout.phl.internal (Postfix) with ESMTP id B0015EC01B1;
 Thu, 13 Nov 2025 08:13:00 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-01.internal (MEProxy); Thu, 13 Nov 2025 08:13:00 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1763039580;
 x=1763125980; bh=4qoZCaXkMp3SbvMTFzh2MMR0PrpHDhb6+6Ui/UbocW8=; b=
 Mz97wOXYf42r3N0/IomZb9eTyDjWdFk1uQt+R7jdbHz12rDG2PL5awBp86vYCULr
 SFmaq6cdWFXeRPw3ky+H5/b0PFd7V/3d7WcPcSO8TL4PX7AFwVt4SnARBbRNhKit
 OHSXQ739n7DkPfbSbCdXi0P1eceQfHNCEzuBeCnXEzokvBxKc7z/HzHPlJ/Cwzwt
 Aw2WVvNflJuj+i9mbrVNEvyJ3KLyq0ueOIcONqHAAfmPdHYjYXzIR8bYvmVFsJMy
 KWcezlaHXnzkzj1Yc22Aq1wSEfyhJSqeGBHRM2sSKe5IjXz4hWBZWWosi/qWy/KI
 mDKHFmrfaH6ciZzoMYahtg==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1763039580; x=1763125980; bh=4qoZCaXkMp3SbvMTFzh2MMR0PrpHDhb6+6U
 i/UbocW8=; b=jXvSUFZQwGNHLOKp/UBRG/G+95L1jBDSeFlae/cUuDKkvlx1wof
 ZMBFHUlup/MUaKMSzOex0ksVpNQH5GskvZ/gzEtLPAH0ynVjCr2ZnnMDqX5JUcWw
 Tgix60RVdD75WdF954hmO8pgO2lv7iV3G0w4hDQNznSiCkplwm5n4nFr/aRBZwB6
 cY6f9D0g8UmxEX+E0lpCgbLlE6LhQuZZqRMiMzSNMr5ZFvc6RXFKXW6Q9EHC0Fur
 m+Lz1B/UzIg4vKZcZ+aX+XTIUJ50wWfggZU+WFM71e+/LsVVNvNjOpC3WtcJMz5K
 pl/vk6ntPx7bi35s+WMg7X1+lZohWkAgtlA==
X-ME-Sender: <xms:XNkVaZXfjzJ0E5p2F8fxmUbl3y37RqU0Z-neGe1jUxP3bZD0McpvQg>
 <xme:XNkVafmvEfZwijvPlhbtC-ky7ZWYgxdO4PtiVoZ7zm4dIw6xCHNobHlyjU-9jyDkD
 8MUPzM8l1GkPWzlR_6PLjI89RYVZXqnTxT-tMGJr1ZDvjdu71GvdA>
X-ME-Received: <xmr:XNkVaTCZdgWbQvnFQ9WveqZ0rAVUEyHLu3tW2_9T8W47-7ZUtX3jSode7bAMX_ZoUybjgnlGCcsN4ycoVNKRiZVawsgw4p9hkyDzAg>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeeffedrtdeggddvtdejtdefucetufdoteggodetrf
 dotffvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdfurfetoffkrfgpnffqhgenuceu
 rghilhhouhhtmecufedttdenucesvcftvggtihhpihgvnhhtshculddquddttddmnecujf
 gurhephffvvefujghffffkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhgu
 ucfnrgifrhgvnhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqe
 enucggtffrrghtthgvrhhnpeefueffvdeffeeftdeutdfgjeettdduveduudefjedtkeej
 gfehhedvgffgffduhfenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrih
 hlfhhrohhmpehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgt
 phhtthhopedvpdhmohguvgepshhmthhpohhuthdprhgtphhtthhopegvlhhiiiesghhnuh
 drohhrghdprhgtphhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:XNkVaXeiOuHY4Or9FhAcv6hkNtqN3DqCBfkh-c9EjEJJHO-RUN3dGQ>
 <xmx:XNkVaWKUaHU4cfkv-snqERQwhXx4QeTJYWab4-5qKmLD8bb23v9h2Q>
 <xmx:XNkVaSclx1YicJkcb4sZ927uGZCxGqj9redLa_Jfb20UvlcJYJLfIQ>
 <xmx:XNkVaV2OtMsBbxGR6wigNI8aJQ3ggeaE5Rw3gCMmvdgBzMANd_w5GQ>
 <xmx:XNkVaTp2BZs4I6rk6yPYQatesYmx6-zf4E3Xba5Q_nkg9SYbBKGYmDn3>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Thu,
 13 Nov 2025 08:12:59 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <86cy5s7otc.fsf@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <87h5vctpwt.fsf@HIDDEN> <86cy5s7otc.fsf@HIDDEN>
Date: Thu, 13 Nov 2025 14:12:50 +0100
Message-ID: <87pl9mcbzh.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Eli Zaretskii <eliz@HIDDEN> writes:

>> 1) When adding :version tags to defcustom/defgroup, I wasn't sure what
>>    version to target, so I went with "32.1". What should this be?
>
> The next feature release will be 31.1, so if your patch will be ready
> in a month or two, use that.  Otherwise, use 32.1.

Got it, thanks. From my side, a month or two sounds realistic, and it'd
be great to make it into the next release, but I'll wait to hear what
you and the other maintainers have to say before I make this change.

>> 2) I have a handful of tests that pass when run interactively, but fail
>>    under "make check". The reason for this is that `pp' produces some
>>    tab characters under "make check" that it does in an interactive
>>    Emacs. Does anyone know what config I need to tweak to fix this?
>
> Please show the tests and their failing results.

Sorry, nevermind -- I double checked and it looks like it's an issue
with my config, not with the different environment under "make check".
I'll fix the tests.

I've got a draft of the new manual section and am working on etc/NEWS.
I'll hopefully be able to submit an updated patch this weekend.

Thanks!

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 8 Nov 2025 11:21:18 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sat Nov 08 06:21:18 2025
Received: from localhost ([127.0.0.1]:51124 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vHh0Q-0003VO-7Q
	for submit <at> debbugs.gnu.org; Sat, 08 Nov 2025 06:21:18 -0500
Received: from eggs.gnu.org ([2001:470:142:3::10]:60018)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <eliz@HIDDEN>) id 1vHh0N-0003VI-1x
 for 74994 <at> debbugs.gnu.org; Sat, 08 Nov 2025 06:21:15 -0500
Received: from fencepost.gnu.org ([2001:470:142:3::e])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <eliz@HIDDEN>)
 id 1vHh0H-0003qh-Lx; Sat, 08 Nov 2025 06:21:09 -0500
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=gnu.org;
 s=fencepost-gnu-org; h=References:Subject:In-Reply-To:To:From:Date:
 mime-version; bh=kqjoWM9kiK9XZ7HuelhK76CbATV39m632924WaVVpRQ=; b=TR9rWpghfkdh
 gTvwsIWExT/AVd6x2X0hOcy68HBNUg1vhU6LXPPs1n5RmlYNcEhsB2y2xpZ3CitYrRmQ1qPNLP9fC
 MGIbRHpuMVf7QkwVkUkS/6yT70ileh5WKbMCORs+r4pE/J6xGdIXMnEN9+x5Te3qsEucjW7HnrTm5
 0x8/lA6Z8+Hg2shMN1HOfa4FP5ZXGN6twfzDgpQSoCpZpJKVgwZY28vKdbl7VDaZ+/hg6CSl6x87o
 7t0YOYgT0Id4CJAxSmPY8RPkWUmVbIpqLWbwZwtEZ27TpO2UTAaB8d2Bieoqm7DItaVIMKA9xu1+M
 vo8PDwVkvjZyMsHyTPjQuw==;
Date: Sat, 08 Nov 2025 13:21:03 +0200
Message-Id: <86cy5s7otc.fsf@HIDDEN>
From: Eli Zaretskii <eliz@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
In-Reply-To: <87h5vctpwt.fsf@HIDDEN> (message from Richard
 Lawrence on Sun, 02 Nov 2025 16:28:18 +0100)
Subject: Re: bug#74994: Acknowledgement (Improve Emacs iCalendar support)
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <87h5vctpwt.fsf@HIDDEN>
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

> From: Richard Lawrence <rwl@HIDDEN>
> Date: Sun, 02 Nov 2025 16:28:18 +0100
> 
> Sorry for the long silence -- I got busy for a while and have only come
> back to this project recently.

Thanks for your work on these parts of Emacs.

> 1) When adding :version tags to defcustom/defgroup, I wasn't sure what
>    version to target, so I went with "32.1". What should this be?

The next feature release will be 31.1, so if your patch will be ready
in a month or two, use that.  Otherwise, use 32.1.

> 2) I have a handful of tests that pass when run interactively, but fail
>    under "make check". The reason for this is that `pp' produces some
>    tab characters under "make check" that it does in an interactive
>    Emacs. Does anyone know what config I need to tweak to fix this?

Please show the tests and their failing results.

In general, if we find that it is impossible to make them work in
non-interactive sessions, we can always use 'skip-unless', but it is
best to adapt the tests to both interactive and non-interactive
invocations, if that is feasible.




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 2 Nov 2025 15:28:38 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sun Nov 02 10:28:38 2025
Received: from localhost ([127.0.0.1]:59521 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1vFa0U-0004Du-IX
	for submit <at> debbugs.gnu.org; Sun, 02 Nov 2025 10:28:38 -0500
Received: from fout-a8-smtp.messagingengine.com ([103.168.172.151]:51421)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1vFa0Q-0004DQ-Ro
 for 74994 <at> debbugs.gnu.org; Sun, 02 Nov 2025 10:28:35 -0500
Received: from phl-compute-06.internal (phl-compute-06.internal [10.202.2.46])
 by mailfout.phl.internal (Postfix) with ESMTP id 08AD1EC02CC
 for <74994 <at> debbugs.gnu.org>; Sun,  2 Nov 2025 10:28:29 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-06.internal (MEProxy); Sun, 02 Nov 2025 10:28:29 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1762097308;
 x=1762183708; bh=jlYl2bbIln+n1DpyHo27I8lNZnH8HKbHCp7y7RUT84E=; b=
 Jpp0Kx641zpF0Q+8nmLPVfDCr4oumw6jSYuPUXboeNCelb9xJD/QPkqTLdm2Yyte
 n07njloJ4zE89QKOoLg+QnZ3+Suunlou5zIWLYmF512YxY6p2KvFTSrClj7u1k0f
 ZUuNQSVZlECflhAsNgGZCzYuFKVHARtedjLAbLyENHTqNlzdU2ksoaTWl4k5WQK0
 SPbpGaj8oxM99MAgX64iA+uAsXXVUeihgXKBNp5BYIKizYCyGA7n+qAn/uZCEpYX
 e6szeFDvMYNHfNy7S6X/zU/+eFmROou5BppObsrtZdWwdG5zBPHaS2hFxWkB84ys
 AVyY7oU2lit++rZg/vkNdQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1762097308; x=1762183708; bh=jlYl2bbIln+n1DpyHo27I8lNZnH8HKbHCp7
 y7RUT84E=; b=MIk353chrjAvsCxSoKC+bdvZP8prZmFzcy/sW+v2rXqHU+SsDkC
 r7iTo9Z56XoUBmyAd8bzujxzGop3QGTfK+jAvIVt1JF+yXdDyOckEEH+PxjnyAg/
 YWQokQXpk/8yqxzH00cSJcZ+KjuZxsINoRkWz6PjkjV153rS98AgBTKtis0hMFUp
 dkAEgc8RRL//iN3Rp56/VIZDRszu3JoiCIlboX0xeD3QgIARgU9x8eZ8YSC1VX/g
 etrhtHxt3mfl/Q+oxV9QLfh3vJ6fPv95nKEbGTgqzzJeN08pypSoetVbQRbDuW+j
 b8yhzbU7VSg/a4eb6KjRXpbagYs5OfErSdA==
X-ME-Sender: <xms:nHgHaRFzRVE0FwfCW954WDqTgT7zXmLQc5tsAO9oPihD1j6vkJnH4w>
 <xme:nHgHadRYP3gFGfmWXfrD9xL2ltCqhK7a2TwQr2BqhGFN4cP_z9Sj2UMZVRwx8F0_d
 y4TdxO6krFl27Ere5lQeS6pQRk2DnDwUnzWqUQawMSqOkEAsVRqEA>
X-ME-Received: <xmr:nHgHaYy1WVR6qSQDzBMBprrS9aU8LylzDA0vYo3_bD5q6BHttH5ICd6BtfAklJx8uUCt1u-Nmg6LmFkwChrqqup8Y1HhSRKDlekdeQ>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeeffedrtdeggddujeehieduucetufdoteggodetrf
 dotffvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdfurfetoffkrfgpnffqhgenuceu
 rghilhhouhhtmecufedttdenucenucfjughrpefhvffujghffffkgggtsehmtderredttd
 dtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgvnhgtvgcuoehrfihlsehrvggtuhhr
 shgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrghtthgvrhhnpefgfffgveetuefgtd
 dugfejtdejfeefudfgkeeffeeuhffgueekieevtdevueetueenucffohhmrghinheprhgv
 tghurhhsvgifihhthhhlvghsshdrnhgvthdpihgtrghlvghnuggrrhdqughirghrhidqih
 hmphhorhhtqdgvgihpohhrthdrohhrghenucevlhhushhtvghrufhiiigvpedtnecurfgr
 rhgrmhepmhgrihhlfhhrohhmpehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvg
 htpdhnsggprhgtphhtthhopedupdhmohguvgepshhmthhpohhuthdprhgtphhtthhopeej
 geelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:nHgHacOV07DovQRygFwJ2DI9lOswOoL400CFUnLL7PBSW7D2iNkZWw>
 <xmx:nHgHaXPrjC2w9ZUccWJiOcj9MvVRge9JR_lcaYW3UWAJnwIk_WwenQ>
 <xmx:nHgHaeR-OIrg5qD9T9mubh-12FlqqXuR2puQ7IQ1vt9RJLn95bhQTQ>
 <xmx:nHgHaZDoPV3wVHKNeu4vOCaeOVvz5GCX_4X9evLe-7GpC2YD-SP78A>
 <xmx:nHgHaWffv723YQOahOjzyGLsxh6WOhDHxIdjnln2NCwIjUlM86UUaJuF>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA for
 <74994 <at> debbugs.gnu.org>; Sun, 2 Nov 2025 10:28:26 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: 74994 <at> debbugs.gnu.org
Subject: Re: bug#74994: Acknowledgement (Improve Emacs iCalendar support)
In-Reply-To: <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
Date: Sun, 02 Nov 2025 16:28:18 +0100
Message-ID: <87h5vctpwt.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="=-=-="
X-Debbugs-Envelope-To: 74994
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>

--=-=-=
Content-Type: text/plain

Sorry for the long silence -- I got busy for a while and have only come
back to this project recently.

But I'm (hopefully) nearly done! Since my last patch, I've finished
reimplementing diary import and export using the new parser. I'm now
confident that the new library can completely replace icalendar.el, and
that it will be a much better base for others to build on.

So at this point, I'd like to ask for a review and for feedback about
what still needs to happen to get this code merged to master.

I already know that the patch will also need an etc/NEWS entry and an
update to the manual. I will be working on these while I wait for
feedback on the code.

I have a couple of questions already:

1) When adding :version tags to defcustom/defgroup, I wasn't sure what
   version to target, so I went with "32.1". What should this be?

2) I have a handful of tests that pass when run interactively, but fail
   under "make check". The reason for this is that `pp' produces some
   tab characters under "make check" that it does in an interactive
   Emacs. Does anyone know what config I need to tweak to fix this?

Here's a brief overview of what's in this patch:

- icalendar.el: removed TODO list, deprecated most functions and variables,
  added library-wide options and error handling code 
- diary-icalendar.el: new implementation of diary import/export
- icalendar-ast.el: parse tree implementation
- icalendar-macs.el: macros
- icalendar-mode.el: major mode that provides syntax highlighting
- icalendar-parser.el: the parser
- icalendar-recur.el: implements recurrence rules for repeating events
- icalendar-utils.el: various utility functions, especially for
  date/time arithmetic
- cal-dst.el: docstring improvement
- calendar.el: new variables to control date format when inserting
  (these were weirdly missing, despite all the existing diary date options)
- diary-lib.el: use the new variables in diary-insert-entry
- test/lisp/calendar: *lots* of tests for all the various parts of the
  implementation, including ports of the old icalendar.el tests to the
  new implementation of diary import/export

Everything *should* be completely backward compatible; please let me
know if you notice otherwise.

I've posted a more detailed writeup documenting some of my decisions here:

https://recursewithless.net/emacs/icalendar-diary-import-export.html
https://recursewithless.net/emacs/icalendar-diary-import-export.org

Looking forward to your feedback!

Best,
Richard


--=-=-=
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
 filename=0001-Updated-patch-for-Bug-74994-reimplement-diary-import.patch
Content-Transfer-Encoding: quoted-printable
Content-Description: "Finished" iCalendar implementation

From 107577ad51b0dd5d9e50b23dabb9a33fe1545ebb Mon Sep 17 00:00:00 2001
From: Richard Lawrence <rwl@HIDDEN>
Date: Thu, 19 Dec 2024 14:30:57 +0100
Subject: [PATCH] Updated patch for Bug#74994: reimplement diary import/expo=
rt

---
 lisp/calendar/cal-dst.el                      |    4 +-
 lisp/calendar/calendar.el                     |   60 +-
 lisp/calendar/diary-icalendar.el              | 3883 +++++++++++++
 lisp/calendar/diary-lib.el                    |    6 +-
 lisp/calendar/icalendar-ast.el                |  863 +++
 lisp/calendar/icalendar-macs.el               | 1153 ++++
 lisp/calendar/icalendar-mode.el               |  605 +++
 lisp/calendar/icalendar-parser.el             | 4784 +++++++++++++++++
 lisp/calendar/icalendar-recur.el              | 2070 +++++++
 lisp/calendar/icalendar-utils.el              |  712 +++
 lisp/calendar/icalendar.el                    |  599 ++-
 .../import-bug-11473.diary-american           |    9 +
 .../import-bug-11473.diary-european           |    9 +
 .../import-bug-11473.diary-iso                |    9 +
 .../import-bug-11473.ics                      |   54 +
 .../import-bug-22092.diary-american           |    6 +
 .../import-bug-22092.diary-european           |    6 +
 .../import-bug-22092.diary-iso                |    6 +
 .../import-bug-22092.ics                      |   30 +
 .../import-bug-24199.diary-all                |   12 +
 .../import-bug-24199.ics                      |   25 +
 .../import-bug-33277.diary-american           |    2 +
 .../import-bug-33277.diary-european           |    2 +
 .../import-bug-33277.diary-iso                |    2 +
 .../import-bug-33277.ics                      |   15 +
 .../import-bug-6766.diary-all                 |   13 +
 .../import-bug-6766.ics                       |   28 +
 .../import-duration-2.diary-all               |    6 +
 .../import-duration-2.ics                     |   17 +
 .../import-duration.diary-american            |    2 +
 .../import-duration.diary-european            |    2 +
 .../import-duration.diary-iso                 |    2 +
 .../import-duration.ics                       |   10 +
 .../import-legacy-function.diary-all          |   10 +
 .../import-legacy-function.ics                |   16 +
 .../import-legacy-vars.diary-american         |    8 +
 .../import-legacy-vars.diary-european         |    8 +
 .../import-legacy-vars.diary-iso              |    8 +
 .../import-legacy-vars.ics                    |   17 +
 .../import-multiple-vcalendars.diary-american |    8 +
 .../import-multiple-vcalendars.diary-european |    8 +
 .../import-multiple-vcalendars.diary-iso      |    8 +
 .../import-multiple-vcalendars.ics            |   21 +
 .../import-non-recurring-1.diary-american     |    2 +
 .../import-non-recurring-1.diary-european     |    2 +
 .../import-non-recurring-1.diary-iso          |    2 +
 .../import-non-recurring-1.ics                |   10 +
 ...mport-non-recurring-all-day.diary-american |    2 +
 ...mport-non-recurring-all-day.diary-european |    2 +
 .../import-non-recurring-all-day.diary-iso    |    2 +
 .../import-non-recurring-all-day.ics          |    9 +
 ...n-recurring-another-example.diary-american |    5 +
 ...n-recurring-another-example.diary-european |    5 +
 ...rt-non-recurring-another-example.diary-iso |    5 +
 .../import-non-recurring-another-example.ics  |   23 +
 .../import-non-recurring-block.diary-american |    5 +
 .../import-non-recurring-block.diary-european |    5 +
 .../import-non-recurring-block.diary-iso      |    5 +
 .../import-non-recurring-block.ics            |   16 +
 ...on-recurring-folded-summary.diary-american |    5 +
 ...on-recurring-folded-summary.diary-european |    5 +
 ...ort-non-recurring-folded-summary.diary-iso |    5 +
 .../import-non-recurring-folded-summary.ics   |   25 +
 ...-non-recurring-long-summary.diary-american |    2 +
 ...-non-recurring-long-summary.diary-european |    2 +
 ...mport-non-recurring-long-summary.diary-iso |    2 +
 .../import-non-recurring-long-summary.ics     |    9 +
 ...mport-real-world-2003-05-29.diary-american |    6 +
 ...mport-real-world-2003-05-29.diary-european |    6 +
 .../import-real-world-2003-05-29.ics          |   54 +
 ...port-real-world-2003-06-18a.diary-american |    6 +
 ...port-real-world-2003-06-18a.diary-european |    6 +
 .../import-real-world-2003-06-18a.ics         |   36 +
 ...port-real-world-2003-06-18b.diary-american |    6 +
 ...port-real-world-2003-06-18b.diary-european |    6 +
 .../import-real-world-2003-06-18b.ics         |   55 +
 ...mport-real-world-2004-11-19.diary-american |   19 +
 ...mport-real-world-2004-11-19.diary-european |   19 +
 .../import-real-world-2004-11-19.ics          |  120 +
 ...mport-real-world-2005-02-07.diary-american |    5 +
 ...mport-real-world-2005-02-07.diary-european |    5 +
 .../import-real-world-2005-02-07.ics          |   26 +
 ...mport-real-world-2005-03-01.diary-american |    2 +
 ...mport-real-world-2005-03-01.diary-european |    2 +
 .../import-real-world-2005-03-01.ics          |   11 +
 .../import-real-world-no-dst.diary-american   |    4 +
 .../import-real-world-no-dst.diary-european   |    4 +
 .../import-real-world-no-dst.ics              |   26 +
 .../import-rrule-anniversary.diary-all        |    2 +
 .../import-rrule-anniversary.ics              |   10 +
 ...mport-rrule-count-bi-weekly.diary-american |    1 +
 ...mport-rrule-count-bi-weekly.diary-european |    1 +
 .../import-rrule-count-bi-weekly.diary-iso    |    1 +
 .../import-rrule-count-bi-weekly.ics          |   11 +
 .../import-rrule-count-daily-long.diary-all   |    4 +
 .../import-rrule-count-daily-long.ics         |   11 +
 .../import-rrule-count-daily-short.diary-all  |    4 +
 .../import-rrule-count-daily-short.ics        |   11 +
 ...t-rrule-count-every-second-month.diary-all |    4 +
 .../import-rrule-count-every-second-month.ics |   11 +
 ...rt-rrule-count-every-second-year.diary-all |    4 +
 .../import-rrule-count-every-second-year.ics  |   10 +
 .../import-rrule-count-monthly.diary-all      |    4 +
 .../import-rrule-count-monthly.ics            |   11 +
 .../import-rrule-count-yearly.diary-all       |    4 +
 .../import-rrule-count-yearly.ics             |   11 +
 .../import-rrule-daily-two-day.diary-all      |    4 +
 .../import-rrule-daily-two-day.ics            |   10 +
 ...port-rrule-daily-with-exceptions.diary-all |    5 +
 .../import-rrule-daily-with-exceptions.ics    |   12 +
 .../import-rrule-daily.diary-all              |    3 +
 .../import-rrule-daily.ics                    |   11 +
 .../import-rrule-monthly-no-end.diary-all     |    4 +
 .../import-rrule-monthly-no-end.ics           |   11 +
 .../import-rrule-monthly-with-end.diary-all   |    4 +
 .../import-rrule-monthly-with-end.ics         |   11 +
 .../import-rrule-weekly.diary-all             |    3 +
 .../import-rrule-weekly.ics                   |   11 +
 .../import-rrule-yearly.diary-all             |    4 +
 .../import-rrule-yearly.ics                   |   11 +
 .../import-time-format-12hr-blank.diary-iso   |    2 +
 .../import-time-format-12hr-blank.ics         |    9 +
 .../import-with-attachment.diary-iso          |    4 +
 .../import-with-attachment.ics                |   10 +
 .../import-with-timezone.diary-iso            |    4 +
 .../import-with-timezone.ics                  |   27 +
 .../import-with-uid.diary-american            |    3 +
 .../import-with-uid.diary-european            |    3 +
 .../import-with-uid.diary-iso                 |    3 +
 .../import-with-uid.ics                       |   10 +
 test/lisp/calendar/diary-icalendar-tests.el   | 1206 +++++
 test/lisp/calendar/icalendar-parser-tests.el  | 2030 +++++++
 test/lisp/calendar/icalendar-recur-tests.el   | 2867 ++++++++++
 133 files changed, 22005 insertions(+), 89 deletions(-)
 create mode 100644 lisp/calendar/diary-icalendar.el
 create mode 100644 lisp/calendar/icalendar-ast.el
 create mode 100644 lisp/calendar/icalendar-macs.el
 create mode 100644 lisp/calendar/icalendar-mode.el
 create mode 100644 lisp/calendar/icalendar-parser.el
 create mode 100644 lisp/calendar/icalendar-recur.el
 create mode 100644 lisp/calendar/icalendar-utils.el
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-11473.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-22092.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-24199.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-24199.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-33277.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-6766.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-bug=
-6766.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation-2.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation-2.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-dur=
ation.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-function.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-function.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-leg=
acy-vars.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-mul=
tiple-vcalendars.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-1.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-all-day.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-another-example.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-block.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-folded-summary.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-non=
-recurring-long-summary.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-05-29.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-05-29.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-05-29.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18a.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18a.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18a.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18b.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18b.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2003-06-18b.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2004-11-19.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2004-11-19.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2004-11-19.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-02-07.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-02-07.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-02-07.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-03-01.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-03-01.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-2005-03-01.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-no-dst.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-no-dst.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rea=
l-world-no-dst.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-anniversary.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-anniversary.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-bi-weekly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-long.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-long.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-short.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-short.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-month.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-month.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-year.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-every-second-year.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-monthly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-monthly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-yearly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-yearly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-two-day.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-two-day.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-with-exceptions.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily-with-exceptions.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-daily.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-no-end.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-no-end.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-with-end.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-monthly-with-end.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-weekly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-weekly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-yearly.diary-all
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-rru=
le-yearly.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-tim=
e-format-12hr-blank.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-tim=
e-format-12hr-blank.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-attachment.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-attachment.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-timezone.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-timezone.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.diary-american
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.diary-european
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.diary-iso
 create mode 100644 test/lisp/calendar/diary-icalendar-resources/import-wit=
h-uid.ics
 create mode 100644 test/lisp/calendar/diary-icalendar-tests.el
 create mode 100644 test/lisp/calendar/icalendar-parser-tests.el
 create mode 100644 test/lisp/calendar/icalendar-recur-tests.el

diff --git a/lisp/calendar/cal-dst.el b/lisp/calendar/cal-dst.el
index e948bdb558e..d8520c8583c 100644
--- a/lisp/calendar/cal-dst.el
+++ b/lisp/calendar/cal-dst.el
@@ -309,7 +309,9 @@ calendar-current-time-zone
 UTC-DIFF is an integer specifying the number of minutes difference between
     standard time in the current time zone and Coordinated Universal Time
     (Greenwich Mean Time).  A negative value means west of Greenwich.
-DST-OFFSET is an integer giving the daylight saving time offset in minutes.
+DST-OFFSET is an integer giving the daylight saving time offset in minutes
+    relative to UTC-DIFF. (That is, the total UTC offset during daylight s=
aving
+    time is UTC-DIFF + DST-OFFSET minutes.)
 STD-ZONE is a string giving the name of the time zone when no seasonal time
     adjustment is in effect.
 DST-ZONE is a string giving the name of the time zone when there is a seas=
onal
diff --git a/lisp/calendar/calendar.el b/lisp/calendar/calendar.el
index 04a42fcd38a..4e2f7b86f22 100644
--- a/lisp/calendar/calendar.el
+++ b/lisp/calendar/calendar.el
@@ -871,7 +871,15 @@ diary-date-forms
 a portion of the first word of the diary entry.
=20
 For examples of three common styles, see `diary-american-date-forms',
-`diary-european-date-forms', and `diary-iso-date-forms'."
+`diary-european-date-forms', and `diary-iso-date-forms'.
+
+If you customize this variable, you should also customize the variable
+`diary-date-insertion-form' to contain a pseudo-pattern which produces
+dates that match one of the forms in this variable. (If
+`diary-date-insertion-form' does not correspond to one of the patterns
+in this variable, then the diary will not recognize such dates,
+including those inserted into the diary from the calendar with
+`diary-insert-entry'.)"
   :type '(repeat (choice (cons :tag "Backup"
                                :value (backup . nil)
                                (const backup)
@@ -895,6 +903,52 @@ diary-date-forms
                 (diary))))
   :group 'diary)
=20
+(defconst diary-american-date-insertion-form '(month "/" day "/" year)
+  "Pseudo-pattern for American dates in `diary-date-insertion-form'")
+
+(defconst diary-european-date-insertion-form '(day "/" month "/" year)
+  "Pseudo-pattern for European dates in `diary-date-insertion-form'")
+
+(defconst diary-iso-date-insertion-form '(year "/" month "/" day)
+  "Pseudo-pattern for ISO dates in `diary-date-insertion-form'")
+
+(defcustom diary-date-insertion-form
+  (cond ((eq calendar-date-style 'iso) diary-iso-date-insertion-form)
+        ((eq calendar-date-style 'european) diary-european-date-insertion-=
form)
+        (t diary-american-date-insertion-form))
+  "Pseudo-pattern describing how to format a date for a new diary entry.
+
+A pseudo-pattern is a list of expressions that can include the symbols
+`month', `day', and `year' (all numbers in string form), and `monthname'
+and `dayname' (both alphabetic strings).  For example, a typical American
+form would be
+
+       (month \"/\" day \"/\" (substring year -2))
+
+whereas
+
+       ((format \"%9s, %9s %2s, %4s\" dayname monthname day year))
+
+would give the usual American style in fixed-length fields.
+
+This pattern will be used by `calendar-date-string' (which see) to
+format dates when inserting them with `diary-insert-entry', or when
+importing them from other formats into the diary.
+
+If you customize this variable, you should also customize the variable
+`diary-date-forms' to include a pseudo-pattern which matches dates
+produced by this pattern. (If there is no corresponding pattern in
+`diary-date-forms', then the diary will not recognize such dates,
+including those inserted into the diary from the calendar with
+`diary-insert-entry'.)"
+  :version "32.1"
+  :type 'sexp
+  :risky t
+  :set-after '(calendar-date-style diary-american-date-insertion-form
+                                   diary-european-date-insertion-form
+                                   diary-iso-date-insertion-form)
+  :group 'diary)
+
 ;; Next three are provided to aid in setting calendar-date-display-form.
 (defcustom calendar-iso-date-display-form '((format "%s-%.2d-%.2d" year
                                                (string-to-number month)
@@ -1028,7 +1082,9 @@ calendar-set-date-style
         calendar-month-header
         (symbol-value (intern-soft (format "calendar-%s-month-header" styl=
e)))
         diary-date-forms
-        (symbol-value (intern-soft (format "diary-%s-date-forms" style))))
+        (symbol-value (intern-soft (format "diary-%s-date-forms" style)))
+        diary-date-insertion-form
+        (symbol-value (intern-soft (format "diary-%s-date-insertion-form" =
style))))
   (calendar-redraw)
   (calendar-update-mode-line))
=20
diff --git a/lisp/calendar/diary-icalendar.el b/lisp/calendar/diary-icalend=
ar.el
new file mode 100644
index 00000000000..7b2b736739f
--- /dev/null
+++ b/lisp/calendar/diary-icalendar.el
@@ -0,0 +1,3883 @@
+;;; diary-icalendar.el --- Display iCalendar data in diary  -*- lexical-bi=
nding: t; -*-
+
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: January 2025
+;; Keywords: calendar
+;; Human-Keywords: diary, calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file is a replacement for icalendar.el that uses a new parser
+;; and offers more features.
+
+;;; Code:
+
+(eval-when-compile (require 'cl-lib))
+(eval-when-compile (require 'icalendar-macs))
+(require 'icalendar)
+(require 'icalendar-utils)
+(require 'icalendar-recur)
+(require 'calendar)
+(require 'cal-dst)
+(require 'diary-lib)
+(require 'skeleton)
+(require 'seq)
+(require 'rx)
+(require 'pp)
+
+;; Customization
+(defgroup diary-icalendar nil
+  "iCalendar import, export, and display in diary"
+  :version 32
+  :group 'diary
+  :prefix 'diary-icalendar)
+
+
+;; Utilities for display and import
+
+;;; Error handling
+(define-error 'ical:diary-import-error "Unable to import iCalendar data"
+              'ical:error)
+
+(cl-defun di:signal-import-error (msg &key (diary-buffer (current-buffer))
+                                           (position (point))
+                                           line
+                                           (severity 2))
+  (let ((err-data
+          (list :message msg
+                :buffer diary-buffer
+                :position position
+                :line line
+                :severity severity)))
+    (signal 'ical:diary-import-error err-data)))
+
+;;; Backward compatibility with icalendar.el
+
+;; icalendar.el provided the following customization variables:
+;; `icalendar-import-format'
+;; `icalendar-import-format-class'
+;; `icalendar-import-format-description'
+;; `icalendar-import-format-location'
+;; `icalendar-import-format-organizer'
+;; `icalendar-import-format-summary'
+;; `icalendar-import-format-status'
+;; `icalendar-import-format-url'
+;; `icalendar-import-format-uid'
+;; These were all format strings: `icalendar-import-format' was the
+;; top-level format string, which would potentially incorporate the
+;; formatted output from the others. This approach to customization
+;; isn't very flexible, though, and doing it right requires a
+;; separate defcustom variable for each iCalendar property. (The above
+;; list is not nearly exhaustive.) I have abandoned this approach in
+;; what follows in favor of skeleton.el templates, but the following two
+;; functions provide backward compatibility for anyone who had
+;; customized the values of the above variables:
+(defun di:-use-legacy-vars-p ()
+  "Return non-nil if any of the `icalendar-import-format*'
+variables are set to non-default values and should be used to import an
+event. This function is for backward compatibility; please do not rely
+on it in new code."
+  (declare (obsolete nil "32.1"))
+  (or
+   (and (boundp 'icalendar-import-format)
+        (not (equal icalendar-import-format
+                    (custom--standard-value 'icalendar-import-format))))
+   (and (boundp 'icalendar-import-format-class)
+        (not (equal icalendar-import-format-class
+                    (custom--standard-value 'icalendar-import-format-class=
))))
+   (and (boundp 'icalendar-import-format-description)
+        (not (equal icalendar-import-format-description
+                    (custom--standard-value
+                     'icalendar-import-format-description))))
+   (and (boundp 'icalendar-import-format-location)
+        (not (equal icalendar-import-format-location
+                    (custom--standard-value 'icalendar-import-format-locat=
ion))))
+   (and (boundp 'icalendar-import-format-organizer)
+        (not (equal icalendar-import-format-organizer
+                    (custom--standard-value 'icalendar-import-format-organ=
izer))))
+   (and (boundp 'icalendar-import-format-summary)
+        (not (equal icalendar-import-format-summary
+                    (custom--standard-value 'icalendar-import-format-summa=
ry))))
+   (and (boundp 'icalendar-import-format-status)
+        (not (equal icalendar-import-format-status
+                    (custom--standard-value 'icalendar-import-format-statu=
s))))
+   (and (boundp 'icalendar-import-format-url)
+        (not (equal icalendar-import-format-url
+                    (custom--standard-value 'icalendar-import-format-url))=
))
+   (and (boundp 'icalendar-import-format-uid)
+        (not (equal icalendar-import-format-uid
+                    (custom--standard-value 'icalendar-import-format-uid))=
))))
+
+(defun di:-format-vevent-legacy (date class desc location organizer
+                                 summary status url uid)
+  "Format an entry on DATE using the values of the legacy
+`icalendar-import-format*' variables. This function is for backward
+compatibility; please do not rely on it in new code."
+  (declare (obsolete nil "32.1"))
+  (insert ical:import-format)
+  (replace-regexp-in-region "%c"
+                            (format ical:import-format-class class)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%d"
+                            (format ical:import-format-description desc)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%l"
+                            (format ical:import-format-location location)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%o"
+                            (format ical:import-format-organizer organizer)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%s"
+                            (format ical:import-format-summary summary)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%t"
+                            (format ical:import-format-status status)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%u"
+                            (format ical:import-format-url url)
+                            (point-min) (point-max))
+  (replace-regexp-in-region "%U"
+                            (format ical:import-format-uid uid)
+                            (point-min) (point-max))
+  (goto-char (point-min))
+  (insert date " "))
+
+(defun di:-vevent-to-legacy-alist (vevent)
+  "Convert an `icalendar-vevent' to an alist of the sort previously used by
+icalendar.el. This function is for backward compatibility; please do not
+rely on it in new code."
+  (declare (obsolete nil "32.1"))
+  ;; function values of `icalendar-import-format' expect a list like:
+  ;; ((VEVENT nil
+  ;;   ((PROP1 params val)
+  ;;    (PROP2 params val)
+  ;;    ...)))
+  (let ((vevent-children (ical:ast-node-children vevent))
+        children)
+    (dolist (p vevent-children)
+      (let* ((type (ical:ast-node-type p))
+             (list-sep (get type 'ical:list-sep))
+             (name (intern (car (rassq type ical:property-types))))
+             ;; icalendar.el did not interpret values when parsing, so we
+             ;; convert back to string representation:
+             (value (ical:ast-node-value p))
+             (value-str
+              (or (ical:ast-node-meta-get :original-value p)
+                  (if list-sep
+                      (string-join (mapcar #'ical:default-value-printer va=
lue)
+                                   list-sep)
+                    (ical:default-value-printer value))))
+             params)
+        (when (ical:ast-node-children p)
+          (dolist (param (ical:ast-node-children p))
+            (let* ((par-str (ical:print-param-node param))
+                   (split (string-split par-str "[;=3D]"))
+                   (parname (intern (nth 1 split)))
+                   (parval (nth 2 split)))
+              (push `(,parname nil parval) params)))
+          (setq params (nreverse params)))
+        (push `(,name ,params ,value-str) children)))
+    (setq children (nreverse children))
+    ;; Return the legacy alist:
+    `((VEVENT nil ,children))))
+
+(defsubst di:-nonempty (s)
+  "Ensure that string S is nonempty once trimmed. Otherwise return nil."
+  (when (and s (stringp s))
+    (let ((trimmed (string-trim s)))
+      (unless (equal "" trimmed) trimmed))))
+
+;; TODO: move to diary-lib.el?
+(defun di:entry-bounds ()
+  "Return markers (START END) bounding the diary entry around point,
+or nil if point is not in an entry."
+  (save-excursion
+    (let* ((pt (point))
+           (bound (point-min))
+           (start (make-marker))
+           (end (make-marker)))
+      (when (re-search-backward "^[[:space:]]*$" nil t)
+        (setq bound (match-end 0)))
+      (goto-char pt)
+      (cond ((looking-at di:entry-regexp)
+             (set-marker start (match-beginning 0))
+             (set-marker end (match-end 0)))
+            ((re-search-backward di:entry-regexp bound t)
+             (set-marker start (match-beginning 0))
+             ;; match again forward, to ensure we get the full entry;
+             ;; see `re-search-backward':
+             (goto-char start)
+             (when (looking-at di:entry-regexp)
+               (set-marker end (match-end 0))))
+            (t nil))
+      (when (and (marker-position start) (marker-position end))
+        (list start end)))))
+
+(defun di:find-entry-with-uid (uid &optional diary-filename)
+  "Search DIARY-FILENAME (default: `diary-file') for an entry containing U=
ID.
+
+The UID must occur on a line matching `diary-icalendar-uid-regexp'.  If
+such an entry exists, return markers (START END) bounding it.
+Otherwise, return nil."
+  (let* ((diary-file (or diary-filename diary-file))
+         (diary-buffer (or (find-buffer-visiting diary-file)
+                           (find-file-noselect diary-file))))
+    (with-current-buffer diary-buffer
+      (save-excursion
+        (save-restriction
+          (widen)
+          (goto-char (point-min))
+          (catch 'found
+            (while (re-search-forward di:uid-regexp nil t)
+              (when (equal uid (match-string 1))
+                (throw 'found (di:entry-bounds))))
+            ;; continue search in included files:
+            ;; TODO: is this a good idea?
+            ;; (goto-char (point-min))
+            ;; (while (re-search-forward
+            ;;         (rx line-start (regexp diary-include-string)
+            ;;             ?\" (group-n 1 (one-or-more (not ?\")) ?\"))
+            ;;         nil t)
+            ;;   (let ((entry (di:find-entry-with-uid uid (match-string 1)=
)))
+            ;;     (when entry
+            ;;       (throw 'found entry))))
+            ;; nothing to return:
+            nil))))))
+
+(defun di:y-or-n-or-edit-p (prompt)
+  "Like `y-or-n-p', but with the option to enter a recursive edit to fix
+something first. Adds a message to current binding of `help-form'
+explaining how."
+  (let* ((allow-edits-map
+          (let ((map (make-sparse-keymap)))
+            (define-key map [remap edit]
+                        (lambda ()
+                          (interactive)
+			  (save-excursion
+			    (save-window-excursion
+			      (recursive-edit)))))
+            map))
+         (y-or-n-p-map (make-composed-keymap allow-edits-map
+                                             y-or-n-p-map))
+         (help-form
+          (concat (when (stringp help-form) (concat help-form "\n\n"))
+                  ;; FIXME: should use substitute-command-keys here, but
+                  ;; for some reason, even with \<y-or-n-p-map>, it
+                  ;; doesn't find the C-r and C-M-c bindings and only
+                  ;; suggests M-x ...
+                  "Type C-r to enter recursive edit before answering "
+                  "(C-M-c to exit).")))
+    (save-excursion
+      (save-restriction
+        (y-or-n-p prompt)))))
+
+;;; Skeletons
+;;
+;; We use skeleton.el's templating facilities to make formatting of
+;; different iCalendar elements in the diary simple and easy to
+;; customize. There are default skeletons for each major type of
+;; iCalendar component (`di:vevent-skeleton', `di:vtodo-skeleton',
+;; `di:vjournal-skeleton'), and a corresponding defcustom pointing to
+;; each of these skeletons (`di:vevent-skeleton-command', etc.).
+;; `di:format-entry' calls these skeletons, or user-provided functions,
+;; to format individual components as diary entries. Since properties
+;; representing people (`icalendar-attendee', `icalendar-organizer') are
+;; important and relatively complex, another skeleton
+;; (`di:attendee-skeleton') takes care of formatting these for the
+;; top-level component skeletons.
+(define-skeleton di:attendee-skeleton
+  "Default skeleton to format an `icalendar-attendee' for the diary.
+
+Includes any data from the attendee's `icalendar-cnparam' and
+`icalendar-partstatparam', and does not insert any data if its
+`icalendar-cutypeparam' is non-nil and anything other than
+\"INDIVIDUAL\" or \"GROUP\".
+
+The result looks like:
+  <foo@HIDDEN>
+or
+  Baz Foo <foo@HIDDEN>
+or
+  Baz Foo <foo@HIDDEN> (declined)"
+  nil
+  ;; skip non-human "attendees":
+  (when (or (not cutype) (equal cutype "INDIVIDUAL") (equal cutype "GROUP"=
))
+    (skeleton-insert
+     '(nil
+       cn & " "
+       (format "<%s>" address) & " "
+       (when partstat (format "(%s)" (downcase partstat)))))))
+
+(defun di:format-attendee (attendee)
+  "Format ATTENDEE for the diary.
+
+ATTENDEE should be an `icalendar-attendee' or `icalendar-organizer'
+property node.  Returns a string representing an entry for the attendee,
+formatted by `diary-icalendar-attendee-skeleton-command', unless the
+attendee's address matches the regexp in
+`diary-icalendar-skip-addresses-regexp'; in that case, nil is returned."
+  (ical:with-property attendee
+    ((ical:cutypeparam :value cutype)
+     (ical:cnparam :value cn)
+     (ical:memberparam :values member)
+     (ical:roleparam :value role)
+     (ical:partstatparam :value partstat)
+     (ical:rsvpparam :value rsvp)
+     (ical:deltoparam :values delto)
+     (ical:delfromparam :values delfrom)
+     (ical:sentbyparam :value sentby)
+     (ical:dirparam :value dir)
+     (ical:languageparam :value language))
+    (calendar-dlet
+        ((full-address value)
+         (address (string-replace "mailto:" "" value))
+         (cn (when cn (string-trim cn)))
+         (cutype cutype)
+         (dir dir)
+         (role role)
+         (partstat partstat)
+         (rsvp rsvp)
+         (delfrom-full-addresses delfrom)
+         (delfrom-addresses
+          (mapcar (apply-partially #'string-replace "mailto:" "")
+                  delfrom))
+         (delto-full-addresses delto)
+         (delto-addresses
+          (mapcar (apply-partially #'string-replace "mailto:" "")
+                  delto))
+         (member-full-addresses member)
+         (member-addresses
+          (mapcar (apply-partially #'string-replace "mailto:" "")
+                  member))
+         (sentby-full-address sentby)
+         (sentby-address
+          (when sentby (string-replace "mailto:" "" sentby)))
+         (language language))
+      (unless (and di:skip-addresses-regexp
+                   (string-match-p di:skip-addresses-regexp full-address))
+        (with-temp-buffer
+          (funcall di:attendee-skeleton-command)
+          (buffer-string))))))
+
+(define-skeleton di:vevent-skeleton
+  "Default skeleton to format an `icalendar-vevent' for the diary."
+  nil
+  (when (or non-marking (equal transparency "TRANSPARENT"))
+    diary-nonmarking-symbol)
+  (or rrule-sexp start-to-end start) & " "
+  summary "\n"
+  @ ; start of body (for indentation)
+  (when (or location geo-location) "Location: ") (or location geo-location)
+  & "\n" (when url "URL: ") & url
+  & "\n" (when status "Status: ") & status
+  & "\n" (when organizer "Organizer: ") & organizer
+  & "\n" (di:format-list attendees "Attendee")
+  & "\n" (di:format-list categories "Category" "Categories")
+  & "\n" (di:format-list comments "Comment")
+  & "\n" (di:format-list contacts "Contact")
+  & "\n" (di:format-list attachments "Attachment")
+  & "\n" (when (and importing access) "Access: ") & access
+  & "\n" (when (and importing uid) "UID: ") & uid
+  & "\n" (when description "Description: ") & description
+  & "\n"
+  @ ; end of body
+  (let* ((end (pop skeleton-positions))
+         (start (pop skeleton-positions)))
+    ;; TODO: should diary define a customizable indentation level?
+    ;; For now, we use 1 because that's what icalendar.el chose
+    (indent-code-rigidly start end 1))
+  (when importing "\n"))
+
+(define-skeleton di:vjournal-skeleton
+  "Default skeleton to format an `icalendar-vjournal' for the diary."
+  nil
+  (when (or non-marking di:import-vjournal-as-nonmarking)
+    diary-nonmarking-symbol)
+  (or rrule-sexp start) & " "
+  summary "\n"
+  @ ; start of body (for indentation)
+  & "\n" (when url "URL: ") & url
+  & "\n" (when status "Status: ") & status
+  & "\n" (when organizer "Organizer: ") & organizer
+  & "\n" (di:format-list attendees "Attendee")
+  & "\n" (di:format-list categories "Category" "Categories")
+  & "\n" (di:format-list comments "Comment")
+  & "\n" (di:format-list contacts "Contact")
+  & "\n" (di:format-list attachments "Attachment")
+  & "\n" (when (and importing access) "Access: ") & access
+  & "\n" (when (and importing uid) "UID: ") & uid
+  ;; In a vjournal, multiple `icalendar-description's are allowed:
+  & "\n" (di:format-list descriptions "Description")
+  & "\n"
+  @ ; end of body
+  (let* ((end (pop skeleton-positions))
+         (start (pop skeleton-positions)))
+    (indent-code-rigidly start end 1))
+  (when importing "\n"))
+
+(define-skeleton di:vtodo-skeleton
+  "Default skeleton to format an `icalendar-vtodo' for the diary."
+  nil
+  (when non-marking diary-nonmarking-symbol)
+  (or rrule-sexp due) & " "
+  (when due "Due: ") summary
+  (when start (concat " (Start: " start ")"))
+  "\n"
+  @ ; start of body (for indentation)
+  & "\n" (when url "URL: ") & url
+  & "\n" (when status "Status: ") & status
+  & "\n" (when completed "Completed: ") & completed
+  & "\n" (when percent-complete (format "Progress: %d%%" percent-complete))
+  & "\n" (when organizer "Organizer: ") & organizer
+  & "\n" (di:format-list attendees "Attendee")
+  & "\n" (di:format-list categories "Category" "Categories")
+  & "\n" (di:format-list comments "Comment")
+  & "\n" (di:format-list contacts "Contact")
+  & "\n" (di:format-list attachments "Attachment")
+  & "\n" (when (and importing access) "Access: ") & access
+  & "\n" (when (and importing uid) "UID: ") & uid
+  & "\n" (when description "Description: ") & description
+  & "\n"
+  @ ; end of body
+  (let* ((end (pop skeleton-positions))
+         (start (pop skeleton-positions)))
+    (indent-code-rigidly start end 1))
+  (when importing "\n"))
+
+;;; Further utilities for formatting/importing special kinds of values:
+(defun di:format-geo-coordinates (geo)
+  "Format an `icalendar-geo-coordinates' value as degrees N/S and E/W."
+  (format "%.6f=C2=B0%s %.6f=C2=B0%s" ; RFC5545 says we may truncate after=
 6 decimal places
+          (abs (car geo)) (if (< 0 (car geo)) "N" "S")
+          (abs (cdr geo)) (if (< 0 (cdr geo)) "E" "W")))
+
+(defun ical:save-binary-attachment (base64-data dir &optional mimetype)
+  "Decode and save BASE64-DATA in a new file in
+`diary-icalendar-attachment-directory'.
+
+The file will be named based on a unique prefix of BASE64-DATA with an
+extension based on MIMETYPE. It will be saved in a subdirectory named
+DIR of `diary-icalendar-attachment-directory', which will be created if
+necessary. Returns the (non-directory part of) the saved filename."
+  (require 'mailcap)
+  ;; Create the subdirectory for the attachment if necessary:
+  (unless (and (directory-name-p di:attachment-directory)
+               (file-writable-p di:attachment-directory))
+    (di:signal-import-error
+     (format "Cannot write to directory: %s" di:attachment-directory)))
+  (make-directory (file-name-concat di:attachment-directory dir) t)
+  ;; Create a unique filename for the attachment. Unfortunately RFC5545
+  ;; has no mechanism for suggesting a filename, so we just use a unique
+  ;; prefix of BASE64-DATA, or a random number as a fallback.
+  (let* ((nchars 4)
+         (max-chars (length base64-data))
+         (prefix (substring base64-data 0 nchars))
+         (extn (when mimetype
+                 (concat "." (symbol-name
+                              (mailcap-mime-type-to-extension mimetype)))))
+         (path (file-name-concat di:attachment-directory dir
+                                 (concat prefix extn))))
+    (while (file-exists-p path)
+      (cl-incf nchars)
+      (setq prefix (if (< nchars max-chars)
+                       (substring base64-data 0 nchars)
+                     (number-to-string (random max-chars))))
+      (setq path (file-name-concat di:attachment-directory dir
+                                   (concat prefix extn))))
+    ;; Save the file and return its name:
+    (let ((data (base64-decode-string base64-data))
+          (coding-system-for-write 'no-conversion))
+      (write-region data nil path)
+      (file-name-nondirectory path))))
+
+(defun di:save-attachments-from (attachment-nodes uid)
+  "Save attachments in ATTACHMENT-NODES and return a list of attachments.
+
+If these nodes contain binary data, rather than an URL, save the data to
+a file in `diary-icalendar-attachment-directory' (unless this variable
+is nil).  The returned list is a list of strings, which are either URLs
+or filenames."
+  (let (entry-attachments)
+    (dolist (node attachment-nodes)
+      (ical:with-property node
+        ((ical:fmttypeparam :value fmttype))
+        (when (and (eq 'ical:binary value-type)
+                   di:attachment-directory)
+          (let ((filename (ical:save-binary-attachment value uid fmttype)))
+            (push filename entry-attachments)))
+        (when (eq 'ical:url value-type)
+          (push value entry-attachments))))
+    ;; Return the list of filenames and URLs:
+    entry-attachments))
+
+(defun di:format-list (values &optional title plural-form sep indent)
+  "Smartly format VALUES for the diary.
+
+VALUES should be a list of strings. nil elements will be ignored, and an
+empty list will return nil.
+
+TITLE is a string to add to the beginning of the list; a colon will be
+appended. PLURAL-FORM is the plural of TITLE, to be used when VALUES
+contains more than one element (default: TITLE+\"s\").
+
+The strings in VALUES are first joined with SEP (default: \", \"), with
+\"TITLE: \" prepended. If the result is longer than the current value of
+`fill-column', the values are instead formatted one per line, with the
+title on its own line at the beginning, and the whole list indented
+relative to the title by INDENT spaces (default: 2). Thus, in the first
+case, the result looks like:
+  TITLE(s): VAL1, VAL2, ...
+and in the second:
+  TITLE(s):
+    VAL1
+    VAL2
+    ..."
+  (when (cdr values)
+    (setq title (when title (or plural-form (concat title "s")))))
+  (unless indent
+    (setq indent 2))
+  ;; Remove nil values and extra whitespace:
+  (setq values (mapcar #'string-trim (delq nil values)))
+  (when values
+    (let ((line (concat
+                 (when title (concat title ": "))
+                 (string-join values (or sep ", ")))))
+      (if (< (length line) fill-column)
+          line
+        ;; Otherwise, one value per line:
+        (with-temp-buffer
+          (insert (string-join values "\n"))
+          (indent-code-rigidly (point-min) (point-max) indent)
+          (goto-char (point-min))
+          (when title
+            (insert title ":\n"))
+          (buffer-string))))))
+
+(defun di:format-time (dt &optional tzname)
+  "Format the `icalendar-date-time' DT for the diary.
+The time is formatted according to `diary-icalendar-time-format', which se=
e.
+TZNAME, if specified, should be a string naming the time zone observance
+in which DT occurs."
+  ;; Diary does not support seconds, so silently truncate:
+  (let ((time (format-time-string di:time-format (encode-time dt))))
+    (if tzname
+        (concat time " " tzname)
+      time)))
+
+(defun di:format-time-as-local (dt &optional original-tzname)
+  "Format the time in `icalendar-date-time' DT for the diary.
+
+DT is translated to the system local time zone if necessary, and the
+original time specification is preserved in parentheses if it was given
+in a different zone.  ORIGINAL-TZNAME, if specified, should be a string
+naming the time zone observance in which DT was originally encoded in
+the iCalendar data."
+  (cl-typecase dt
+    (ical:date "")
+    (ical:date-time
+     (let* ((ts (encode-time dt))
+            (original-offset (decoded-time-zone dt))
+            (local-tz (current-time-zone ts))
+            (local-offset (car local-tz))
+            (local-dt (decode-time ts local-tz))
+            (local-str (di:format-time local-dt)))
+       (if (and original-tzname original-offset
+                (not (=3D original-offset local-offset)))
+           (format "%s (%s)" local-str (di:format-time dt original-tzname))
+         local-str)))))
+
+(defun di:format-date (dt)
+  "Format the `icalendar-date' or `icalendar-date-time' DT for the diary.
+If DT is a date-time, only the date part is considered. The date is
+formatted with `calendar-date-string' according to the pattern in
+`diary-date-insertion-form'."
+  (calendar-dlet ((calendar-date-display-form diary-date-insertion-form))
+    (cl-typecase dt
+      (ical:date (calendar-date-string dt t t))
+      (ical:date-time (calendar-date-string (ical:date-time-to-date dt) t =
t)))))
+
+(defun di:format-date/time-as-local (dt &optional original-tzname)
+  "Format the `icalendar-date' or `icalendar-date-time' DT for the diary.
+
+If DT is a plain date, only the date will be formatted.  If DT is a
+date-time, both the date and the time will formatted, after translating
+DT into a date and time into the system local time.
+
+If specified, ORIGINAL-TZNAME should be a string naming the time zone
+observance in which DT was originally encoded in the iCalendar data. In
+this case, the original clock time in DT will also be added in
+parentheses, with date if necessary. For example:
+  2025/05/01 09:00 (08:00 GMT)
+or
+  2025/05/01 18:00 (2025/05/02 08:00 JST)"
+  (let ((local-dt (ical:date/time-to-local dt)))
+    (cl-typecase local-dt
+      (ical:date (di:format-date local-dt))
+      (ical:date-time
+       (let ((date (di:format-date local-dt))
+             (time (di:format-time local-dt))
+             (orig-date (di:format-date dt))
+             (orig-time (di:format-time dt original-tzname)))
+         (if original-tzname
+             (format "%s %s (%s)" date time
+                     (if (equal date orig-date)
+                         orig-time
+                       (format "%s %s" orig-date orig-time)))
+           (format "%s %s" date time)))))))
+
+(defun di:format-time-range (start end &optional omit-start-date)
+  "Format a time range for the diary.
+
+START and END should be `icalendar-date-time' values where the date part
+is the same. (If they are not on the same date, nil is returned; use
+`diary-icalendar-format-time-block-sexp' to make a diary S-exp for this
+range instead.)
+
+The date is only formatted once, and the time is formatted as a range, lik=
e:
+  STARTDATE STARTTIME-ENDTIME
+If OMIT-START-DATE is non-nil, STARTDATE will be omitted."
+  (when (equal (ical:date/time-to-date start) (ical:date/time-to-date end))
+    (format "%s%s-%s"
+            (if omit-start-date ""
+              (concat (di:format-date start) " "))
+            (di:format-time-as-local start)
+            (di:format-time-as-local end))))
+
+(defun di:format-block-sexp (start end)
+  "Format a `diary-block' diary S-expression between START and END.
+
+START and END may be `icalendar-date' or `icalendar-date-time'
+values. If they are date-times, only the date parts will be considered.
+Returns a string like \"%%(diary-block ...)\" with the arguments properly
+ordered for the current value of `calendar-date-style'."
+  (unless (cl-typep start 'ical:date)
+    (setq start (ical:date-time-to-date start)))
+  (unless (cl-typep end 'ical:date)
+    (setq end (ical:date-time-to-date end)))
+  (concat
+   diary-sexp-entry-symbol
+   (apply #'format "(diary-block %d %d %d %d %d %d)"
+          (cl-case calendar-date-style
+            ;; M/D/Y
+            (american (list (calendar-extract-month start)
+                            (calendar-extract-day start)
+                            (calendar-extract-year start)
+                            (calendar-extract-month end)
+                            (calendar-extract-day end)
+                            (calendar-extract-year end)))
+            ;; D/M/Y
+            (european (list (calendar-extract-day start)
+                            (calendar-extract-month start)
+                            (calendar-extract-year start)
+                            (calendar-extract-day end)
+                            (calendar-extract-month end)
+                            (calendar-extract-year end)))
+            ;; Y/M/D
+            (iso      (list (calendar-extract-year start)
+                            (calendar-extract-month start)
+                            (calendar-extract-day start)
+                            (calendar-extract-year end)
+                            (calendar-extract-month end)
+                            (calendar-extract-day end)))))))
+
+(defun di:format-time-block-sexp (start end)
+  "Format a `diary-time-block' diary S-expression for times between START =
and END."
+  (concat
+   diary-sexp-entry-symbol
+   (format "(diary-time-block :start '%s :end '%s)" start end)))
+
+(defun di:format-rrule-sexp (component)
+  "Format the recurrence rule data in COMPONENT as a diary S-expression.
+
+The returned string looks like \"%%(diary-rrule ...)\", and contains the
+necessary data from COMPONENT for the calendar to compute recurrences of
+the event."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:dtend :value dtend)
+       (ical:duration :value duration)
+       (ical:rrule :value rrule)
+       (ical:rdate :all rdate-nodes)
+       (ical:exdate :all exdate-nodes))
+    (unless (or rrule rdate-nodes)
+      (di:signal-import-error "No recurrence data in component"))
+    (let ((exdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append
+                          (mapcar #'ical:ast-node-value exdate-nodes))))
+          (rdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append
+                          (mapcar #'ical:ast-node-value rdate-nodes))))
+          ;; N.B. we intentionally *don't* add any clock times to the
+          ;; imported diary entry, since they could conflict with the
+          ;; times generated by the recurrence rule, e.g. if the rule is
+          ;; an 'HOURLY rule.  Instead we always specify the end time
+          ;; (if any) via a duration, and take care of displaying the
+          ;; correct clocks times after computing recurrences during
+          ;; diary display (see `diary-rrule').
+          (dur-value (cond (duration duration)
+                           (dtend (unless (equal dtstart dtend)
+                                    (ical:duration-between dtstart dtend)))
+                           (t nil)))
+          (arg-plist nil))
+
+      (when exdates
+        (setq arg-plist (plist-put arg-plist :exclude `(quote ,exdates))))
+      (when rdates
+        (setq arg-plist (plist-put arg-plist :include `(quote ,rdates))))
+      (when dtstart
+        (setq arg-plist (plist-put arg-plist :start `(quote ,dtstart))))
+      (when dur-value
+        (setq arg-plist (plist-put arg-plist :duration `(quote ,dur-value)=
)))
+      (when rrule
+        ;; TODO: make this prettier to look at?
+        (setq arg-plist (append (list :rule `(quote ,rrule)) arg-plist)))
+      ;; TODO: timezones??
+
+      (setq arg-plist (cons 'diary-rrule arg-plist))
+      (string-trim ; removing trailing \n added by pp
+       (concat diary-sexp-entry-symbol
+               (with-output-to-string (pp arg-plist)))))))
+
+;; This function puts all of the above together to format individual
+;; iCalendar components as diary entries. The final formatting is done
+;; by the appropriate skeleton command for the component, or by
+;; `di:-format-vevent-legacy' if the legacy format string variables from
+;; icalendar.el are set.
+(defun di:format-entry (component index &optional non-marking)
+  "Format an iCalendar component for the diary.
+
+COMPONENT should be an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal'. INDEX should be an index into the calendar where
+COMPONENT occurs, as returned by `icalendar-parse-and-index'.
+
+Depending on the type of COMPONENT, the body will be formatted by one of:
+`diary-icalendar-vevent-skeleton-command'
+`diary-icalendar-vtodo-skeleton-command'
+`diary-icalendar-vjournal-skeleton-command'
+which see.
+
+The variable `non-marking' will be bound to the value of NON-MARKING in
+the relevant skeleton command. If it is non-nil, the user requested the
+entry to be non-marking.
+
+Returns a string containing the diary entry."
+  (ical:with-component component
+      ((ical:attach :all attach-nodes)
+       (ical:attendee :all attendee-nodes)
+       (ical:categories :all categories-nodes)
+       (ical:class :value access)
+       (ical:comment :all comment-nodes)
+       (ical:completed :value completed-dt)
+       (ical:contact :all contact-nodes)
+       (ical:created :value created-dt)
+       (ical:description :value description)
+       ;; in `icalendar-vjournal', multiple `icalendar-description'
+       ;; nodes are allowed:
+       (ical:description :all description-nodes)
+       (ical:dtend :first dtend-node :value dtend)
+       (ical:dtstamp :value dtstamp)
+       (ical:dtstart :first dtstart-node :value dtstart)
+       (ical:duration :value duration)
+       (ical:due :first due-node :value due-dt)
+       (ical:geo :value geo)
+       (ical:last-modified :value last-modified-dt)
+       (ical:location :value location)
+       (ical:organizer :first organizer-node  ; for skeleton formatting
+                       :value organizer-addr) ; for legacy formatting
+       (ical:percent-complete :value percent-complete)
+       (ical:priority :value priority)
+       (ical:recurrence-id :first recurrence-id-node :value recurrence-id-=
dt)
+       (ical:related-to :all related-to-nodes)
+       (ical:request-status :all request-status-nodes)
+       (ical:resources :all resources-nodes)
+       (ical:rrule :value rrule)
+       (ical:rdate :all rdate-nodes)
+       (ical:sequence :value revision)
+       (ical:status :value status)
+       (ical:summary :value summary)
+       (ical:transp :value transp)
+       (ical:uid :value uid)
+       (ical:url :value url)
+       (ical:valarm :all alarms))
+    (let* ((is-recurring (or rdate-nodes rrule))
+           (start-tz (when dtstart-node
+                       (ical:with-property dtstart-node
+                         ((ical:tzidparam :value tzid))
+                         (when tzid (ical:index-get index :tzid tzid)))))
+           (start-tzname (when start-tz (icr:tzname-on dtstart start-tz)))
+           (dtstart-local (ical:date/time-to-local dtstart))
+           (due-tz (when due-node
+                     (ical:with-property due-node
+                       ((ical:tzidparam :value tzid))
+                       (when tzid (ical:index-get index :tzid tzid)))))
+           (due-tzname (when due-tz (icr:tzname-on due-dt due-tz)))
+           (dtend
+            (cond (dtend dtend)
+                  ;; DTEND and DUE never occur in the same component,
+                  ;; so we alias dtend to due:
+                  (due-dt due-dt)
+                  (duration
+                   (ical:date/time-add-duration dtstart duration start-tz)=
)))
+           (dtend-local (ical:date/time-to-local dtend))
+           (end-tz
+            (cond (dtend-node
+                   (ical:with-property dtend-node
+                     ((ical:tzidparam :value tzid))
+                     (when tzid (ical:index-get index :tzid tzid))))
+                  (due-node due-tz)
+                  (duration start-tz)))
+           (end-tzname (when end-tz (icr:tzname-on dtend end-tz)))
+           (component-type (ical:ast-node-type component)))
+      (calendar-dlet
+          (;; TODO: interpret alarms? Diary has its own mechanism for
+           ;; this (but no syntax). We could theoretically use alarms to
+           ;; set up notifications. For now we just pass them on to
+           ;; user skeletons, so users can do this if desired.
+           (alarms alarms)
+           (attachments
+            (when attach-nodes
+              (di:save-attachments-from attach-nodes uid)))
+           (attendees (mapcar #'di:format-attendee attendee-nodes))
+           (categories
+            (mapcan
+             (lambda (node)
+               (mapcar #'ical:text-to-string (ical:ast-node-value node)))
+             categories-nodes))
+           (access (when access (downcase access)))
+           (comments
+            (mapcar
+             (lambda (node) (ical:text-to-string (ical:ast-node-value node=
)))
+             comment-nodes))
+           (contacts
+            (mapcar
+             (lambda (node) (ical:text-to-string (ical:ast-node-value node=
)))
+             contact-nodes))
+           (completed-dt completed-dt)
+           (completed
+            (when completed-dt (di:format-date/time-as-local completed-dt)=
))
+           (created-dt created-dt)
+           (created
+            (when created-dt (di:format-date/time-as-local created-dt)))
+           (description (when description (di:-nonempty description)))
+           (descriptions
+            (when (eq 'icalendar-vjournal component-type)
+              (mapcar
+               (lambda (node)
+                 (di:-nonempty (ical:text-to-string (ical:ast-node-value n=
ode))))
+               description-nodes)))
+           (dtstart dtstart)
+           (start
+            (when dtstart
+              (if (bound-and-true-p importing)
+                  (di:format-date/time-as-local dtstart start-tzname)
+                (di:format-time-as-local dtstart start-tzname))))
+           (dtend dtend)
+           (end
+            (when dtend
+              (if (bound-and-true-p importing)
+                  (di:format-date/time-as-local dtend end-tzname)
+                (di:format-time-as-local dtend end-tzname))))
+           (dtstamp dtstamp)
+           (start-to-end
+            (when (and dtstart dtend (not (equal dtstart dtend)))
+              (cond ((and (bound-and-true-p importing)
+                          (cl-typep dtstart 'ical:date)
+                          (cl-typep dtend 'ical:date))
+                     ;; Importing two dates:
+                     ;; %%(diary-block ...)
+                     (di:format-block-sexp
+                      dtstart
+                      ;; DTEND is an exclusive bound, while
+                      ;; diary-block needs an inclusive bound, so
+                      ;; subtract a day:
+                      (ical:date-add dtend :day -1)))
+                    ((and (bound-and-true-p importing)
+                          (equal (ical:date/time-to-date dtstart-local)
+                                 (ical:date/time-to-date dtend-local)))
+                     ;; Importing, start and end times on same day:
+                     ;; DATE HH:MM-HH:MM
+                     (di:format-time-range dtstart-local dtend-local))
+                    ((bound-and-true-p importing)
+                     ;; Importing at least one date-time, on different day=
s:
+                     ;; %%(diary-time-block :start ... :end ...)
+                     (di:format-time-block-sexp dtstart-local dtend-local))
+                    ((and (boundp 'date) ; bound when displaying diary
+                          (cl-typep dtstart-local 'ical:date-time)
+                          (cl-typep dtend-local 'ical:date-time)
+                          (equal date (ical:date-time-to-date dtstart-loca=
l))
+                          (equal date (ical:date-time-to-date dtend-local)=
))
+                     ;; Displaying, start and end times on the day display=
ed:
+                     ;; HH:MM-HH:MM
+                     (di:format-time-range dtstart-local dtend-local t))
+                    ((and (boundp 'date) ; bound when displaying diary
+                          (cl-typep dtstart-local 'ical:date-time)
+                          (cl-typep dtend-local 'ical:date-time))
+                     ;; Displaying, start and/or end time on other days:
+                     ;; HH:MM-HH:MM for just the times on `date'
+                     (di:format-time-range
+                      (ical:date/time-max dtstart-local
+                                          (ical:make-date-time
+                                           :year (calendar-extract-year da=
te)
+                                           :month (calendar-extract-month =
date)
+                                           :day (calendar-extract-day date)
+                                           :hour 0 :minute 0 :second 0
+                                           :zone
+                                           (decoded-time-zone dtstart-loca=
l)))
+                      (ical:date/time-min dtend-local
+                                          (ical:make-date-time
+                                           :year (calendar-extract-year da=
te)
+                                           :month (calendar-extract-month =
date)
+                                           :day (calendar-extract-day date)
+                                           :hour 23 :minute 59 :second 59
+                                           :zone
+                                           (decoded-time-zone dtend-local)=
))))
+                     (t
+                      ;; That's all the cases we care about here.
+                      nil))))
+           (duration duration)
+           (due-dt
+            (when (eq component-type 'ical:vtodo)
+              ;; in VTODO, DUE does the job of DTEND, so we alias them;
+              ;; see above
+              dtend))
+           (due
+            (when (eq component-type 'ical:vtodo)
+              (if due-node
+                  (di:format-date/time-as-local due-dt due-tzname)
+                ;; here we use start-tzname because due/dtend is calculate=
d from
+                ;; dtstart, not its own node with a tzid:
+                (di:format-date/time-as-local dtend start-tzname))))
+           (work-time-sexp
+            (when (and dtstart due-dt (bound-and-true-p importing))
+              (di:format-time-block-sexp dtstart-local due-dt)))
+           (coordinates geo)
+           (geo-location (when geo (di:format-geo-coordinates geo)))
+           (importing (bound-and-true-p importing))
+           (last-modified-dt last-modified-dt)
+           (last-modified (di:format-date/time-as-local last-modified-dt))
+           (location (di:-nonempty location))
+           (non-marking non-marking)
+           (organizer (di:format-attendee organizer-node))
+           (percent-complete percent-complete)
+           (priority priority)
+           (recurrence-id-dt recurrence-id-dt)
+           (recurrence-id
+            (di:format-date/time-as-local recurrence-id-dt))
+           (related-tos related-to-nodes)
+           (request-statuses request-status-nodes)
+           (resources
+            (mapcan
+             (lambda (node)
+               (mapcar #'ical:text-to-string (ical:ast-node-value node)))
+             resources-nodes))
+           (rrule-sexp
+            (when (and is-recurring (bound-and-true-p importing))
+              (di:format-rrule-sexp component)))
+           (revision revision)
+           (status (when status (di:-nonempty (downcase status))))
+           (summary (di:-nonempty summary))
+           (transparency transp)
+           (uid (di:-nonempty uid))
+           (url (di:-nonempty url)))
+        (with-temp-buffer
+          (cl-case (ical:ast-node-type component)
+            (ical:vevent
+             ;; N.B. icalendar.el *only* imported VEVENT components
+             (if (di:-use-legacy-vars-p)
+                 (if (functionp ical:import-format)
+                     (insert (funcall ical:import-format
+                                      (di:-vevent-to-legacy-alist componen=
t)))
+                   (di:-format-vevent-legacy (or rrule-sexp start-to-end s=
tart)
+                                             access description location
+                                             organizer-addr
+                                             summary status url uid))
+               (funcall di:vevent-skeleton-command)))
+            (ical:vtodo (funcall di:vtodo-skeleton-command))
+            (ical:vjournal (funcall di:vjournal-skeleton-command)))
+          (buffer-string))))))
+
+
+;; Import to Diary
+;;
+;; `di:import-file' and `di:import-buffer' are the main user commands
+;; for import. (These replace `icalendar-import-file' and
+;; `icalendar-import-buffer' defined by icalendar.el, which are now
+;; obsolete aliases to these commands.) `di:import-buffer-to-buffer' is
+;; the function underlying these commands; it is the main import
+;; function available for external Lisp code.
+
+;;; Import customizations
+(defgroup diary-icalendar-import nil
+  "iCalendar import into diary"
+  :version "32.1"
+  :group 'diary-icalendar
+  :prefix 'diary-icalendar)
+
+(defcustom di:always-import-quietly nil
+  "When non-nil, diary will never ask for confirmations when importing eve=
nts.
+
+`diary-icalendar-import-file' and `diary-icalendar-import-buffer' both
+accept an optional argument, QUIETLY, which determines whether these
+functions ask for confirmation when importing individual events and
+saving the diary file. If you set this variable to t, you will never be
+asked to confirm."
+  :version "32.1"
+  :type '(choice (const :tag "Ask for confirmations" nil)
+                 (const :tag "Never ask for confirmations" t)))
+
+(defcustom di:after-mailcap-viewer-hook nil
+  "Hook run after `diary-icalendar-mailcap-viewer'.
+
+The functions in this hook will be run after formatting the contents of
+iCalendar data as diary entries in a temporary buffer. You can add
+functions to this hook if you want, for example, to copy these entries
+somewhere else."
+  :version "32.1"
+  :type '(hook))
+
+(defcustom di:attachment-directory nil
+  "Directory in which to save iCalendar attachments when importing.
+
+If the value is nil, binary attachments encoded in an ATTACH property
+are never saved. If it is the name of a directory, attachments will be
+saved in per-component subdirectories of this directory, with each
+subdirectory named by the component's UID value."
+  :version "32.1"
+  :type '(choice
+          (const :tag "Do not save attachments" nil)
+          directory)
+  :group 'icalendar)
+
+(defcustom di:time-format "%H:%M"
+  "Format string to use for event times.
+
+The value must be a valid format string for `format-time-string'; see
+its docstring for more information. The value only needs to format clock
+times, and should format them in a way that will be recognized by
+`diary-time-regexp'. (Date information is formatted separately at the
+start of the imported entry.) Examples:
+
+  \"%H:%M\" - 24-hour, 0-padded: 09:00 or 21:00
+  \"%k.%Mh\" - 24-hour, blank-padded: 9.00h or 21.00h
+  \"%I:%M%p\" - 12-hour, 0-padded, with AM/PM: 09:00AM or 09:00PM
+  \"%l.%M%p\" - 12-hour, blank-padded, with AM/PM: 9.00AM or 9.00PM"
+  :version "32.1"
+  :type '(string))
+
+(defcustom di:attendee-skeleton-command 'di:attendee-skeleton
+  "Function to format ATTENDEE properties during diary import.
+
+This should be a symbol naming a function which inserts information
+about an `icalendar-attendee' into the current buffer.  It is convenient
+to express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information.
+
+The function will be called with no arguments and should insert
+information about the attendee into the current buffer.
+
+The following variables will be (dynamically) bound when the function is
+called.  All values will be strings (unless another type is noted), or
+nil:
+
+address - the attendee's calendar address, with \"mailto:\" removed
+full-address - the attendee's calendar address, with nothing removed
+cn - the attendee's common name (`icalendar-cnparam')
+dir - URL of attendee's directory entry (`icalendar-directoryparam')
+cutype - the attendee's user type (`icalendar-cutypeparam')
+language - a language abbreviation (`icalendar-languageparam')
+role - the attendee's role in the event (`icalendar-roleparam')
+partstat - the attendee's participation status (`icalendar-partstatparam')
+rsvp - whether an RSVP is requested (`icalendar-rsvpparam')
+member-addresses (list of strings) - any groups/lists where the attendee
+  is a member (`icalendar-memberparam'), with \"mailto:\" removed
+member-full-addresses - like member-addresses, but nothing removed
+delfrom-addresses (list of strings) - addresses of users who delegated
+  their participation to the attendee (`icalendar-delfromparam'), with
+  \"mailto:\" removed
+delfrom-full-addresses - like delfrom-addresses, but nothing removed
+delto-addresses (list of strings) - addresses of users to whom the
+  attendee delegated participation (`icalendar-deltoparam'), with
+  \"mailto:\" removed
+delto-full-addresses - like delto-addresses, but nothing removed
+sentby-address - address of user who sent the invitation on someone
+  else's behalf (`icalendar-sentbyparam'), with \"mailto:\" removed
+sentby-full-address - like sentby-address, but nothing removed"
+  :version "32.1"
+  :type '(radio (function-item di:attendee-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:skip-addresses-regexp user-mail-address
+  "Regular expression matching addresses to skip when importing.
+
+This regular expression should match calendar addresses (which are
+typically \"mailto:\" URIs) which should be skipped when importing
+ATTENDEE, ORGANIZER, and other iCalendar properties that identify a
+contact.
+
+You can make this match your own email address(es) to prevent them from
+being formatted by `diary-icalendar-attendee-skeleton-command' and
+listed in diary entries."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:vevent-skeleton-command #'di:vevent-skeleton
+  "Function to format VEVENT components for the diary.
+
+This should be a symbol naming a function which inserts information
+about an `icalendar-vevent' into the current buffer.  It is convenient
+to express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information.
+
+The following variables will be bound when the function is called.
+All values will be strings unless another type is noted, or nil:
+
+alarms (list of `icalendar-valarm' nodes) - notifications in the event
+as-alarm (symbol) - non-nil when the event should be formatted for an
+  alarm notification in advance of the event. The symbol indicates the
+  type of alarm: `email' means to format the event as the body of an email.
+  (Currently only used for EMAIL alarms; see `icalendar-export-alarms'.)
+attachments (list of strings) - URLs or filenames of attachments in the ev=
ent
+attendees (list of strings) - the participants of the event,
+  formatted by `diary-icalendar-attendee-skeleton-command'
+categories (list of strings) - categories specified in the event
+access - the event's access classification
+comments (list of strings) - comments specified in the event
+created-dt (an `icalendar-date-time' value) - when the event was created
+created - created-dt, formatted as a local date-time string
+description - the event's description
+dtstart (an `icalendar-date' or `icalendar-date-time' value) - when the ev=
ent
+  starts
+dtend (an `icalendar-date' or `icalendar-date-time' value) - when the
+  event ends; this is either the value of the `icalendar-dtend'
+  property, or the end time calculated by adding the event's
+  `icalendar-duration' to its `icalendar-dtstart' properties
+start - start date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time.
+end - end date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time.
+start-to-end - a single string containing both start and end date and
+  (local) time.  If the event starts and ends on the same day, the date
+  is not repeated.  When importing, dates are included, and the string
+  may contain a diary s-exp; when displaying, the string contains only
+  the times for the displayed date.
+dtstamp (an `icalendar-date' or `icalendar-date-time' value) - when the ev=
ent
+  was last revised
+duration (an `icalendar-dur-value') - the event's duration
+coordinates (an `icalendar-geo-coordinates' value) - the event's geographi=
cal
+  coordinates
+geo-location - coordinates, formatted as a string with degrees N/S and E/W
+importing (a boolean) - t if the event should be formatted for import.
+  When nil, the event should be formatted for display rather than import.
+  When importing it is important to include all information from the event
+  that you want to be saved in the diary; when displaying, information like
+  the date (or date-related S-expressions) and UID can be left out.
+last-modified-dt (an `icalendar-date-time' value) - the date and time the =
event
+  was last modified
+last-modified - last-modified-dt, formatted as a local date and time string
+location - the event's location
+non-marking (a boolean) - if non-nil, the diary entry should be non-marking
+organizer - the event's organizer, formatted by
+  `diary-icalendar-attendee-skeleton-command'
+priority (a number) - the event's priority (1 =3D highest priority, 9 =3D =
lowest;
+  0 =3D undefined)
+recurrence-id-dt (an `icalendar-date' or `icalendar-date-time' value) - the
+  date or date-time of a particular recurrence of the event
+recurrence-id - recurrence-id-dt, formatted as a local date and time string
+related-tos (a list of `icalendar-related-to' property nodes) -
+  these contain the UIDs of related events and their relationship type
+request-statuses (a list of `icalendar-request-status' property nodes) -
+  these contain status information about requests made
+resources (a list of strings) - resources used or needed for the event
+rrule-sexp - a string containing a diary S-expression for a recurring even=
t.
+  If this is non-nil, you should normally use it instead of the start-* and
+  end-* variables to form the date of the entry.
+revision (a number) - the revision number of the event; see
+  `icalendar-sequence'
+status - overall status specified by the organizer (e.g. \"confirmed\")
+summary - a summary of the event
+transparency - the event's time transparency status; see `icalendar-transp'
+uid - the unique identifier of the event
+url - a URL for the event"
+  :version "32.1"
+  :type '(radio (function-item di:vevent-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:vjournal-skeleton-command #'di:vjournal-skeleton
+  "Function to format VJOURNAL components for the diary.
+
+This should be a symbol naming a function which inserts information about
+an `icalendar-vjournal' into the current buffer.  It is convenient to
+express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information, and see
+`diary-icalendar-vjournal-skeleton' for an example.
+
+The following variables will be bound when the function is called.
+All values will be strings unless another type is noted, or nil:
+
+alarms (list of `icalendar-valarm' nodes) - notifications in the journal e=
ntry
+attachments (list of strings) - URLs or filenames of attachments in the jo=
urnal
+  entry
+attendees (list of strings) - the participants of the journal entry,
+  formatted by `diary-icalendar-attendee-skeleton-command'
+categories (list of strings) - categories specified in the journal entry
+access - the journal entry's access classification
+comments (list of strings) - comments specified in the journal entry
+created-dt (an `icalendar-date-time' value) - the date and time the
+  journal entry was created
+created - created-dt, formatted as a local date-time string
+descriptions (list of strings) - the journal entry's descriptions
+  (more than one description is allowed in iCalendar VJOURNAL components)
+dtstamp (an `icalendar-date' or `icalendar-date-time' value) - when the
+  journal entry was last revised
+dtstart (an `icalendar-date' or `icalendar-date-time' value) - when the jo=
urnal
+  entry starts
+start - start date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time.
+importing (a boolean) - t if the journal entry should be formatted for imp=
ort.
+  When nil, the entry should be formatted for display rather than import.
+  When importing it is important to include all information from the entry
+  that you want to be saved in the diary; when displaying, information like
+  the date (or date-related S-expressions) and UID can be left out.
+last-modified-dt (an `icalendar-date-time' value) - the date and time
+  the journal entry was last modified
+last-modified - last-modified-dt, formatted as a local date and time string
+non-marking (a boolean) - if non-nil, the diary entry should be non-marking
+organizer - the journal entry's organizer, formatted by
+  `diary-icalendar-attendee-skeleton-command'
+recurrence-id-dt (an `icalendar-date' or `icalendar-date-time' value) - the
+  date or date-time of a particular recurrence of the journal entry
+recurrence-id - recurrence-id-dt, formatted as a local date and time string
+related-tos (a list of `icalendar-related-to' property nodes) -
+  these contain the UIDs of related journal entrys and their relationship =
type
+request-statuses (a list of `icalendar-request-status' property nodes) -
+  these contain status information about requests made
+rrule-sexp - a string containing a diary S-expression for a recurring
+  journal entry.  If this is non-nil, you should normally use it instead
+  of the start-* variables to form the date of the entry.
+revision (a number) - the revision number of the journal entry; see
+  `icalendar-sequence'
+status - overall status specified by the organizer (e.g. \"draft\")
+summary - a summary of the journal entry
+uid - the unique identifier of the journal entry
+url - a URL for the journal entry"
+  :version "32.1"
+  :type '(radio (function-item di:vjournal-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:import-vjournal-as-nonmarking t
+  "Whether to import VJOURNAL components as nonmarking diary entries.
+
+If this variable is non-nil, VJOURNAL components will be imported into
+the diary as \"nonmarking\" entries by prefixing
+`diary-nonmarking-symbol'. This means they will not cause their date to
+be marked in the calendar when the command `diary-mark-entries' is
+called.  See Info node `(emacs)Displaying the Diary' for more
+information."
+  :version "32.1"
+  :type '(choice (const :tag "Import as nonmarking entries" t)
+                 (const :tag "Import as normal (marking) entries" nil)))
+
+(defcustom di:vtodo-skeleton-command #'di:vtodo-skeleton
+  "Function to format VTODO components for the diary.
+
+This should be a symbol naming a function which inserts information about
+an `icalendar-vtodo' into the current buffer.  It is convenient to
+express such a function as a skeleton; see `define-skeleton' and
+`skeleton-insert' for more information.
+
+The following variables will be bound when the function is called.
+All values will be strings unless another type is noted, or nil:
+
+alarms (list of `icalendar-valarm' nodes) - notifications in the task
+as-alarm (symbol) - non-nil when the task should be formatted for an
+  alarm notification in advance of the task. The symbol indicates the
+  type of alarm: `email' means to format the task as the body of an email.
+  (Currently only used for EMAIL alarms; see `icalendar-export-alarms'.)
+attachments (list of strings) - URLs or filenames of attachments in the ta=
sk
+attendees (list of strings) - the participants of the task,
+  formatted by `diary-icalendar-attendee-skeleton-command'
+categories (list of strings) - categories specified in the task
+access - the task's access classification
+comments (list of strings) - comments specified in the task
+completed-dt (an `icalendar-date-time' value) - when the task was completed
+completed - completed-dt, formatted as a local date-time string
+created-dt (an `icalendar-date-time' value) - when the task was created
+created - created-dt, formatted as a local date-time string
+description - the task's description
+dtstamp (an `icalendar-date' or `icalendar-date-time' value) - when the ta=
sk
+  was last revised
+dtstart (an `icalendar-date' or `icalendar-date-time' value) - when the ta=
sk
+  starts
+start - start-date and time in a single string.  When importing,
+  includes the date, otherwise just the (local) time
+start-to-end - a single string containing both start and due date and
+  time.  If the task starts and ends on the same day, the date is not
+  repeated.  When importing, dates are included, and the string may
+  contain a diary s-exp; when displaying, the string contains only the
+  times for the displayed date.
+duration (an `icalendar-dur-value') - the task's duration
+due-dt (an `icalendar-date' or `icalendar-date-time' value) - when the
+  task is due
+dtend - same as `due-dt'
+due - due date and time in a single string
+end - same as `due'
+work-time-sexp - when the task has both a start date and a due date,
+  this is a %%(diary-time-block ...) diary S-expression representing the
+  time from the start date to the due date (only non-nil when
+  importing). You can use this e.g. to make a separate entry for the
+  task's work time, so that it shows up every day in the diary until it
+  is due.
+coordinates (an `icalendar-geo-coordinates' value) - the task's geographic=
al
+  coordinates
+geo-location - coordinates, formatted as a string with degrees N/S and E/W
+importing (a boolean) - t if the task should be formatted for import.
+  When nil, the task should be formatted for display rather than import.
+  When importing it is important to include all information from the task
+  that you want to be saved in the diary; when displaying, information like
+  the date (or date-related S-expressions) and UID can be left out.
+last-modified-dt (an `icalendar-date-time' value) - the date and time the =
task
+  was last modified
+last-modified - last-modified-dt, formatted as a local date and time string
+location - the task's location
+non-marking (a boolean) - if non-nil, the diary entry should be non-marking
+organizer - the task's organizer, formatted by
+  `diary-icalendar-attendee-skeleton-command'
+percent-complete (a number between 0 and 100) - the percentage of the task=
 which
+  has already been completed
+priority (a number) - the task's priority (1 =3D highest priority, 9 =3D l=
owest;
+  0 =3D undefined)
+recurrence-id-dt (an `icalendar-date' or `icalendar-date-time' value) - the
+  date or date-time of a particular recurrence of the task
+recurrence-id - recurrence-id-dt, formatted as a local date and time string
+related-tos (a list of `icalendar-related-to' property nodes) -
+  these contain the UIDs of related tasks and their relationship type
+request-statuses (a list of `icalendar-request-status' property nodes) -
+  these contain status information about requests made
+resources (a list of strings) - resources used or needed for the task
+rrule-sexp - a string containing a diary S-expression for a recurring task
+  (only non-nil when importing).  When this is non-nil, you should
+  normally use it instead of the start and end variables to form the
+  date of the entry.
+revision (a number) - the revision number of the task; see
+  `icalendar-sequence'
+status - overall status specified by the organizer (e.g. \"confirmed\")
+summary - a summary of the task
+uid - the unique identifier of the task
+url - a URL for the task"
+  :version "32.1"
+  :type '(radio (function-item di:vjournal-skeleton)
+                (function :tag "Other function")))
+
+(defcustom di:import-predicate #'identity
+  "Predicate to filter iCalendar components before importing.
+
+This function must accept one argument, which will be an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. It should return non-nil if this component should be
+formatted for import, or nil if it should be skipped.
+
+The default value will format all the events, todos, and journal entries
+in a given calendar."
+  :version "32.1"
+  :type '(radio (function-item identity)
+                (function :tag "Other predicate")))
+
+;; `di:import-buffer-to-buffer' is the underlying function that formats
+;; a complete `icalendar-vcalendar' as diary entries. This function runs
+;; `di:post-entry-format-hook' after formatting each component as an
+;; entry, and it runs `di:post-calendar-format-hook' after all entries
+;; have been formatted. These hooks enable e.g. user review and
+;; confirmation of each imported entry and of the whole imported
+;; calendar.
+(defvar di:post-entry-format-hook nil
+  "Hook run after formatting a single iCalendar component as a diary entry.
+
+The functions in this hook are run by `diary-icalendar-import-buffer-to-bu=
ffer'
+(which see) after each component it formats. Each function will be
+called in a (narrowed) buffer whose contents represent a single diary
+entry.")
+
+(defvar di:post-calendar-format-hook nil
+  "Hook run after formatting a complete `icalendar-vcalendar' as diary ent=
ries.
+
+The functions in this hook are run by `diary-icalendar-import-buffer-to-bu=
ffer'
+(which see) after formatting all the diary entries created from the
+calendar. Each function will be called in a buffer containing all the
+diary entries.")
+
+(defun di:sort-by-start-ascending (c1 c2)
+  "Sort iCalendar component C1 before C2 if C1 starts strictly before C2.
+Components with no start date/time are sorted after components that do."
+  (let ((c1start (ical:with-property-of c1 'ical:dtstart nil value))
+        (c2start (ical:with-property-of c2 'ical:dtstart nil value)))
+    (cond ((and c1start c2start)
+           (ical:date/time< c1start c2start))
+          ;; order anything with a start before anything without:
+          (c1start t)
+          (c2start nil)
+          ;; otherwise they can stay as-is:
+          (t t))))
+
+(defcustom di:import-comparison-function #'di:sort-by-start-ascending
+  "Comparison function for sorting imported iCalendar components.
+See the :lessp argument of `sort' for more information."
+  :version "32.1"
+  :type '(radio (function-item di:sort-by-start-ascending)
+                (function :tag "Other comparison function")))
+
+(defun di:import-buffer-to-buffer (&optional all-non-marking)
+  "Format iCalendar data in current buffer as diary entries.
+
+This function parses the first iCalendar VCALENDAR in the current buffer
+and formats its VEVENT, VJOURNAL, and VTODO components as diary entries.
+It returns a new buffer containing those diary entries. The caller
+should kill this buffer when it is no longer needed.
+
+If ALL-NON-MARKING is non-nil, all diary entries will be non-marking.
+
+The list of components to import can be filtered by binding
+`diary-icalendar-import-predicate'. After each component is formatted as
+a diary entry, `diary-icalendar-post-entry-format-hook' is run in a (narro=
wed)
+buffer containing that entry. After all components have been formatted,
+`diary-icalendar-post-calendar-format-hook' is run in the (widened) buffer
+containing all the entries.
+
+The formatting of imported entries depends on a number of
+user-customizable variables, including: `diary-date-forms',
+`calendar-date-style', `calendar-date-display-form' and customizations
+in the `diary-icalendar' group."
+  (unless (ical:contains-vcalendar-p (current-buffer))
+    (di:signal-import-error (format "No VCALENDAR object in buffer %s"
+                                    (buffer-name))))
+  (save-excursion
+    (goto-char (point-min))
+    (let (vcalendar index)
+      (ical:init-error-buffer)
+      (let ((vcal/idx (ical:parse-and-index (current-buffer))))
+        (when vcal/idx
+          (setq vcalendar (car vcal/idx))
+          (setq index (cadr vcal/idx))
+          (let* ((vevents (ical:ast-node-children-of 'ical:vevent vcalenda=
r))
+                 (vjournals (ical:ast-node-children-of 'ical:vjournal vcal=
endar))
+                 (vtodos (ical:ast-node-children-of 'ical:vtodo vcalendar))
+                 (to-import
+                  (seq-filter di:import-predicate
+                              (append vevents vjournals vtodos)))
+                 ;; prevent point from being reset from window-point
+                 ;; when narrowed buffer is displayed for confirmation:
+                 (window-point-insertion-type t)
+                 ;; position at start of each entry:
+                 entry-start)
+            (sort to-import :in-place t :lessp di:import-comparison-functi=
on)
+            (let ((import-buf (get-buffer-create
+                               (generate-new-buffer " *diary-import*"))))
+              (with-current-buffer import-buf
+                (calendar-dlet ((importing t)) ; inform skeletons we're im=
porting
+                  (dolist (component to-import)
+                    (setq entry-start (point))
+                    (insert (di:format-entry component index all-non-marki=
ng))
+                    (with-restriction entry-start (point)
+                      (save-excursion
+                        (run-hooks 'di:post-entry-format-hook)))
+                    (unless (bolp) (insert "\n"))))
+                (save-excursion
+                  (run-hooks 'di:post-calendar-format-hook))
+                import-buf))))))))
+
+;; Internal variables needed by `di:-entry-import'. They are dynamically
+;; bound in `di:import-buffer'.
+(defvar di:-no-queries nil)
+(defvar di:-entry-count nil)
+
+(defun di:-entry-import ()
+  ;; Used via `di:post-entry-format-hook' in `di:import-buffer', below
+  (unless di:-no-queries
+    (display-buffer (current-buffer)))
+  (when (or di:-no-queries
+            (let ((help-form
+                   "Type y to add this entry to the diary, n to skip to ne=
xt."))
+              (di:y-or-n-or-edit-p "Add this entry to the diary?")))
+    (ical:condition-case err
+       (let* ((uid (save-excursion
+                     (goto-char (point-min))
+                     (when (re-search-forward di:uid-regexp nil t)
+                       (match-string 1))))
+              (other-entry (di:find-entry-with-uid uid))
+              (entry (buffer-string)))
+         (if (and other-entry
+                  (not di:-no-queries)
+                  (y-or-n-p "Replace existing entry with same UID?"))
+             (with-current-buffer (marker-buffer (car other-entry))
+               (replace-region-contents
+                (car other-entry) (cadr other-entry) entry))
+           ;; Otherwise, diary-make-entry inserts the new entry at the end
+           ;; of the main diary file:
+           (diary-make-entry
+            entry
+            nil ; skeleton has already interpreted non-marking
+            nil ; use dynamic value of `diary-file'
+            t   ; skeleton responsible for final spaces
+            t))  ; no need to show diary file while importing
+         (when other-entry
+           (set-marker (car other-entry) nil)
+           (set-marker (cadr other-entry) nil))
+         (cl-incf di:-entry-count)))))
+
+;;;###autoload
+(defun di:import-buffer (&optional diary-filename quietly all-non-marking)
+  "Import iCalendar events from current buffer into diary.
+
+This function parses the first iCalendar VCALENDAR in the current buffer
+and imports VEVENT, VJOURNAL, and VTODO components to the diary file
+DIARY-FILENAME (default: `diary-file').
+
+For each entry, you are asked whether to add it to the diary unless
+QUIETLY is non-nil.  After all entries are imported, you are also asked
+if you want to save the diary file unless QUIETLY is non-nil.  When
+called interactively, you are asked if you want to confirm each entry
+individually; answer No to make QUIETLY non-nil.
+
+ALL-NON-MARKING determines whether all diary events are created as
+non-marking entries. When called interactively, you are asked whether
+you want to make all entries non-marking.
+
+The formatting of imported entries in the diary depends on a number of
+user-customizable variables. Before running this command for the first
+time, you may especially wish to check the values of:
+`diary-file'
+`diary-date-forms'
+`diary-date-insertion-form'
+`calendar-date-style'
+`calendar-date-display-form'
+as well as customizations in the `diary-icalendar' group."
+  (interactive
+   (list (read-file-name "Diary file: "
+                         (when diary-file (file-name-directory diary-file))
+                         (cons diary-file diary-included-files))
+         (or di:always-import-quietly
+             (not (y-or-n-p "Confirm entries individually?")))
+         (y-or-n-p "Treat all entries as non-marking?")))
+
+  (let* ((diary-file diary-filename) ; dynamically bound for `di:-entry-im=
port',
+         (di:-entry-count 0)         ; see above
+         (di:-no-queries quietly)    ;
+         (di:post-entry-format-hook
+          (append di:post-entry-format-hook (list #'di:-entry-import)))
+         (diary-buffer (or (find-buffer-visiting diary-filename)
+                           (find-file-noselect diary-filename)))
+         import-buffer)
+    (unwind-protect
+        (setq import-buffer (di:import-buffer-to-buffer all-non-marking))
+      (when (bufferp import-buffer)
+        (kill-buffer import-buffer)))
+    (display-buffer diary-buffer)
+    (when (or quietly
+              (y-or-n-p (format "%d entries imported. Save diary file?"
+                                di:-entry-count)))
+      (with-current-buffer diary-buffer
+        (goto-char (point-max))
+        (save-buffer)))))
+
+;; Shim for backward compatibility with icalendar.el's
+;; `icalendar-import-file' signature, which didn't support `quietly' arg:
+;;;###autoload
+(defun di:-import-file-compat (filename diary-filename &optional non-marki=
ng)
+  "See `diary-icalendar-import-file'"
+  (di:import-file filename diary-filename di:always-import-quietly non-mar=
king))
+
+;;;###autoload
+(defun di:import-file (filename &optional diary-filename quietly non-marki=
ng)
+  "Import iCalendar diary entries from FILENAME into DIARY-FILENAME.
+
+This function parses the first iCalendar VCALENDAR in FILENAME and
+imports VEVENT, VJOURNAL, and VTODO components to the diary
+DIARY-FILENAME (default: `diary-file').
+
+For each entry, you are asked whether to add it to the diary unless
+QUIETLY is non-nil.  After all entries are imported, you are also asked
+if you want to save the diary file unless QUIETLY is non-nil. When
+called interactively, you are asked if you want to confirm each entry
+individually; answer No to make QUIETLY non-nil.
+
+NON-MARKING determines whether all diary events are created as
+non-marking entries. When called interactively, you are asked whether
+you want to make all entries non-marking.
+
+The formatting of imported entries in the diary depends on a number of
+user-customizable variables. Before running this command for the first
+time, you may especially wish to check the values of:
+`diary-file'
+`diary-date-forms'
+`diary-date-insertion-form'
+`calendar-date-style'
+`calendar-date-display-form'
+as well as customizations in the `diary-icalendar' group."
+  (interactive
+   (list (read-file-name "iCalendar file: " nil nil 'confirm)
+         (read-file-name "Diary file: "
+                         (when diary-file (file-name-directory diary-file))
+                         (cons diary-file diary-included-files))
+         (or di:always-import-quietly
+             (not (y-or-n-p "Confirm entries individually?")))
+         (y-or-n-p "Make all entries as non-marking?")))
+  (let ((parse-buf (ical:find-unfolded-buffer-visiting filename)))
+    (unless parse-buf
+      (ical:condition-case err
+        (setq parse-buf
+              (ical:unfolded-buffer-from-file (expand-file-name filename))=
)))
+    ;; Hand off to `di:import-buffer' for the actual import:
+    (if parse-buf
+        (with-current-buffer parse-buf
+          (di:import-buffer diary-filename quietly non-marking))
+      ;; If we get here, we weren't able to open the file for parsing:
+      (warn "Unable to open file %s; see %s"
+            filename (buffer-name (ical:error-buffer))))))
+
+;; Some simple support for viewing iCalendar data in MIME message
+;; parts. Mail readers may want to build their own viewer using the
+;; import functions above, but this is a good starting point:
+(defun di:mailcap-viewer ()
+  "View iCalendar data in the current message part as diary entries.
+
+This function is a suitable viewer for text/calendar parts in MIME
+messages, such as email attachments. To use this function as a viewer,
+customize the variable `mailcap-user-mime-data' and add an entry
+containing this function for the MIME type \"text/calendar\".
+
+To extend the behavior of this function, see
+`diary-icalendar-after-mailcap-viewer-hook'."
+  (let ((entries-buf (diary-icalendar-import-buffer-to-buffer)))
+    (unwind-protect
+        (progn
+          ;; Since this is already a temporary viewer buffer, we replace
+          ;; its contents with the imported entries, so we can (a) keep
+          ;; the window configuration setup by the calling mailcap code
+          ;; and (b) already kill the import buffer here.
+          (erase-buffer)
+          (insert-buffer-substring entries-buf)
+          (diary-mode)
+          (run-hooks di:after-mailcap-viewer-hook))
+      (kill-buffer entries-buf))))
+
+
+;; Export
+
+;;; Error handling
+(define-error 'ical:diary-export-error "Unable to export diary data" 'ical=
:error)
+
+(cl-defun di:signal-export-error (msg &key (diary-buffer (current-buffer))
+                                           (position (point))
+                                           line
+                                           (severity 2))
+  (let ((err-data
+          (list :message msg
+                :buffer diary-buffer
+                :position position
+                :line line
+                :severity severity)))
+    (signal 'ical:diary-export-error err-data)))
+
+;;; Customization
+(defgroup diary-icalendar-export nil
+  "iCalendar export from diary"
+  :version "32.1"
+  :group 'diary-icalendar
+  :prefix 'diary-icalendar)
+
+(defcustom di:address-regexp
+  (rx line-start
+      (one-or-more space)
+      (zero-or-one ;; property prefix, e.g. "Attendee:" or "Organizer:"
+       (seq (one-or-more word) ":"))
+      (group-n 2 (zero-or-more (not (any "<" "\n"))))
+      "<"
+      (group-n 1 (one-or-more (not (any "@" "\n")))
+                 "@"
+                 (one-or-more (not (any ">" "\n"))))
+      ">")
+  "Regular expression to match calendar user (email) addresses.
+
+The full address should match group 1; \"mailto:\" will be prepended to
+the full address during export, unless it or another URI scheme is
+present.  If there is a match in group 2, it will be used as the
+common name associated with the address (see `icalendar-cnparam').
+
+The default value matches names and addresses on lines like:
+
+  Ms. Baz <baz@HIDDEN>
+
+as well as on lines like:
+
+  Property: Ms. Baz <baz@HIDDEN> other data...
+
+Any matching address within a diary entry will be exported as an
+iCalendar ATTENDEE property, unless the line on which it appears is also
+a match for `diary-icalendar-organizer-regexp', in which case it will be
+exported as the ORGANIZER property."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:description-regexp nil
+  "Regular expression to match description in an entry.
+
+If this is nil, the entire entry (after the date and time specification)
+is used as the description. Thus, it is only necessary to set this
+variable if you want to export diary entries where the text to be used
+as the description should not include the full entry body. In that case,
+the description should match group 1 of this regexp."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:organizer-regexp
+  (rx line-start
+      (one-or-more space)
+      "Organizer:")
+  "Regular expression to match line of an entry specifying the ORGANIZER.
+
+This regular expression need *not* match the name and address of the
+organizer (`diary-icalendar-address-regexp' is responsible for that).
+It only needs to match a line on which the organizer's address appears,
+to distinguish the organizer's address from other addresses."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:class-regexp
+  (rx line-start
+      (one-or-more space)
+      (or "Class:" ; for backward compatibility
+          "Access:")
+      (zero-or-more space)
+      (group-n 1 (or "public" "private" "confidential")))
+  "Regular expression to match access classification.
+
+The access classification value should be matched by group 1. The default
+regexp matches access classifications like:
+  Access: C
+or
+  Class: C
+where C can be any of:
+  public
+  private
+  confidential"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:location-regexp
+  (rx line-start
+      (one-or-more space)
+      "Location:"
+      (zero-or-more space)
+      (group-n 1 (one-or-more not-newline)))
+  "Regular expression to match location of an event.
+
+The location value should be matched by group 1. The default regexp
+matches lines like:
+
+  Location: Some place"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:status-regexp
+  (rx line-start
+      (one-or-more space)
+      "Status:"
+      (zero-or-more space)
+      (group-n 1 (or "tentative" "confirmed" "cancelled" "needs-action" "c=
ompleted"
+                     "in-process" "draft" "final")))
+  "Regular expression to match status of an event.
+
+The status value should be matched by group 1. The default regexp
+matches statuses on lines like:
+
+  Status: S
+
+where S can be any of:
+
+  tentative
+  confirmed
+  cancelled
+  needs-action
+  completed
+  in-process
+  draft
+  final"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:summary-regexp nil
+  "Regular expression to match summary in an entry.
+
+If this is nil, the first line of the entry (after the date and time
+specification) is used as the summary. Thus, it is only necessary to set
+this variable if you want to export diary entries where the text to be
+used as the summary does not appear on the first line of the entry. In
+that case, the summary should match group 1 of this regexp."
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:todo-regexp nil
+  "Regular expression that identifies an entry as a task (VTODO).
+
+If this is non-nil, any diary entry that matches this regexp will be
+exported as an iCalendar VTODO component (instead of VEVENT), with its
+due date equal to the entry date."
+  :version "32.1"
+  :type '(radio (const :tag "Do not export VTODO tasks" nil)
+                (regexp :tag "Regexp for tasks")))
+
+(defcustom di:uid-regexp
+  (rx line-start
+      (one-or-more space)
+      "UID:"
+      (zero-or-more space)
+      (group-n 1 (one-or-more not-newline)))
+  "Regular expression to match UID of an entry.
+
+The UID value should be matched by group 1. The default regexp matches
+UIDs on lines like:
+
+  UID: some-unique-identifier"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:url-regexp
+  (rx line-start
+      (one-or-more space)
+      "URL:"
+      (zero-or-more space)
+      (group-n 1 (eval 'ical:uri)))
+  "Regular expression to match URL of an entry.
+
+The full URL should be matched by group 1. The default regexp matches
+URLs on lines like:
+
+  URL: http://example.com/foo/bar"
+  :version "32.1"
+  :type '(regexp))
+
+(defcustom di:export-nonmarking-entries t
+  "Whether to export nonmarking diary entries.
+
+If this variable is nil, nonmarking diary entries (those prefixed with
+`diary-nonmarking-symbol') are never exported. If it is non-nil,
+nonmarking diary entries are exported; see also
+`diary-icalendar-export-nonmarking-as-vjournal' for more control over
+how they are exported."
+  :version "32.1"
+  :type '(choice (const :tag "Export nonmarking entries" t)
+                 (const :tag "Do not export nonmarking entries" nil)))
+
+(defcustom di:export-nonmarking-as-vjournal nil
+  "Whether to export nonmarking diary entries as VJOURNAL components.
+
+If this variable is non-nil, nonmarking diary entries (those prefixed
+with `diary-nonmarking-symbol') will be exported as iCalendar VJOURNAL
+components, rather than VEVENT components. VJOURNAL components are
+intended to represent notes, documents, or other data associated with a
+date. External calendar applications may treat VJOURNAL components
+differently than VEVENTs, so consult your application's documentation
+before setting this variable to t.
+
+If this variable is nil, nonmarking entries will be exported as VEVENT
+components which do not take up busy time in the calendar (i.e., with
+the TRANSP property set to \"TRANSPARENT\"; see `icalendar-transp')."
+  :version "32.1"
+  :type '(choice (const :tag "Export nonmarking entries as VEVENT" nil)
+                 (const :tag "Export nonmarking entries as VJOURNAL" t))
+  :link '(url-link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3"))
+
+(defcustom di:export-alarms
+  nil
+  "Determine whether and how alarms are included in exported diary events.
+
+If this variable is nil, no alarms are created during export.
+If it is non-nil, it should be a list of lists like:
+
+((TYPE LEAD-TIME [OPTIONS]) ...)
+
+In each inner list, the first element TYPE should be a symbol indicating
+an alarm type to generate: one of \\=3D'audio, \\=3D'display, or \\=3D'ema=
il.
+The second element LEAD-TIME should be an integer specifying the amount
+of time before the event, in minutes, when the alarm should be
+triggered. For audio alarms, there are currently no other
+OPTIONS.
+
+For display and email alarms, the next OPTION is a format string for the
+displayed alarm, or the email subject line. In this string, \"%t\" will
+be replaced with LEAD-TIME and \"%s\" with the event's summary.
+
+If TYPE is \\=3D'email, the next OPTION should be a list whose members
+specify the email addresses to which email alarms should be sent. These
+can either be email addresses (as strings), or the symbol
+\\=3D'from-entry, meaning that these addresses should be taken from the
+exported diary entry (see `diary-icalendar-address-regexp')."
+  :version "32.1"
+  :type
+  '(choice (const :tag "Do not include alarms when exporting diary entries=
" nil)
+           (set :tag "Create alarms of these types"
+                (list :tag "Audio alarms"
+                      (const :tag "Options" audio)
+                      (integer :tag "Advance time (in minutes)"
+                               :value 10)
+                      ;; TODO: specify an audio file to attach?
+                      ;; TODO: other options we could have here and below:
+                      ;; - whether alarm is before event start or end
+                      ;; - repetitions and delays between repetitions
+                      )
+                (list :tag "Display alarms"
+                      (const :tag "Options" display)
+                      (integer :tag "Advance time (minutes)"
+                               :value 10)
+                      (string :tag "Display format"
+                              :value "In %t minutes: %s")
+                      ;; TODO: other options?
+                      )
+                (list :tag "Email alarms"
+                      (const :tag "Options" email)
+                      (integer :tag "Advance time (minutes)"
+                               :value 10)
+                      ;; TODO: other options?
+                      (string :tag "Subject line format"
+                              :value "In %t minutes: %s")
+                      (set
+                       :tag "Attendees"
+                       (const :tag "Parse addresses from entry"
+                              from-entry)
+                       (repeat :tag "Other addresses"
+                               (string :tag "Email address")))))))
+
+(defcustom di:export-sexp-enumeration-days
+  14
+  "Number of days over which an S-expression diary entry is enumerated.
+
+Some S-expression entries cannot be translated to iCalendar format.
+They are therefore enumerated, i.e., explicitly evaluated for a
+certain number of days, and then exported. The enumeration starts
+on the current day and continues for the number of days given here.
+
+See `icalendar-export-sexp-enumerate-all' for a list of sexp
+entries which by default are NOT enumerated."
+  :version "32.1"
+  :type 'integer
+  :group 'icalendar)
+
+(defcustom di:export-sexp-enumerate-all
+  nil
+  "Whether all S-expression diary entries are enumerated.
+
+If this variable is non-nil, all S-expression diary entries are
+enumerated for `diary-icalendar-export-sexp-enumeration-days' days
+instead of translating them into an iCalendar equivalent.
+This causes the following S-expression entries to be enumerated
+instead of translated to a recurrence rule:
+ `diary-anniversary'
+ `diary-block'
+ `diary-cyclic'
+ `diary-date'
+ `diary-float'
+ `diary-remind'
+ `diary-rrule'
+ `diary-time-block'
+All other S-expression entries are enumerated in any case."
+  :version "32.1"
+  :type '(choice (const :tag "Export without enumeration when possible" ni=
l)
+                 (const :tag "Always enumerate S-expression entries" t)))
+
+(defcustom di:recurring-start-year
+  (1- (decoded-time-year (decode-time)))
+  "Start year for recurring events.
+
+Set this to a year just before the start of your personal calendar.
+It is needed when exporting certain diary S-expressions to iCalendar
+recurring events, and because some calendar browsers only propagate
+recurring events for several years beyond the start time."
+  :version "32.1"
+  :type 'integer)
+
+(defun di:-tz-info-sexp-p (_ sexp)
+  "Validate that SEXP gives time zone info like from `calendar-current-tim=
e-zone'"
+  (and (listp sexp)
+       (length=3D sexp 8)
+       (let ((utc-diff (nth 0 sexp))
+             (dst-offset (nth 1 sexp))
+             (std-zone (nth 2 sexp))
+             (dst-zone (nth 3 sexp))
+             (dst-starts (nth 4 sexp))
+             (dst-ends (nth 5 sexp))
+             (dst-starts-time (nth 6 sexp))
+             (dst-ends-time (nth 7 sexp)))
+         (and
+          (integerp utc-diff) (< (abs utc-diff) (* 60 24))
+          (integerp dst-offset) (< (abs utc-diff) (* 60 24))
+          (stringp std-zone)
+          (stringp dst-zone)
+          (or (and (listp dst-starts) (memq 'year (flatten-list dst-starts=
)))
+              (and (null dst-starts) (equal std-zone dst-zone)))
+          (or (and (listp dst-ends) (memq 'year (flatten-list dst-ends)))
+              (and (null dst-ends) (equal std-zone dst-zone)))
+          (or (and (integerp dst-starts-time) (< (abs dst-starts-time) (* =
60 24)))
+              (null dst-starts-time))
+          (or (and (integerp dst-ends-time) (< (abs dst-ends-time) (* 60 2=
4)))
+              (null dst-ends-time))))))
+
+(defcustom di:time-zone-export-strategy
+  'local
+  "Strategy to use for exporting clock times in diary files.
+
+The symbol `local' (the default) means to assume that times are in the
+time zone determined by `calendar-current-time-zone'. The time zone
+information returned by that function will be exported as an iCalendar
+VTIMEZONE component, and clock times in the diary file will be exported
+with a reference to that time zone definition.
+
+On some systems, `calendar-current-time-zone' cannot determine time zone
+information for the local time zone. In that case, you can set this
+variable to a list in the format returned by that function:
+
+ (UTC-DIFF DST-OFFSET STD-ZONE DST-ZONE
+  DST-STARTS DST-ENDS DST-STARTS-TIME DST-ENDS-TIME)
+
+This list describes the time zone you would like to use for export. See
+the docstring of `calendar-current-time-zone' for details. Times in the
+diary file will be exported like with `local' for this time zone.
+
+The other possible values for this variable avoid the need to include
+any time zone information in the exported iCalendar data:
+
+The symbol `to-utc' means to re-encode all exported times to UTC
+time. In this case, export will assume that times are in Emacs local
+time, and rely on `encode-time' and `decode-time' to convert them to UTC
+times.
+
+The symbol `floating' means to export clock times without any time
+zone identifier, which the iCalendar standard (RFC5545) calls
+\"floating\" times. RFC5545 specifies that floating times should be
+interpreted as local to whichever time zone the recipient of the
+iCalendar data is currently in (which might be different from your local
+time zone). You should only use this if that behavior makes sense for
+the events you are exporting."
+  :version "32.1"
+  :type
+  '(radio (const :tag "Use TZ from `calendar-current-time-zone'" local)
+          (const :tag "Convert local times to UTC" to-utc)
+          (const :tag "Use floating times" floating)
+          (sexp :tag "User-provided TZ information"
+                :match di:-tz-info-sexp-p
+                :type-error
+                "See `calendar-current-time-zone' for format"))
+  :link '(url-link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5"))
+
+(defcustom di:export-linewise
+  nil
+  "Export entries with multiple lines to distinct events.
+
+If this is non-nil, each line of a diary entry will be exported as a
+separate iCalendar event.
+
+If you write your diary entries in a one-entry-per-day style, with
+multiple events or appointments per day, you can use this variable to
+export these individual events to iCalendar format. For example, an
+entry like:
+
+2025-05-03
+  9AM Lab meeting
+    Gunther to present on new assay
+  Start experiment A
+  12:30-1:30PM Lunch with Phil
+  16:00 Experiment A finishes; move to freezer
+
+will be exported as four events, each on 2025-05-03 but with different
+start times (except for the second event, \"Start experiment A\", which
+has no start time). An event line can be continued onto subsequent lines
+via additional indentation, as in the first event in this entry.
+
+If this variable is non-nil, each distinct event must begin on a
+continuation line of the entry (below the date); any text on the same
+line as the date is ignored.  A time specification can only appear at
+the beginning of each continuation line of the entry, immediately after
+the leading whitespace.
+
+If this variable is nil, each entry will be exported as exactly one
+event, and only a time specification immediately following the date will
+determine the start and end times for that event. Thus, in the example
+above, the exported event would have a start date but no start time or
+end time.  The times in the entry would be preserved as text in the
+event description."
+  :version "32.1"
+  :type '(radio (const :tag "Do not export linewise" nil)
+                (const :tag "Export linewise" t)))
+
+(defcustom di:other-properties-parser nil
+  "Function to parse additional iCalendar properties from diary entries.
+
+If you like to keep your diary entries in a particular format, you can
+set this to a function which parses that format to iCalendar properties
+during iCalendar export, so that other calendar applications can use
+them.
+
+The parsing function will be called with no arguments, with the current
+restriction set to the boundaries of a diary entry. If
+`diary-icalendar-export-linewise' is true, the restriction will
+correspond to a single event in a multi-line diary entry.
+
+The function should return a list of iCalendar property nodes, which
+will be incorporated into the `icalendar-vevent', `icalendar-vjournal',
+or `icalendar-vtodo' component node created from the current entry.  See
+the docstrings of those symbols for more information on the properties
+they can contain, and the `icalendar-make-property' macro for a simple
+way to create property nodes from values parsed from the entry.
+
+When the function is called, the variables `type' and `properties' will
+be dynamically bound. `type' is bound to the iCalendar type symbol (one
+of \\=3D'icalendar-vevent, \\=3D'icalendar-vjournal, or \\=3D'icalendar-vt=
odo)
+for the component being generated for the entry. `properties' is bound
+to the list of property nodes that `diary-icalendar-parse-entry' has
+already parsed from the entry and will be included in the exported
+component."
+  :version "32.1"
+  :type '(radio (const :tag "Do not parse additional properties" nil)
+                (function :tag "Parsing function")))
+
+;;; Export utility functions
+(defun di:parse-attendees-and-organizer ()
+  "Parse `icalendar-attendee' and `icalendar-organizer' nodes from entry.
+
+Searches the entry in the current restriction for addresses matching
+`diary-icalendar-address-regexp'. If an address is found on a
+line that also matches `diary-icalendar-organizer-regexp', it will be
+parsed as an `icalendar-organizer' node, or otherwise as an
+`icalendar-attendee'. Returns the list of nodes for all addresses found."
+  (goto-char (point-min))
+  (let (attendees organizer)
+    (while (re-search-forward di:address-regexp nil t)
+      (let ((addr (match-string 1))
+            (cn (match-string 2)))
+        (unless (string-match ":" addr) ; URI scheme already present
+          (setq addr (concat "mailto:" addr)))
+        (when cn
+          (setq cn (di:-nonempty cn)))
+        (if (string-match di:organizer-regexp
+                          (buffer-substring (line-beginning-position)
+                                            (line-end-position)))
+            (setq organizer
+                  (ical:make-property ical:organizer addr (ical:cnparam cn=
)))
+          (push (ical:make-property ical:attendee addr (ical:cnparam cn))
+                attendees))))
+    (if organizer
+        (cons organizer attendees)
+      attendees)))
+
+(defun di:parse-location ()
+  "Parse `icalendar-location' node from entry.
+
+Searches the entry in the current restriction for a location matching
+`diary-icalendar-location-regexp'. If a location is found, it will be
+parsed as an `icalendar-location' node. Returns a list containing just
+this node, or nil."
+  (goto-char (point-min))
+  (when (and di:location-regexp
+             (re-search-forward di:location-regexp nil t))
+    (ical:make-property ical:location (di:-nonempty (match-string 1)))))
+
+(defun di:parse-class ()
+  "Parse `icalendar-class' node from entry.
+
+Searches the entry in the current restriction for an access
+classification matching `diary-icalendar-class-regexp'. If a
+classification is found, it will be parsed as an `icalendar-class'
+node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:class-regexp
+             (re-search-forward di:class-regexp nil t))
+    (ical:make-property ical:class
+                        (upcase (string-trim (match-string 1))))))
+
+(defun di:parse-status ()
+  "Parse `icalendar-status' node from entry.
+
+Searches the entry in the current restriction for a status matching
+`diary-icalendar-status-regexp'. If a status is found, it will be parsed
+as an `icalendar-status' node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:status-regexp
+             (re-search-forward di:status-regexp nil t))
+    (ical:make-property ical:status
+                        (upcase (string-trim (match-string 1))))))
+
+(defun di:parse-url ()
+  "Parse `icalendar-url' node from entry.
+
+Searches the entry in the current restriction for an URL matching
+`diary-icalendar-url-regexp'. If an URL is found, it will be parsed as an
+`icalendar-url' node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:url-regexp
+             (re-search-forward di:url-regexp nil t))
+    (ical:make-property ical:url (di:-nonempty (match-string 1)))))
+
+(defun di:parse-uid ()
+  "Parse `icalendar-uid' node from entry.
+
+Searches the entry in the current restriction for a UID matching
+`diary-icalendar-uid-regexp'. If a UID is found, it will be parsed as an
+`icalendar-uid' node. Return this node, or nil."
+  (goto-char (point-min))
+  (when (and di:uid-regexp
+             (re-search-forward di:uid-regexp nil t))
+    (ical:make-property ical:uid (di:-nonempty (match-string 1)))))
+
+(defun di:parse-summary-and-description ()
+  "Parse summary and description nodes from current restriction.
+
+When `diary-icalendar-summary-regexp' or
+`diary-icalendar-description-regexp' are non-nil, and the entry matches
+them, the matches will be used to generate the summary and description.
+
+Otherwise, the first line of the entry (after any nonmarking symbol and
+date and time specification) is used as the summary.  The description is
+the full body of the entry, excluding the nonmarking symbol, date and
+time, but including the summary.
+
+Returns a list containing an `icalendar-summary' node and
+`icalendar-description' node, or nil."
+  (goto-char (point-min))
+  (let (summary description)
+    (when (and di:summary-regexp
+               (re-search-forward di:summary-regexp nil t))
+      (setq summary (match-string 1)))
+    (goto-char (point-min))
+    (when (and di:description-regexp
+               (re-search-forward di:description-regexp nil t))
+      (setq description (match-string 1)))
+    ;; Fall back to using first line and entire entry:
+    (goto-char (point-min))
+    (while (looking-at-p "[[:space:]]")
+      (forward-char))
+    (unless summary
+      (setq summary (buffer-substring (point) (line-end-position))))
+    (unless description
+      (setq description (buffer-substring (point) (point-max))))
+    ;; Remove any indentation on subsequent lines from description:
+    (setq description (replace-regexp-in-string "^[[:space:]]+" "" descrip=
tion))
+
+    (list (ical:make-property ical:summary summary)
+          (ical:make-property ical:description description))))
+
+(defun di:parse-entry-type ()
+  "Return the type symbol for the component type used to export an entry.
+
+Default is `icalendar-vevent'. If the entry is nonmarking and
+`diary-icalendar-export-nonmarking-as-vjournal' is non-nil,
+`icalendar-vjournal' is returned. If `diary-icalendar-todo-regexp' is
+non-nil and the entry matches it, `icalendar-vtodo' is returned.
+
+If the entry is nonmarking and `diary-icalendar-export-nonmarking-entries'
+is nil, nil is returned, indicating that the entry should not be
+exported."
+  (let (type)
+    (goto-char (point-min))
+    (unless (and (looking-at-p diary-nonmarking-symbol)
+                 (not di:export-nonmarking-entries))
+      (setq type 'ical:vevent)
+      (when (and (looking-at-p diary-nonmarking-symbol)
+                 di:export-nonmarking-as-vjournal)
+        (setq type 'ical:vjournal))
+      (when (and di:todo-regexp (re-search-forward di:todo-regexp nil t))
+        (setq type 'ical:vtodo)))
+    type))
+
+(defun di:parse-transparency (type)
+  "Return the iCalendar time transparency of an entry.
+
+TYPE should be the type symbol for the component to be exported, as
+returned by `diary-icalendar-parse-entry-type'. If the entry is
+non-marking (i.e., begins with `diary-nonmarking-symbol'), and it is to
+be exported as an `icalendar-vevent' (according to TYPE), then this
+function returns a list containing the appropriate `icalendar-transp'
+property node to mark the event as transparent, and moves the current
+restriction past the non-marking symbol.  Otherwise it returns nil."
+  (save-excursion
+    (goto-char (point-min))
+    (when (and (eq type 'ical:vevent)
+               (re-search-forward (concat "^" diary-nonmarking-symbol)
+                                  (line-end-position) t))
+      (narrow-to-region (point) (point-max))
+      (list
+       (ical:make-property ical:transp "TRANSPARENT")))))
+
+;; TODO: move to diary-lib?
+(defun di:parse-date-form ()
+  "Parse a date matching `diary-date-forms' on the current line.
+
+If a date is found, moves the current restriction past the end of the
+date and returns a list (MONTH DAY YEAR), where each value is an integer
+or `t' if the date is generic in that unit. Otherwise returns nil."
+  (goto-char (point-min))
+  (catch 'date
+    (let (date-regexp backup)
+      (dolist (date-sexp diary-date-forms)
+        (when (eq 'backup (car date-sexp))
+          (setq date-sexp (cdr date-sexp))
+          (setq backup t))
+        (setq date-regexp (di:date-form-to-regexp date-sexp))
+        (when backup (beginning-of-line))
+        (when (let ((case-fold-search t))
+                (re-search-forward date-regexp nil t))
+          (let ((year
+                 (let ((match (match-string 1)))
+                   (if (or (null match) (equal match "*"))
+                       t
+                     (if (and diary-abbreviated-year-flag (length=3D match=
 2))
+                         ;; from diary-lib.el:
+                         ;; Add 2-digit year to current century.
+                         ;; If more than 50 years in the future,
+                         ;; assume last century. If more than 50
+                         ;; years in the past, assume next century.
+                         (let* ((current-y
+                                 (calendar-extract-year (calendar-current-=
date)))
+                                (y (+ (string-to-number match)
+                                      ;; Current century, eg 2000.
+                                      (* 100 (/ current-y 100))))
+                                (offset (- y current-y)))
+                           (cond ((> offset 50)
+                                  (- y 100))
+                                 ((< offset -50)
+                                  (+ y 100))
+                                 (t y)))
+                       (string-to-number match)))))
+                (month
+                 (let ((month-num (match-string 2))
+                       (month-name (match-string 4)))
+                   (cond ((or (equal month-name "*") (equal month-num "*")=
) t)
+                         (month-num (string-to-number month-num))
+                         (month-name
+                          (alist-get
+                           (capitalize month-name)
+                           (calendar-make-alist
+                            calendar-month-name-array
+                            1 nil
+                            calendar-month-abbrev-array
+                            (mapcar (lambda (e) (format "%s." e))
+                                    calendar-month-abbrev-array))
+                           nil nil #'equal)))))
+                (day
+                 (let ((day-num (match-string 3))
+                       (day-name (match-string 5)))
+                   (cond
+                    ;; We don't care about the day name here, unless it
+                    ;; is "*", since it won't help us identify a day of
+                    ;; the month.  Weekly entries under a weekday name
+                    ;; are parsed by `di:parse-weekday-name', below.
+                    ((or (equal day-name "*") (equal day-num "*")) t)
+                    (day-num (string-to-number day-num))))))
+            (when (and year month day)
+              (narrow-to-region (match-end 0) (point-max))
+              (throw 'date (list month day year)))))))))
+
+(defun di:date-form-to-regexp (date-sexp)
+  "Convert DATE-SEXP to a regular expression.
+
+DATE-SEXP should be an S-expression in the variables `year', `month',
+`day', `monthname', and `dayname', as found e.g. in `diary-date-forms'.
+The returned regular expression matches dates of this form, including
+generic dates specified with \"*\", and abbreviated and long-form month
+and day names (based on `calendar-month-name-array' and
+`calendar-month-abbrev-array', and similarly for day names). The match
+groups contain the following data:
+
+Group 1: the 2-4 digit year, or a literal *
+Group 2: the 1-2 digit month number, or a literal *
+Group 3: the 1-2 digit day number, or a literal *
+Group 4: the (long-form or abbreviated) month name, or a literal *
+Group 5: the (long-form or abbreviated) day name, or a literal *"
+  (when (eq 'backup (car date-sexp))
+    (setq date-sexp (cdr date-sexp)))
+  (let ((month-names-regexp
+         (rx
+          (group-n 4
+            (or (regexp (diary-name-pattern calendar-month-name-array
+                                            calendar-month-abbrev-array))
+                "*"))))
+        (day-names-regexp
+         (rx
+          (group-n 5
+            (or (regexp (diary-name-pattern calendar-day-name-array
+                                            calendar-day-abbrev-array))
+                "*"))))
+          date-regexp)
+    (calendar-dlet
+        ((prefix (rx line-start
+                     (zero-or-one (regexp diary-nonmarking-symbol))))
+         (year (rx (group-n 1 (or (** 2 4 digit) "*"))))
+         (month (rx (group-n 2 (or (** 1 2 digit) "*"))))
+         (day (rx (group-n 3 (or (** 1 2 digit) "*"))))
+         (monthname month-names-regexp)
+         (dayname day-names-regexp))
+      (setq date-regexp (apply #'concat (cons prefix (mapcar #'eval date-s=
exp)))))
+  date-regexp))
+
+(defun di:parse-weekday-name ()
+  "Parse a weekday name on the current line.
+
+The day name must appear in `calendar-day-name-array' or
+`calendar-day-abbrev-array'. If a day name is found, move the current
+restriction past it, and return a day number between 0 (=3DSunday) and
+6 (=3DSaturday). Otherwise, return nil."
+  (goto-char (point-min))
+  (let ((day-names-regexp
+         (rx line-start
+             (zero-or-one (regexp diary-nonmarking-symbol))
+             (group-n 1
+               (regexp (diary-name-pattern calendar-day-name-array
+                                           calendar-day-abbrev-array))))))
+    (when (re-search-forward day-names-regexp (line-end-position) t)
+      (let ((day-name (capitalize (match-string 1))))
+        (narrow-to-region (match-end 0) (point-max))
+        (alist-get
+         day-name
+         (calendar-make-alist calendar-day-name-array 0 nil
+                              calendar-day-abbrev-array
+                              (mapcar (lambda (e) (format "%s." e))
+                                      calendar-day-abbrev-array))
+         nil nil #'equal)))))
+
+(defun di:weekday-to-recurrence (weekday)
+  "Convert WEEKDAY to a WEEKLY iCalendar recurrence rule.
+
+WEEKDAY must be an integer between 0 (=3DSunday) and 6 (=3DSaturday).
+Returns a list (START RRULE), with START being an `icalendar-dtstart'
+property and RRULE an `icalendar-rrule'."
+  (let ((dtstart (calendar-nth-named-day 1 weekday 1 ical:recurring-start-=
year))
+        (rrule `((FREQ WEEKLY)
+                 (BYDAY (,weekday)))))
+    (list (ical:make-property ical:dtstart dtstart)
+          (ical:make-property ical:rrule rrule))))
+
+;; TODO: give this value to diary-time-regexp?
+(defconst di:time-regexp
+  (rx-let ((hours (seq (opt (any "0-2")) (any "0-9")))
+           (minutes (seq (any "0-5") (any "0-9")))
+           (am/pm (seq (any "ap") "m"))) ;; am, pm
+    (rx
+     (group-n 1 ;; START
+       (group-n 11 hours) ;; start hour
+       (or "h" ;; 10h
+           (group-n 13 am/pm) ;; 10am
+           (zero-or-one
+            (or
+             ;; 10:00 or 10h00:
+             (seq (or ":" "h") (group-n 12 minutes) (opt (group-n 13 am/pm=
)))
+             ;; 10.00h or 10.00am: (a bare "10.00" is not matched)
+             (seq "." (group-n 12 minutes) (or "h" (group-n 13 am/pm)))))))
+     (zero-or-one
+      (one-or-more "-")
+      (group-n 2 ;; END
+        (group-n 21 hours) ;; end hour
+        (or "h" ;; 10h
+            (group-n 23 am/pm) ;; 10am
+            (zero-or-one
+             (or
+              ;; 10:00 or 10h00:
+              (seq (or ":" "h") (group-n 22 minutes) (opt (group-n 23 am/p=
m)))
+              ;; 10.00h or 10.00am:
+              (seq "." (group-n 22 minutes) (or "h" (group-n 23 am/pm)))))=
)))
+     (one-or-more space)))
+  "Regular expression to match diary appointment times.
+
+Accepted time formats look like e.g.:
+  9AM 9:00 09:00 9h 9h00 9.00am 9.00h
+  9PM 9:00pm 21:00 21h00 21.00pm 21.00h
+  9AM-1PM 09:00-13:00
+
+Group 1 matches the start time:
+  Group 11 matches the hours digits
+  Group 12 matches the minutes digits
+  Group 13 matches an AM/PM specification
+
+Group 2 matches the end time:
+  Group 21 matches the hours digits
+  Group 22 matches the minutes digits
+  Group 23 matches an AM/PM specification")
+
+(defun di:parse-time ()
+  "Parse diary time string in the current restriction.
+
+If a time specification is found, move the current restriction past it,
+and return a list (START END), where START and END are decoded-time
+values containing the hours and minutes slots parsed from the time
+specification. END may be nil if no end time was specified."
+  (goto-char (point-min))
+  (let ((regexp di:time-regexp)
+        (case-fold-search t))
+    (when di:export-linewise
+      ;; In this case, only look for a time following whitespace,
+      ;; at the beginning of a continuation line of the full entry:
+      (setq regexp (concat "^[[:space:]]+" di:time-regexp)))
+
+    (when (re-search-forward regexp (line-end-position) t)
+      (let* ((start-hh (string-to-number (match-string 11)))
+             (start-am/pm (when (match-string 13)
+                            (upcase (match-string 13))))
+             (start-hours (if (and (equal start-am/pm "PM") (< start-hh 12=
))
+                              (+ 12 start-hh)
+                            start-hh))
+             (start-minutes (string-to-number (or (match-string 12) "0")))
+             (start
+              (when (and start-hours start-minutes)
+                (make-decoded-time :hour start-hours
+                                   :minute start-minutes
+                                   :second 0)))
+             (end-hh (when (match-string 21)
+                       (string-to-number (match-string 21))))
+             (end-am/pm (when (match-string 23)
+                          (upcase (match-string 23))))
+             (end-hours (if (and end-hh (equal end-am/pm "PM") (< end-hh 1=
2))
+                            (+ 12 end-hh)
+                          end-hh))
+             (end-minutes (when end-hours
+                            (string-to-number (or (match-string 22) "0"))))
+             (end (when (and end-hours end-minutes)
+                    (make-decoded-time :hour end-hours
+                                       :minute end-minutes
+                                       :second 0))))
+        (narrow-to-region (match-end 0) (point-max))
+        ;; Return the times:
+        (list start end)))))
+
+(defun di:convert-time-via-strategy (dt &optional vtimezone)
+  "Reinterpret the local time DT according to
+`diary-icalendar-time-zone-export-strategy', which see.
+
+DT may be an `icalendar-date' or `icalendar-date-time'. If it is a date,
+it is returned unmodified. If it is a date-time, depending on the
+strategy and any existing zone information in DT, it will be converted
+to a correct local, UTC, or floating time."
+  (cl-typecase dt
+    (ical:date dt)
+    (ical:date-time
+     (cond
+       ((or (eq 'local di:time-zone-export-strategy)
+            (listp di:time-zone-export-strategy))
+        (unless (ical:vtimezone-component-p vtimezone)
+          (di:signal-export-error
+           (format
+            "%s time export strategy requires a time zone definition;\n%s"
+            (if (eq 'local di:time-zone-export-strategy) "`local'" "list-b=
ased")
+            (concat
+             "check the value of `diary-icalendar-time-zone-export-strateg=
y'\n"
+             "and the output of `calendar-current-time-zone'"))))
+        (if (decoded-time-zone dt)
+            (icr:tz-decode-time (encode-time dt) vtimezone)
+          (icr:tz-set-zone dt vtimezone :error)))
+       ((eq 'to-utc di:time-zone-export-strategy)
+        (decode-time (encode-time dt) t))
+       ((eq 'floating di:time-zone-export-strategy)
+        (setf (decoded-time-zone dt) nil)
+        dt)))))
+
+(defun di:parse-sexp ()
+  "Parse a diary S-expression at the beginning of the current restriction.
+
+The S-expression must appear at the start of line, immediately after
+`diary-sexp-entry-symbol'.  If an S-expression is found, move the
+current restriction past it, and return the S-expression. Otherwise,
+return nil."
+  (goto-char (point-min))
+  (let ((regexp (rx line-start
+                    (regexp diary-sexp-entry-symbol))))
+    (when (re-search-forward regexp (line-end-position) t)
+      (let ((sexp (read (current-buffer))))
+        (narrow-to-region (point) (point-max))
+        sexp))))
+
+(defun di:anniversary-sexp-to-recurrence (sexp)
+  "Convert `diary-anniversary' SEXP to `icalendar-dtstart' and `icalendar-=
rrule'.
+Returns a pair of nodes (START RRULE)."
+  (let* ((d1 (nth 1 sexp))
+         (d2 (nth 2 sexp))
+         (d3 (nth 3 sexp))
+         (dtstart (diary-make-date d1 d2 (or d3 di:recurring-start-year)))
+         (rrule '((FREQ YEARLY))))
+    (list
+     (ical:make-property ical:dtstart dtstart (ical:valuetypeparam 'ical:d=
ate))
+     (ical:make-property ical:rrule rrule))))
+
+(defun di:block-sexp-to-recurrence (sexp)
+  "Convert `diary-block' SEXP to `icalendar-dtstart' and `icalendar-rrule'=
 nodes.
+Returns a pair of nodes (START RRULE)."
+  (let* ((dtstart (diary-make-date (nth 1 sexp) (nth 2 sexp) (nth 3 sexp)))
+         (end (diary-make-date (nth 4 sexp) (nth 5 sexp) (nth 6 sexp)))
+         (rrule `((FREQ DAILY)
+                  (UNTIL ,end))))
+    (list (ical:make-property ical:dtstart dtstart
+            (ical:valuetypeparam 'ical:date))
+          (ical:make-property ical:rrule rrule))))
+
+(defun di:time-block-sexp-to-start-end (sexp &optional vtimezone)
+  "Convert `diary-time-block' SEXP to `icalendar-dtstart' and `icalendar-d=
tend'.
+Returns a pair of nodes (START END).
+
+VTIMEZONE, if specified, should be an `icalendar-vtimezone'.  Times in
+SEXP will be reinterpreted as local to VTIMEZONE, as UTC, or as floating
+times according to `diary-icalendar-time-zone-export-strategy'."
+  (let* ((start (plist-get sexp :start))
+         (dtstart (di:convert-time-via-strategy start vtimezone))
+         (end (plist-get sexp :end))
+         (dtend (di:convert-time-via-strategy end vtimezone))
+         (tzid (ical:with-property-of vtimezone 'ical:tzid)))
+    (list (ical:make-property ical:dtstart dtstart (ical:tzidparam tzid))
+          (ical:make-property ical:dtend dtend (ical:tzidparam tzid)))))
+
+(defun di:cyclic-sexp-to-recurrence (sexp)
+  "Convert `diary-cyclic' SEXP to `icalendar-dtstart' and `icalendar-rrule=
'.
+Returns a pair of nodes (START RRULE)."
+  (let* ((ndays (nth 1 sexp))
+         (dtstart (diary-make-date (nth 2 sexp) (nth 3 sexp) (nth 4 sexp)))
+         (rrule `((FREQ DAILY)
+                  (INTERVAL ,ndays))))
+    (list
+     (ical:make-property ical:dtstart dtstart (ical:valuetypeparam 'ical:d=
ate))
+     (ical:make-property ical:rrule rrule))))
+
+(defun di:float-sexp-to-recurrence (sexp)
+  "Convert `diary-float' SEXP to `icalendar-dtstart' and `icalendar-rrule'.
+Returns a pair of nodes (START RRULE)."
+  (let* ((month-exp (nth 1 sexp))
+         (months (cond ((eq month-exp t) nil) ; don't add a BYMONTH clause
+                       ((integerp month-exp) (list month-exp))
+                       ((and (listp month-exp) (eq 'quote (car month-exp)))
+                        (eval month-exp nil)) ; unquote a literal list of =
ints
+                       (t month-exp)))
+         (_ (unless (seq-every-p #'integerp months)
+              (di:signal-export-error
+               (format "Malformed month(s) in `diary-float' S-expression:\=
n%s"
+                       sexp))))
+         (dow (nth 2 sexp))
+         (n (nth 3 sexp))
+         (day (or (nth 4 sexp)
+                  (if (< 0 n) 1
+                    'last))) ; =3D "last day of the month" for any month
+         ;; Calculate the offset within the month from day, n:
+         (offset
+          (cond ((eq day 'last) n)
+                ((and (< 0 day) (< 0 n))
+                 ;; In this case, to get the offset relative to
+                 ;; the start of the month, we need to add to n
+                 ;; the number of weeks in the month before day:
+                 ;; e.g. if day =3D 8, n =3D 2, then we are looking
+                 ;; for the second DOW after the 8th of the
+                 ;; month, which is the 3rd DOW after the 1st of
+                 ;; the month
+                 (+ n (/ (1- day) 7)))
+                ((and (< 0 day) (< n 0) (< day (* 7 (abs n))))
+                 ;; In this case, we need to cross into the
+                 ;; previous month and adjust the offset
+                 ;; accordingly to reflect the correct number of
+                 ;; weeks before the end of the month.
+                 ;; e.g. if day =3D 15, n =3D -3, we're looking for the
+                 ;; 3rd DOW before the 15th of the month,
+                 ;; which is the 1st DOW "before" the end of the
+                 ;; previous month (where "before" is inclusive,
+                 ;; e.g offset =3D -1 will work when DOW is the last
+                 ;; day of the month)
+                 (when months
+                   (setq months
+                         (sort
+                          :in-place t
+                          (mapcar
+                           (lambda (m) (if (eql m 1) 12 (1- m)))
+                           months))))
+                 (+ n (/ (1- day) 7)))))
+         (rrule (delq nil
+                      `((FREQ MONTHLY)
+                        ,(when months
+                           (list 'BYMONTH months))
+                        (BYDAY ((,dow . ,offset))))))
+         (dtstart
+          (calendar-nth-named-day n dow
+                                  (if months (apply #'min months) 1)
+                                  di:recurring-start-year
+                                  (unless (eq day 'last) day))))
+
+    ;; if at this point we have an offset which could put us outside the
+    ;; month boundaries, warn the user that this may not be supported:
+    (when (< 4 (abs offset))
+      (ical:warn
+       (format
+        "`diary-float' with large N=3D%d may not be supported on other sys=
tems" n)))
+
+    (list (ical:make-property ical:dtstart dtstart
+                              (ical:valuetypeparam 'ical:date))
+          (ical:make-property ical:rrule rrule))))
+
+(defun di:offset-sexp-to-nodes (sexp)
+  "Convert a `diary-offset' SEXP to a list of property nodes.
+
+SEXP must have the form (diary-offset INNER-SEXP NDAYS). The conversion
+is only possible for relatively simple cases of INNER-SEXP. The
+INNER-SEXP is first converted to a list of property nodes (see
+`diary-icalendar-export-sexp'), and then any date, time, period, and
+recurrence rule values in these nodes are adjusted NDAYS forward."
+  (let* ((arg1 (nth 1 sexp))
+         (inner-sexp (if (eq (car arg1) 'quote)
+                         (eval arg1 nil) ; unquote a quoted inner sexp
+                       arg1))
+         (nodes (di:sexp-to-nodes inner-sexp))
+         (ndays (nth 2 sexp)))
+    (dolist (node nodes)
+      (ical:with-property node nil
+       (cl-case (ical:ast-node-type node)
+         ((ical:dtstart ical:dtend)
+          (ical:ast-node-set-value
+           value-node
+           (ical:date/time-add value :day ndays)))
+         (ical:exdate
+          (dolist (val-node value-nodes)
+            (ical:with-node-value val-node nil
+              (ical:ast-node-set-value
+               val-node
+               (ical:date/time-add value :day ndays)))))
+         (ical:rdate
+          (dolist (val-node value-nodes)
+            (ical:ast-node-set-value
+              val-node
+              (ical:with-node-value val-node nil
+               (cl-typecase value
+                (ical:period
+                 (ical:make-period
+                  (ical:date/time-add (ical:period-start value) :day ndays)
+                  :end (when (ical:period--defined-end value)
+                         (ical:date/time-add
+                          (ical:period--defined-end value) :day ndays))
+                  :duration (ical:period-dur-value value)))
+                (t (ical:date/time-add value :day ndays)))))))
+         (ical:rrule
+          (let ((mdays (ical:recur-by* 'BYMONTHDAY value))
+                (ydays (ical:recur-by* 'BYYEARDAY value))
+                (dows (ical:recur-by* 'BYDAY value))
+                (bad-clause
+                 (cond ((ical:recur-by* 'BYSETPOS value) 'BYSETPOS)
+                       ((ical:recur-by* 'BYWEEKNO value) 'BYWEEKNO))))
+            ;; We can't reliably subtract days in the following cases, so =
bail:
+            (when (< 28 ndays)
+              (di:signal-export-error
+               (format "Cannot export `diary-offset' with large offset %d"=
 ndays)))
+            (when bad-clause
+              (di:signal-export-error
+               (format "Cannot export `diary-offset': inner SEXP %s contai=
ns %s"
+                       sexp bad-clause)))
+            (when (seq-some (lambda (md)
+                              (or (and (< 0 md) (< 28 (+ md ndays)))
+                                  (and (< md 0) (< 0 (+ md ndays)))))
+                            mdays)
+              (di:signal-export-error
+               (format "Cannot export `diary-offset': inner SEXP %s contai=
ns %s"
+                       inner-sexp
+                       "BYMONTHDAY clause that could cross month bounds")))
+            (when (seq-some (lambda (yd)
+                              (or (and (< 0 yd) (< 365 (+ yd ndays)))
+                                  (and (< yd 0) (< 0 (+ yd ndays)))))
+                            ydays)
+              (di:signal-export-error
+               (format "Cannot export `diary-offset': inner SEXP %s contai=
ns %s"
+                       inner-sexp
+                       "BYYEARDAY clause that could cross year bounds")))
+            ;; Adjust the rule's clauses to account for the offset:
+            (when mdays
+              (setf (alist-get 'BYMONTHDAY value)
+                    (list ; FIXME: make recur-values plists or dotted alis=
ts
+                     (mapcar (apply-partially #'+ ndays) mdays))))
+            (when ydays
+              (setf (alist-get 'BYYEARDAY value)
+                    (list
+                     (mapcar (apply-partially #'+ ndays) ydays))))
+            (when dows
+              (setf (alist-get 'BYDAY value)
+                    (list
+                     (mapcar
+                      (lambda (dow)
+                        (if (integerp dow)
+                            (mod (+ dow ndays) 7)
+                          (let* ((wkday (car dow))
+                                 (new-wkday (mod (+ wkday ndays) 7))
+                                 (new-offs
+                                  (if (< new-wkday wkday) ; FIXME: this is=
 not right, should be sth like < (abs (+ wkday ndays)) 7
+                                      ;; we moved into another week, so we=
 need
+                                      ;; to modify the offset within the m=
onth/year
+                                      ;; by the number of weeks moved:
+                                      (+ 1 (/ (+ wkday ndays) 7) (cdr dow))
+                                    ;; otherwise it stays the same:
+                                    (cdr dow))))
+                            (cons new-wkday new-offs))))
+                      dows)))))))))
+    ;; Return the modified nodes:
+    nodes))
+
+(defun di:-convert-legacy-alarm-options (alarm-options)
+  "Convert a legacy-style value of `icalendar-export-alarms' to the format
+of `diary-icalendar-export-alarms'."
+  (let ((lead-time (car alarm-options))
+        (by-types (cadr alarm-options)))
+    (mapcar
+     (lambda (l)
+       (cl-case (car l)
+         (audio `(audio ,lead-time))
+         (display `(display ,lead-time "%s"))
+         (email `(email ,lead-time "%s" ,(cadr l)))))
+     by-types)))
+
+(defun di:add-valarms (component &optional vtimezone)
+  "Add VALARMs to COMPONENT according to `diary-icalendar-export-alarms'.
+
+COMPONENT should be an `icalendar-vevent' or `icalendar-vtodo'. The
+generated VALARM components will be added to this node's children.
+Returns the modified COMPONENT."
+  (let* ((alarm-options
+         (if (and (bound-and-true-p icalendar-export-alarms)
+                  (null di:export-alarms))
+             ;; For backward compatibility with icalendar.el:
+             (di:-convert-legacy-alarm-options icalendar-export-alarms)
+           di:export-alarms))
+         valarms)
+    (dolist (opts alarm-options)
+      (let* ((type (nth 0 opts))
+             (minutes (nth 1 opts)))
+        (cl-case type
+          (audio
+           (push (ical:make-valarm
+                  (ical:action "AUDIO")
+                  (ical:trigger (make-decoded-time :minute (* -1 minutes))=
))
+                 valarms))
+          (display
+           (ical:with-component component
+             ((ical:summary :value summary)
+              (ical:description :value description))
+             (let* ((displayed-summary
+                     (replace-regexp-in-string
+                      "%t" (number-to-string minutes)
+                      (replace-regexp-in-string
+                       "%s" summary
+                       (nth 2 opts)))))
+               (push (ical:make-valarm
+                      (ical:action "DISPLAY")
+                      (ical:trigger (make-decoded-time :minute (* -1 minut=
es)))
+                      (ical:summary displayed-summary)
+                      (ical:description description))
+                     valarms))))
+          (email
+           (ical:with-component component
+             ((ical:summary :value summary)
+              (ical:attendee :all entry-attendees))
+             (let* ((subject
+                     (replace-regexp-in-string
+                      "%t" (number-to-string minutes)
+                      (replace-regexp-in-string
+                       "%s" summary
+                       (nth 2 opts))))
+                    (index (ical:index-insert-tz (ical:make-index) vtimezo=
ne))
+                    (body
+                     (calendar-dlet ((as-alarm 'email))
+                       (di:format-entry component index)))
+                    (addresses (nth 3 opts))
+                    all-attendees)
+               (dolist (address addresses)
+                 (cond
+                  ((eq address 'from-entry)
+                   (setq all-attendees (append entry-attendees all-attende=
es)))
+                  ((stringp address)
+                   (push (ical:make-property ical:attendee
+                                             (concat "mailto:" address))
+                         all-attendees))))
+               (push (ical:make-valarm
+                      (ical:action "EMAIL")
+                      (ical:trigger (make-decoded-time :minute (* -1 minut=
es)))
+                      (ical:summary subject)
+                      (ical:description body)
+                      (@ all-attendees))
+                     valarms)))))))
+    (apply #'ical:ast-node-adopt-children component valarms)
+    component))
+
+(defun di:rrule-sexp-to-recurrence (sexp &optional vtimezone)
+  "Convert a `diary-rrule' SEXP to iCalendar recurrence rule properties.
+Returns a list containing at least `icalendar-dtstart' and
+`icalendar-rrule' nodes, and zero or more `icalendar-rdate',
+`icalendar-exdate', and `icalendar-duration' nodes.
+
+VTIMEZONE, if specified, should be an `icalendar-vtimezone'.  Times in
+SEXP will be reinterpreted as local to VTIMEZONE, as UTC, or as floating
+times according to `diary-icalendar-time-zone-export-strategy'."
+  (let* ((args (cdr sexp))
+         (start (plist-get args :start))
+         (dtstart (di:convert-time-via-strategy
+                   (if (eq 'quote (car start)) (eval start nil) start)
+                   vtimezone))
+         (rule (plist-get args :rule))
+         (rrule (if (eq 'quote (car rule)) (eval rule nil) rule))
+         (included (plist-get args :include))
+         (rdates (mapcar
+                  (lambda (dt) (di:convert-time-via-strategy dt vtimezone))
+                  (if (eq 'quote (car included)) (eval included nil) inclu=
ded)))
+         (excluded (plist-get args :exclude))
+         (exdates (mapcar
+                   (lambda (dt) (di:convert-time-via-strategy dt vtimezone=
))
+                   (if (eq 'quote (car excluded)) (eval excluded nil) excl=
uded)))
+         (duration (eval (plist-get args :duration)))
+         (dur-value
+          (if (eq 'quote (car duration)) (eval duration nil) duration))
+         (tzid (ical:with-property-of vtimezone 'ical:tzid))
+         nodes)
+    (push (ical:make-property ical:rrule rrule) nodes)
+    (push (ical:make-property ical:dtstart dtstart (ical:tzidparam tzid))
+          nodes)
+    (when rdates
+      (push (ical:make-property ical:rdate rdates (ical:tzidparam tzid))
+            nodes))
+    (when exdates
+      (push (ical:make-property ical:exdate exdates (ical:tzidparam tzid))
+            nodes))
+    (when duration
+      (push (ical:make-property ical:duration dur-value) nodes))
+    nodes))
+
+(defun di:dates-to-recurrence (months days years)
+  "Convert values representing one or more dates to iCalendar recurrences.
+
+MONTHS, DAYS, and YEARS should either be integers, lists of integers, or
+the symbol `t'.
+
+Returns a pair of nodes (START R), where START is an `icalendar-dtstart'
+node and R is an `icalendar-rrule' node or `icalendar-rdate' node (or
+nil, if MONTHS, DAYS and YEARS are all integers)."
+  (if (and (integerp months) (integerp days) (integerp years))
+      ;; just a regular date, without recurrence data:
+      (list
+       (ical:make-property ical:dtstart (list months days years))
+       nil)
+
+    (when (integerp months) (setq months (list months)))
+    (when (integerp days) (setq days (list days)))
+    (when (integerp years) (setq years (list years)))
+    (let (dtstart freq bymonth bymonthday rdates rdate-type)
+      (cond ((and (eq days t) (eq months t) (eq years t))
+             (setq freq 'DAILY
+                   dtstart (list 1 1 ical:recurring-start-year)))
+            ((and (eq months t) (eq years t))
+             (setq freq 'MONTHLY
+                   bymonthday days
+                   dtstart (list 1 (car days) ical:recurring-start-year)))
+            ((and (eq years t) (eq days t))
+             (setq freq 'DAILY
+                   bymonth months
+                   dtstart (list (apply #'min months)
+                                 1
+                                 ical:recurring-start-year)))
+            ((eq years t)
+             (setq freq 'YEARLY
+                   bymonth months
+                   bymonthday days
+                   dtstart
+                   (list (apply #'min months)
+                         (apply #'min days)
+                         di:recurring-start-year)))
+            ;; The remaining cases are not representable as RRULEs,
+            ;; because there is no BYYEAR clause. So we generate an RDATE
+            ;; covering each specified date.
+            ((and (eq months t) (eq days t))
+             ;; In this case we represent each of the specified years as a=
 period:
+             (setq rdate-type 'ical:period
+                   rdates
+                   (mapcar
+                    (lambda (y)
+                      (ical:make-period
+                       (ical:make-date-time :year y :month 1 :day 1
+                                            :hour 0 :minute 0 :second 0)
+                       :end
+                       (ical:make-date-time :year (1+ y) :month 1 :day 1
+                                            :hour 0 :minute 0 :second 0)))
+                    years)
+                   dtstart (ical:date-time-to-date
+                            (ical:period-start (car rdates)))))
+            (t
+             ;; Otherwise, represent each date individually:
+             (setq rdate-type 'ical:date
+                   rdates
+                   (mapcan
+                    (lambda (y)
+                      (mapcan
+                       (lambda (m)
+                         (mapcar
+                          (lambda (d) (list m d y))
+                          (if (listp days) days
+                            ;; days =3D t:
+                            (number-sequence 1 (calendar-last-day-of-month=
 m y)))))
+                       (if (listp months) months
+                         ;; months =3D t:
+                         (number-sequence 1 12))))
+                    years)
+                   ;; ensure dtstart is the earliest recurrence:
+                   dtstart (apply #'ical:date/time-min rdates)
+                   rdates (seq-remove (apply-partially #'equal dtstart) rd=
ates))))
+
+      ;; Return the pair of nodes (DTSTART RRULE) or (DTSTART RDATE):
+      (let* ((recur-value
+              (delq nil
+                    `((FREQ ,freq)
+                      ,(when bymonth (list 'BYMONTH bymonth))
+                      ,(when bymonthday (list 'BYMONTHDAY bymonthday)))))
+           (rrule-node (when freq (ical:make-property ical:rrule recur-val=
ue)))
+           (rdate-node (when rdates
+                         (ical:make-property ical:rdate rdates
+                                             (ical:valuetypeparam rdate-ty=
pe))))
+           (dtstart-node (ical:make-property ical:dtstart dtstart)))
+        (list dtstart-node (or rrule-node rdate-node))))))
+
+(defun di:date-sexp-to-recurrence (sexp)
+  "Convert a `diary-date' SEXP to an `icalendar-rrule' or `icalendar-rdate=
' node.
+Returns a pair of nodes (START R), where START is an `icalendar-dtstart'
+node and R is the RRULE or RDATE node."
+  (let* ((d1 (nth 1 sexp))
+         (d2 (nth 2 sexp))
+         (d3 (nth 3 sexp))
+         years months days)
+    (cl-case calendar-date-style
+      (iso (setq years (if (integerp d1) (list d1) d1)
+                 months (if (integerp d2) (list d2) d2)
+                 days (if (integerp d3) (list d3) d3)))
+      (american (setq months (if (integerp d1) (list d1) d1)
+                      days (if (integerp d2) (list d2) d2)
+                      years (if (integerp d3) (list d3) d3)))
+      (european (setq days (if (integerp d1) (list d1) d1)
+                      months (if (integerp d2) (list d2) d2)
+                      years (if (integerp d3) (list d3) d3))))
+
+    ;; unquote lists of integers read as quoted lists:
+    (when (and (listp months) (eq 'quote (car months)))
+      (setq months (eval months nil)))
+    (when (and (listp days) (eq 'quote (car days)))
+      (setq days (eval days nil)))
+    (when (and (listp years) (eq 'quote (car years)))
+      (setq years (eval years nil)))
+
+    ;; if at this point we don't have lists of integers or "t", user
+    ;; entered a malformed diary-date sexp:
+    (unless (or (eq months t) (seq-every-p #'integerp months))
+      (di:signal-export-error
+       (format "Malformed months in `diary-date' S-expression:\n%s" sexp)))
+    (unless (or (eq days t) (seq-every-p #'integerp days))
+      (di:signal-export-error
+       (format "Malformed days in `diary-date' S-expression:\n%s" sexp)))
+    (unless (or (eq years t) (seq-every-p #'integerp years))
+      (di:signal-export-error
+       (format "Malformed years in `diary-date' S-expression:\n%s" sexp)))
+
+    (di:dates-to-recurrence months days years)))
+
+(defun di:other-sexp-to-recurrence (sexp)
+  "Convert diary SEXP to `icalendar-rdate' by enumerating its recurrences.
+
+The enumeration starts on the current date and includes recurrences in
+the next `diary-icalendar-export-sexp-enumeration-days' days. Returns a
+list (START COMMENT RDATE), where START is an `icalendar-dtstart',
+COMMENT is an `icalendar-comment' containing SEXP, and RDATE is an
+`icalendar-rdate' containing the enumerated recurrences.  If there are
+no recurrences, (START COMMENT EXDATE) is returned, where START is the
+current date, and EXDATE is an `icalendar-exdate' excluding that start
+date as a recurrence. (This is because `icalendar-dtstart' is a required
+property and must be present even if the recurrence set is empty.)"
+  (let* ((today (calendar-absolute-from-gregorian (calendar-current-date)))
+         (end (+ today (1- di:export-sexp-enumeration-days)))
+        dtstart rdates exdates)
+    (dolist (absdate (number-sequence today end))
+      (calendar-dlet ((date (calendar-gregorian-from-absolute absdate)))
+        (when (eval sexp)
+          (push date rdates))))
+    (if rdates
+        (progn
+          (setq rdates (nreverse rdates))
+          (setq dtstart (car rdates)
+                rdates (cdr rdates)))
+      (ical:warn
+       (format "No recurrences in the next %d days: %s"
+               di:export-sexp-enumeration-days
+               sexp)
+       :severity 0)
+      ;; When there are no recurrences, we still need a DTSTART, but we
+      ;; can exclude it via an EXDATE:
+      (setq dtstart (calendar-current-date)
+            exdates (list dtstart)))
+
+    (append
+     (list
+      (ical:make-property ical:dtstart dtstart
+                          (ical:valuetypeparam 'ical:date))
+      ;; TODO: should we maybe use an X-name property for this?
+      (ical:make-property ical:comment (format "%s" sexp)))
+     (if rdates
+         (list
+          (ical:make-property ical:rdate rdates
+                              (ical:valuetypeparam 'ical:date)))
+       (list
+        (ical:make-property ical:exdate exdates
+                            (ical:valuetypeparam 'ical:date)))))))
+
+(defun di:sexp-to-nodes (sexp &optional vtimezone)
+  "Convert a diary S-expression SEXP to a list of iCalendar property nodes.
+
+The fully supported S-expressions are:
+`diary-anniversary'
+`diary-block'
+`diary-cyclic'
+`diary-date'
+`diary-float'
+`diary-remind'
+`diary-rrule'
+`diary-time-block'
+
+There is partial support for `diary-offset' S-expressions; see
+`diary-icalendar-offset-to-nodes'.
+
+Other S-expressions are only supported via enumeration. Their
+recurrences are enumerated for
+`diary-icalendar-export-sexp-enumeration-days' starting from the current
+date; see `diary-icalendar-other-sexp-to-recurrence'.  If
+`diary-icalendar-export-sexp-enumerate-all' is non-nil, all
+S-expressions are enumerated rather than converted to recurrence rules.
+
+VTIMEZONE, if specified, should be an `icalendar-vtimezone'.  Times in
+SEXP will be reinterpreted as local to VTIMEZONE, as UTC, or as floating
+times according to `diary-icalendar-time-zone-export-strategy'."
+  (if di:export-sexp-enumerate-all ;; see Bug#7911 for motivation
+      (di:other-sexp-to-recurrence sexp)
+    (cl-case (car sexp)
+      (diary-anniversary (di:anniversary-sexp-to-recurrence sexp))
+      (diary-block (di:block-sexp-to-recurrence sexp))
+      (diary-cyclic (di:cyclic-sexp-to-recurrence sexp))
+      (diary-date (di:date-sexp-to-recurrence sexp))
+      (diary-float (di:float-sexp-to-recurrence sexp))
+      (diary-offset (di:offset-sexp-to-nodes sexp))
+      (diary-rrule (di:rrule-sexp-to-recurrence sexp vtimezone))
+      (diary-time-block (di:time-block-sexp-to-start-end sexp vtimezone))
+      ;; For `diary-remind' we only handle the inner sexp:
+      (diary-remind (di:sexp-to-nodes (nth 1 sexp) vtimezone))
+      (t (di:other-sexp-to-recurrence sexp)))))
+
+;;; Time zone handling during export:
+
+(defconst di:-tz-warning
+  "This time zone information was inferred from incomplete system informat=
ion; it should be correct for the date-times within this calendar file refe=
rencing this zone, but you should not rely on it more widely.")
+
+(defconst di:-emacs-local-tzid
+  "Emacs_Local_")
+
+(defun di:current-tz-to-vtimezone (&optional tz tzid start-year)
+  "Convert TZ to an `icalendar-vtimezone'.
+
+TZ defaults to the output of `calendar-current-time-zone'; if specified,
+it should be a list of the same form as that function returns.
+
+TZID, if specified, should be a string to identify this time zone; it
+defaults to `diary-icalendar--emacs-local-tzid' plus the name of the
+standard observance according to `calendar-current-time-zone'.
+
+START-YEAR, if specified, should be an integer giving the year in which
+to start the observances in the time zone. It defaults to 1970."
+  (when (and tz (not (di:-tz-info-sexp-p nil tz)))
+    (di:signal-export-error
+     (format "Invalid time zone data: %s.\n%s." tz
+             "Check the value of `diary-icalendar-time-zone-export-strateg=
y'")))
+  (let* ((tzdata (or tz (calendar-current-time-zone)))
+         (std-offset (* 60 (nth 0 tzdata)))
+         (dst-offset (+ std-offset
+                        (* 60 (nth 1 tzdata))))
+         (std-name (nth 2 tzdata))
+         (dst-name (nth 3 tzdata))
+         (dst-starts (nth 4 tzdata))
+         (dst-ends (nth 5 tzdata))
+         (dst-start-minutes (nth 6 tzdata))
+         (dst-end-minutes (nth 7 tzdata)))
+
+    (unless (and std-offset
+                 (or (equal std-name dst-name)
+                     (and dst-starts dst-ends dst-start-minutes dst-end-mi=
nutes)))
+      (di:signal-export-error
+       "Insufficient time zone information to create VTIMEZONE"))
+
+    (if (equal std-name dst-name)
+        ;; Local time zone doesn't use DST:
+        (ical:make-vtimezone
+         (ical:tzid (or tzid (concat di:-emacs-local-tzid std-name)))
+         (ical:make-standard
+          (ical:tzname std-name)
+          (ical:dtstart (ical:make-date-time :year (or start-year 1970)
+                                             :month 1 :day 1
+                                             :hour 0 :minute 0 :second 0))
+          (ical:tzoffsetfrom std-offset)
+          (ical:tzoffsetto std-offset)
+          (ical:comment di:-tz-warning)))
+
+      ;; Otherwise we can provide both STANDARD and DAYLIGHT subcomponents:
+      (let* ((std->dst-rule
+              (if (eq (car dst-starts) 'calendar-nth-named-day)
+                  `((FREQ YEARLY)
+                    (BYMONTH (,(nth 3 dst-starts)))
+                    (BYDAY (,(cons (nth 2 dst-starts)
+                                   (nth 1 dst-starts)))))
+                ;; The only other rules that `calendar-current-time-zone'
+                ;; can return are based on the Persian calendar, which we
+                ;; cannot express in an `icalendar-recur' value, at least
+                ;; pending an implementation of RFC 7529
+                (di:signal-export-error
+                 (format "Unable to export DST rule for current time zone:=
 %s"
+                         dst-starts))))
+             (dst-start-date (calendar-dlet ((year (or start-year 1970)))
+                               (eval dst-starts)))
+             (dst-start
+              (ical:date-to-date-time dst-start-date
+                                      :hour (/ dst-start-minutes 60)
+                                      :minute (mod dst-start-minutes 60)
+                                      :second 0))
+             (dst->std-rule
+              (if (eq (car dst-ends) 'calendar-nth-named-day)
+                  `((FREQ YEARLY)
+                    (BYMONTH (,(nth 3 dst-ends)))
+                    (BYDAY (,(cons (nth 2 dst-ends)
+                                   (nth 1 dst-ends)))))
+                (di:signal-export-error
+                 (format "Unable to export DST rule for current time zone:=
 %s"
+                         dst-ends))))
+             (std-start-date (calendar-dlet ((year (1- (or start-year 1970=
))))
+                               (eval dst-ends)))
+             (std-start
+              (ical:date-to-date-time std-start-date
+                                      :hour (/ dst-end-minutes 60)
+                                      :minute (mod dst-end-minutes 60)
+                                      :second 0)))
+
+      (ical:make-vtimezone
+       (ical:tzid (or tzid (concat di:-emacs-local-tzid std-name)))
+       (ical:make-standard
+        (ical:tzname std-name)
+        (ical:dtstart std-start)
+        (ical:rrule dst->std-rule)
+        (ical:tzoffsetfrom dst-offset)
+        (ical:tzoffsetto std-offset)
+        (ical:comment di:-tz-warning))
+       (ical:make-daylight
+        (ical:tzname dst-name)
+        (ical:dtstart dst-start)
+        (ical:rrule std->dst-rule)
+        (ical:tzoffsetfrom std-offset)
+        (ical:tzoffsetto dst-offset)
+        (ical:comment di:-tz-warning)))))))
+
+;;; Parsing complete diary entries:
+
+(defun di:parse-entry-linewise (begin end vtimezone type date-nodes)
+  "Convert the entry between BEGIN and END linewise to iCalendar component=
s.
+
+\"Linewise\" means each line of a diary entry will be exported as a
+distinct event; see `diary-icalendar-export-linewise'.
+Returns a list of component nodes representing the events.
+
+VTIMEZONE must be the `icalendar-vtimezone' in which times in the entry
+appear (or nil). TYPE and DATE-NODES must contain the iCalendar component
+type and date information parsed from the beginning of the entry which
+apply to all of the events. These arguments are passed on in recursive
+calls to `diary-icalendar-parse-entry'."
+  (save-restriction
+    (narrow-to-region begin end)
+    (goto-char (point-min))
+    (let ((subentry-regexp
+           ;; match to the end of lines which have indentation equal to
+           ;; or greater than the current one:
+           (rx line-start
+               (group-n 1 (+ space))
+               (* not-newline)
+               (* "\n" (backref 1) (+ space) (* not-newline))))
+          components)
+
+      (while (re-search-forward subentry-regexp end t)
+        (let ((next-pos (1+ (match-end 0))))
+          (setq components
+                (append
+                 (di:parse-entry (match-beginning 0) (match-end 0)
+                                  vtimezone type date-nodes)
+                 components))
+          (goto-char next-pos)))
+      components)))
+
+(defun di:parse-entry (begin end &optional vtimezone type date-nodes)
+  "Convert the entry between BEGIN and END to a list of iCalendar componen=
ts.
+
+The region between BEGIN and END will be parsed for a date, time,
+summary, description, attendees, and UID. This information will be
+combined into an `icalendar-vevent' (or `icalendar-vjournal' or
+`icalendar-vtodo', depending on the values of
+`diary-icalendar-export-nonmarking-entries',
+`diary-icalendar-export-nonmarking-as-vjournal' and
+`diary-icalendar-todo-regexp') and that component will be returned
+wrapped in a list. Returns nil if the entry should not be exported
+according to `diary-icalendar-export-nonmarking-entries'.
+
+If `diary-icalendar-export-linewise' is non-nil, then a top-level call
+to this function will return a list of several such components. (Thus,
+the function always returns a list of components.)
+
+VTIMEZONE, if specified, should be the `icalendar-vtimezone' in which
+times in the entry appear. If
+`diary-icalendar-time-zone-export-strategy' is not either \\=3D'to-utc or
+\\=3D'floating, VTIMEZONE must be provided.
+
+DATE-NODES and TYPE should be nil in a top-level call; they are used in
+recursive calls to this function made by
+`diary-icalendar-parse-entry-linewise'."
+  (save-restriction
+    (narrow-to-region begin end)
+    (goto-char (point-min))
+    (let (sexp dateform weekday tzid transparency all-props should-recurse)
+      (setq should-recurse (and di:export-linewise (not date-nodes) (not t=
ype)))
+      (when (ical:vtimezone-component-p vtimezone)
+        (setq tzid (ical:with-property-of vtimezone 'ical:tzid)))
+      (unless date-nodes
+        ;; If we don't already have date information, we are in a
+        ;; top-level call and need to collect the date and type
+        ;; information from the start of the entry:
+        (setq type (di:parse-entry-type))
+        ;; N.B. the following four parsing functions successively
+        ;; narrow the current restriction past anything they parse:
+        (setq transparency (di:parse-transparency type))
+        (setq sexp (di:parse-sexp))
+        (setq dateform (di:parse-date-form))
+        (setq weekday (di:parse-weekday-name))
+        (setq date-nodes
+              (append
+               transparency
+               (when sexp (di:sexp-to-nodes sexp vtimezone))
+               (when dateform
+                 (apply #'di:dates-to-recurrence dateform))
+               (when (and weekday (not dateform))
+                 (di:weekday-to-recurrence weekday)))))
+
+      (when type ; nil means entry should not be exported
+        (if should-recurse
+            ;; If we are in a top level call and should export linewise,
+            ;; do that recursively now:
+            (di:parse-entry-linewise (point) end vtimezone type date-nodes)
+
+          ;; Otherwise, we are either in a recursive call with a
+          ;; narrower restriction, or don't need to export linewise. In
+          ;; both cases, we gather the remaining data from the current
+          ;; restriction and combine everything into a component node:
+          (let* ((times (di:parse-time))
+                 (start-time (when times (car times)))
+                 (end-time (when times (cadr times))))
+            ;; Combine clock time values in the current restriction with
+            ;; date information parsed at the top level. Doing this here
+            ;; allows us to combine a different time on each line of an
+            ;; entry exported linewise with the date information for the
+            ;; whole entry:
+            (dolist (node date-nodes)
+              (ical:with-property node nil
+                (cond
+                 ((and (ical:dtstart-property-p node)
+                       (eq 'ical:date value-type)
+                       start-time)
+                  (let ((dtstart
+                         (di:convert-time-via-strategy
+                          (ical:date-time-variant
+                           start-time
+                           :year (calendar-extract-year value)
+                           :month (calendar-extract-month value)
+                           :day (calendar-extract-day value))
+                          vtimezone)))
+                    (push (ical:make-property ical:dtstart dtstart
+                            (ical:tzidparam tzid))
+                          all-props)
+                    (when end-time
+                      ;; an end time parsed from a time specification
+                      ;; in the entry is always on the same day as
+                      ;; DTSTART.
+                      (let* ((dtend
+                              (di:convert-time-via-strategy
+                               (ical:date-time-variant
+                                end-time
+                                :year (calendar-extract-year value)
+                                :month (calendar-extract-month value)
+                                :day (calendar-extract-day value))
+                               vtimezone))
+                             (is-recurring
+                              (seq-find
+                               (lambda (n) (or (ical:rrule-property-p n)
+                                               (ical:rdate-property-p n)))
+                               date-nodes)))
+                        (if is-recurring
+                            ;; If the entry is recurring, we interpret
+                            ;; the end time as giving us a duration for all
+                            ;; recurrences:
+                            (progn
+                              (when (seq-find #'ical:duration-property-p
+                                              date-nodes)
+                                (ical:warn
+                                 (concat "Parsed both duration and end tim=
e; "
+                                         "ignoring end time specification")
+                                 :buffer (current-buffer)
+                                 :position (point)))
+                              (push (ical:make-property ical:duration
+                                      (ical:duration-between dtstart dtend=
))
+                                    all-props))
+                          ;; Otherwise we make a normal DTEND:
+                          (push (ical:make-property ical:dtend dtend)
+                                all-props))))))
+
+                 ((and (ical:rdate-property-p node)
+                       start-time
+                       (seq-every-p (apply-partially #'eq 'ical:date)
+                                    value-types))
+                  (let ((rdates
+                         (mapcar
+                          (lambda (dt)
+                            (if end-time
+                                (ical:make-period
+                                 (di:convert-time-via-strategy
+                                  (ical:date-time-variant
+                                   start-time
+                                   :year (calendar-extract-year dt)
+                                   :month (calendar-extract-month dt)
+                                   :day (calendar-extract-day dt))
+                                  vtimezone)
+                                 :end
+                                 (di:convert-time-via-strategy
+                                  (ical:date-time-variant
+                                   end-time
+                                   :year (calendar-extract-year dt)
+                                   :month (calendar-extract-month dt)
+                                   :day (calendar-extract-day dt))
+                                  vtimezone))
+                              (di:convert-time-via-strategy
+                               (ical:date-time-variant
+                                start-time
+                                :year (calendar-extract-year dt)
+                                :month (calendar-extract-month dt)
+                                :day (calendar-extract-day dt))
+                               vtimezone)))
+                          values)))
+                    (push (ical:make-property ical:rdate rdates
+                                              (ical:tzidparam tzid))
+                          all-props)))
+
+                   ;; preserve any other node read from date, e.g. RRULE, =
as is:
+                   (node (push node all-props))))))
+
+          ;; In a VTODO, entry date must become the DUE date; either
+          ;; DTEND becomes DUE, or if there is no DTEND, then DTSTART:
+          (when (eq type 'ical:vtodo)
+            (unless (catch 'found-dtend
+                      (dolist (node all-props)
+                        (when (ical:dtend-property-p node)
+                          (ical:ast-node-set-type node 'ical:due)
+                          (throw 'found-dtend t))))
+              (dolist (node all-props)
+                (when (ical:dtstart-property-p node)
+                  (ical:ast-node-set-type node 'ical:due)))))
+
+          ;; Collect the remaining properties:
+          (setq all-props (append (di:parse-summary-and-description) all-p=
rops))
+          (setq all-props (append (di:parse-attendees-and-organizer) all-p=
rops))
+          (push (ical:make-property ical:dtstamp (decode-time nil t)) all-=
props)
+          (let ((class (di:parse-class))
+                (location (di:parse-location))
+                (status (di:parse-status))
+                (url (di:parse-url)))
+            (when class (push class all-props))
+            (when location (push location all-props))
+            (when status (push status all-props))
+            (when url (push url all-props)))
+          (push (or (di:parse-uid)
+                     (ical:make-property ical:uid
+                      (ical:make-uid all-props)))
+                all-props)
+
+          ;; Allow users to add to the properties parsed:
+          (when (functionp di:other-properties-parser)
+            (calendar-dlet
+                ((type type)
+                 (properties all-props))
+              (let ((others (funcall di:other-properties-parser)))
+                (dolist (p others)
+                  (condition-case nil
+                      (push (ical:ast-node-valid-p p)
+                            all-props)
+                    (ical:validation-error
+                     (ical:warn
+                      (format "`%s' returned invalid `%s' property; ignori=
ng"
+                              di:other-properties-parser
+                              (ical:ast-node-type p))
+                      :buffer (current-buffer)
+                      :position (point))))))))
+
+          ;; Construct, validate and return a component of the appropriate=
 type:
+          (let ((component
+                 (ical:ast-node-valid-p
+                  (ical:make-ast-node type nil all-props))))
+
+            ;; Add alarms per `diary-icalendar-export-alarms', except for
+            ;; in VJOURNAL, where alarms are not allowed:
+            ;; TODO: should we also add alarms for `diary-remind' sexps?
+            (when (not (eq type 'ical:vjournal))
+              (di:add-valarms component vtimezone))
+
+            ;; Return the component wrapped in a list (for type consistenc=
y):
+            (list component)))))))
+
+(defconst di:entry-regexp
+  (rx line-start
+      (group-n 1 ; first line of entry
+        (or (group-n 2 (regexp diary-nonmarking-symbol))
+            (not (any "\t\n #")))
+        (one-or-more not-newline))
+      (group-n 3 ; continuation lines of entry
+        (zero-or-more "\n" (any space) (zero-or-more not-newline))))
+  "Regular expression to match a full diary entry.
+
+Group 1 matches the first line of the entry. Group 2 contains
+`diary-nonmarking-symbol', if it was present at the start of the first
+line. Group 3 contains any continuation lines of the entry.")
+
+;;;###autoload
+(defun di:export-region (begin end filename &optional erase)
+  "Export diary entries between BEGIN and END to iCalendar format in FILEN=
AME.
+
+If FILENAME exists and is not empty, this function asks whether to erase
+its contents first. If ERASE is non-nil, the contents of FILENAME will
+always be erased without asking. Otherwise the exported data will be
+appended to the end of FILENAME."
+  (interactive (list (region-beginning)
+                     (region-end)
+                     (expand-file-name
+                      (read-file-name "iCalendar file: "))))
+
+  (ical:init-error-buffer)
+  (let (output-buffer local-tz components vcalendar)
+    (when (and (null erase)
+               (file-exists-p filename)
+               (< 0 (file-attribute-size (file-attributes filename)))
+               (y-or-n-p (format "Delete existing contents of %s?" filenam=
e)))
+      (setq erase t))
+    (ical:condition-case err
+      (setq output-buffer (find-file-noselect filename)))
+    (when output-buffer
+      (save-excursion
+        (save-restriction
+          (narrow-to-region begin end)
+          (goto-char (point-min))
+          (cond ((eq 'local di:time-zone-export-strategy)
+                 (setq local-tz (di:current-tz-to-vtimezone)))
+                ((listp di:time-zone-export-strategy)
+                 (setq local-tz (di:current-tz-to-vtimezone
+                                 di:time-zone-export-strategy))))
+          (while (re-search-forward di:entry-regexp nil t)
+            (let ((entry-start (match-beginning 0))
+                  (entry-end (match-end 0))
+                  (first-line (match-string 1)))
+              (ical:condition-case err-data
+                 (setq components
+                       (append (di:parse-entry entry-start entry-end local=
-tz)
+                               components))
+                (ical:export-error
+                 (ical:warn
+                  (concat
+                   (format "Unable to export entry \"%s...\"; skipping" fi=
rst-line)
+                   "\nError was:\n"
+                   (plist-get err-data :message))
+                  :position entry-start
+                  :buffer (current-buffer))))
+              (goto-char (1+ entry-end))))
+          (setq components (nreverse components))
+          (when local-tz (push local-tz components))
+          (ical:condition-case err-data
+             (setq vcalendar (ical:make-vcalendar (@ components))))
+
+          (when vcalendar
+            (with-current-buffer output-buffer
+              (when erase (erase-buffer))
+              (goto-char (point-max)) ; append, if user chose not to erase
+              (unless (bolp) (insert "\n"))
+              (ical:condition-case err-data
+                 (insert (ical:print-calendar-node vcalendar)))
+              (let ((coding-system-for-write 'utf-8-dos)) ;; TODO
+                (save-buffer))))))))
+
+  (message
+   (if (ical:errors-p)
+       (format "iCalendar export completed with errors; see buffer %s"
+               (buffer-name (ical:error-buffer)))
+     "iCalendar export completed successfully.")))
+
+;;;###autoload
+(defun di:export-file (diary-filename filename &optional erase)
+  "Export DIARY-FILENAME to iCalendar format in FILENAME"
+  (interactive (list
+                (read-file-name "Diary file: "
+                                (when diary-file (file-name-directory diar=
y-file))
+                                (cons diary-file diary-included-files)
+                                'confirm)
+                (read-file-name "iCalendar file: "
+                                (when diary-file (file-name-directory diar=
y-file))
+                                (when diary-file
+                                  (concat
+                                   (file-name-sans-extension diary-file)
+                                   ".ics")))))
+  (when (and (null erase)
+             (file-exists-p filename)
+             (< 0 (file-attribute-size (file-attributes filename)))
+             (y-or-n-p (format "Delete existing contents of %s?" filename)=
))
+      (setq erase t))
+  (with-current-buffer (find-file-noselect diary-filename)
+    (di:export-region (point-min) (point-max) filename erase)))
+
+
+;; Display in Diary
+
+;;; Functions implementing diary-icalendar sexps.
+;;; TODO: move these to diary-lib.el?
+
+;; To be called from diary-sexp-entry, where DATE, ENTRY are bound.
+(cl-defun diary-time-block (&key start end)
+  "Diary S-expression for time blocks.
+
+Entry applies if the queried date occurs between START and END,
+inclusive. START and END may be `icalendar-date' or
+`icalendar-date-time' values."
+  (with-no-warnings (defvar date) (defvar entry))
+  (when (and (ical:date/time<=3D start date) (ical:date/time<=3D date end))
+    entry))
+
+;; To be called from diary-sexp-entry, where DATE, ENTRY are bound.
+(cl-defun diary-rrule (&key rule start duration include exclude)
+  "Diary S-expression for iCalendar recurrence rules.
+
+Entry applies if the queried date matches the recurrence rule.
+
+The keyword arguments RULE, START, INCLUDE and EXCLUDE should contain
+the recurrence data from an iCalendar component. RULE should be an
+`icalendar-recur' value, START an `icalendar-date' or
+`icalendar-date-time', DURATION an `icalendar-dur-value', and INCLUDE
+and EXCLUDE should be lists of `icalendar-date' or `icalendar-date-time'
+values (of the same type as START)."
+  (with-no-warnings (defvar date) (defvar entry))
+  (when (ical:date<=3D start date)
+    (let* ((vevent (ical:make-vevent
+                    (ical:rrule rule)
+                    (ical:dtstart start)
+                    (ical:rdate include)
+                    (ical:exdate exclude)))
+           (interval (icr:find-interval date start rule)))
+      (cl-typecase start
+        (ical:date
+         (when (member date (icr:recurrences-in-interval interval vevent))
+           entry))
+        (ical:date-time
+         ;; TODO. If start is a date-time, it was probably imported from
+         ;; an iCalendar file, but in order to calculate recurrences, we
+         ;; really need all the time zone information from that file,
+         ;; not just the rule, start, include and exclude. But encoding
+         ;; all that tz info in a diary s-exp is cumbersome and ugly and
+         ;; probably not worth the trouble. Since this is the diary, we
+         ;; assume that all we really care about here is whether there
+         ;; are recurrences on a particular day. Thus we convert
+         ;; HOURLY/MINUTELY/SECONDLY rules to a DAILY rule, and all
+         ;; values to plain dates. This keeps things simple (and
+         ;; hopefully quicker) but means that information gets lost.  I
+         ;; hope this can be changed to do things right at some point,
+         ;; but that will require first adding more robust time zone
+         ;; support to the diary somehow -- perhaps via #included
+         ;; iCalendar files?
+         (let* ((date-rule (copy-sequence rule))
+                (start-date (ical:date-time-to-date start))
+                (include-dates (mapcar #'ical:date-time-to-date include))
+                (exclude-dates (mapcar #'ical:date-time-to-date exclude))
+                ;; Preserve the clock times in the entry:
+                (entry-time
+                 (if duration
+                     (di:format-time-range
+                      start
+                      (ical:date/time-add-duration start duration))
+                   (di:format-time-as-local start)))
+                (date-entry (concat entry-time " " entry)))
+           (when (memq (ical:recur-freq date-rule) '(HOURLY MINUTELY SECON=
DLY))
+             (setf (alist-get 'FREQ date-rule) 'DAILY)
+             (setf (alist-get 'INTERVAL date-rule) 1)
+             (setf (alist-get 'BYHOUR date-rule nil t) nil)
+             (setf (alist-get 'BYMINUTE date-rule nil t) nil)
+             (setf (alist-get 'BYSECOND date-rule nil t) nil))
+           ;; Recurse with the plain date values:
+           (calendar-dlet
+               ((date date)
+                (entry date-entry))
+             (diary-rrule :rule date-rule :start start-date
+                          :include include-dates :exclude exclude-dates)))=
)))))
+
+(defun di:display-entries ()
+  "Display iCalendar data from a file in the diary.
+
+This function allows you to display the data in an iCalendar-formatted
+file in the diary without importing it. The data is read directly from
+the currently value of `diary-file'. If this file contains iCalendar
+data, any events, tasks, and journal entries in the file which occur on
+`original-date' and `number' of days after are formatted for display in
+the diary. (All three of these variables are dynamically bound by the
+diary when this function is called.)
+
+To use this function, add an '#include \"FILE\"' entry in your diary
+file for each iCalendar file you want to display (see
+`diary-include-string').  Then add `diary-include-other-diary-files' to
+`diary-list-entries-hook'.  Finally, add this function to
+`diary-nongregorian-listing-hook', so that it is called once for each
+included file when the diary is displayed."
+  (with-no-warnings (defvar original-date) ; the start date
+                    (defvar number) ; number of days to generate entries f=
or
+                    (defvar diary-file)) ; dyn. bound to included file name
+  (let ((diary-buffer (or (find-buffer-visiting diary-file)
+                          (find-file-noselect diary-file))))
+    (when (ical:contains-vcalendar-p diary-buffer)
+      (let ((vcal/idx (ical:parse-and-index diary-file)))
+        (when vcal/idx
+          (let* ((index (cadr vcal/idx))
+                 (absstart (calendar-absolute-from-gregorian original-date=
))
+                 (absend (+ absstart (1- number))))
+
+            (dolist (absdate (number-sequence absstart absend))
+              (let* ((date (calendar-gregorian-from-absolute absdate))
+                     (to-format (ical:index-get index :date date)))
+                (dolist (component to-format)
+                  ;; Format the entry, with a pointer back to its location
+                  ;; in the parsed buffer:
+                  (let ((marker (make-marker)))
+                    (set-marker marker
+                                (ical:ast-node-meta-get :begin component)
+                                (ical:ast-node-meta-get :buffer component))
+                    (diary-add-to-list
+                     date
+                     (di:format-entry component index)
+                     ""
+                     marker)))))))))))
+
+(defun di:marking-dates-of (component index)
+  "Return the dates in COMPONENT that should be marked in the calendar.
+
+The dates to mark are derived from COMPONENT's start and end date and
+time, and any recurrences it has within the year currently displayed by
+the calendar.
+
+No dates are returned if COMPONENT's `icalendar-transp' property has the
+value \"TRANSPARENT\" (which means the component does not form a block
+of busy time on a schedule), or if COMPONENT is an `icalendar-vjournal'
+and `diary-icalendar-import-vjournal-as-nonmarking' is non-nil."
+  (ical:with-component component
+    ((ical:dtstart :first dtstart-node :value dtstart)
+     (ical:dtend :first dtend-node :value dtend)
+     (ical:due :value due)
+     (ical:duration :value duration)
+     (ical:rdate :first rdate)
+     (ical:rrule :first rrule)
+     (ical:transp :value transparency))
+    (let* ((start-tz (ical:with-param-of dtstart-node 'ical:tzidparam
+                                         (ical:index-get index :tzid value=
)))
+           (end
+            (cond
+             (dtend dtend)
+             (due due)
+             (duration (ical:date/time-add-duration dtstart duration start=
-tz))))
+           dates)
+
+      (unless (or (equal transparency "TRANSPARENT")
+                  (and di:import-vjournal-as-nonmarking
+                       (ical:vjournal-component-p component)))
+        ;; Mark the start date(s) for every (marking) entry:
+        (setq dates (if end
+                        (ical:dates-until dtstart end t)
+                      (list (ical:date/time-to-date
+                             (ical:date/time-to-local dtstart)))))
+        ;; Mark the dates for any recurrences in the displayed calendar ye=
ar:
+        (let ((year (when (boundp 'displayed-year) ; bound by calendar
+                      displayed-year)))
+          (when (and year (or rdate rrule))
+            (let* ((low (list 1 1 year))
+                   (high (list 12 31 year))
+                   (recs (icr:recurrences-in-window-w/end-times
+                          low high component start-tz)))
+              (dolist (rec recs)
+                (setq dates (append (ical:dates-until (car rec) (cadr rec)=
 t)
+                                    dates)))))))
+      dates)))
+
+(defun di:mark-entries ()
+  "Mark calendar dates for iCalendar data from a file.
+
+This function allows you to mark the dates in an iCalendar-formatted
+file in the calendar without importing it. The data is read directly
+from the current value of `diary-file' (which is dynamically bound by
+the diary when this function is called).
+
+To use this function, add an '#include \"FILE\"' entry in your diary
+file for each iCalendar file you want to display (see
+`diary-include-string').  Then add `diary-mark-included-diary-files' to
+`diary-mark-entries-hook'.  Finally, add this function to
+`diary-nongregorian-marking-hook', so that it is called once for each
+included file when dates are marked in the calendar."
+  (with-no-warnings (defvar diary-file)) ; dyn. bound to included file name
+  (let ((diary-buffer (or (find-buffer-visiting diary-file)
+                          (find-file-noselect diary-file))))
+    (when (ical:contains-vcalendar-p diary-buffer)
+      (let ((vcal/idx (ical:parse-and-index diary-buffer)))
+        (when vcal/idx
+          (let* ((index (cadr vcal/idx))
+                 (vcalendar (car vcal/idx))
+                 (to-mark
+                  (append (ical:ast-node-children-of 'ical:vevent vcalenda=
r)
+                          (ical:ast-node-children-of 'ical:vjournal vcalen=
dar)
+                          (ical:ast-node-children-of 'ical:vtodo vcalendar=
)))
+                 (all-dates (mapcan (lambda (c) (di:marking-dates-of c ind=
ex))
+                                    to-mark))
+                 (dates (seq-uniq
+                         (sort all-dates :lessp #'ical:date< :in-place t))=
))
+
+            (dolist (date dates)
+              (let ((month (calendar-extract-month date))
+                    (year (calendar-extract-year date)))
+                ;; avoid marking outside the displayed months,
+                ;; to speed things up:
+                (with-current-buffer calendar-buffer
+                  (when (and (=3D year displayed-year)
+                             (<=3D (1- displayed-month) month)
+                             (<=3D month (1+ displayed-month)))
+                    (calendar-mark-visible-date date)))))))))))
+
+
+
+(provide 'diary-icalendar)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-=
recur-") ("di:" . "diary-icalendar-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/diary-lib.el b/lisp/calendar/diary-lib.el
index 056360bbede..e794ccd891d 100644
--- a/lisp/calendar/diary-lib.el
+++ b/lisp/calendar/diary-lib.el
@@ -2117,8 +2117,9 @@ diary-insert-entry
 Prefix argument ARG makes the entry nonmarking."
   (interactive
    (list current-prefix-arg last-nonmenu-event))
-  (diary-make-entry (calendar-date-string (calendar-cursor-to-date t event=
) t t)
-                    arg))
+  (calendar-dlet ((calendar-date-display-form diary-date-insertion-form))
+    (diary-make-entry (calendar-date-string (calendar-cursor-to-date t eve=
nt) t t)
+                      arg)))
=20
 ;;;###cal-autoload
 (defun diary-insert-weekly-entry (arg)
@@ -2315,6 +2316,7 @@ diary-time-regexp
   ;; Accepted formats: 10:00 10.00 10h00 10h 10am 10:00am 10.00am
   ;; Use of "." as a separator annoyingly matches numbers, eg "123.45".
   ;; Hence often prefix this with "\\(^\\|\\s-\\)."
+  ;; FIXME.
   (concat "[0-9]?[0-9]\\([AaPp][mM]\\|\\("
           "[Hh]\\([0-9][0-9]\\)?\\|[:.][0-9][0-9]"
           "\\)\\([AaPp][Mm]\\)?\\)")
diff --git a/lisp/calendar/icalendar-ast.el b/lisp/calendar/icalendar-ast.el
new file mode 100644
index 00000000000..db92528b3a0
--- /dev/null
+++ b/lisp/calendar/icalendar-ast.el
@@ -0,0 +1,863 @@
+;;; icalendar-ast.el --- Syntax trees for iCalendar  -*- lexical-binding: =
t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the abstract syntax tree representation for
+;; iCalendar data. The AST is based on `org-element-ast' (which see;
+;; that feature will eventually be renamed and moved out of the Org tree
+;; into the main tree).
+
+;; The file contains low-level functions for constructing and
+;; manipulating the AST, most of which are minimal wrappers around the
+;; functions provided by `org-element-ast'. This low-level API is
+;; primarily used by `icalendar-parser'. It also contains a higher-level
+;; API for constructing AST nodes in Lisp code. Finally, it defines
+;; functions for validating AST nodes.
+
+
+;;; Code:
+(eval-when-compile (require 'cl-lib))
+(require 'org-element-ast)
+(require 'icalendar)
+
+;;; Type symbols and metadata
+
+;; All nodes in the syntax tree have a type symbol as their first element.
+;; We use the following symbol properties (all prefixed with 'icalendar-')
+;; to associate type symbols with various important data about the type:
+;;
+;; is-type - t (marks this symbol as an icalendar type)
+;; is-value, is-param, is-property, or is-component - t
+;;   (specifies what sort of value this type represents)
+;; list-sep - for property and parameters types, a string (typically
+;;   "," or ";") which separates individual printed values, if the
+;;   type allows lists of values. If this is non-nil, syntax nodes of
+;;   this type should always have a list of values in their VALUE
+;;   field (even if there is only one value)
+;; matcher - a function to match this type. This function matches the
+;;   regular expression defined under the type's name; it is used to provi=
de
+;;   syntax highlighting in `icalendar-mode'
+;; begin-rx, end-rx - for component-types, an `rx' regular expression which
+;;   matches the BEGIN and END lines that form its boundaries
+;; value-rx - an `rx' regular expression which matches individual values
+;;   of this type, with no consideration for quoting or lists of values.
+;;   (For value types, this is just a synonym for the rx definition
+;;   under the type's symbol)
+;; values-rx - for types that accept lists of values, an `rx' regular
+;;   expression which matches the whole list (including quotes, if require=
d)
+;; full-value-rx - for property and parameter types, an `rx' regular
+;;   expression which matches a valid value expression in group 2, or
+;;   an invalid value in group 3
+;; value-reader - for value types, a function which creates syntax
+;;   nodes of this type given a string representing their value
+;; value-printer - for value types, a function to print individual
+;;   values of this type. It accepts a value and returns its string
+;;   representation.
+;; default-value - for property and parameter types, a string
+;;   representing a default value for nodes of this type. This is the
+;;   value assumed when no node of this type is present in the
+;;   relevant part of the syntax tree.
+;; substitute-value - for parameter types, a string representing a value
+;;   which will be substituted at parse times for unrecognized values.
+;;   (This is normally the same as default-value, but differs from it
+;;   in at least one case in RFC5545, thus it is stored separately.)
+;; default-type - for property types which can have values of multiple
+;;   types, this is the default type when no type for the value is
+;;   specified in the parameters. Any type of value other than this
+;;   one requires a VALUE=3D... parameter when the property is read or pri=
nted.
+;; other-types - for property types which can have values of multiple type=
s,
+;;   this is a list of other types that the property can accept.
+;; child-spec - for property and component types, a plist describing the
+;;   required and optional child nodes. See `icalendar-define-property' and
+;;   `icalendar-define-component' for details.
+;; other-validator - a function to perform type-specific validation
+;;   for nodes of this type. If present, this function will be called
+;;   by `icalendar-ast-node-valid-p' during validation.
+;; type-documentation - a string documenting the type. This documentation =
is
+;;   printed in the help buffer when `describe-symbol' is called on TYPE.
+;; link - a hyperlink to the documentation of the type in the relevant sta=
ndard
+
+(defun ical:type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is an iCalendar type symbol.
+
+This function only checks that SYMBOL has been marked as a type;
+it returns t for value types defined by `icalendar-define-type',
+but also e.g. for types defined by `icalendar-define-param' and
+`icalendar-define-property'. To check that SYMBOL names a value
+type for property or parameter values, see
+`icalendar-value-type-symbol-p' and
+`icalendar-printable-value-type-symbol-p'."
+  (and (symbolp symbol)
+       (get symbol 'ical:is-type)))
+
+(defun ical:value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a value
+type, i.e., a type for an iCalendar property or parameter value
+defined by `icalendar-define-type'.
+
+This means that SYMBOL must both satisfy
+`icalendar-type-symbol-p' and have the property
+`icalendar-is-value'. It does not require the type to be
+associated with a print name in `icalendar-value-types';
+for that see `icalendar-printable-value-type-symbol-p'."
+  (and (ical:type-symbol-p symbol)
+       (get symbol 'ical:is-value)))
+
+(defun ical:expects-list-of-values-p (type)
+  "Return non-nil if the syntax node type named by TYPE accepts a
+list of values. This is never t for value types or component
+types. For property and parameter types defined with
+`ical:define-param' and `ical:define-property', it is true if the
+:list-sep argument was specified in the definition."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:list-sep)))
+
+(defun ical:param-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+parameter."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-param)))
+
+(defun ical:property-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+property."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-property)))
+
+(defun ical:component-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+component."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-component)))
+
+;; TODO: we could define other accessors here for the other metadata
+;; properties, but at the moment I see no advantage to this; they would
+;; all just be long-winded wrappers around `get'.
+
+
+;; The basic, low-level API for the AST, mostly intended for use by
+;; `icalendar-parser'. These functions are mostly aliases and simple
+;; wrappers around functions provided by `org-element-ast', which does
+;; the heavy lifting.
+(defalias 'ical:ast-node-type #'org-element-type)
+
+(defsubst ical:ast-node-value (node)
+  "Return the value of iCalendar syntax node NODE.
+In component nodes, this is nil. Otherwise, it is a syntax node
+representing an iCalendar (property or parameter) value."
+  (org-element-property :value node))
+
+(defalias 'ical:ast-node-children #'org-element-contents)
+
+;; TODO: probably don't want &rest form for this
+(defalias 'ical:ast-node-set-children #'org-element-set-contents)
+
+(defalias 'ical:ast-node-adopt-children #'org-element-adopt-elements)
+
+(defalias 'ical:ast-node-meta-get #'org-element-property)
+
+(defalias 'ical:ast-node-meta-set #'org-element-put-property)
+
+(defun ical:ast-node-set-type (node type)
+  "Set the type of iCalendar syntax node NODE to TYPE.
+
+This function is probably not what you want! It directly modifies the
+type of NODE in-place, which could make the node invalid if its value or
+children do not match the new TYPE. If you do not know in advance that
+the data in NODE is compatible with the new TYPE, it is better to
+construct a new syntax node."
+  (setcar node type))
+
+(defun ical:ast-node-set-value (node value)
+  "Set the value of iCalendar syntax node NODE to VALUE."
+  (ical:ast-node-meta-set node :value value))
+
+(defun ical:make-ast-node (type props &optional children)
+  "Construct an iCalendar syntax node of type TYPE with the properties
+PROPS and descendants CHILDREN.
+
+This is a low-level constructor. If you are constructing iCalendar
+syntax nodes directly in Lisp code, consider using one of the
+higher-level macros based on `icalendar-make-node-from-templates'
+instead, which expand to calls to this function but also perform type
+checking and validation.
+
+TYPE should be an iCalendar type symbol.  CHILDREN, if given, should be
+a list of syntax nodes. In property nodes, these should be the
+parameters of the property.  In component nodes, these should be the
+properties or subcomponents of the component. It should otherwise be
+nil.
+
+PROPS should be a plist with any of the following keywords:
+
+:value - in value nodes, this should be the Elisp value parsed from a
+  property or parameter's value string. In parameter and property nodes,
+  this should be a value node or list of value nodes. In component
+  nodes, it should not be present.
+:buffer - buffer from which VALUE was parsed
+:begin - position at which this node begins in BUFFER
+:end - position at which this node ends in BUFFER
+:value-begin - position at which VALUE begins in BUFFER
+:value-end - position at which VALUE ends in BUFFER
+:original-value - a string containing the original, uninterpreted value
+  of the node. This can differ from (a string represented by) VALUE
+  if e.g. a default VALUE was substituted for an unrecognized but
+  syntactically correct value.
+:original-name - a string containing the original, uninterpreted name
+  of the parameter, property or component this node represents.
+  This can differ from (a string representing) TYPE
+  if e.g. a default TYPE was substituted for an unrecognized but
+  syntactically correct one."
+  ;; automatically mark :value as a "secondary property" for org-element-a=
st
+  (let ((full-props (if (plist-member props :value)
+                        (plist-put props :secondary (list :value))
+                      props)))
+    (apply #'org-element-create type full-props children)))
+
+(defun ical:ast-node-p (val)
+  "Return non-nil if VAL is an iCalendar syntax node"
+  (and (listp val)
+       (length> val 1)
+       (ical:type-symbol-p (ical:ast-node-type val))
+       (plistp (cadr val))
+       (listp (ical:ast-node-children val))))
+
+(defun ical:param-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a parameter type."
+  (and (ical:ast-node-p node)
+       (ical:param-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:property-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a property type."
+  (and (ical:ast-node-p node)
+       (ical:property-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:component-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a component type."
+  (and (ical:ast-node-p node)
+       (ical:component-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:ast-node-first-child-of (type node)
+  "Return the first child of NODE of type TYPE, or nil if there is
+no such child."
+  (assq type (ical:ast-node-children node)))
+
+(defun ical:ast-node-children-of (type node)
+  "Return a list of all the children of NODE of type TYPE, or nil if
+there are none."
+  (seq-filter (lambda (c) (eq type (ical:ast-node-type c)))
+              (ical:ast-node-children node)))
+
+
+;; A high-level API for constructing iCalendar syntax nodes in Lisp code:
+
+(defun ical:type-of (value &optional types)
+  "Find the iCalendar type symbol for the type to which VALUE belongs.
+
+TYPES, if specified, should be a list of type symbols to check.
+TYPES defaults to all type symbols listed in `icalendar-value-types'."
+  (require 'icalendar-parser) ; for ical:value-types
+  (catch 'found
+    (when (ical:ast-node-p value)
+      (throw 'found (ical:ast-node-type value)))
+    (dolist (type (or types (mapcar #'cdr ical:value-types)))
+      (if (ical:expects-list-of-values-p type)
+          (when (ical:list-of-p value type)
+            (throw 'found type))
+        (when (cl-typep value type)
+          (throw 'found type))))))
+
+;; A more flexible constructor for value nodes which can choose the
+;; correct type from a list. This helps keep templates succinct and easy
+;; to use in `icalendar-make-node-from-templates', and related macros
+;; below.
+(defun ical:make-value-node-of (type value)
+  "Make an iCalendar syntax node of type TYPE containing VALUE as its valu=
e.
+
+TYPE should be a symbol for an iCalendar value type, and VALUE should be
+a value of that type. If TYPE is the symbol \\=3D'plain-text, VALUE should
+be a string, and in that case VALUE is returned as-is.
+
+TYPE may also be a list of type symbols; in that case, the first type in
+the list which VALUE satisfies is used as the returned node's type.  If
+the list is nil, VALUE will be checked against all types in
+`icalendar-value-types'.
+
+If VALUE is nil, and `icalendar-boolean' is not (in) TYPE, nil is
+returned. Otherwise, a \\=3D'wrong-type-argument error is signaled if
+VALUE does not satisfy (any type in) TYPE."
+  (require 'icalendar-parser)
+  (cond
+   ((and (null value)
+         (not (if (listp type) (memq 'ical:boolean type)
+                (eq 'ical:boolean type))))
+    ;; Instead of signaling an error, we just return nil in this case.
+    ;; This allows the `ical:make-*' macros higher up the stack to
+    ;; filter out templates that evaluate to nil at run time:
+    nil)
+   ((eq type 'plain-text)
+    (unless (stringp value)
+      (signal 'wrong-type-argument (list 'stringp value)))
+    value)
+   ((symbolp type)
+    (unless (ical:value-type-symbol-p type)
+      (signal 'wrong-type-argument (list 'icalendar-value-type-symbol-p ty=
pe)))
+    (if (ical:expects-list-of-values-p type)
+        (unless (ical:list-of-p value type)
+          (signal 'wrong-type-argument (list `(list-of ,type) value)))
+      (unless (cl-typep value type)
+        (signal 'wrong-type-argument (list type value)))
+    (ical:make-ast-node type (list :value value))))
+   ((listp type)
+    ;; N.B. nil is allowed; in that case, `ical:type-of' will check all
+    ;; types in `ical:value-types':
+    (let ((the-type (ical:type-of value type)))
+      (if the-type
+          (ical:make-ast-node the-type (list :value value))
+        (signal 'wrong-type-argument (list (if (length> type 1) (cons 'or =
type) (car type))
+                                           value)))))
+   (t (signal 'wrong-type-argument (list '(or symbolp listp) type)))))
+
+(defmacro ical:make-param (type value)
+  "Construct an iCalendar parameter node of TYPE with value VALUE.
+
+TYPE should be an iCalendar type symbol satisfying
+`icalendar-param-type-symbol-p'; it should not be quoted.
+
+VALUE should evaluate to a value appropriate for TYPE. In particular, if
+TYPE expects a list of values (see `icalendar-expects-list-p'), VALUE
+should be such a list. If necessary, the value(s) in VALUE will be
+wrapped in syntax nodes indicating their type.
+
+For example,
+
+  (icalendar-make-param icalendar-deltoparam
+    (list \"mailto:minionA@HIDDEN\" \"mailto:minionB@HIDDEN\"))
+
+will return an `icalendar-deltoparam' node whose value is a list of
+`icalendar-cal-address' nodes containing the two addresses.
+
+The resulting syntax node is checked for validity by
+`icalendar-ast-node-valid-p' before it is returned."
+  ;; TODO: support `ical:otherparam'
+  (unless (ical:param-type-symbol-p type)
+    (error "Not an iCalendar param type: %s" type))
+  (let ((value-type (or (get type 'ical:value-type) 'plain-text))
+        (needs-list (ical:expects-list-of-values-p type)))
+    `(let* ((raw-value ,value)
+            (value-type (quote ,value-type))
+            (value
+             ,(if needs-list
+                  '(if (seq-every-p #'ical:ast-node-p raw-value)
+                       raw-value
+                     (mapcar
+                      (lambda (c) (ical:make-value-node-of value-type c))
+                      raw-value))
+                '(if (ical:ast-node-p raw-value)
+                     raw-value
+                    (ical:make-value-node-of value-type raw-value)))))
+        (when value
+          (ical:ast-node-valid-p
+           (ical:make-ast-node
+            (quote ,type)
+            (list :value value)))))))
+
+(defmacro ical:make-property (type value &rest param-templates)
+  "Construct an iCalendar property node of TYPE with value VALUE.
+
+TYPE should be an iCalendar type symbol satisfying
+`icalendar-property-type-symbol-p'; it should not be quoted.
+
+VALUE should evaluate to a value appropriate for TYPE. In particular, if
+TYPE expects a list of values (see
+`icalendar-expects-list-of-values-p'), VALUE should be such a list. If
+necessary, the value(s) in VALUE will be wrapped in syntax nodes
+indicating their type. If VALUE is not of the default value type for
+TYPE, an `icalendar-valuetypeparam' will automatically be added to TEMPLAT=
ES.
+
+Each element of PARAM-TEMPLATES should represent a parameter node; see
+`icalendar-make-node-from-templates' for the format of such TEMPLATES.
+A template can also have the form (@ L), where L evaluates to a list of
+parameter nodes to be added to the component.
+
+PARAM-TEMPLATES which evaluate to nil are removed when the property node
+is constructed.
+
+For example,
+
+  (icalendar-make-property icalendar-rdate (list \\=3D'(2 1 2025) \\=3D'(3=
 1 2025)))
+
+will return an `icalendar-rdate' node whose value is a list of
+`icalendar-date' nodes containing the dates above as their values.
+
+The resulting syntax node is checked for validity by
+`icalendar-ast-node-valid-p' before it is returned."
+  ;; TODO: support `ical:other-property', maybe like
+  ;; (ical:other-property "X-NAME" value ...)
+  (unless (ical:property-type-symbol-p type)
+    (error "Not an iCalendar property type: %s" type))
+  (let ((value-types (cons (get type 'ical:default-type)
+                           (get type 'ical:other-types)))
+        (needs-list (ical:expects-list-of-values-p type))
+        params-expr children lists-of-children)
+    (dolist (c param-templates)
+      (cond ((and (listp c) (ical:type-symbol-p (car c)))
+             ;; c is a template for a child node, so it should be
+             ;; recursively expanded:
+             (push (cons 'ical:make-node-from-templates c)
+                   children))
+            ((and (listp c) (eq '@ (car c)))
+             ;; c is a template (@ L) where L evaluates to a list of child=
ren:
+             (push (cadr c) lists-of-children))
+            (t
+             ;; otherwise, just pass c through as is; this allows
+             ;; interleaving templates with other expressions that
+             ;; evaluate to syntax nodes:
+             (push c children))))
+    (when (or children lists-of-children)
+      (setq params-expr
+            `(seq-filter #'identity
+                         (append (list ,@children) ,@lists-of-children))))
+
+    `(let* ((raw-value ,value)
+            (value-types (quote ,value-types))
+            (value
+               ,(if needs-list
+                    '(if (seq-every-p #'ical:ast-node-p raw-value)
+                         raw-value
+                       (mapcar
+                        (lambda (c) (ical:make-value-node-of value-types c=
))
+                        raw-value))
+                  '(if (ical:ast-node-p raw-value)
+                       raw-value
+                     (ical:make-value-node-of value-types raw-value)))))
+       (when value
+         (ical:ast-node-valid-p
+          (ical:maybe-add-value-param
+           (ical:make-ast-node
+            (quote ,type)
+            (list :value value)
+            ,params-expr)))))))
+
+(defmacro ical:make-component (type &rest templates)
+  "Construct an iCalendar component node of TYPE from TEMPLATES.
+
+TYPE should be an iCalendar type symbol satisfying
+`icalendar-component-type-symbol-p'; it should not be quoted.
+
+Each expression in TEMPLATES should represent a child node of the
+component; see `icalendar-make-node-from-templates' for the format of
+such TEMPLATES. A template can also have the form (@ L), where L
+evaluates to a list of child nodes to be added to the component.
+
+Any value in TEMPLATES that evaluates to nil will be removed before the
+component node is constructed.
+
+If TYPE is `icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal',
+or `icalendar-vfreebusy', the properties `icalendar-dtstamp' and
+`icalendar-uid' will be automatically provided, if they are absent in
+TEMPLATES. Likewise, if TYPE is `icalendar-vcalendar', the properties
+`icalendar-prodid', `icalendar-version', and `icalendar-calscale' will
+be automatically provided if absent.
+
+For example,
+
+  (icalendar-make-component icalendar-vevent
+     (icalendar-summary \"Party\")
+     (icalendar-location \"Robot House\")
+     (@ list-of-other-properties))
+
+will return an `icalendar-vevent' node containing the provided
+properties as well as `icalendar-dtstamp' and `icalendar-uid'
+properties.
+
+The resulting syntax node is checked for validity by
+`icalendar-ast-node-valid-p' before it is returned."
+  ;; TODO: support `ical:other-component', maybe like
+  ;; (ical:other-component "X-NAME" templates ...)
+  (unless (ical:component-type-symbol-p type)
+    (error "Not an iCalendar component type: %s" type))
+  ;; Add templates for required properties automatically if we can:
+  (when (memq type '(ical:vevent ical:vtodo ical:vjournal ical:vfreebusy))
+    (unless (assq 'ical:dtstamp templates)
+      (push '(ical:dtstamp (decode-time nil t))
+            templates))
+    (unless (assq 'ical:uid templates)
+      (push `(ical:uid ,(ical:make-uid templates))
+            templates)))
+  (when (eq type 'ical:vcalendar)
+    (unless (assq 'ical:prodid templates)
+      (push `(ical:prodid ,ical:vcalendar-prodid)
+            templates))
+    (unless (assq 'ical:version templates)
+      (push `(ical:version ,ical:vcalendar-version)
+            templates))
+    (unless (assq 'ical:calscale templates)
+      (push '(ical:calscale "GREGORIAN")
+            templates)))
+  (when (null templates)
+    (error "At least one template is required"))
+
+  (let (children lists-of-children)
+    (dolist (c templates)
+      (cond ((and (listp c) (ical:type-symbol-p (car c)))
+             ;; c is a template for a child node, so it should be
+             ;; recursively expanded:
+             (push (cons 'ical:make-node-from-templates c)
+                   children))
+            ((and (listp c) (eq '@ (car c)))
+             ;; c is a template (@ L) where L evaluates to a list of child=
ren:
+             (push (cadr c) lists-of-children))
+            (t
+             ;; otherwise, just pass c through as is; this allows
+             ;; interleaving templates with other expressions that
+             ;; evaluate to syntax nodes:
+             (push c children))))
+    (when (or children lists-of-children)
+      `(ical:ast-node-valid-p
+        (ical:make-ast-node
+         (quote ,type)
+         nil
+         (seq-filter #'identity
+                     (append (list ,@children) ,@lists-of-children)))))))
+
+;; TODO: allow disabling the validity check??
+(defmacro ical:make-node-from-templates (type &rest templates)
+  "Construct an iCalendar syntax node of TYPE from TEMPLATES.
+
+TYPE should be an iCalendar type symbol; it should not be quoted.  This
+macro (and the derived macros `icalendar-make-vcalendar',
+`icalendar-make-vevent', `icalendar-make-vtodo',
+`icalendar-make-vjournal', `icalendar-make-vfreebusy',
+`icalendar-make-valarm', `icalendar-make-vtimezone',
+`icalendar-make-standard', and `icalendar-make-daylight') makes it easy
+to write iCalendar syntax nodes of TYPE as Lisp code.
+
+Each expression in TEMPLATES represents a child node of the constructed
+node.  It must either evaluate to such a node, or it must have one of
+the following forms:
+
+(VALUE-TYPE VALUE) - constructs a node of VALUE-TYPE containing the
+  value VALUE.
+
+(PARAM-TYPE VALUE) - constructs a parameter node of PARAM-TYPE
+  containing the VALUE.
+
+(PROPERTY-TYPE VALUE [PARAM ...]) - constructs a property node of
+  PROPERTY-TYPE containing the value VALUE and PARAMs as child
+  nodes. Each PARAM should be a template (PARAM-TYPE VALUE), as above,
+  or any other expression that evaluates to a parameter node.
+
+(COMPONENT-TYPE CHILD [CHILD ...]) - constructs a component node of
+  COMPONENT-TYPE with CHILDs as child nodes. Each CHILD should either be
+  a template for a property (as above), a template for a
+  sub-component (of the same form), or any other expression that
+  evaluates to an iCalendar syntax node.
+
+If TYPE is an iCalendar component or property type, a TEMPLATE can also
+have the form (@ L), where L evaluates to a list of child nodes to be
+added to the component or property node.
+
+For example, an iCalendar VEVENT could be written like this:
+
+  (icalendar-make-node-from-templates icalendar-vevent
+    (icalendar-dtstamp (decode-time (current-time) 0))
+    (icalendar-uid \"some-unique-id\")
+    (icalendar-summary \"Party\")
+    (icalendar-location \"Robot House\")
+    (icalendar-organizer \"mailto:bender@HIDDEN\")
+    (icalendar-attendee  \"mailto:philip.j.fry@HIDDEN\"
+      (icalendar-partstatparam \"ACCEPTED\"))
+    (icalendar-attendee  \"mailto:gunther@HIDDEN\"
+      (icalendar-partstatparam \"DECLINED\"))
+    (icalendar-categories (list \"MISCHIEF\" \"DOUBLE SECRET PROBATION\"))
+    (icalendar-dtstart (icalendar-make-date-time :year 3003 :month 3 :day =
13
+                                                 :hour 22 :minute 0 :secon=
d 0)
+       (icalendar-tzidparam \"Mars/University_Time\")))
+
+Before the constructed node is returned, it is validated by
+`icalendar-ast-node-valid-p'."
+  (cond
+   ((not (ical:type-symbol-p type))
+    (error "Not an iCalendar type symbol: %s" type))
+   ((ical:value-type-symbol-p type)
+    `(ical:ast-node-valid-p
+      (ical:make-value-node-of (quote ,type) ,(car templates))))
+   ((ical:param-type-symbol-p type)
+    `(ical:make-param ,type ,(car templates)))
+   ((ical:property-type-symbol-p type)
+    `(ical:make-property ,type ,(car templates) ,@(cdr templates)))
+   ((ical:component-type-symbol-p type)
+    `(ical:make-component ,type ,@templates))))
+
+(defmacro ical:make-vcalendar (&rest templates)
+  "Construct an iCalendar VCALENDAR object from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vcalendar' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-prodid' and
+`icalendar-version' properties, they will be automatically added; see
+the variables `icalendar-vcalendar-prodid' and
+`icalendar-vcalendar-version'."
+  `(ical:make-node-from-templates ical:vcalendar ,@templates))
+
+(defmacro ical:make-vevent (&rest templates)
+  "Construct an iCalendar VEVENT node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vevent' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vevent ,@templates))
+
+(defmacro ical:make-vtodo (&rest templates)
+  "Construct an iCalendar VTODO node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vtodo' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vtodo ,@templates))
+
+(defmacro ical:make-vjournal (&rest templates)
+  "Construct an iCalendar VJOURNAL node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vjournal' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vjournal ,@templates))
+
+(defmacro ical:make-vfreebusy (&rest templates)
+  "Construct an iCalendar VFREEBUSY node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vfreebusy' for the permissible child types.
+
+If TEMPLATES does not contain templates for the `icalendar-dtstamp' and
+`icalendar-uid' properties (both required), they will be automatically
+provided."
+  `(ical:make-node-from-templates ical:vfreebusy ,@templates))
+
+(defmacro ical:make-valarm (&rest templates)
+  "Construct an iCalendar VALARM node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-valarm' for the permissible child types."
+  `(ical:make-node-from-templates ical:valarm ,@templates))
+
+(defmacro ical:make-vtimezone (&rest templates)
+  "Construct an iCalendar VTIMEZONE node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-vtimezone' for the permissible child types."
+  `(ical:make-node-from-templates ical:vtimezone ,@templates))
+
+(defmacro ical:make-standard (&rest templates)
+  "Construct an iCalendar STANDARD node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-standard' for the permissible child types."
+  `(ical:make-node-from-templates ical:standard ,@templates))
+
+(defmacro ical:make-daylight (&rest templates)
+  "Construct an iCalendar DAYLIGHT node from TEMPLATES.
+See `icalendar-make-node-from-templates' for the format of TEMPLATES.
+See `icalendar-daylight' for the permissible child types."
+  `(ical:make-node-from-templates ical:daylight ,@templates))
+
+
+;;; Validation:
+
+;; Errors at the validation stage:
+;; e.g. property/param values did not match, or are of the wrong type,
+;; or required properties not present in a component
+(define-error 'ical:validation-error "Invalid iCalendar data" 'ical:error)
+
+(cl-defun ical:signal-validation-error (msg &key node (severity 2))
+  (signal 'ical:validation-error
+              (list :message msg
+                    :buffer (ical:ast-node-meta-get :buffer node)
+                    :position (ical:ast-node-meta-get :begin node)
+                    :severity severity
+                    :node node)))
+
+(defun ical:ast-node-required-child-p (child parent)
+  "Return non-nil if CHILD is required by PARENT's node type."
+  (let* ((type (ical:ast-node-type parent))
+         (child-spec (get type 'ical:child-spec))
+         (child-type (ical:ast-node-type child)))
+    (or (memq child-type (plist-get child-spec :one))
+        (memq child-type (plist-get child-spec :one-or-more)))))
+
+(defun ical:ast-node-valid-value-p (node)
+  "Validate that NODE's value satisfies the requirements of its type.
+Signals an `icalendar-validation-error' if NODE's value is
+invalid, or returns NODE."
+  (let* ((type (ical:ast-node-type node))
+         (value (ical:ast-node-value node)))
+    (cond ((ical:value-type-symbol-p type)
+           (unless (cl-typep value type) ; see `ical:define-type'
+             (ical:signal-validation-error
+              (format "Invalid value for `%s' node: %s" type value)
+              :node node))
+           node)
+          ((ical:component-node-p node)
+           ;; component types have no value, so no need to check anything
+           node)
+          ((and (or (ical:param-type-symbol-p type)
+                    (ical:property-type-symbol-p type))
+                (null (get type 'ical:value-type))
+                (stringp value))
+           ;; property and param nodes with no value type are assumed to c=
ontain
+           ;; strings which match a value regex:
+           (unless (string-match (rx-to-string (get type 'ical:value-rx)) =
value)
+             (ical:signal-validation-error
+              (format "Invalid string value for `%s' node: %s" type value)
+              :node node))
+           node)
+          ;; otherwise this is a param or property node which itself
+          ;; should have one or more syntax nodes as a value, so
+          ;; recurse on value(s):
+          ((ical:expects-list-of-values-p type)
+           (unless (listp value) ;; TODO: check elements' types...?
+             (ical:signal-validation-error
+              (format "Expected list of values for `%s' node" type)
+              :node node))
+           (mapc #'ical:ast-node-valid-value-p value)
+           node)
+          (t
+           (unless (ical:ast-node-p value)
+             (ical:signal-validation-error
+              (format "Invalid value for `%s' node: %s" type value)
+              :node node))
+           (ical:ast-node-valid-value-p value)))))
+
+(defun ical:count-children-by-type (node)
+  "Return an alist mapping type symbols to the number of child nodes
+of that type in NODE."
+  (let ((children (ical:ast-node-children node))
+        (map nil))
+    (dolist (child children map)
+      (let* ((type (ical:ast-node-type child))
+             (n (alist-get type map)))
+        (setf (alist-get type map) (1+ (or n 0)))))))
+
+(defun ical:ast-node-valid-children-p (node)
+  "Validate that NODE's children satisfy the :child-spec associated
+with its type by `icalendar-define-component',
+`icalendar-define-property', `icalendar-define-param', or
+`icalendar-define-type'. Signals an `icalendar-validation-error'
+if NODE is invalid, or returns NODE.
+
+Note that this function does not check that the children of NODE
+are themselves valid; for that, see `ical:ast-node-valid-p'."
+  (let* ((type (ical:ast-node-type node))
+         (child-spec (get type 'ical:child-spec))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when child-spec
+
+      (dolist (child-type (plist-get child-spec :one))
+        (unless (=3D 1 (alist-get child-type child-counts 0))
+          (ical:signal-validation-error
+            (format "iCalendar `%s' node must contain exactly one `%s'"
+                    type child-type)
+            :node node)))
+
+      (dolist (child-type (plist-get child-spec :one-or-more))
+        (unless (<=3D 1 (alist-get child-type child-counts 0))
+          (ical:signal-validation-error
+           (format "iCalendar `%s' node must contain one or more `%s'"
+                   type child-type)
+           :node node)))
+
+      (dolist (child-type (plist-get child-spec :zero-or-one))
+        (unless (<=3D (alist-get child-type child-counts 0)
+                    1)
+          (ical:signal-validation-error
+           (format "iCalendar `%s' node may contain at most one `%s'"
+                   type child-type)
+           :node node)))
+
+      ;; check that all child nodes are allowed:
+      (unless (plist-get child-spec :allow-others)
+        (let ((allowed-types (append (plist-get child-spec :one)
+                                     (plist-get child-spec :one-or-more)
+                                     (plist-get child-spec :zero-or-one)
+                                     (plist-get child-spec :zero-or-more)))
+              (appearing-types (mapcar #'car child-counts)))
+
+          (dolist (child-type appearing-types)
+            (unless (member child-type allowed-types)
+              (ical:signal-validation-error
+               (format "`%s' may not contain `%s'" type child-type)
+               :node node))))))
+    ;; success:
+    node))
+
+(defun ical:ast-node-valid-p (node &optional recursively)
+  "Check that NODE is a valid iCalendar syntax node.
+By default, the check will only validate NODE itself, but if
+RECURSIVELY is non-nil, it will recursively check all its
+descendants as well. Signals an `icalendar-validation-error' if
+NODE is invalid, or returns NODE."
+  (unless (ical:ast-node-p node)
+    (ical:signal-validation-error
+     "Not an iCalendar syntax node"
+     :node node))
+
+  (ical:ast-node-valid-value-p node)
+  (ical:ast-node-valid-children-p node)
+
+  (let* ((type (ical:ast-node-type node))
+         (other-validator (get type 'ical:other-validator)))
+
+    (unless (ical:type-symbol-p type)
+      (ical:signal-validation-error
+       (format "Node's type `%s' is not an iCalendar type symbol" type)
+       :node node))
+
+    (when (and other-validator (not (functionp other-validator)))
+      (ical:signal-validation-error
+       (format "Bad validator function `%s' for type `%s'" other-validator=
 type)))
+
+    (when other-validator
+      (funcall other-validator node)))
+
+  (when recursively
+    (dolist (c (ical:ast-node-children node))
+      (ical:ast-node-valid-p c recursively)))
+
+  ;; success:
+  node)
+
+(provide 'icalendar-ast)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-ast.el ends here
diff --git a/lisp/calendar/icalendar-macs.el b/lisp/calendar/icalendar-macs=
.el
new file mode 100644
index 00000000000..f620f275ed1
--- /dev/null
+++ b/lisp/calendar/icalendar-macs.el
@@ -0,0 +1,1153 @@
+;;; icalendar-macs.el --- Macros for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', used in
+;; icalendar-parser.el to define the particular value types, parameters,
+;; properties and components in the standard as type symbols.
+
+;; TODOs:
+;;   - in the define* macros, :default needs rethinking.
+;;     I had made this a string because otherwise you can't distinguish
+;;     an unspecified default from an explicit "FALSE" for icalendar-boole=
an
+;;     But this might not be true/might not matter anyway, and it's a pain
+;;     to have to read the default value where you need it. Probably
+;;     should just change these to be the value as read.
+
+
+(eval-when-compile (require 'cl-lib))
+
+(declare-function ical:ast-node-p "icalendar-ast")
+(declare-function ical:ast-node-type "icalendar-ast")
+(declare-function ical:ast-node-value "icalendar-ast")
+(declare-function ical:type-symbol-p "icalendar-ast")
+(declare-function ical:value-type-symbol-p "icalendar-ast")
+(declare-function ical:expects-list-of-values-p "icalendar-ast")
+
+;; Some utilities:
+
+;; TODO: This may have outlived its usefulness. Delete?
+(defun ical:protected-intern (sym-name)
+  "Call `intern' on SYM-NAME and return the result, but warn if the
+resulting symbol already has icalendar-relevant properties."
+  (let ((sym (intern sym-name)))
+    (when (or (fboundp sym)
+              (get sym 'rx-definition)
+              (get sym 'ical:is-type))
+      (warn "Symbol `%s' already has iCalendar properties" sym))
+    sym))
+
+(defun ical:format-child-spec (child-spec)
+  "Format CHILD-SPEC as a table for use in symbol documentation."
+  (concat
+   (format "%-30s%6s\n" "Type" "Number")
+   (make-string 36 ?-) "\n"
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1"))
+    (plist-get child-spec :one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1+"))
+    (plist-get child-spec :one-or-more))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0-1"))
+    (plist-get child-spec :zero-or-one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0+"))
+    (plist-get child-spec :zero-or-more))))
+
+
+;; Define value types:
+(cl-defmacro ical:define-type (symbolic-name print-name doc specifier matc=
her
+                               &key link
+                                    (reader #'identity)
+                                    (printer #'identity))
+  "Define an iCalendar value type named SYMBOLIC-NAME.
+
+PRINT-NAME should be the string used to represent this type in
+the value of an `icalendar-valuetypeparam' property parameter, or
+nil if this is not a type that should be specified there. DOC
+should be a documentation string for the type. SPECIFIER should
+be a type specifier in the sense of `cl-deftype'. MATCHER should
+be an RX definition body (see `rx-define'; argument lists are not
+supported).
+
+Before the type is defined with `cl-deftype', a function will be
+defined named `icalendar-match-PRINT-NAME-value'
+(or `icalendar-match-OTHER-value', if PRINT-NAME is nil, where
+OTHER is derived from SYMBOLIC-NAME by removing any prefix
+\"icalendar-\" and suffix \"value\"). This function takes a
+string argument and matches it against MATCHER. This function may
+thus occur in SPECIFIER (e.g. in a (satisfies ...) clause).
+
+See the functions `icalendar-read-value-node',
+`icalendar-parse-value-node', and `icalendar-print-value-node' to
+convert values defined with this macro to and from their text
+representation in iCalendar format.
+
+The following keyword arguments are accepted:
+
+:reader - a function to read data of this type. It will be passed
+  a string matching MATCHER and should return an Elisp data structure.
+  Its name does not need to be quoted. (default: identity)
+
+:printer - a function to convert an Elisp data structure of this
+  type to a string. Its name does not need to be quoted.
+  (default: identity)
+
+:link - a string containing an URL for further documentation of this type"
+  (declare (doc-string 2))
+  (let* (;; Related functions:
+         (type-dname (if print-name
+                         (downcase print-name)
+                       (string-trim
+                        (symbol-name symbolic-name)
+                        "icalendar-" "value")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" type-dname "-value")))
+
+         ;; Documentation:
+         (header "It names a value type defined by `icalendar-define-type'=
.")
+         (matcher-doc (format
+"Strings representing values of this type can be matched with
+`%s'.\n" matcher-name))
+         (reader-doc (format "They can be read with `%s'\n" reader))
+         (printer-doc (format "and printed with `%s'." printer))
+         (full-doc (concat header "\n\n" doc "\n\n"
+                           matcher-doc reader-doc printer-doc "\n\n"
+"A syntax node of this type can be read with
+`icalendar-read-value-node' or parsed with `icalendar-parse-value-node',
+and printed with `icalendar-print-value-node'.")))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-value t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-rx (quote ,symbolic-name)
+                    'ical:value-reader (function ,reader)
+                    'ical:value-printer (function ,printer)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       (rx-define ,symbolic-name
+         ,matcher)
+
+       (defun ,matcher-name (s)
+         ,(format "Match string S against rx `%s'." symbolic-name)
+         (string-match (rx ,symbolic-name) s))
+
+       (cl-deftype ,symbolic-name () ,specifier)
+
+       ;; Store the association between the print name and the type
+       ;; symbol in ical:value-types. The check against print name
+       ;; here allows us to also define value types that aren't
+       ;; "really" types according to the standard, like
+       ;; `ical:geo-coordinates'. Only types that have a
+       ;; print-name can be specified in a VALUE parameter.
+       (when ,print-name
+         (push (cons ,print-name (quote ,symbolic-name)) ical:value-types)=
))))
+
+;; TODO: not sure this is needed. I've only used it once in the parser.
+(cl-defmacro ical:define-keyword-type (symbolic-name print-name doc matcher
+                                       &key link
+                                            (reader 'intern)
+                                            (printer 'symbol-name))
+  "Like `icalendar-define-type', except that string values matching MATCHER
+are assumed to be type-specific keywords that should be interned
+as symbols when read. (Thus no type specifier is necessary: it is
+always just \\=3D'symbol.) Their printed representation is their
+symbol name."
+  `(ical:define-type ,symbolic-name ,print-name ,doc
+                     'symbol
+                     ,matcher
+                     :link ,link
+                     :reader ,reader
+                     :printer ,printer))
+
+
+;; Define parameters:
+(cl-defmacro ical:define-param (symbolic-name param-name doc value
+                                &key quoted
+                                     list-sep
+                                     default
+                                     (unrecognized default)
+                                     ((:name-face name-face)
+                                      'ical:parameter-name nondefault-name=
-face)
+                                     ((:value-face value-face)
+                                      'ical:parameter-value nondefault-val=
ue-face)
+                                     ((:warn-face warn-face)
+                                      'ical:warning nondefault-warn-face)
+                                     extra-faces
+                                     link)
+  "Define iCalendar parameter PARAM-NAME under the symbol SYMBOLIC-NAME.
+PARAM-NAME should be the parameter name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing values.
+If it is a regular expression, it is assumed that the values of
+this parameter are strings which match that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME which matches the
+parameter is defined:
+  Group 1 of this regex matches PARAM-NAME
+    (or any valid parameter name, if PARAM-NAME is nil).
+  Group 2 matches VALUE, which specifies a correct value
+    for this parameter according to RFC5545.
+  Group 3, if matched, contains any parameter value which does
+    *not* match VALUE, and is incorrect according to the standard.
+
+This regex matches the entire string representing this parameter,
+from \";\" to the end of its value. Another regular expression
+named `SYMBOLIC-NAME-value' is also defined to match just the
+value part, after \";PARAM-NAME=3D\", with groups 2 and 3 as above.
+
+A function to match the complete parameter expression called
+`icalendar-match-PARAM-NAME-param' is defined
+(or `icalendar-match-OTHER-param-value' if PARAM-NAME is nil,
+where OTHER is derived from SYMBOLIC-NAME by removing any prefix
+`icalendar-' and suffix `param'). This function is used
+to provide syntax highlighting in `icalendar-mode'.
+
+See the functions `icalendar-read-param-value',
+`icalendar-parse-param-value', `icalendar-parse-params' and
+`icalendar-print-param-node' to convert parameters defined with
+this macro to and from their text representation in iCalendar
+format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if the
+  parameter is not specified on a given property.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but syntactically
+  correct according to RFC5545. Unrecognized values must be in match
+  group 5 of the regex determined by VALUE. An unrecognized value will
+  be preserved in the syntax tree metadata and printed instead of this
+  value when the node is printed. Defaults to any value specified for
+  :default.
+
+:quoted - non-nil if values of this parameter must always be surrounded
+  by (double-)quotation marks when printed, according to RFC5545.
+
+:list-sep - if the parameter accepts a list of values, this should be a
+  string which separates the values (typically \",\"). If :list-sep is
+  non-nil, the value string will first be split on the separator, then
+  if :quoted is non-nil, the individual values will be unquoted, then
+  each value will be read according to VALUE and collected into a list
+  when parsing.  When printing, the inverse happens: values are quoted
+  if :quoted is non-nil, then joined with :list-sep. Passing this
+  argument marks SYMBOLIC-NAME as a type that accepts a list of values
+  for `icalendar-expects-list-of-values-p'.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-parameter-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-parameter-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form accepted for HIGHLIGHT in
+  `font-lock-keywords'.  In particular,
+    ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]]) ...)
+  can be used to apply different faces to different
+  match subgroups.
+
+:link - a string containing a URL for documentation of this parameter.
+  The URL will be provided in the documentation shown by
+  `describe-symbol' for SYMBOLIC-NAME."
+  (declare (doc-string 2))
+  (let* (;; Related function names:
+         (param-dname (if param-name
+                          (downcase param-name)
+                        (string-trim (symbol-name symbolic-name)
+                                     "icalendar-" "param")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" param-dname "-param")))
+
+         (type-predicate-name
+          (ical:protected-intern (concat "icalendar-" param-dname "-param-=
p")))
+         ;; Value regexes:
+         (qvalue-rx (if quoted `(seq ?\" ,value ?\") value))
+         (values-rx (when list-sep
+                     `(seq ,qvalue-rx (zero-or-more ,list-sep ,qvalue-rx))=
))
+         (full-value-rx-name (ical:protected-intern
+                               (concat (symbol-name symbolic-name) "-value=
")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-value-face
+                        nondefault-warn-face extra-faces))
+         ;; Documentation:
+         (header "It names a parameter type defined by `icalendar-define-p=
aram'.")
+         (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep ".=
..")
+                     "VAL"))
+         (s (if list-sep "s" "")) ; to make plurals
+         (val-doc (concat "VAL" s " "
+                          "must be " (unless list-sep "a ") (when quoted "=
quoted ")
+                          (if (ical:value-type-symbol-p value)
+                              (format "`%s' value%s" (symbol-name value) s)
+                            (format "string%s matching rx `%s'" s value))))
+         (syntax-doc (format "Syntax: %s=3D%s\n%s"
+                             (or param-name "(NAME)") val-list val-doc))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-param t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:is-quoted ,quoted
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regex which matches just the value of the parameter:
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx qvalue-rx))
+             (group-n 3 ical:param-value)))
+
+       ;; Regex which matches the full parameter:
+       ;; Group 1: the parameter name,
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,symbolic-name
+         (seq ";"
+              ;; if the parameter name has no printed form, the best we
+              ;; can do is match ical:param-name:
+              (group-n 1 ,(or param-name 'ical:param-name))
+              "=3D"
+              ,full-value-rx-name))
+
+       ;; CL-type to represent syntax nodes for this parameter:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s parameter" param=
-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Matcher for the full param string, for syntax highlighting:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s parameter (defined by define-param)" par=
am-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; Entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `ical:parse-params' and `ical:print-param':
+       (when ,param-name
+         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types)=
))))
+
+
+;; Define properties:
+(cl-defmacro ical:define-property (symbolic-name property-name doc value
+                                   &key default
+                                        (unrecognized default)
+                                        (default-type
+                                         (if (ical:value-type-symbol-p val=
ue)
+                                             value
+                                           'ical:text))
+                                        other-types
+                                        list-sep
+                                        child-spec
+                                        other-validator
+                                        ((:name-face name-face)
+                                         'ical:property-name nondefault-na=
me-face)
+                                        ((:value-face value-face)
+                                         'ical:property-value nondefault-v=
alue-face)
+                                        ((:warn-face warn-face)
+                                         'ical:warning nondefault-warn-fac=
e)
+                                        extra-faces
+                                        link)
+  "Define iCalendar property PROPERTY-NAME under SYMBOLIC-NAME.
+PROPERTY-NAME should be the property name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing the
+property's value. If it is a regular expression, it is assumed
+that the values are strings of type `icalendar-text' which match
+that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME is defined to
+match the property:
+  Group 1 of this regex matches PROPERTY-NAME.
+  Group 2 matches VALUE.
+  Group 3, if matched, contains any property value which does
+   *not* match VALUE, and is incorrect according to the standard.
+  Group 4, if matched, contains the (unparsed) property parameters;
+   its boundaries can be used for parsing these.
+
+This regex matches the entire string representing this property,
+from the beginning of the content line to the end of its value.
+Another regular expression named `SYMBOLIC-NAME-value' is also
+defined to match just the value part, after the separating colon,
+with groups 2 and 3 as above.
+
+A function to match the complete property expression called
+`icalendar-match-PROPERTY-NAME-property' is defined. This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+See the functions `icalendar-read-property-value',
+`icalendar-parse-property-value', `icalendar-parse-property', and
+`icalendar-print-property-node' to convert properties defined
+with this macro to and from their text representation in
+iCalendar format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if
+  the property is not specified in a given component.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but
+  syntactically correct according to RFC5545. Unrecognized values
+  must be in match group 5 of the regex determined by VALUE. An
+  unrecognized value will be preserved in the syntax tree
+  metadata and printed instead of this value when the node is
+  printed. Defaults to any value specified for :default.
+
+:default-type - a type symbol naming the default type of the
+  property's value. If the property's value differs from this
+  type, an `icalendar-valuetypeparam' parameter will be added to
+  the property's syntax node and printed when the node is
+  printed. Default is VALUE if VALUE is a value type symbol,
+  otherwise the type `icalendar-text'.
+
+:other-types - a list of type symbols naming value types other
+  than :default-type. These represent alternative types for the
+  property's value. If parsing the property's value under its
+  default type fails, these types will be tried in turn, and only
+  if the property's value matches none of them will an error be
+  signaled.
+
+:list-sep - if the property accepts a list of values, this should
+  be a string which separates the values (typically \",\"). If
+  :list-sep is non-nil, the value string will first be split on
+  the separator, then each value will be read according to VALUE
+  and collected into a list when parsing. When printing, the
+  inverse happens: values are printed individually and then
+  joined with :list-sep. Passing this argument marks
+  SYMBOLIC-NAME as a type that accepts a list of values for
+  `icalendar-expects-list-of-values-p'.
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - parameters that must appear exactly once
+  :one-or-more   - parameters that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - parameters that must appear at most once
+  :zero-or-more  - parameters that may appear more than once
+  :allow-others  - if non-nil, other parameters besides those listed in
+                   the above are allowed to appear. (In this case, a
+                   :zero-or-more clause is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-property-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-property-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form for HIGHLIGHT in `font-lock-keywords'.
+  In particular, ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]])...)
+  can be used to apply different faces to different match subgroups.
+
+:link - a string containing a URL for documentation of this property"
+  (declare (doc-string 2))
+  (let* (;; Value RX:
+        (full-value-rx-name
+         (ical:protected-intern
+          (concat (symbol-name symbolic-name) "-property-value")))
+        (values-rx (when list-sep
+                    `(seq ,value (zero-or-more ,list-sep ,value))))
+        ;; Related functions:
+        (property-dname (if property-name
+                            (downcase property-name)
+                          (string-trim (symbol-name symbolic-name)
+                                       "icalendar-" "-property")))
+        (matcher-name (ical:protected-intern
+                       (concat "icalendar-match-"
+                               property-dname
+                               "-property")))
+        (type-predicate-name
+         (ical:protected-intern (concat "icalendar-"
+                                        property-dname
+                                        "-property-p")))
+        ;; Faces:
+        (has-faces (or nondefault-name-face nondefault-value-face
+                       nondefault-warn-face extra-faces))
+        ;; Documentation:
+        (header "It names a property type defined by `icalendar-define-pro=
perty'.")
+        (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "..=
.")
+                    "VAL"))
+        (default-doc (if default (format "The default value is: \"%s\"\n" =
default)
+                       ""))
+        (s (if list-sep "s" "")) ; to make plurals
+        (val-doc (concat "VAL" s " "
+                         "must be " (unless list-sep "a ")
+                         (format "value%s of one of the following types:\n=
" s)
+                         (string-join
+                          (cons
+                           (format "`%s' (default)" default-type)
+                           (mapcar (lambda (type) (format "`%s'" type))
+                                   other-types))
+                          "\n")
+                         default-doc))
+        (name-doc (if property-name "" "NAME must match rx `icalendar-name=
'"))
+        (syntax-doc (format "Syntax: %s[;PARAM...]:%s\n%s\n%s\n"
+                            (or property-name "NAME") val-list name-doc va=
l-doc))
+        (child-doc
+         (concat
+          "The following parameters are required or allowed\n"
+          "as children in syntax nodes of this type:\n\n"
+          (ical:format-child-spec child-spec)
+          (when (plist-get child-spec :allow-others)
+            "\nOther parameters of any type are also allowed.\n")))
+        (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child=
-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-property t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:default-type (quote ,default-type)
+                    'ical:other-types (quote ,other-types)
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Value regex which matches:
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx value))
+             (group-n 3 (zero-or-more any))))
+
+       ;; Full property regex which matches:
+       ;; Group 1: the property name,
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,symbolic-name
+         (seq line-start
+              (group-n 1 ,(or property-name 'ical:name))
+              (group-n 4 (zero-or-more ical:other-param-safe))
+              ":"
+              ,full-value-rx-name
+              line-end))
+
+       ;; Matcher:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for `%s' property (defined by define-property)"
+                  symbolic-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; CL-type to represent syntax nodes for this property:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s property" proper=
ty-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-property', `icalendar-print-property-node', etc=
.:
+       (when ,property-name
+         (push (cons ,property-name (quote ,symbolic-name)) ical:property-=
types))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords)))))
+
+
+;; Define components:
+(cl-defmacro ical:define-component (symbolic-name component-name doc
+                                    &key
+                                    ((:keyword-face keyword-face)
+                                     'ical:keyword nondefault-keyword-face)
+                                    ((:name-face name-face)
+                                     'ical:component-name nondefault-name-=
face)
+                                    child-spec
+                                    other-validator
+                                    link)
+  "Define iCalendar component COMPONENT-NAME under SYMBOLIC-NAME.
+COMPONENT-NAME should be the name of the component as it should
+appear in iCalendar data.
+
+Regular expressions to match the component boundaries are defined
+named `COMPONENT-NAME-begin' and `COMPONENT-NAME-end' (or
+`OTHER-begin' and `OTHER-end', where `OTHER' is derived from
+SYMBOLIC-NAME by removing any prefix `icalendar-' and suffix
+`-component' if COMPONENT-NAME is nil).
+  Group 1 of these regexes matches the \"BEGIN\" or \"END\"
+    keyword that marks a component boundary.
+  Group 2 matches the component name.
+
+A function to match the component boundaries is defined called
+`icalendar-match-COMPONENT-NAME-component' (or
+`icalendar-match-OTHER-component', with OTHER as above). This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+The following keyword arguments are accepted:
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - properties or components that must appear exactly once
+  :one-or-more   - properties or components that must appear at least once=
 and
+                   may appear more than once
+  :zero-or-one   - properties or components that must appear at most once
+  :zero-or-more  - properties or components that may appear more than once
+  :allow-others  - if non-nil, other children besides those listed in the =
above
+                   are allowed to appear. (In this case, a :zero-or-more c=
lause
+                   is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:keyword-face - a face symbol for highlighting the BEGIN/END keyword
+  (default: `icalendar-keyword')
+
+:name-face - a face symbol for highlighting the component name
+  (default: `icalendar-component-name')
+
+:link - a string containing a URL for documentation of this component"
+  (declare (doc-string 2))
+  (let* (;; Regexes:
+         (name-rx (or component-name 'ical:name))
+         (component-dname (if component-name
+                              (downcase component-name)
+                            (string-trim (symbol-name symbolic-name)
+                                         "icalendar-" "-component")))
+         (begin-rx-name (ical:protected-intern
+                         (concat "icalendar-" component-dname "-begin")))
+         (end-rx-name (ical:protected-intern
+                       (concat "icalendar-" component-dname "-end")))
+         ;; Related functions:
+         (matcher-name
+          (ical:protected-intern
+           (concat "icalendar-match-" component-dname "-component")))
+         (type-predicate-name
+          (ical:protected-intern
+           (concat "icalendar-" component-dname "-component-p")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-keyword-face))
+         ;; Documentation:
+         (header "It names a component type defined by
+`icalendar-define-component'.")
+         (name-doc (if (not component-name)
+                       "\nNAME must match rx `icalendar-name'"
+                     ""))
+         (syntax-doc (format "Syntax:\nBEGIN:%s\n[contentline ...]\nEND:%1=
$s%s"
+                             (or component-name "NAME")
+                             name-doc))
+         (child-doc
+          (concat
+           "The following properties and components are required or "
+           "allowed\nas children in syntax nodes of this type:\n\n"
+           (ical:format-child-spec child-spec)
+           (when (plist-get child-spec :allow-others)
+             "\nOther properties and components of any type are also allow=
ed.\n")))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" chil=
d-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-component t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:begin-rx (quote ,begin-rx-name)
+                    'ical:end-rx (quote ,end-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regexes which match:
+       ;; Group 1: BEGIN or END, and
+       ;; Group 2: the component name
+       (rx-define ,begin-rx-name
+         (seq line-start
+              (group-n 1 "BEGIN")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (rx-define ,end-rx-name
+         (seq line-start
+              (group-n 1  "END")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s component boundaries"
+                  (or component-name "unrecognized"))
+           (re-search-forward (rx (or ,begin-rx-name ,end-rx-name)) limit =
t))
+
+       ;; CL-type to represent syntax nodes for this component:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s component"
+                  (or component-name "unrecognized"))
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,keyword-face) t t)
+                       (2 (quote ,name-face) t t)))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-component', `icalendar-print-component' etc.:
+       (when ,component-name
+         (push (cons ,component-name (quote ,symbolic-name))
+               ical:component-types)))))
+
+
+;; Macros for destructuring and binding AST nodes
+;;
+;; TODO: move these to icalendar-ast.el? It makes no sense to have these
+;; here but the various icalendar-make-* macros there.
+
+(defmacro ical:with-node-children (node bindings &rest body)
+  "Bind the variables in BINDINGS to the corresponding
+child nodes in NODE, and execute BODY with these bindings.  NODE should
+be an iCalendar syntax node representing a component or property.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :first - the first child node of TYPE
+   :default - the default value, if any, for TYPE
+   :value-node - the value of the node in :first
+   :value-type - the type of the node in :value-node (if it is a node).
+   :value - the value of the node in :value-node, if it is a node,
+     or :value-node itself, if it is not.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values of the node in :first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the nodes in :value-nodes (if they are
+     nodes), or the :value-nodes themselves (if they are not).
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not."
+  (declare (indent 2))
+  ;; Static checks on the bindings prevent various annoying bugs:
+  (dolist (b bindings)
+    (let ((type (car b))
+          (kwargs (cdr b)))
+      (unless (ical:type-symbol-p type)
+        (error "Not an iCalendar type symbol: %s" type))
+      (when (and (plist-member kwargs :all)
+                 (> 2 (length kwargs)))
+        (error ":all may not be combined with other bindings"))
+      (if (ical:expects-list-of-values-p type)
+            (when (or (plist-member kwargs :value-node)
+                      (plist-member kwargs :value-type)
+                      (plist-member kwargs :value))
+              (error "Type `%s' expects a list of values" type))
+        (when (or (plist-member kwargs :value-nodes)
+                  (plist-member kwargs :value-types)
+                  (plist-member kwargs :values))
+              (error "Type `%s' does not expect a list of values" type)))))
+
+  (let ((nd (gensym "icalendar-node")))
+    `(let* ((,nd ,node)
+            ,@(mapcan
+               (lambda (tv)
+                 (let ((type (car tv))
+                       (vars (cdr tv)))
+                   (when (and (symbolp (car vars)) (null (cdr vars)))
+                     ;; the simple (TYPE VAR) case:
+                     (setq vars (list :first (car vars))))
+
+                   (let ((first-var (or (plist-get vars :first)
+                                        (gensym "first")))
+                         (default-var (or (plist-get vars :default)
+                                          (gensym "default")))
+                         (vnode-var (or (plist-get vars :value-node)
+                                        (gensym "value-node")))
+                         (vtype-var (or (plist-get vars :value-type)
+                                        (gensym "value-type")))
+                         (vval-var (or (plist-get vars :value)
+                                       (gensym "value")))
+
+                         (vnodes-var (or (plist-get vars :value-nodes)
+                                         (gensym "value-nodes")))
+                         (vtypes-var (or (plist-get vars :value-types)
+                                         (gensym "value-types")))
+                         (vvals-var (or (plist-get vars :values)
+                                        (gensym "values")))
+
+                         (all-var (or (plist-get vars :all)
+                                      (gensym "all")))
+                         ;; The corresponding vars for :all are mostly
+                         ;; too complicated to be useful, I think, so
+                         ;; not implementing them for now.
+                         ;; TODO: but it *would* be helpful to have an
+                         ;; :all-values clause especially for RDATE and
+                         ;; EXDATE, since they both accept lists, and
+                         ;; can also occur multiple times.
+                         ;; I've found myself needing to write
+                         ;; (mapcar #'ical:ast-node-value
+                         ;;   (apply #'append
+                         ;;     (mapcar #'ical:ast-node-value rdate-nodes))
+                         ;; a bit too often.
+                         )
+                     (delq nil
+                           (list
+                            (when (plist-member vars :all)
+                              `(,all-var (ical:ast-node-children-of
+                                          (quote ,type) ,nd)))
+                            (when (not (plist-member vars :all))
+                              `(,first-var (ical:ast-node-first-child-of
+                                            (quote ,type) ,nd)))
+                            (when (plist-member vars :default)
+                              `(,default-var (get (quote ,type)
+                                                  'ical:default-value)))
+                            ;; Single value:
+                            (when (or (plist-member vars :value-node)
+                                      (plist-member vars :value-type)
+                                      (plist-member vars :value))
+                              `(,vnode-var (when (ical:ast-node-p ,first-v=
ar)
+                                             (ical:ast-node-value ,first-v=
ar))))
+                            (when (plist-member vars :value-type)
+                              `(,vtype-var
+                                (when ,vnode-var
+                                  (ical:ast-node-type ,vnode-var))))
+                            (when (plist-member vars :value)
+                              `(,vval-var
+                                (when ,vnode-var
+                                  (if (ical:ast-node-p ,vnode-var)
+                                      (ical:ast-node-value ,vnode-var)
+                                    ,vnode-var))))
+
+                            ;; List of values:
+                            (when (or (plist-member vars :value-nodes)
+                                      (plist-member vars :value-types)
+                                      (plist-member vars :values))
+                              `(,vnodes-var
+                                (when (ical:ast-node-p ,first-var)
+                                  (ical:ast-node-value ,first-var))))
+                            (when (plist-member vars :value-types)
+                              `(,vtypes-var
+                                (when ,vnodes-var
+                                  (mapcar #'ical:ast-node-type ,vnodes-var=
))))
+                            (when (plist-member vars :values)
+                              `(,vvals-var
+                                (when ,vnodes-var
+                                  (if (ical:ast-node-p (car ,vnodes-var))
+                                      (mapcar #'ical:ast-node-value
+                                              ,vnodes-var)
+                                    ,vnodes-var)))))))))
+
+               bindings))
+       ,@body)))
+
+(defalias 'ical:with-component #'ical:with-node-children
+    "Bind the variables in BINDINGS to the corresponding
+properties in NODE, and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing an iCalendar
+component: `icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal',
+`icalendar-vtimezone', `icalendar-vfreebusy', `icalendar-standard',
+`icalendar-daylight'. It may also be an entire `icalendar-vcalendar'.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :default - the default value, if any, for TYPE
+   :first - the first child node of TYPE
+   :value-node - the value (which is itself a node) of the node in :first
+   :value-type - the type of the node in :value-node.
+   :value - the value of the node in :value-node.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values (which are themselves nodes) of the node in :=
first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the node in :value-node.
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not.=
")
+
+(defmacro ical:with-node-value (node &optional bindings &rest body)
+  "Bind the value in NODE and any of NODE's children in BINDINGS
+and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing a property or
+parameter. If NODE is not a syntax node, this form evalutes to nil
+without binding the variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is a list of syntax nodes, then within BODY,
+`value-nodes' will be bound to those value nodes, `value-types' will be
+bound to a list of their types, and `values' will be bound to their
+values.
+
+If NODE's value is not a syntax node, then `value' is instead bound
+directly to NODE's value, and `value-type' and `value-node' are bound to
+nil.
+
+If BODY is nil, it is assumed to be the symbol `value'; thus
+  (icalendar-with-node-value some-node)
+is equivalent to
+  (icalendar-with-node-value some-node nil value)
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form."
+  (let ((vn (gensym "icalendar-node"))
+        (val (gensym "icalendar-value"))
+        (is-list (gensym "is-list")))
+    `(let ((,vn ,node))
+       (when (ical:ast-node-p ,vn)
+         (let* ((,val (ical:ast-node-value ,vn))
+                (value-node (when (ical:ast-node-p ,val) ,val))
+                (value-type (when (ical:ast-node-p value-node)
+                              (ical:ast-node-type value-node)))
+                (value (if (ical:ast-node-p value-node)
+                           (ical:ast-node-value value-node)
+                         ,val))
+                (,is-list (ical:expects-list-of-values-p (ical:ast-node-ty=
pe ,vn)))
+                (value-nodes (when ,is-list
+                               (seq-filter #'ical:ast-node-p ,val)))
+                (value-types (when ,is-list
+                               (mapcar #'ical:ast-node-type value-nodes)))
+                (values (when ,is-list
+                          (mapcar #'ical:ast-node-value value-nodes))))
+           (ignore value-type ; Silence the byte compiler when
+                   value      ; one of these goes unused
+                   value-types
+                   values)
+           (ical:with-node-children ,vn ,bindings ,@(or body (list 'value)=
)))))))
+
+(defalias 'ical:with-property #'ical:with-node-value
+    "Bind the property value in NODE and any of its parameters in BINDINGS
+and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing a property. If NODE
+is not a syntax node, this form evalutes to nil without binding the
+variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is a list of syntax nodes, then within BODY,
+`value-nodes' will be bound to those value nodes, `value-types' will be
+bound to a list of their types, and `values' will be bound to their
+values.
+
+If NODE's value is not a syntax node, then `value' is bound directly to
+NODE's value, and `value-type' and `value-node' are bound to nil.
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form.")
+
+(defmacro ical:with-param (parameter &rest body)
+  "Bind the value in PARAMETER and execute BODY.
+
+PARAMETER should be an iCalendar syntax node representing a
+parameter. If PARAMETER is nil, this form evalutes to nil without
+executing BODY.
+
+Within BODY, if PARAMETER's value is a syntax node, the symbol
+`value-node' will be bound to that syntax node, `value-type' will be
+bound to the value node's type, and `value' will be bound to the value
+node's value.
+
+If PARAMETER's value is not a syntax node, then `value' is bound
+directly to PARAMETER's value, and `value-type' and `value-node' are
+bound to nil."
+  `(ical:with-node-value ,parameter nil ,@body))
+
+(defmacro ical:with-child-of (node type &optional bindings &rest body)
+  "Like `icalendar-with-node-value', but applies to the parent of the node
+of interest.
+
+Find the first child node of type TYPE in NODE, bind that
+child node's value and any of its children in BINDINGS and execute BODY
+with these bindings.  If there is no such node, this form evalutes to
+nil without executing BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound as in `icalendar-with-node-value'.
+If BODY is nil, it is assumed to be the symbol `value'; thus
+  (icalendar-with-child-of some-node some-type)
+is equivalent to
+  (icalendar-with-child-of some-node some-type nil value)
+
+See `icalendar-with-node-children' for the form of BINDINGS."
+  (let ((child (gensym "icalendar-node")))
+    `(let ((,child (ical:ast-node-first-child-of ,type ,node)))
+       (ical:with-node-value ,child ,bindings ,@body))))
+
+(defalias 'ical:with-property-of #'ical:with-child-of
+  "Like `icalendar-with-property', but applies to the parent component NOD=
E.
+
+Find the first property node of type TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the property's value node, type and value as in
+`icalendar-with-node-value'.  If BODY is nil, it is assumed to be the
+symbol `value'; thus
+  (icalendar-with-property-of some-component some-type)
+is equivalent to
+  (icalendar-with-property-of some-component some-type nil value)
+
+BINDINGS can be used to bind the property's parameters; see
+`icalendar-with-node-children' for the form of BINDINGS.")
+
+(defmacro ical:with-param-of (node type &rest body)
+  "Like `icalendar-with-param', but applies to the parent property NODE.
+
+Find the first parameter node of TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the parameter's value node, type and value as in
+`icalendar-with-node-value'.  If BODY is nil, it is assumed to be the
+symbol `value'; thus
+  (icalendar-with-param-of some-property some-type)
+is equivalent to
+  (icalendar-with-param-of some-property some-type nil value)
+"
+  `(ical:with-child-of ,node ,type nil ,@body))
+
+(provide 'icalendar-macs)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-macs.el ends here
diff --git a/lisp/calendar/icalendar-mode.el b/lisp/calendar/icalendar-mode=
.el
new file mode 100644
index 00000000000..6d3ba157d35
--- /dev/null
+++ b/lisp/calendar/icalendar-mode.el
@@ -0,0 +1,605 @@
+;;; icalendar-mode.el --- Major mode for iCalendar format  -*- lexical-bin=
ding: t; -*-
+;;;
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines icalendar-mode, a major mode for iCalendar
+;; data. Its main job is to provide syntax highlighting using the
+;; matching functions created for iCalendar syntax in
+;; icalendar-parser.el.
+
+;; When activated, icalendar-mode offers to unfold content lines if
+;; necessary, and switch to a new buffer containing the unfolded data;
+;; see `ical:maybe-switch-to-unfolded-buffer'. This is because the
+;; parsing functions, and thus syntax highlighting, assume that content
+;; lines have already been unfolded. When a buffer is saved,
+;; icalendar-mode also offers to fold long content if necessary, as
+;; required by RFC5545; see `ical:before-save-checks'.
+
+;;; Code:
+
+(require 'icalendar-parser)
+
+;; Faces and font lock:
+(defgroup ical:faces
+  '((ical:property-name custom-face)
+    (ical:property-value custom-face)
+    (ical:parameter-name custom-face)
+    (ical:parameter-value custom-face)
+    (ical:component-name custom-face)
+    (ical:keyword custom-face)
+    (ical:binary-data custom-face)
+    (ical:date-time-types custom-face)
+    (ical:numeric-types custom-face)
+    (ical:recurrence-rule custom-face)
+    (ical:warning custom-face)
+    (ical:ignored custom-face))
+  "Faces for icalendar-mode."
+  :version "32.1"
+  :group 'icalendar
+  :prefix 'icalendar)
+
+(defface ical:property-name
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for iCalendar property names")
+
+(defface ical:property-value
+  '((default . (:inherit default)))
+  "Face for iCalendar property values")
+
+(defface ical:parameter-name
+  '((default . (:inherit font-lock-property-name-face)))
+  "Face for iCalendar parameter names")
+
+(defface ical:parameter-value
+  '((default . (:inherit font-lock-property-use-face)))
+  "Face for iCalendar parameter values")
+
+(defface ical:component-name
+  '((default . (:inherit font-lock-constant-face)))
+  "Face for iCalendar component names")
+
+(defface ical:keyword
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for other iCalendar keywords")
+
+(defface ical:binary-data
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar values that represent binary data")
+
+(defface ical:date-time-types
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar values that represent dates, date-times,
+durations, periods, and UTC offsets")
+
+(defface ical:numeric-types
+  '((default . (:inherit ical:property-value-face)))
+  "Face for iCalendar values that represent integers, floats, and geolocat=
ions")
+
+(defface ical:recurrence-rule
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar recurrence rule values")
+
+(defface ical:uri
+  '((default . (:inherit ical:property-value-face :underline t)))
+  "Face for iCalendar values that are URIs (including URLs and mail addres=
ses)")
+
+(defface ical:warning
+  '((default . (:inherit font-lock-warning-face)))
+  "Face for iCalendar syntax errors")
+
+(defface ical:ignored
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar syntax which is parsed but ignored")
+
+;;; Font lock:
+(defconst ical:params-font-lock-keywords
+  '((ical:match-other-param
+     (1 'font-lock-comment-face t t)
+     (2 'font-lock-comment-face t t)
+     (3 'ical:warning t t))
+    (ical:match-value-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sent-by-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-rsvp-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-role-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-reltype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-related-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-range-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-partstat-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-member-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-language-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-fbtype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-fmttype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-encoding-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-dir-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-to-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-from-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-cutype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-cn-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-altrep-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar property parameters in `font-lock-keywords'.")
+
+(defconst ical:properties-font-lock-keywords
+  '((ical:match-request-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-other-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sequence-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-last-modified-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstamp-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-created-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-trigger-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-repeat-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-action-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-rrule-property
+     (1 'ical:property-name t t)
+     (2 'ical:recurrence-rule t t)
+     (3 'ical:warning t t))
+    (ical:match-rdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-exdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-uid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-url-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-related-to-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-recurrence-id-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-organizer-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-contact-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attendee-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzurl-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetto-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetfrom-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzname-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-transp-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-freebusy-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-duration-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstart-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-due-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtend-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-completed-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-summary-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-resources-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-priority-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-percent-complete-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-location-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-geo-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-description-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-comment-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-class-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-categories-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attach-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t)
+     (13 'ical:uri t t)
+     (14 'ical:binary-data t t))
+    (ical:match-version-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-prodid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-method-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-calscale-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar properties in `font-lock-keywords'.")
+
+(defconst ical:ignored-properties-font-lock-keywords
+  `((,(rx ical:other-property) (1 'ical:ignored keep)
+                               (2 'ical:ignored keep)))
+  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
+
+(defconst ical:components-font-lock-keywords
+  '((ical:match-vcalendar-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-other-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-valarm-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-daylight-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-standard-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtimezone-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vfreebusy-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vjournal-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtodo-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vevent-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t)))
+  "Entries for iCalendar components in `font-lock-keywords'.")
+
+(defvar ical:font-lock-keywords
+  (append ical:params-font-lock-keywords
+          ical:properties-font-lock-keywords
+          ical:components-font-lock-keywords
+          ical:ignored-properties-font-lock-keywords)
+  "Value of `font-lock-keywords' for icalendar-mode.")
+
+
+;; The major mode:
+
+;;; Mode hook
+(defvar ical:mode-hook nil
+  "Hook run when activating `icalendar-mode'.")
+
+;;; Activating the mode for .ics files:
+(add-to-list 'auto-mode-alist '("\\.ics\\'" . icalendar-mode))
+
+;;; Syntax table
+(defvar ical:mode-syntax-table
+    (let ((st (make-syntax-table)))
+      ;; Characters for which the standard syntax table suffices:
+      ;; ; (punctuation): separates some property values, and property par=
ameters
+      ;; " (string): begins and ends string values
+      ;; : (punctuation): separates property name (and parameters) from pr=
operty
+      ;;                  values
+      ;; , (punctuation): separates values in a list
+      ;; CR, LF (whitespace): content line endings
+      ;; space (whitespace): when at the beginning of a line, continues the
+      ;;                     previous line
+
+      ;; Characters which need to be adjusted from the standard syntax tab=
le:
+      ;; =3D is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?=3D ".   " st)
+      ;; / is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?/ ".   " st)
+      st)
+    "Syntax table used in `icalendar-mode'.")
+
+;;; Commands
+
+(defun ical:switch-to-unfolded-buffer ()
+  "Switch to viewing the contents of the current buffer in a new
+buffer where content lines have been unfolded.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets; `unfolding'
+means removing the extra whitespace inserted by folding. The
+iCalendar standard (RFC5545) requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it. In icalendar-mode, folded lines may not have proper
+syntax highlighting; this command allows you to view iCalendar
+data with proper syntax highlighting, as the parser sees it.
+
+If the current buffer is visiting a file, this function will
+offer to save the buffer first, and then reload the contents from
+the file, performing unfolding with `icalendar-unfold-undecoded-region'
+before decoding it. This is the most reliable way to unfold lines.
+
+If it is not visiting a file, it will unfold the new buffer
+with `icalendar-unfold-region'. This can in some cases have
+undesirable effects (see its docstring), so the original contents
+are preserved unchanged in the current buffer.
+
+In both cases, after switching to the new buffer, this command
+offers to kill the original buffer.
+
+It is recommended to turn off `auto-fill-mode' when viewing an
+unfolded buffer, so that filling does not interfere with syntax
+highlighting. This function offers to disable `auto-fill-mode' if
+it is enabled in the new buffer; consider using
+`visual-line-mode' instead."
+  (interactive)
+  (when (and buffer-file-name (buffer-modified-p))
+    (when (y-or-n-p (format "Save before reloading from %s?"
+                            (file-name-nondirectory buffer-file-name)))
+      (save-buffer)))
+  (let ((old-buffer (current-buffer))
+        (mmode major-mode)
+        (uf-buffer (if buffer-file-name
+                       (ical:unfolded-buffer-from-file buffer-file-name)
+                     (ical:unfolded-buffer-from-buffer (current-buffer)))))
+    (switch-to-buffer uf-buffer)
+    ;; restart original major mode, in case the new buffer is
+    ;; still in fundamental-mode: TODO: is this necessary?
+    (funcall mmode)
+    (when (y-or-n-p (format "Unfolded buffer is shown. Kill %s?"
+                            (buffer-name old-buffer)))
+      (kill-buffer old-buffer))
+    (when (and auto-fill-function
+               (y-or-n-p "Disable auto-fill-mode?"))
+      (auto-fill-mode -1))))
+
+(defun ical:maybe-switch-to-unfolded-buffer ()
+  "Check for folded lines and ask for confirmation before calling
+`icalendar-switch-to-unfolded-buffer', which see.
+
+This function is intended to be run via `icalendar-mode-hook'
+when `icalendar-mode' is activated."
+  (interactive)
+  (when (and (ical:contains-folded-lines-p)
+             (y-or-n-p "Buffer contains folded lines; unfold in new buffer=
?"))
+        (ical:switch-to-unfolded-buffer)))
+
+(add-hook 'ical:mode-hook #'ical:maybe-switch-to-unfolded-buffer)
+
+(defun ical:before-save-checks ()
+  "Offer to change coding system and fold content lines in the
+current buffer when saving a buffer in `icalendar-mode'.
+
+The iCalendar standard requires CR-LF line endings, so if
+`buffer-file-coding-system' does not use a coding system which
+specifies them, this command offers to switch to a corresponding
+coding system which does.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets. The iCalendar
+standard requires folding lines when serializing data to
+iCalendar format, so if the buffer contains unfolded lines, this
+command asks you whether you want to fold them."
+  (interactive)
+  (when (eq major-mode 'icalendar-mode)
+    (let* ((cs buffer-file-coding-system)
+           (suggested-cs (if cs (coding-system-change-eol-conversion cs 'd=
os)
+                           'prefer-utf-8-dos)))
+      (when (and (not (coding-system-equal cs suggested-cs))
+                 (y-or-n-p
+                  (format "Current coding system %s does not use CR-LF lin=
e endings. Change to %s for save?" cs suggested-cs)))
+        (set-buffer-file-coding-system suggested-cs))
+      (when (and (ical:contains-unfolded-lines-p)
+                 (y-or-n-p "Fold content lines before saving?"))
+        (ical:fold-region (point-min) (point-max))))))
+
+(add-hook 'before-save-hook #'ical:before-save-checks)
+
+;;; Mode definition
+(define-derived-mode icalendar-mode text-mode "iCalendar"
+  "Major mode for viewing and editing iCalendar (RFC5545) data.
+
+This mode provides syntax highlighting for iCalendar components,
+properties, values, and property parameters, and commands to deal
+with folding and unfolding iCalendar content lines.
+
+`Folding' means inserting whitespace characters to continue long
+lines; `unfolding' means removing the extra whitespace inserted
+by folding. The iCalendar standard requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it.
+
+Thus icalendar-mode's syntax highlighting is designed to work with
+unfolded lines. When icalendar-mode is activated, it will offer to
+unfold lines; see `icalendar-switch-to-unfolded-buffer'. It will also
+offer to fold lines when saving a buffer to a file; see
+`icalendar-before-save-checks'. That function also offers to convert the
+line endings in the file to CR-LF, as the standard requires."
+  :group 'icalendar
+  :syntax-table ical:mode-syntax-table
+  ;; TODO: Keymap?
+  ;; TODO: buffer-local variables?
+  ;; TODO: indent-line-function and indentation variables
+  ;; TODO: mode-specific menu and context menus
+  ;; TODO: eldoc integration
+  ;; TODO: completion of keywords
+  ;; TODO: hook for folding in change-major-mode-hook?
+  (progn
+    (setq font-lock-defaults '(ical:font-lock-keywords nil t))))
+
+(provide 'icalendar-mode)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-mode.el ends here
diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-pa=
rser.el
new file mode 100644
index 00000000000..1d3f7327443
--- /dev/null
+++ b/lisp/calendar/icalendar-parser.el
@@ -0,0 +1,4784 @@
+;;; icalendar-parser.el --- Parse iCalendar grammar  -*- lexical-binding: =
t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines regular expressions, constants and functions that
+;; implement the iCalendar grammar according to RFC5545.
+;;
+;; iCalendar data is grouped into *components*, such as events or
+;; to-do items. Each component contains one or more *content lines*,
+;; which each contain a *property* name and its *value*, and possibly
+;; also property *parameters* with additional data that affects the
+;; interpretation of the property.
+;;
+;; The macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', defined in
+;; icalendar-macs.el, each create rx-style regular expressions for one
+;; of these categories in the grammar and are used here to define the
+;; particular value types, parameters, properties and components in the
+;; standard as type symbols. These type symbols store all the metadata
+;; about the relevant types, and are used for type-based dispatch in the
+;; parser and printer functions. In the abstract syntax tree, each node
+;; contains a type symbol naming its type. A number of other regular
+;; expressions which encode basic categories of the grammar are also
+;; defined in this file.
+;;
+;; The following functions provide the high-level interface to the parser:
+;;
+;;   `icalendar-parse-and-index'
+;;   `icalendar-parse'
+;;   `icalendar-parse-calendar'
+;;   `icalendar-parse-component'
+;;   `icalendar-parse-property'
+;;   `icalendar-parse-params'
+;;
+;; The format of the abstract syntax tree which these functions create
+;; is documented in icalendar-ast.el. Nodes in this tree can be
+;; serialized to iCalendar format with the corresponding printer
+;; functions:
+;;
+;;   `icalendar-print-calendar-node'
+;;   `icalendar-print-component-node'
+;;   `icalendar-print-property-node'
+;;   `icalendar-print-params'
+
+;;; Code:
+
+(require 'icalendar)
+(eval-when-compile (require 'icalendar-macs))
+(require 'icalendar-ast)
+(eval-when-compile (require 'cl-lib))
+(require 'subr-x)
+(require 'seq)
+(require 'rx)
+(require 'calendar)
+(require 'time-date)
+(require 'simple)
+(require 'help-mode)
+
+;;; Customization
+(defgroup icalendar-parser nil
+  "iCalendar parsing options"
+  :version "32.1"
+  :group 'icalendar
+  :prefix 'icalendar)
+
+(defcustom ical:parse-strictly nil
+  "When non-nil, iCalendar data will be parsed strictly.
+
+By default, the iCalendar parser accepts certain harmless deviations
+from RFC5545 that are common in real-world data (e.g., unescaped commas
+in text values). Setting this to t will cause the parser to produce
+errors instead of silently accepting such data."
+  :version "32.1"
+  :type '(choice (const :tag "Ignore minor errors" nil)
+                 (const :tag "Parse strictly" t)))
+
+;;; Functions for folding and unfolding
+;;
+;; According to RFC5545, iCalendar content lines longer than 75 octets
+;; should be *folded* by inserting extra line breaks and leading
+;; whitespace to continue the line. Such lines must be *unfolded*
+;; before they can be parsed.  Unfolding can only reliably happen
+;; before Emacs decodes a region of text, because decoding potentially
+;; replaces the CR-LF line endings which terminate content lines.
+;; Programs that can control when decoding happens should use the
+;; stricter `ical:unfold-undecoded-region' to unfold text; programs
+;; that must work with decoded data should use the looser
+;; `ical:unfold-region'. `ical:fold-region' will fold content lines
+;; using line breaks appropriate to the buffer's coding system.
+;;
+;; All the parsing-related code belows assumes that lines have
+;; already been unfolded if necessary.
+(defcustom ical:pre-unfolding-hook nil
+  "Hook run before unfolding iCalendar data.
+
+The functions in this hook will be run before the iCalendar data is
+\"unfolded\", i.e., before whitespace introduced for breaking long lines
+is removed (see `icalendar-unfold-region' and
+`icalendar-unfold-undecoded-region').  If you routinely receive
+iCalendar data that is not correctly folded, you can add functions to
+this hook which clean up that data before unfolding is attempted.
+
+Each function should accept zero arguments and should perform its
+operation on the entire current buffer."
+  :version "32.1"
+  :type '(hook))
+
+(defun ical:unfold-undecoded-region (start end &optional buffer)
+  "Unfold an undecoded region in BUFFER between START and END.
+If omitted, BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation). RFC5545 specifies these whitespace
+characters to be a CR-LF sequence followed by a single space or
+tab character. Unfolding can only be done reliably before a
+region is decoded, since decoding potentially replaces CR-LF line
+endings.
+
+When `icalendar-parse-strictly' is non-nil, this function searches
+strictly for CR-LF sequences and will fail if they have already been
+replaced, so it should only be called with a region that has not yet
+been decoded. Otherwise, it also searches for folds containing
+Unix-style LF line endings, since these are common in real data."
+  (with-current-buffer (or buffer (current-buffer))
+    (with-restriction start end
+      (run-hooks 'ical:pre-unfolding-hook)
+      (goto-char (point-min))
+      ;; Testing reveals that a *significant* amount of real-world data
+      ;; does not use CR-LF line endings, even if it is otherwise
+      ;; OK. So unless we're explicitly parsing strictly, we allow the
+      ;; CR to be missing, as we do in `icalendar-unfold-region':
+      (let ((fold (if ical:parse-strictly (rx (seq "\r\n" (or " " "\t")))
+                    (rx (seq (zero-or-one "\r") "\n" (or " " "\t"))))))
+      (while (re-search-forward fold nil t)
+        (replace-match "" nil nil))))))
+
+(defun ical:unfold-region (start end &optional buffer)
+  "Unfold a region in BUFFER between START and END. If omitted,
+BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation).
+
+WARNING: Unfolding can only be done reliably before text is
+decoded, since decoding potentially replaces CR-LF line endings.
+Unfolding an already-decoded region could lead to unexpected
+results, such as displaying multibyte characters incorrectly,
+depending on the contents and the coding system used.
+
+This function attempts to do the right thing even if the region
+is already decoded. If it is still undecoded, it is better to
+call `icalendar-unfold-undecoded-region' directly instead, and
+decode it afterward."
+  ;; TODO: also make this a command so it can be run manually?
+  (with-current-buffer (or buffer (current-buffer))
+    (let ((was-multibyte enable-multibyte-characters)
+          (start-char (position-bytes start))
+          (end-char (position-bytes end)))
+      ;; we put the buffer in unibyte mode and later restore its
+      ;; previous state, so that if the buffer was already multibyte,
+      ;; any multibyte characters where line folds broke up their
+      ;; bytes can be reinterpreted:
+      (set-buffer-multibyte nil)
+      (with-restriction start-char end-char
+        (run-hooks 'ical:pre-unfolding-hook)
+        (goto-char (point-min))
+        ;; since we can't be sure that line folds have a leading CR
+        ;; in already-decoded regions, do the best we can:
+        (while (re-search-forward (rx (seq (zero-or-one "\r") "\n"
+                                           (or " " "\t")))
+                                  nil t)
+          (replace-match "" nil nil)))
+      ;; restore previous state, possibly reinterpreting characters:
+      (set-buffer-multibyte was-multibyte))))
+
+(defun ical:unfolded-buffer-from-region (start end &optional buffer)
+  "Create a new buffer with the same contents as the region between
+START and END (in BUFFER, if provided) and perform line unfolding
+in the new buffer with `icalendar-unfold-region'. That function
+can in some cases have undesirable effects; see its docstring. If
+BUFFER is visiting a file, it may be better to reload its
+contents from that file and perform line unfolding before
+decoding; see `icalendar-unfolded-buffer-from-file'. Returns the
+new buffer."
+  (let* ((old-buffer (or buffer (current-buffer)))
+         (contents (with-current-buffer old-buffer
+                     (buffer-substring start end)))
+         (uf-buffer (generate-new-buffer
+                     (concat (buffer-name old-buffer)
+                             "~UNFOLDED")))) ;; TODO: again, move to model=
ine?
+    (with-current-buffer uf-buffer
+      (insert contents)
+      (ical:unfold-region (point-min) (point-max))
+      ;; ensure we'll use CR-LF line endings on write, even if they weren't
+      ;; in the source data. The standard also says UTF-8 is the default
+      ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+      ;; is nil.
+      (setq buffer-file-coding-system
+            (if last-coding-system-used
+                (coding-system-change-eol-conversion last-coding-system-us=
ed
+                                                     'dos)
+              'prefer-utf-8-dos))
+      ;; inhibit auto-save-mode, which will otherwise create save
+      ;; files containing the unfolded data; these are probably
+      ;; not useful to the user and a nuisance when running tests:
+      (auto-save-mode -1))
+    uf-buffer))
+
+(defun ical:unfolded-buffer-from-buffer (buffer)
+  "Create a new buffer with the same contents as BUFFER and perform
+line unfolding with `icalendar-unfold-region'. That function can in
+some cases have undesirable effects; see its docstring. If BUFFER
+is visiting a file, it may be better to reload its contents from
+that file and perform line unfolding before decoding; see
+`icalendar-unfolded-buffer-from-file'. Returns the new buffer."
+  (with-current-buffer buffer
+    (ical:unfolded-buffer-from-region (point-min) (point-max) buffer)))
+
+(defun ical:find-unfolded-buffer-visiting (filename)
+  "Find an existing buffer visiting FILENAME that satisfies
+`icalendar-unfolded-p'."
+  ;; FIXME: I was previously using
+  ;;   (find-buffer-visiting filename #'ical:unfolded-p)
+  ;; for this, but found that it would sometimes return nil even when an
+  ;; unfolded buffer already existed for FILENAME, leading to buffers
+  ;; getting unfolded and parsed multiple times. Hence this kludge.
+  (catch 'unfolded
+    (let ((exp-name (expand-file-name filename)))
+      (dolist (buf (match-buffers "UNFOLDED"))
+        (when (and (equal exp-name (buffer-file-name buf))
+                   (ical:unfolded-p buf))
+          (throw 'unfolded buf))))))
+
+(defun ical:unfolded-buffer-from-file (filename &optional visit beg end)
+    "Return a buffer visiting FILENAME with unfolded lines.
+
+If an unfolded buffer is already visiting FILENAME, return
+it. Otherwise, create a new buffer with the contents of FILENAME and
+perform line unfolding with `icalendar-unfold-undecoded-region', then
+decode the buffer, setting an appropriate value for
+`buffer-file-coding-system', and return the new buffer. Optional
+arguments VISIT, BEG, END are as in `insert-file-contents'."
+    (unless (and (file-exists-p filename)
+                 (file-readable-p filename))
+      (error "File cannot be read: %s" filename))
+    ;; TODO: instead of messing with the buffer name, it might be more
+    ;; useful to keep track of the folding state in a variable and
+    ;; display it somewhere else in the mode line
+    (or (ical:find-unfolded-buffer-visiting filename)
+        (let ((uf-buffer
+               (generate-new-buffer
+                (concat " *UNFOLDED:" (file-name-nondirectory filename)))))
+          (with-current-buffer uf-buffer
+            (set-buffer-multibyte nil)
+            (insert-file-contents-literally filename visit beg end t)
+            (ical:unfold-undecoded-region (point-min) (point-max))
+            (set-buffer-multibyte t)
+            (decode-coding-inserted-region (point-min) (point-max) filenam=
e)
+            ;; ensure we'll use CR-LF line endings on write, even if they =
weren't
+            ;; in the source data. The standard also says UTF-8 is the def=
ault
+            ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-=
used
+            ;; is nil. FIXME: for some reason, this doesn't seem to run at=
 all!
+            (setq buffer-file-coding-system
+                  (if last-coding-system-used
+                      (coding-system-change-eol-conversion last-coding-sys=
tem-used
+                                                           'dos)
+                    'prefer-utf-8-dos))
+            ;; restore buffer name after renaming by set-visited-file-name:
+            (let ((bname (buffer-name)))
+              (set-visited-file-name filename t)
+              (rename-buffer bname))
+            ;; inhibit auto-save-mode, which will otherwise create save
+            ;; files containing the unfolded data; these are probably
+            ;; not useful to the user and a nuisance when running tests:
+            (auto-save-mode -1))
+        uf-buffer)))
+
+(defun ical:fold-region (begin end &optional use-tabs)
+  "Fold all content lines in the region longer than 75 octets.
+
+\"Folding\" means inserting a line break and a single space
+character at the beginning of the new line. If USE-TABS is
+non-nil, insert a tab character instead of a single space.
+
+RFC5545 specifies that lines longer than 75 *octets* (excluding
+the line-ending CR-LF sequence) must be folded, and allows that
+some implementations might fold lines in the middle of a
+multibyte character. This function takes care not to do that in a
+buffer where `enable-multibyte-characters' is non-nil, and only
+folds between character boundaries. If the buffer is in unibyte
+mode, however, and contains undecoded multibyte data, it may fold
+lines in the middle of a multibyte character."
+  ;; TODO: also make this a command so it can be run manually?
+  (save-excursion
+    (goto-char begin)
+    (when (not (bolp))
+      (let ((inhibit-field-text-motion t))
+        (beginning-of-line)))
+    (let ((bol (point))
+          (eol (make-marker))
+          (reg-end (make-marker))
+          (line-fold (if use-tabs "\n\t" "\n ")))
+      (set-marker reg-end end)
+      (while (< bol reg-end)
+        (let ((inhibit-field-text-motion t))
+          (end-of-line))
+        (set-marker eol (point))
+        (when (< 75 (- (position-bytes (marker-position eol))
+                       (position-bytes bol)))
+          (goto-char
+           ;; the max of 75 excludes the two CR-LF
+           ;; characters we're about to add:
+           (byte-to-position (+ 75 (position-bytes bol))))
+          (insert line-fold)
+          (set-marker eol (point)))
+        (setq bol (goto-char (1+ eol)))))))
+
+(defun ical:contains-folded-lines-p (&optional buffer)
+  "Determine whether BUFFER (default: current buffer) contains folded
+content lines that should be unfolded for parsing and display
+purposes. If it does, return the position at the end of the first fold."
+  (with-current-buffer (or buffer (current-buffer))
+    (save-excursion
+      (goto-char (point-min))
+      (re-search-forward (rx (seq line-start (or " " "\t")))
+                         nil t))))
+
+(defun ical:unfolded-p (&optional buffer)
+  "Return non-nil if BUFFER (default: current buffer) does not contain
+folded content lines that need to be unfolded for parsing and display
+purposes."
+  (not (ical:contains-folded-lines-p buffer)))
+
+(defun ical:contains-unfolded-lines-p (&optional buffer)
+  "Determine whether BUFFER (default: current buffer) contains long content
+lines that should be folded before saving or transmitting. If it
+does, return the position at the beginning of the first line that
+requires folding."
+  (with-current-buffer (or buffer (current-buffer))
+    (save-excursion
+      (goto-char (point-min))
+      (let ((bol (point))
+            (eol (make-marker)))
+        (catch 'unfolded-line
+          (while (< bol (point-max))
+            (let ((inhibit-field-text-motion t))
+              (end-of-line))
+            (set-marker eol (point))
+            ;; the max of 75 excludes the two CR-LF characters
+            ;; after position eol:
+            (when (< 75 (- (position-bytes (marker-position eol))
+                           (position-bytes bol)))
+              (throw 'unfolded-line bol))
+            (setq bol (goto-char (1+ eol))))
+          nil)))))
+
+(defun ical:folded-p (&optional buffer)
+  "Return non-nil if BUFFER (default: current buffer) does not contain long
+content lines that need to be folded before saving or transmitting."
+  (not (ical:contains-unfolded-lines-p buffer)))
+
+
+;;; Pre-parsing cleanup
+;;
+;; The following functions are based on observed syntax errors in
+;; real-world data and can help clean up such data before parsing.
+;; More functions can be added here based on user feedback.
+(defcustom ical:pre-parsing-hook nil
+  "Hook run by `icalendar-parse' before parsing iCalendar data.
+
+If you routinely receive iCalendar data in an incorrect format, you can
+add functions to this hook which clean up that data before parsing is
+attempted. The functions in this hook will be run after the iCalendar
+data has been \"unfolded\" but before parsing begins. (If you need to
+clean up data before unfolding happens, see
+`icalendar-pre-unfolding-hook'.)
+
+Each function should accept zero arguments and should perform its
+operation on the entire current buffer."
+  :version "32.1"
+  :type '(hook)
+  :options '(ical:fix-blank-lines
+             ical:fix-hyphenated-dates
+             ical:fix-missing-mailtos))
+
+(defun ical:fix-blank-lines ()
+  "Remove blank lines.
+This function is intended to be used from `icalendar-pre-parsing-hook',
+which see."
+  (goto-char (point-min))
+  (while (re-search-forward (rx "\n" (zero-or-more space) line-end)
+                            nil t)
+    (replace-match "" nil nil)))
+
+(defun ical:fix-hyphenated-dates ()
+  "Correct dates in \"YYYY-MM-DD...\" format to \"YYYYMMDD...\" format.
+This function is intended to be used from `icalendar-pre-parsing-hook',
+which see."
+  (goto-char (point-min))
+  (while (re-search-forward
+          (rx line-start
+              (or "COMPLETED" "DTEND" "DUE" "DTSTART" "RECURRENCE-ID"
+                  "EXDATE" "RDATE" "CREATED" "DTSTAMP" "LAST-MODIFIED")
+              (zero-or-more ical:other-param-safe)
+              ":")
+          nil t)
+    (unless (looking-at-p (rx (or ical:date ical:date-time)))
+      (while (re-search-forward ; exdate, rdate allow lists
+              (rx (group-n 1 (=3D 4 digit))
+                  "-"
+                  (group-n 2 (=3D 2 digit))
+                  "-"
+                  (group-n 3 (=3D 2 digit)))
+              (line-end-position) t)
+      (replace-match "\\1\\2\\3" nil nil)))))
+
+(defun ical:fix-missing-mailtos ()
+  "Insert \"mailto:\" when it is missing before email addresses.
+This function is intended to be used from `icalendar-pre-parsing-hook',
+which see."
+  ;; fix property values in properties that require an address:
+  (goto-char (point-min))
+  (while (re-search-forward
+          (rx line-start (or "ORGANIZER" "ATTENDEE")
+              (zero-or-more ical:other-param-safe) ":")
+          nil t)
+   (unless (looking-at-p (rx ical:cal-address))
+     (when (looking-at
+            (rx
+             ;; match local part of mail address: all the characters
+             ;; allowed after a URI scheme, *except*
+             ;; ?@ (so we can match that after) and
+             ;; ?: (in case we're looking at a non-"mailto:" scheme)
+             (group-n 1
+               (one-or-more
+                (any "A-Za-z0-9" ?- ?. ?_ ?~ ?/ ?? ?# ?\[ ?\] ?! ?$ ?& ?'
+                     ?\( ?\) ?* ?+ ?, ?\; ?=3D ?%)))
+             "@"))
+       (when (or (< (length (match-string 0)) 7)
+                 (not (equal "mailto:"
+                             (substring (downcase (match-string 0)) 0 7))))
+         (replace-match "mailto:\\1" nil nil nil 1)))))
+
+  ;; fix parameter values in parameters that require an address:
+  (goto-char (point-min))
+  (while (re-search-forward
+          (rx line-start ical:name
+              (zero-or-more icalendar-other-param-safe)
+              ";"
+              (or "DELEGATED-FROM" "DELEGATED-TO" "MEMBER" "SENT-BY")
+              "=3D")
+          nil t)
+    (unless (looking-at-p (rx ical:cal-address))
+      (while ; DELEGATED* params accept lists
+          (looking-at
+           (rx
+            ?\" ; values of these params must always be quoted
+            (group-n 1 ; matches local part of mail address as above
+              (one-or-more
+               (any "A-Za-z0-9" ?- ?. ?_ ?~ ?/ ?? ?# ?\[ ?\] ?! ?$ ?& ?'
+                    ?\( ?\) ?* ?+ ?, ?=3D ?%)))
+            "@"
+            (zero-or-more (not ?\"))
+            ?\"
+            (zero-or-one ",")))
+        (when (or (< (length (match-string 1)) 7)
+                  (not (equal "mailto:"
+                              (substring (downcase (match-string 1)) 0 7))=
))
+          (replace-match "mailto:\\1" nil nil nil 1))
+        (goto-char (match-end 0))))))
+
+
+;; Parsing-related code starts here. All the parsing code assumes that
+;; content lines have already been unfolded.
+
+;;;; Error handling:
+
+;; Errors at the parsing stage:
+;; e.g. value does not match expected regex
+(define-error 'ical:parse-error "Could not parse iCalendar data" 'ical:err=
or)
+
+(cl-defun ical:signal-parse-error (msg &key (buffer (current-buffer))
+                                       (position (point))
+                                       (severity 2)
+                                       (line (line-number-at-pos position))
+                                       column restart-at)
+  (signal 'ical:parse-error
+              (list :message msg
+                    :line line
+                    :column column
+                    :severity severity
+                    :position position
+                    :buffer buffer
+                    :restart-at restart-at)))
+
+(defun ical:handle-parse-error (err-data &optional skip-msg err-buffer)
+  (let* ((err-sym (car err-data))
+         (err-plist (cdr err-data))
+         (buf (plist-get err-plist :buffer))
+         (restart-pos (plist-get err-plist :restart-at))
+         (new-msg
+          (concat (plist-get err-plist :message)
+                  "..."
+                  (cond (skip-msg skip-msg)
+                        (restart-pos (format "skipping to %d" restart-pos))
+                        (t "skipping")))))
+    (setq err-plist (plist-put err-plist :message new-msg))
+    (setq err-plist (plist-put err-plist :severity 1))
+    (ical:handle-generic-error (cons err-sym err-plist) err-buffer)
+    (when restart-pos
+      (with-current-buffer buf
+        (goto-char restart-pos)))))
+
+;; Errors at the printing stage:
+;; e.g. default print function doesn't know how to print value
+(define-error 'ical:print-error "Unable to print iCalendar data" 'ical:err=
or)
+
+(cl-defun ical:signal-print-error (msg &key (severity 2) node)
+  (signal 'ical:print-error
+          (list :message msg
+                :node node
+                :buffer (ical:ast-node-meta-get :buffer node)
+                :severity severity
+                :position (ical:ast-node-meta-get :begin node))))
+
+(defun ical:handle-print-error (err-data &optional skip-msg err-buffer)
+  (let* ((err-sym (car err-data))
+         (err-plist (cdr err-data))
+         (new-msg (concat (plist-get err-plist :message)
+                          "..."
+                          (or skip-msg "skipping"))))
+    (setq err-plist (plist-put err-plist :message new-msg))
+    (setq err-plist (plist-put err-plist :severity 1))
+    (ical:handle-generic-error (cons err-sym err-plist) err-buffer))
+  (ical:handle-generic-error err-data err-buffer))
+
+;;;; Some utilities:
+(defun ical:parse-from-string (type s)
+  "Parse string S to an iCalendar syntax node of type TYPE."
+  (with-temp-buffer
+    (insert s)
+    (goto-char (point-min))
+    ;; TODO: unfold?
+    (cond ((ical:component-type-symbol-p type)
+           (ical:parse-component (point-max)))
+          ((ical:property-type-symbol-p type)
+           (ical:parse-property (point-max)))
+          ((ical:param-type-symbol-p type)
+           (unless (looking-at-p ";")
+             (insert ";")
+             (backward-char))
+           (ical:parse-params (point-max)))
+          ((ical:value-type-symbol-p type)
+           (ical:parse-value-node type (point-max)))
+          (t
+           (error "Don't know how to parse type %s" type)))))
+
+(defun ical:parse-one-of (types limit)
+  "Parse a value of one of the TYPES, which should be a list of type
+symbols, from point up to LIMIT. For each type in TYPES, the
+parser function associated with that type will be called at
+point. The return value of the first successful parser function
+is returned. If none of the parser functions are able to parse a
+value, an `icalendar-parse-error' is signaled."
+  (let* ((value nil)
+         (start (point))
+         (type (car types))
+         (parser (get type 'ical:value-parser))
+         (rest (cdr types)))
+    (while (and parser (not value))
+      (condition-case nil
+          (setq value (funcall parser limit))
+        (ical:parse-error
+         ;; value of this type not found, so try again:
+         (goto-char start)
+         (setq type (car rest)
+               rest (cdr rest)
+               parser (get type 'ical:value-parser)))))
+    (unless value
+      (ical:signal-parse-error
+       (format "Unable to parse any of %s between %d and %d" types start l=
imit)
+       :position start))
+    value))
+
+(defun ical:read-list-with (reader string
+                            &optional value-regex separators omit-nulls tr=
im)
+  "Read a list of values from STRING with READER.
+
+READER should be a reader function that accepts a single string argument.
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string'.
+SEPARATORS defaults to \"[^\\][,;]\". TRIM defaults to matching a
+double quote character.
+
+VALUE-REGEX should be a regular expression if READER assumes that
+individual substrings in STRING have previously been matched
+against this regex. In this case, each value in S is placed in a
+temporary buffer and the match against VALUE-REGEX is performed
+before READER is called."
+  (let* ((wrapped-reader
+           (if (not value-regex)
+               ;; no need for temp buffer:
+               reader
+             ;; match the regex in a temp buffer before calling reader:
+             (lambda (s)
+               (with-temp-buffer
+                 (insert s)
+                 (goto-char (point-min))
+                 (unless (looking-at value-regex)
+                   (ical:signal-parse-error
+                    (format "Expected list of values matching '%s'" value-=
regex)))
+                 (funcall reader (match-string 0))))))
+         (seps (or separators "[^\\][,;]"))
+         (trm (or trim "\""))
+         (raw-values (split-string string seps omit-nulls trm)))
+
+    (unless (functionp reader)
+      (signal 'ical:parser-error
+              (list (format "`%s' is not a reader function" reader))))
+
+    (mapcar wrapped-reader raw-values)))
+
+(defun ical:read-list-of (type string
+                          &optional separators omit-nulls trim)
+  "Read a list of values of type TYPE from STRING.
+
+TYPE should be a value type symbol. The reader function
+associated with that type will be called to read the successive
+values in STRING, and the values will be returned as a list of
+syntax nodes.
+
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string' and
+will be passed on, if provided, to `icalendar-read-list-with'."
+  (let* ((reader (lambda (s) (ical:read-value-node type s)))
+         (val-regex (rx-to-string (get type 'ical:value-rx))))
+    (ical:read-list-with reader string val-regex
+                         separators omit-nulls trim)))
+
+(defun ical:list-of-p (list type)
+  "Returns non-nil if each value in LIST satisfies TYPE according to
+`cl-typep'"
+  (seq-every-p (lambda (val) (cl-typep val type)) list))
+
+(defun ical:default-value-printer (val)
+  "Default printer for a *single* property or parameter value.
+
+If VAL is a string, just return it unchanged.
+
+Otherwise, VAL should be a syntax node representing a value. In
+that case, return the original string value if another was
+substituted at parse time, or look up the printer function for
+the node's type and call it on the value inside the node.
+
+For properties and parameters that only allow a single value,
+this function should be a sufficient value printer. It is not
+sufficient for those that allow lists of values, or which have
+other special requirements like quoting or escaping."
+  (cond ((stringp val) val)
+        ((and (ical:ast-node-p val)
+              (get (ical:ast-node-type val) 'ical:value-printer))
+         (or (ical:ast-node-meta-get :original-value val)
+             (let* ((stored-value (ical:ast-node-value val))
+                    (type (ical:ast-node-type val))
+                    (printer (get type 'ical:value-printer)))
+               (funcall printer stored-value))))
+        ;; TODO: other cases to make things easy?
+        ;; e.g. symbols print as their names?
+        (t (ical:signal-print-error
+            (format "Don't know how to print value: %s" val)))))
+
+
+;;; Section 3.1: Content lines
+
+;; Regexp constants for parsing:
+
+;; In the following regexps and define-* declarations, because
+;; Emacs does not have named groups, we observe the following
+;; convention so that the regexps can be combined in sensible ways:
+;;
+;; - Groups 1 through 5 are reserved for the highest-level regexes
+;;   created by define-param, define-property and define-component and
+;;   used in the match-* functions. Group 1 always represents a 'key'
+;;   (e.g. param or property name), group 2 always represents a
+;;   correctly parsed value for that key, and group 3 (if matched) an
+;;   invalid or unknown value.
+;;
+;;   Groups 4 and 5 are reserved for other information in these
+;;   highest-level regexes, such as the parameter string between a
+;;   property name and its value, or unrecognized values allowed by
+;;   the standard and required to be treated like a default value.
+;;
+;; - Groups 6 through 10 are currently unused
+;; - Groups 11 through 20 are reserved for significant sub-expressions
+;;   of individual value expressions, e.g. the number of weeks in a
+;;   duration value. The various read-* functions rely on these groups
+;;   when converting iCalendar data to Elisp data structures.
+
+(rx-define ical:iana-token
+  (one-or-more (any "A-Za-z0-9" "-")))
+
+(rx-define ical:x-name
+  (seq "X-"
+      (zero-or-one (>=3D 3 (any "A-Za-z0-9")) "-") ; Vendor ID
+      (one-or-more (any "A-Za-z0-9" "-")))) ; Name
+
+(rx-define ical:name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:crlf
+  (seq #x12 #xa))
+
+(rx-define ical:control
+  ;; All the controls except HTAB
+  (any (#x00 . #x08) (#x0A . #x1F) #x7F))
+
+;; TODO: double check that "nonascii" class actually corresponds to
+;; the range in the standard
+(rx-define ical:safe-char
+  ;; Any character except ical:control, ?\", ?\;, ?:, ?,
+  (any #x09 #x20 #x21  (#x23 . #x2B) (#x2D . #x39) (#x3C . #x7E) nonascii))
+
+(rx-define ical:qsafe-char
+  ;; Any character except ical:control and ?\"
+  (any #x09 #x20 #x21 (#x23 . #x7E) nonascii))
+
+(rx-define ical:quoted-string
+  (seq ?\" (zero-or-more ical:qsafe-char) ?\"))
+
+(rx-define ical:paramtext
+  ;; RFC5545 allows *zero* characters here, but that would mean we could
+  ;; have parameters like ;FOO=3D;BAR=3D"somethingelse", and what would th=
en
+  ;; be the value of FOO? I see no reason to allow this and it breaks
+  ;; parameter parsing so I have required at least one char here
+  (one-or-more ical:safe-char))
+
+(rx-define ical:param-name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:param-value
+  (or ical:paramtext ical:quoted-string))
+
+(rx-define ical:value-char
+  (any #x09 #x20 (#x21 . #x7E) nonascii))
+
+(rx-define ical:value
+  (zero-or-more ical:value-char))
+
+;; some helpers for brevity, not defined in the standard:
+(rx-define ical:comma-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?, item-rx))))
+
+(rx-define ical:semicolon-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?\; item-rx))))
+
+
+;;; Section 3.3: Property Value Data Types
+
+;; Note: These definitions are here (out of order with respect to the
+;; standard) because a few of them are already required for property
+;; parameter definitions (section 3.2) below.
+
+(defconst ical:value-types nil ;; populated by define-type
+  "Alist mapping value type strings in `icalendar-valuetypeparam'
+parameters to type symbols defined with `icalendar-define-type'")
+
+(defun ical:read-value-node (type s)
+  "Read an iCalendar value of type TYPE from string S to a syntax node.
+Returns a syntax node containing the value."
+  (let ((reader (get type 'ical:value-reader)))
+    (ical:make-ast-node type (list :value (funcall reader s)))))
+
+(defun ical:parse-value-node (type limit)
+  "Parse an iCalendar value of type TYPE from point up to LIMIT.
+Returns a syntax node containing the value."
+  (let ((value-regex (rx-to-string (get type 'ical:value-rx))))
+
+    (unless (re-search-forward value-regex limit t)
+      (ical:signal-parse-error
+       (format "No %s value between %d and %d" type (point) limit)))
+
+    (let ((begin (match-beginning 0))
+          (end (match-end 0))
+          (node (ical:read-value-node type (match-string 0))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      (ical:ast-node-meta-set node :begin begin)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-value-node (node)
+  "Serialize an iCalendar syntax node containing a value to a string."
+  (let* ((type (ical:ast-node-type node))
+         (value-printer (get type 'ical:value-printer)))
+    (funcall value-printer (ical:ast-node-value node))))
+
+(defun ical:printable-value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a printable
+iCalendar value type, i.e., a type for a property or parameter
+value defined by `icalendar-define-type' which has a print
+name (mainly for use in `icalendar-valuetypeparam' parameters).
+
+This means that SYMBOL must both satisfy
+`icalendar-value-type-symbol-p' and be associated with a print
+name in `icalendar-value-types'."
+  (and (ical:value-type-symbol-p symbol)
+       (rassq symbol ical:value-types)))
+
+(defun ical:value-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a value type."
+  (and (ical:ast-node-p node)
+       (ical:value-type-symbol-p (ical:ast-node-type node))))
+
+;;;; 3.3.1 Binary
+;; from https://www.rfc-editor.org/rfc/rfc4648#section-4:
+(rx-define ical:base64char
+  (any (?A . ?Z) (?a . ?z) (?0 . ?9) ?+ ?/))
+
+(ical:define-type ical:binary "BINARY"
+   "Type for Binary values.
+
+The parsed and printed representations are the same: a string of characters
+representing base64-encoded data."
+   '(and string (satisfies ical:match-binary-value))
+   (seq (zero-or-more (=3D 4 ical:base64char))
+        (zero-or-one (or (seq (=3D 2 ical:base64char) "=3D=3D")
+                         (seq (=3D 3 ical:base64char) "=3D"))))
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.1")
+
+;;;; 3.3.2 Boolean
+(defun ical:read-boolean (s)
+  "Read an `icalendar-boolean' value from a string S.
+S should be a match against rx `icalendar-boolean'."
+  (let ((upcased (upcase s)))
+    (cond ((equal upcased "TRUE") t)
+          ((equal upcased "FALSE") nil)
+          (t (ical:signal-parse-error
+              (format "Expected 'TRUE' or 'FALSE'; got %s" s))))))
+
+(defun ical:print-boolean (b)
+  "Serialize an `icalendar-boolean' value B to a string."
+    (if b "TRUE" "FALSE"))
+
+(ical:define-type ical:boolean "BOOLEAN"
+   "Type for Boolean values.
+
+When printed, either the string 'TRUE' or 'FALSE'.
+When read, either t or nil."
+   'boolean
+   (or "TRUE" "FALSE")
+   :reader ical:read-boolean
+   :printer ical:print-boolean
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.2")
+
+;;;; 3.3.3 Calendar User Address
+;; Defined with URI, below
+
+;; Dates and Times:
+
+;;;; 3.3.4 Date
+(cl-deftype ical:numeric-year () '(integer 0 9999))
+(cl-deftype ical:numeric-month () '(integer 1 12))
+(cl-deftype ical:numeric-monthday () '(integer 1 31))
+
+(rx-define ical:year
+  (=3D 4 digit))
+
+(rx-define ical:month
+  (=3D 2 digit))
+
+(rx-define ical:mday
+  (=3D 2 digit))
+
+(defun ical:read-date (s)
+  "Read an `icalendar-date' from a string S.
+S should be a match against rx `icalendar-date'."
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8))))
+    (list month day year)))
+
+(defun ical:print-date (d)
+  "Serialize an `icalendar-date' to a string."
+  (format "%04d%02d%02d"
+          (calendar-extract-year d)
+          (calendar-extract-month d)
+          (calendar-extract-day d)))
+
+(ical:define-type ical:date "DATE"
+   "Type for Date values.
+
+When printed, a date is a string of digits in YYYYMMDD format.
+
+When read, a date is a list (MONTH DAY YEAR), with the three
+values being integers in the appropriate ranges; see calendar.el
+for functions that work with this representation."
+   '(and (satisfies calendar-date-is-valid-p))
+   (seq ical:year ical:month ical:mday)
+   :reader ical:read-date
+   :printer ical:print-date
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.4")
+
+;;;; 3.3.12 Time
+;; (Defined here so that ical:time RX can be used in ical:date-time)
+(cl-deftype ical:numeric-hour () '(integer 0 23))
+(cl-deftype ical:numeric-minute () '(integer 0 59))
+(cl-deftype ical:numeric-second () '(integer 0 60)) ; 60 represents a leap=
 second
+
+(declare-function ical:make-date-time "icalendar-utils")
+
+(defun ical:read-time (s)
+  "Read an `icalendar-time' from a string S.
+S should be a match against rx `icalendar-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular re=
quire
+  (let ((hour (string-to-number (substring s 0 2)))
+        (minute (string-to-number (substring s 2 4)))
+        (second (string-to-number (substring s 4 6)))
+        (utcoffset (if (and (length=3D s 7)
+                            (equal "Z" (substring s 6 7)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :zone utcoffset)))
+
+(defun ical:print-time (time)
+  "Serialize an `icalendar-time' to a string."
+  (format "%02d%02d%02d%s"
+          (decoded-time-hour time)
+          (decoded-time-minute time)
+          (decoded-time-second time)
+          (if (eql 0 (decoded-time-zone time))
+              "Z" "")))
+
+(defun ical:-decoded-time-p (val)
+  "Return non-nil if VAL is a valid decoded *time*.
+This predicate does not check date-related values in VAL;
+for that, see `icalendar--decoded-date-time-p'."
+  (and (listp val)
+       (length=3D val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(ical:define-type ical:time "TIME"
+  "Type for Time values.
+
+When printed, a time is a string of six digits HHMMSS, followed
+by the letter 'Z' if it is in UTC.
+
+When read, a time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values. When
+read, the DAY, MONTH, YEAR, and DOW fields are nil, and these
+fields and DST are ignored when printed."
+  '(satisfies ical:-decoded-time-p)
+  (seq (=3D 6 digit) (zero-or-one ?Z))
+  :reader ical:read-time
+  :printer ical:print-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.12")
+
+;;;; 3.3.5 Date-Time
+(defun ical:-decoded-date-time-p (val)
+  (and (listp val)
+       (length=3D val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-day val) 'ical:numeric-monthday)
+       (cl-typep (decoded-time-month val) 'ical:numeric-month)
+       (cl-typep (decoded-time-year val) 'ical:numeric-year)
+       (calendar-date-is-valid-p (list (decoded-time-month val)
+                                       (decoded-time-day val)
+                                       (decoded-time-year val)))
+       ;; FIXME: the weekday slot value should be automatically
+       ;; calculated from month, day, and year, like:
+       ;;   (calendar-day-of-week (list month day year))
+       ;; Although `ical:read-date-time' does this correctly,
+       ;; `make-decoded-time' does not. Thus we can't use
+       ;; `make-decoded-time' to construct valid `ical:date-time'
+       ;; values unless this check is turned off,
+       ;; which means it's annoying to write tests and anything
+       ;; that uses cl-typecase to dispatch on values created by
+       ;; `make-decoded-time':
+       ;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(defun ical:read-date-time (s)
+  "Read an `icalendar-date-time' from a string S.
+S should be a match against rx `icalendar-date-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular re=
quires
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8)))
+        ;; "T" is index 8
+        (hour (string-to-number (substring s 9 11)))
+        (minute (string-to-number (substring s 11 13)))
+        (second (string-to-number (substring s 13 15)))
+        (utcoffset (if (and (length=3D s 16)
+                            (equal "Z" (substring s 15 16)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :day day
+                         :month month
+                         :year year
+                         :zone utcoffset)))
+
+(defun ical:print-date-time (datetime)
+  "Serialize an `icalendar-date-time' to a string."
+  (format "%04d%02d%02dT%02d%02d%02d%s"
+          (decoded-time-year datetime)
+          (decoded-time-month datetime)
+          (decoded-time-day datetime)
+          (decoded-time-hour datetime)
+          (decoded-time-minute datetime)
+          (decoded-time-second datetime)
+          (if (ical:date-time-is-utc-p datetime)
+              "Z" "")))
+
+(defun ical:date-time-is-utc-p (datetime)
+  "Return non-nil if DATETIME is in UTC time"
+  (let ((offset (decoded-time-zone datetime)))
+    (and offset (=3D 0 offset))))
+
+(ical:define-type ical:date-time "DATE-TIME"
+   "Type for Date-Time values.
+
+When printed, a date-time is a string of digits like:
+  YYYYMMDDTHHMMSS
+where the 'T' is literal, and separates the date string from the
+time string.
+
+When read, a date-time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values."
+   '(satisfies ical:-decoded-date-time-p)
+  (seq ical:date ?T ical:time)
+  :reader ical:read-date-time
+  :printer ical:print-date-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5")
+
+;;;; 3.3.6 Duration
+(rx-define ical:dur-second
+  (seq (group-n 19 (one-or-more digit)) ?S))
+
+(rx-define ical:dur-minute
+  (seq (group-n 18 (one-or-more digit)) ?M (zero-or-one ical:dur-second)))
+
+(rx-define ical:dur-hour
+  (seq (group-n 17 (one-or-more digit)) ?H (zero-or-one ical:dur-minute)))
+
+(rx-define ical:dur-day
+  (seq (group-n 16 (one-or-more digit)) ?D))
+
+(rx-define ical:dur-week
+  (seq (group-n 15 (one-or-more digit)) ?W))
+
+(rx-define ical:dur-time
+  (seq ?T (or ical:dur-hour ical:dur-minute ical:dur-second)))
+
+(rx-define ical:dur-date
+  (seq ical:dur-day (zero-or-one ical:dur-time)))
+
+;; TODO: This function already exists! Super: replace with iso8601-parse-d=
uration
+(defun ical:read-dur-value (s)
+  "Read an `icalendar-dur-value' from a string S.
+S should be a match against rx `icalendar-dur-value'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((sign (if (equal (match-string 20) "-") -1 1)))
+    (if (match-string 15)
+        ;; dur-value specified in weeks, so just return an integer:
+        (* sign (string-to-number (match-string 15)))
+      ;; otherwise, make a time delta from the other units:
+      (let* ((days (match-string 16))
+             (ndays (* sign (if days (string-to-number days) 0)))
+             (hours (match-string 17))
+             (nhours (* sign (if hours (string-to-number hours) 0)))
+             (minutes (match-string 18))
+             (nminutes (* sign (if minutes (string-to-number minutes) 0)))
+             (seconds (match-string 19))
+             (nseconds (* sign (if seconds (string-to-number seconds) 0))))
+        (make-decoded-time :second nseconds :minute nminutes :hour nhours
+                           :day ndays)))))
+
+(defun ical:print-dur-value (dur)
+  "Serialize an `icalendar-dur-value' to a string"
+  (if (integerp dur)
+      ;; dur-value specified in weeks can only contain weeks:
+      (format "%sP%dW" (if (< dur 0) "-" "") (abs dur))
+    ;; otherwise, show all the time units present:
+    (let* ((days+- (or (decoded-time-day dur) 0))
+           (hours+- (or (decoded-time-hour dur) 0))
+           (minutes+- (or (decoded-time-minute dur) 0))
+           (seconds+- (or (decoded-time-second dur) 0))
+           ;; deal with the possibility of mixed positive and negative val=
ues
+           ;; in a time delta list:
+           (sum (+ seconds+-
+                   (* 60 minutes+-)
+                   (* 60 60 hours+-)
+                   (* 60 60 24 days+-)))
+           (abssum (abs sum))
+           (days (/ abssum (* 60 60 24)))
+           (sumnodays (mod abssum (* 60 60 24)))
+           (hours (/ sumnodays (* 60 60)))
+           (sumnohours (mod sumnodays (* 60 60)))
+           (minutes (/ sumnohours 60))
+           (seconds (mod sumnohours 60))
+           (sign (when (< sum 0) "-"))
+           (time-sep (unless (and (zerop hours) (zerop minutes) (zerop sec=
onds))
+                       "T")))
+      (concat sign
+              "P"
+              (unless (zerop days) (format "%dD" days))
+              time-sep
+              (unless (zerop hours) (format "%dH" hours))
+              (unless (zerop minutes) (format "%dM" minutes))
+              (unless (zerop seconds) (format "%dS" seconds))))))
+
+(defun ical:-time-delta-p (val)
+  (and (listp val)
+       (length=3D val 9)
+       (let ((seconds (decoded-time-second val))
+             (minutes (decoded-time-minute val))
+             (hours (decoded-time-hour val))
+             (days (decoded-time-day val))) ; other values in list are ign=
ored
+         (or (and (integerp seconds) (not (zerop seconds)))
+             (and (integerp minutes) (not (zerop minutes)))
+             (and (integerp hours) (not (zerop hours)))
+             (and (integerp days) (not (zerop days)))))))
+
+(ical:define-type ical:dur-value "DURATION"
+  "Type for Duration values.
+
+When printed, a duration is a string containing:
+  - possibly a +/- sign
+  - the letter 'P'
+  - one or more sequences of digits followed by a letter representing a un=
it
+    of time: 'W' for weeks, 'D' for days, etc. Units smaller than a day are
+    separated from days by the letter 'T'. If a duration is specified in w=
eeks,
+    other units of time are not allowed.
+
+For example, a duration of 15 days, 5 hours, and 20 seconds would be print=
ed:
+   P15DT5H0M20S
+and a duration of 7 weeks would be printed:
+   P7W
+
+When read, a duration is either an integer, in which case it
+represents a number of weeks, or a decoded time, in which case it
+must represent a time delta in the sense of `decoded-time-add'.
+Note that, in the time delta representation, units of time longer
+than a day are not supported and will be ignored if present.
+
+This type is named `icalendar-dur-value' rather than
+`icalendar-duration' for consistency with the text of RFC5545 and
+so that its name does not collide with the symbol for the
+`DURATION' property."
+  '(or integer (satisfies ical:-time-delta-p))
+  ;; Group 15: weeks
+  ;; Group 16: days
+  ;; Group 17: hours
+  ;; Group 18: minutes
+  ;; Group 19: seconds
+  ;; Group 20: sign
+  (seq
+   (group-n 20 (zero-or-one (or ?+ ?-)))
+   ?P
+   (or ical:dur-date ical:dur-time ical:dur-week))
+  :reader ical:read-dur-value
+  :printer ical:print-dur-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.6")
+
+
+;;;; 3.3.7 Float
+(ical:define-type ical:float "FLOAT"
+   "Type for Float values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits,
+and possibly a decimal. When read, an Elisp float value."
+   '(float * *)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit)
+    (zero-or-one (seq ?. (one-or-more digit))))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.7")
+
+;;;; 3.3.8 Integer
+(ical:define-type ical:integer "INTEGER"
+   "Type for Integer values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits.
+When read, an Elisp integer value between -2147483648 and 2147483647."
+   '(integer -2147483648 2147483647)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8")
+
+;;;; 3.3.9 Period
+(defsubst ical:period-start (period)
+  "Return the `icalendar-date-time' which marks the start of PERIOD."
+  (car period))
+
+(defsubst ical:period--defined-end (period)
+  "Return the `icalendar-date-time' which marks the end of PERIOD, or nil."
+  (cadr period))
+
+
+(declare-function ical:date/time-add-duration "icalendar-utils")
+
+(defun ical:period-end (period &optional vtimezone)
+  "Return the `icalendar-date-time' which marks the end of PERIOD.
+If the end is not explicitly specified, it will be computed from the
+period's start and duration.  VTIMEZONE, if given, should be the
+`icalendar-vtimezone' in which to compute the end time."
+  (require 'icalendar-utils) ; for date/time-add-duration; avoids circular=
 import
+  (or (ical:period--defined-end period)
+      ;; compute end from duration and cache it:
+      (setf (cadr period)
+            (ical:date/time-add-duration
+             (ical:period-start period)
+             (ical:period-dur-value period)
+             vtimezone))))
+
+(defsubst ical:period-dur-value (period)
+  "Return the `icalendar-dur-value' which gives the length of PERIOD, or n=
il."
+  (caddr period))
+
+(defun ical:period-p (val)
+  (and (listp val)
+       (length=3D val 3)
+       (cl-typep (ical:period-start val) 'ical:date-time)
+       (cl-typep (ical:period-end val) '(or null ical:date-time))
+       (cl-typep (ical:period-dur-value val) '(or null ical:dur-value))))
+
+(cl-defun ical:make-period (start &key end duration)
+  "Make an `icalendar-period' value.
+
+START and END (if given) should be `icalendar-date-time' values.
+DURATION, if given, should be an `icalendar-dur-value'. It is an error
+to pass both END and DURATION, or neither."
+  (when (and end duration)
+    (signal 'wrong-type-argument (list end duration)))
+  (unless (or end duration)
+    (signal 'wrong-type-argument (list end duration)))
+  (list start end duration))
+
+;; TODO: This function already exists! Super: replace with iso8601-parse-i=
nterval
+(defun ical:read-period (s)
+  "Read an `icalendar-period' from a string S.
+S should have been matched against rx `icalendar-period'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((start (ical:read-date-time (match-string 11)))
+        (end (when (match-string 12) (ical:read-date-time (match-string 12=
))))
+        (dur (when (match-string 13) (ical:read-dur-value (match-string 13=
)))))
+    (ical:make-period start :end end :duration dur)))
+
+(defun ical:print-period (per)
+  "Serialize an `icalendar-period' to a string"
+  (let ((start (ical:period-start per))
+        (end (ical:period-end per))
+        (dur (ical:period-dur-value per)))
+    (concat (ical:print-date-time start)
+            "/"
+            (if dur
+                (ical:print-dur-value dur)
+              (ical:print-date-time end)))))
+
+(ical:define-type ical:period "PERIOD"
+   "Type for Period values.
+
+A period of time is specified as a starting date-time together
+with either an explicit date-time as its end, or a duration which
+gives its length and implicitly marks its end.
+
+When printed, the starting date-time is separated from the end or
+duration by a / character.
+
+When read, a period is represented as a list (START END DUR), where
+START is an `icalendar-date-time', END is either an
+`icalendar-date-time' or nil, and DUR is either an `icalendar-dur-value'
+or nil. See the functions `icalendar-make-period',
+`icalendar-period-start', `icalendar-period-end', and
+`icalendar-period-dur-value' to work with period values."
+  '(satisfies ical:period-p)
+  (seq (group-n 11 ical:date-time)
+       "/"
+       (or (group-n 12 ical:date-time)
+           (group-n 13 ical:dur-value)))
+  :reader ical:read-period
+  :printer ical:print-period
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.9")
+
+;;;; 3.3.10 Recurrence rules:
+(rx-define ical:freq
+   (or "SECONDLY" "MINUTELY" "HOURLY" "DAILY" "WEEKLY" "MONTHLY" "YEARLY"))
+
+(rx-define ical:weekday
+   (or "SU" "MO" "TU" "WE" "TH" "FR" "SA"))
+
+(rx-define ical:ordwk
+  (** 1 2 digit)) ; 1 to 53
+
+(rx-define ical:weekdaynum
+  ;; Group 19: Week num, if present
+  ;; Group 20: week day abbreviation
+   (seq (zero-or-one
+         (group-n 19 (seq (zero-or-one (or ?+ ?-))
+                          ical:ordwk)))
+        (group-n 20 ical:weekday)))
+
+(rx-define ical:weeknum
+  (seq (zero-or-one (or ?+ ?-))
+       ical:ordwk))
+
+(rx-define ical:monthdaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 31
+
+(rx-define ical:monthnum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 12
+
+(rx-define ical:yeardaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 3 digit))) ; 1 to 366
+
+(defconst ical:weekday-numbers
+  '(("SU" . 0)
+    ("MO" . 1)
+    ("TU" . 2)
+    ("WE" . 3)
+    ("TH" . 4)
+    ("FR" . 5)
+    ("SA" . 6))
+  "Alist mapping two-letter weekday abbreviations to numbers 0 to 6.
+Weekday abbreviations in recurrence rule parts are translated to
+and from numbers for compatibility with calendar-* and
+decoded-time-* functions.")
+
+(defun ical:read-weekdaynum (s)
+  "Read a weekday abbreviation to a number.
+If the abbreviation is preceded by an offset, read a dotted
+pair (WEEKDAY . OFFSET). Thus \"SU\" becomes 0, \"-1SU\"
+becomes (0 . -1), etc. S should have been matched against
+`icalendar-weekdaynum'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((dayno (cdr (assoc (match-string 20) ical:weekday-numbers)))
+        (weekno (match-string 19)))
+    (if weekno
+        (cons dayno (string-to-number weekno))
+      dayno)))
+
+(defun ical:print-weekdaynum (val)
+  "Serialize a number or dotted pair VAL to a string
+(as part of a BYDAY recur rule part). See `icalendar-read-weekdaynum'
+for the value format."
+  (if (consp val)
+      (let* ((dayno (car val))
+             (day (car (rassq dayno ical:weekday-numbers)))
+             (offset (cdr val)))
+        (concat (number-to-string offset) day))
+    ;; number alone just stands for a day:
+    (car (rassq val ical:weekday-numbers))))
+
+(defun ical:read-recur-rule-part (s)
+  "Read an `icalendar-recur-rule-part' from string S.
+S should have been matched against `icalendar-recur-rule-part'.
+The return value is a list (KEYWORD VALUE), where VALUE may
+itself be a list, depending on the values allowed by KEYWORD."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((keyword (intern (upcase (match-string 11))))
+        (values (match-string 12)))
+    (list keyword
+      (cl-case keyword
+        (FREQ (intern (upcase values)))
+        (UNTIL (if (length> values 8)
+                   (ical:read-date-time values)
+                 (ical:read-date values)))
+        ((COUNT INTERVAL)
+         (string-to-number values))
+        ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH B=
YSETPOS)
+         (ical:read-list-with #'string-to-number values nil ","))
+        (BYDAY
+         (ical:read-list-with #'ical:read-weekdaynum values
+                              (rx ical:weekdaynum) ","))
+        (WKST (cdr (assoc values ical:weekday-numbers)))))))
+
+(defun ical:print-recur-rule-part (part)
+  "Serialize recur rule part PART to a string."
+  (let ((keyword (car part))
+        (values (cadr part))
+        values-str)
+    (cl-case keyword
+      (FREQ (setq values-str (symbol-name values)))
+      (UNTIL (setq values-str (cl-typecase values
+                                (ical:date-time (ical:print-date-time valu=
es))
+                                (ical:date (ical:print-date values)))))
+      ((COUNT INTERVAL)
+       (setq values-str (number-to-string values)))
+      ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYS=
ETPOS)
+       (setq values-str (string-join (mapcar #'number-to-string values)
+                                     ",")))
+      (BYDAY
+       (setq values-str (string-join (mapcar #'ical:print-weekdaynum value=
s)
+                                     ",")))
+      (WKST (setq values-str (car (rassq values ical:weekday-numbers)))))
+
+    (concat (symbol-name keyword) "=3D" values-str)))
+
+(rx-define ical:recur-rule-part
+  ;; Group 11: keyword
+  ;; Group 12: value(s)
+  (or (seq (group-n 11 "FREQ") "=3D" (group-n 12 ical:freq))
+      (seq (group-n 11 "UNTIL") "=3D" (group-n 12 (or ical:date-time ical:=
date)))
+      (seq (group-n 11 "COUNT") "=3D" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "INTERVAL") "=3D" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "BYSECOND") "=3D" (group-n 12 ; 0 to 60
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYMINUTE") "=3D" (group-n 12 ; 0 to 59
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYHOUR") "=3D" (group-n 12 ; 0 to 23
+                                       (ical:comma-list (** 1 2 digit)))) =
; 0 to 23
+      (seq (group-n 11 "BYDAY") "=3D" (group-n 12 ; weeknum? daynum, e.g. =
SU or 34SU
+                                      (ical:comma-list ical:weekdaynum)))
+      (seq (group-n 11 "BYMONTHDAY") "=3D" (group-n 12
+                                           (ical:comma-list ical:monthdayn=
um)))
+      (seq (group-n 11 "BYYEARDAY") "=3D" (group-n 12
+                                          (ical:comma-list ical:yeardaynum=
)))
+      (seq (group-n 11 "BYWEEKNO") "=3D" (group-n 12 (ical:comma-list ical=
:weeknum)))
+      (seq (group-n 11 "BYMONTH") "=3D" (group-n 12 (ical:comma-list ical:=
monthnum)))
+      (seq (group-n 11 "BYSETPOS") "=3D" (group-n 12
+                                         (ical:comma-list ical:yeardaynum)=
))
+      (seq (group-n 11 "WKST") "=3D" (group-n 12 ical:weekday))))
+
+(defun ical:read-recur (s)
+  "Read a recurrence rule value from string S.
+S should be a match against rx `icalendar-recur'."
+  ;; TODO: let's switch to keywords and a plist, so we can more easily
+  ;; write these clauses also in diary sexp entries without so many parens
+  (ical:read-list-with #'ical:read-recur-rule-part s (rx ical:recur-rule-p=
art) ";"))
+
+(defun ical:print-recur (val)
+  "Serialize a recurrence rule value VAL to a string."
+  ;; RFC5545 sec. 3.3.10: "to ensure backward compatibility with
+  ;; applications that pre-date this revision of iCalendar the
+  ;; FREQ rule part MUST be the first rule part specified in a
+  ;; RECUR value."
+  (string-join
+   (cons
+    (ical:print-recur-rule-part (assq 'FREQ val))
+    (mapcar #'ical:print-recur-rule-part
+            (seq-filter (lambda (part) (not (eq 'FREQ (car part))))
+                        val)))
+   ";"))
+
+(defconst ical:-recur-value-types
+  ;; `list-of' is not a cl-type specifier, just a symbol here; it is
+  ;; handled specially when checking types in `ical:recur-value-p':
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+    UNTIL (or ical:date-time ical:date)
+    COUNT (integer 1 *)
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) (satisfies ical:dayno-offset-p)))
+    BYMONTHDAY (list-of (or (integer -31 -1) (integer 1 31)))
+    BYYEARDAY (list-of (or (integer -366 -1) (integer 1 366)))
+    BYWEEKNO (list-of (or (integer -53 -1) (integer 1 53)))
+    BYMONTH (list-of (integer 1 12)) ; unlike the others, months cannot be=
 negative
+    BYSETPOS (list-of (or (integer -366 -1) (integer 1 366)))
+    WKST (integer 0 6))
+  "Plist mapping `icalendar-recur' keywords to type specifiers")
+
+(defun ical:dayno-offset-p (val)
+  "Return non-nil if VAL is a pair (DAYNO . OFFSET), part of a
+recurrence rule BYDAY value"
+  (and (consp val)
+       (cl-typep (car val) '(integer 0 6))
+       (cl-typep (cdr val) '(or (integer -53 -1) (integer 1 53)))))
+
+(defun ical:recur-value-p (vals)
+  "Return non-nil if VALS is an iCalendar recurrence rule value."
+  (and (listp vals)
+       ;; FREQ is always required:
+       (assq 'FREQ vals)
+       ;; COUNT and UNTIL are mutually exclusive if present:
+       (not (and (assq 'COUNT vals) (assq 'UNTIL vals)))
+       ;; If BYSETPOS is present, another BYXXX clause must be too:
+       (or (not (assq 'BYSETPOS vals))
+           (assq 'BYMONTH vals)
+           (assq 'BYWEEKNO vals)
+           (assq 'BYYEARDAY vals)
+           (assq 'BYMONTHDAY vals)
+           (assq 'BYDAY vals)
+           (assq 'BYHOUR vals)
+           (assq 'BYMINUTE vals)
+           (assq 'BYSECOND vals))
+       (let ((freq (ical:recur-freq vals))
+             (byday (ical:recur-by* 'BYDAY vals))
+             (byweekno (ical:recur-by* 'BYWEEKNO vals))
+             (bymonthday (ical:recur-by* 'BYMONTHDAY vals))
+             (byyearday (ical:recur-by* 'BYYEARDAY vals)))
+         (and
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value when the FREQ rule part is not set to MONTHLY or
+          ;; YEARLY."
+          (or (not (consp (car byday)))
+              (memq freq '(MONTHLY YEARLY)))
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value with the FREQ rule part set to YEARLY when the
+          ;; BYWEEKNO rule part is specified." This also covers:
+          ;; "[The BYWEEKNO] rule part MUST NOT be used when the FREQ
+          ;; rule part is set to anything other than YEARLY."
+          (or (not byweekno)
+              (and (eq freq 'YEARLY)
+                   (not (consp (car byday)))))
+          ;; "The BYMONTHDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to WEEKLY."
+          (not (and bymonthday (eq freq 'WEEKLY)))
+          ;; "The BYYEARDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
+          (not (and byyearday (memq freq '(DAILY WEEKLY MONTHLY))))))
+       ;; check types of all rule parts:
+       (seq-every-p
+        (lambda (kv)
+          (when (consp kv)
+            (let* ((keyword (car kv))
+                   (val (cadr kv))
+                   (type (plist-get ical:-recur-value-types keyword)))
+              (and keyword val type
+                   (if (and (consp type)
+                            (eq (car type) 'list-of))
+                       (ical:list-of-p val (cadr type))
+                     (cl-typep val type))))))
+         vals)))
+
+(ical:define-type ical:recur "RECUR"
+  "Type for Recurrence Rule values.
+
+When printed, a recurrence rule value looks like
+  KEY1=3DVAL1;KEY2=3DVAL2;...
+where the VALs may themselves be lists or have other syntactic
+structure; see RFC5545 sec. 3.3.10 for all the details.
+
+The KEYs and their associated value types when read are as follows.
+The first is required:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+These two are mutually exclusive; at most one may appear:
+    UNTIL (or icalendar-date-time icalendar-date)
+    COUNT (integer 1 *)
+All others are optional:
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) ; day of week
+                       (pair (integer 0 6)  ; (day of week . offset)
+                             (integer -53 53))) ; except 0
+    BYMONTHDAY (list-of (integer -31 31))  ; except 0
+    BYYEARDAY (list-of (integer -366 366)) ; except 0
+    BYWEEKNO (list-of (integer -53 53))    ; except 0
+    BYMONTH (list-of (integer 1 12))       ; months cannot be negative
+    BYSETPOS (list-of (integer -366 366))  ; except 0
+    WKST (integer 0 6))
+
+When read, these KEYs and their associated VALs are gathered into
+an alist.
+
+In general, the VALs consist of integers or lists of integers.
+Abbreviations for weekday names are translated into integers
+0 (=3DSunday) through 6 (=3DSaturday), for compatibility with
+calendar.el and decoded-time-* functions.
+
+Some examples:
+
+1) Printed: FREQ=3DDAILY;COUNT=3D10;INTERVAL=3D2
+   Meaning: 10 occurrences that occur every other day
+   Read: ((FREQ DAILY)
+          (COUNT 10)
+          (INTERVAL 2))
+
+2) Printed: FREQ=3DYEARLY;UNTIL=3D20000131T140000Z;BYMONTH=3D1;BYDAY=3DSU,=
MO,TU,WE,TH,FR,SA
+   Meaning: Every day in January of every year until 2000/01/31 at 14:00 U=
TC
+   Read: ((FREQ YEARLY)
+          (UNTIL (0 0 14 31 1 2000 1 -1 0))
+          (BYMONTH (1))
+          (BYDAY (0 1 2 3 4 5 6)))
+
+3) Printed: FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-2
+   Meaning: Every month on the second-to-last weekday of the month
+   Read: ((FREQ MONTHLY)
+          (BYDAY (1 2 3 4 5))
+          (BYSETPOS (-2)))
+
+Notice that singleton values are still wrapped in a list when the
+KEY accepts a list of values, but not when the KEY always has a
+single (e.g. integer) value."
+  '(satisfies ical:recur-value-p)
+  (ical:semicolon-list ical:recur-rule-part)
+  :reader ical:read-recur
+  :printer ical:print-recur
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10")
+
+(defun ical:recur-freq (recur-value)
+  "Return the frequency in RECUR-VALUE"
+  (car (alist-get 'FREQ recur-value)))
+
+(defun ical:recur-interval-size (recur-value)
+  "Return the interval size specified in RECUR-VALUE, or the default
+of 1."
+  (or (car (alist-get 'INTERVAL recur-value)) 1))
+
+(defun ical:recur-until (recur-value)
+  "Return the UNTIL date(-time) in RECUR-VALUE"
+  (car (alist-get 'UNTIL recur-value)))
+
+(defun ical:recur-count (recur-value)
+  "Return the COUNT in RECUR-VALUE"
+  (car (alist-get 'COUNT recur-value)))
+
+(defun ical:recur-weekstart (recur-value)
+  "Return the weekday which starts the work week specified in
+RECUR-VALUE, or the default (1 =3D Monday)"
+  (or (car (alist-get 'WKST recur-value)) 1))
+
+(defun ical:recur-by* (byunit recur-value)
+  "Return the values in the BYUNIT clause in RECUR-VALUE.
+BYUNIT should be a symbol: \\=3D'BYMONTH, \\=3D'BYDAY, etc.
+See `icalendar-recur' for all the possible BYUNIT values."
+  (car (alist-get byunit recur-value)))
+
+;;;; 3.3.11 Text
+(rx-define ical:escaped-char
+   (seq ?\\ (or ?\\ ?\; ?, ?N ?n)))
+
+(rx-define ical:text-safe-char
+  ;; "Any character except CONTROLs not needed by the current character
+  ;; set, DQUOTE, ";", ":", "\", "," "
+  (any #x09 #x20 #x21 ; htab, space, and "!"
+       (#x23 . #x2B) (#x2D . #x39) ; "#".."9" skipping #x2C=3D","
+       (#x3C . #x5B) (#x5D . #x7E) ; "<".."~" skipping #x5C=3D"\"
+       nonascii))
+
+(defun ical:text-region-p (val)
+  "Return t if VAL represents a region of text."
+  (and (listp val)
+       (markerp (car val))
+       (not (null (marker-buffer (car val))))
+       (markerp (cdr val))))
+
+(defun ical:make-text-region (&optional buffer begin end)
+  "Return an object that represents the region of text in BUFFER
+between BEGIN and END. BUFFER defaults to the current buffer, and
+BEGIN and END default to point and mark in BUFFER."
+  (let ((buf (or buffer (current-buffer)))
+        (b (make-marker))
+        (e (make-marker)))
+    (with-current-buffer buf
+      (set-marker b (or begin (min (point) (mark))) buf)
+      (set-marker e (or end (max (point) (mark))))
+      (cons b e))))
+
+(defsubst ical:text-region-begin (r)
+  "Return the marker at the beginning of the text region R"
+  (car r))
+
+(defsubst ical:text-region-end (r)
+  "Return the marker at the end of the text region R"
+  (cdr r))
+
+(defun ical:unescape-text-in-region (begin end)
+ "Unescape the text between BEGIN and END, replacing
+literal '\\n' and '\\N' with newline, and removing backslashes that escape
+commas, semicolons, and backslashes."
+ (with-restriction begin end
+   (save-excursion
+    (replace-string-in-region "\\N" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\n" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\," "," (point-min) (point-max))
+    (replace-string-in-region "\\;" ";" (point-min) (point-max)))
+    (replace-string-in-region (concat "\\" "\\") "\\" (point-min) (point-m=
ax))))
+
+(defun ical:unescape-text-string (s)
+ "Unescape the text in string S, replacing literal '\\n' and '\\N'
+with newline, and removing backslashes that escape commas, semicolons
+and backslashes."
+  (with-temp-buffer
+    (insert s)
+    (ical:unescape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:escape-text-in-region (begin end)
+  "Escape the text between BEGIN and END, replacing newlines with
+literal '\\n', and escaping commas, semicolons and backslashes with a
+backslash."
+ (with-restriction begin end
+  (save-excursion
+    ;; replace backslashes first, so the ones introduced when
+    ;; escaping other characters don't end up double-escaped:
+    (replace-string-in-region "\\" (concat "\\" "\\") (point-min) (point-m=
ax))
+    (replace-string-in-region "\n" "\\n" (point-min) (point-max))
+    (replace-string-in-region "," "\\," (point-min) (point-max))
+    (replace-string-in-region ";" "\\;" (point-min) (point-max)))))
+
+(defun ical:escape-text-string (s)
+  "Escape the text in S, replacing newlines with '\\n', and escaping
+commas, semicolons, and backslashes with a backslash."
+  (with-temp-buffer
+    (insert s)
+    (ical:escape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:read-text (s)
+  "Read an `icalendar-text' value from a string S.
+S should be a match against rx `icalendar-text'."
+  (ical:unescape-text-string s))
+
+(defun ical:print-text (val)
+  "Serialize an iCalendar text value. VAL may be a string or a text
+region (see `icalendar-make-text-region'). The text will be escaped before
+printing. If VAL is a region, the text it contains will not be
+modified; it is copied before escaping."
+  (if (stringp val)
+      (ical:escape-text-string val)
+    ;; val is a region, so copy and escape its contents:
+    (let* ((beg (ical:text-region-begin val))
+           (buf (marker-buffer beg))
+           (end (ical:text-region-end val)))
+      (with-temp-buffer
+        (insert-buffer-substring buf (marker-position beg) (marker-positio=
n end))
+        (ical:escape-text-in-region (point-min) (point-max))
+        (buffer-string)))))
+
+(defun ical:text-to-string (node)
+  "Return the value of an `icalendar-text' NODE as a string.
+The returned string is *not* escaped. For that, see `icalendar-print-text'=
."
+  (ical:with-node-value node nil
+    (if (stringp value) value
+      ;; Otherwise the value is a text region:
+      (let* ((beg (ical:text-region-begin value))
+             (buf (marker-buffer beg))
+             (end (ical:text-region-end value)))
+        (with-current-buffer buf
+          (buffer-substring (marker-position beg) (marker-position end))))=
)))
+
+;; TODO: would it be useful to add a third representation, namely a
+;; function or thunk? So that e.g. Org can pre-process its own syntax
+;; and return a plain text string to use in the description?
+(ical:define-type ical:text "TEXT"
+   "Type for Text values.
+
+Text values can be represented in Elisp in two ways: as strings,
+or as buffer regions. For values which aren't expected to change,
+such as property values in a text/calendar email attachment, use
+strings. For values which are user-editable and might change
+between parsing and serializing to iCalendar format, use a
+region. In that case, a text value contains two markers BEGIN and
+END which mark the bounds of the region. See
+`icalendar-make-text-region' to create such values, and
+`icalendar-text-region-begin' and `icalendar-text-region-end' to
+access the markers.
+
+Certain characters in text values are required to be escaped by
+the iCalendar standard. These characters should NOT be
+pre-escaped when inserting them into the parse tree. Instead,
+`icalendar-print-text' takes care of escaping text values, and
+`icalendar-read-text' takes care of unescaping them, when parsing and
+printing iCalendar data."
+  '(or string (satisfies ical:text-region-p))
+  (zero-or-more (or ical:text-safe-char ?: ?\" ical:escaped-char))
+  :reader ical:read-text
+  :printer ical:print-text
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11")
+
+;; 3.3.12 Time - Defined above
+
+;;;; 3.3.13 URI
+;; see https://www.rfc-editor.org/rfc/rfc3986#section-3
+(rx-define ical:uri-with-scheme
+  ;; Group 11: URI scheme; see icalendar-uri-schemes.el
+  ;; Group 12: rest of URI after ":"
+  ;; This regex mostly just scans for all characters allowed by RFC3986,
+  ;; except we make an effort to parse the scheme, because otherwise the
+  ;; regex is either too permissive (ical:binary, in particular, matches
+  ;; a subset of the characters allowed in a URI) or too complicated to
+  ;; be useful.
+  ;; TODO: use url-parse.el to parse to struct?
+  (seq (group-n 11 (any "a-zA-Z") (zero-or-more (any ?- ?+ ?. "A-Za-z0-9")=
))
+       ":"
+       (group-n 12
+         (one-or-more
+          (any "A-Za-z0-9" ?- ?. ?_ ?~             ; unreserved chars
+               ?: ?/ ?? ?# ?\[ ?\] ?@              ; gen-delims
+               ?! ?$ ?& ?' ?\( ?\) ?* ?+ ?, ?\; ?=3D ; sub-delims
+               ?%)))))                             ; for %-encoding
+
+(ical:define-type ical:uri "URI"
+   "Type for URI values.
+
+The parsed and printed representations are the same: a URI string."
+   '(satisfies ical:match-uri-value)
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.13")
+
+;;;; 3.3.3 Calendar User Address
+(ical:define-type ical:cal-address "CAL-ADDRESS"
+   "Type for Calendar User Address values.
+
+The parsed and printed representations are the same: a URI string.
+Typically, this should be a mailto: URI.
+
+RFC5545 says: '*When used to address an Internet email transport
+  address* for a calendar user, the value MUST be a mailto URI,
+  as defined by [RFC2368]'
+
+Since it is unclear whether there are Calendar User Address values
+which are not used to address email, this type does not enforce the use
+of the mailto: scheme, but be prepared for problems if you create
+values of this type with any other scheme."
+   '(and string (satisfies ical:match-cal-address-value))
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.3")
+
+;;;; 3.3.14 UTC Offset
+(defun ical:read-utc-offset (s)
+  "Read a UTC offset from a string.
+S should be a match against rx `icalendar-utc-offset'"
+  (let ((sign (if (equal (substring s 0 1) "-") -1 1))
+        (nhours (string-to-number (substring s 1 3)))
+        (nminutes (string-to-number (substring s 3 5)))
+        (nseconds (if (length=3D s 7)
+                      (string-to-number (substring s 5 7))
+                    0)))
+    (* sign (+ nseconds (* 60 nminutes) (* 60 60 nhours)))))
+
+(defun ical:print-utc-offset (utcoff)
+  "Serialize a UTC offset to a string"
+  (let* ((sign (if (< utcoff 0) "-" "+"))
+         (absoff (abs utcoff))
+         (nhours (/ absoff (* 60 60)))
+         (no-hours (mod absoff (* 60 60)))
+         (nminutes (/ no-hours 60))
+         (nseconds (mod no-hours 60)))
+    (if (zerop nseconds)
+        (format "%s%02d%02d" sign nhours nminutes)
+      (format "%s%02d%02d%02d" sign nhours nminutes nseconds))))
+
+(ical:define-type ical:utc-offset "UTC-OFFSET"
+  "Type for UTC Offset values.
+
+When printed, a sign followed by a string of digits, like +HHMM
+or -HHMMSS. When read, an integer representing the number of
+seconds offset from UTC. This representation is for compatibility
+with `decode-time' and related functions."
+  '(integer -999999 999999)
+  (seq (or ?+ ?-) ; + is not optional for positive values!
+       (=3D 4 digit) ; HHMM
+       (zero-or-one (=3D 2 digit))) ; SS
+  :reader ical:read-utc-offset
+  :printer ical:print-utc-offset
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.14")
+
+
+;;; Section 3.2: Property Parameters
+
+(defconst ical:param-types nil ;; populated by ical:define-param
+  "Alist mapping printed parameter names to type symbols")
+
+(defun ical:maybe-quote-param-value (s &optional always)
+  "Add quotes around param value string S if required. If ALWAYS is non-ni=
l,
+add quotes to S regardless of its contents"
+  (if (or always
+          (not (string-match (rx ical:paramtext) s))
+          (< (match-end 0) (length s)))
+      (concat "\"" s "\"")
+    s))
+
+(defun ical:read-param-value (type s)
+  "Read a value for a parameter of type TYPE from a string S.
+S should have already been matched against the regex for TYPE and
+the match data should be available to this function. Returns a
+syntax node of type TYPE containing the read value.
+
+If TYPE accepts a list of values, S will be split on the list
+separator for TYPE and read individually."
+  (let* ((value-type (get type 'ical:value-type)) ; if nil, value is just =
a string
+         (value-regex (when (get type 'ical:value-rx)
+                         (rx-to-string (get type 'ical:value-rx))))
+         (list-sep (get type 'ical:list-sep))
+         (substitute-val (get type 'ical:substitute-value))
+         (unrecognized-val (match-string 5)) ; see :unrecognized in define=
-param
+         (raw-val (if unrecognized-val substitute-val s))
+         (one-val-reader (if (ical:value-type-symbol-p value-type)
+                             (lambda (s) (ical:read-value-node value-type =
s))
+                           #'identity)) ; value is just a string
+         ;; values may be quoted even if :quoted does not require it,
+         ;; so they need to be stripped of quotes. read-list-with does
+         ;; this by default; in the single value case, use string-trim
+         (read-val (if list-sep
+                       (ical:read-list-with one-val-reader raw-val
+                                            value-regex list-sep)
+                     (funcall one-val-reader
+                              (string-trim raw-val "\"" "\"")))))
+    (ical:make-ast-node type
+                        (list :value read-val
+                              :original-value unrecognized-val))))
+
+(defun ical:parse-param-value (type limit)
+  "Parse the value for a parameter of type TYPE from point up to LIMIT.
+TYPE should be a type symbol for an iCalendar parameter type.
+This function expects point to be at the start of the value
+string, after the parameter name and the equals sign. Returns a
+syntax node representing the parameter."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+    ;; By far the most common invalid data seem to be text values that
+    ;; contain unescaped characters (e.g. commas in addresses).  These
+    ;; are harmless as long as the parameter accepts arbitrary text and
+    ;; does not expect a list of values.  The only such parameter
+    ;; defined in RFC5545 is `ical:cnparam', so we treat this as a
+    ;; special case and loosen the official regexp to accept anything up
+    ;; to the start of the next param or property value:
+    (when (and (eq type 'ical:cnparam)
+               (not ical:parse-strictly))
+      (setq full-value-regex
+            (rx (group-n 2 (or ical:quoted-string
+                               (zero-or-more (not (any ?: ?\;))))))))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (ical:signal-parse-error
+       (format "Unable to parse `%s' value between %d and %d"
+               type (point) limit)))
+    (when (match-string 3)
+      (ical:signal-parse-error
+       (format "Invalid value for `%s' parameter: %s" type (match-string 3=
))))
+
+    (let ((value-begin (match-beginning 2))
+          (value-end (match-end 2))
+          (node (ical:read-param-value type (match-string 2))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; :begin must be set by parse-params
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end value-end)
+
+      node)))
+
+(defun ical:parse-params (limit)
+  "Parse the parameter string of the current property, up to LIMIT.
+Point should be at the \";\" at the start of the first parameter.
+Returns a list of parameters, which may be nil if none are present.
+After parsing, point is at the end of the parameter string and the
+start of the property value string."
+  (let (params param-node)
+    (rx-let ((ical:param-start (seq ";" (group-n 1 ical:param-name) "=3D")=
))
+      (while (re-search-forward (rx ical:param-start) limit t)
+        (when-let* ((begin (match-beginning 1))
+                    (param-name (match-string 1))
+                    (param-type (alist-get (upcase param-name)
+                                           ical:param-types
+                                           'ical:otherparam
+                                            nil #'equal)))
+          (condition-case err
+              (setq param-node (ical:parse-param-value param-type limit))
+            (ical:parse-error
+             (ical:handle-parse-error err (format "Skipping bad %s paramet=
er"
+                                                  param-name))
+             (setq param-node nil)))
+          (when param-node
+            (ical:ast-node-meta-set param-node :begin begin)
+            ;; store the original param name if we didn't recognize it:
+            (when (eq param-type 'ical:otherparam)
+              (ical:ast-node-meta-set param-node :original-name param-name=
))
+            (push param-node params))))
+    (nreverse params))))
+
+(defun ical:print-param-node (node)
+  "Serialize a parameter syntax node NODE to a string.
+NODE should be a syntax node whose type is an iCalendar
+parameter type."
+  (let* ((param-type (ical:ast-node-type node))
+         (param-name (car (rassq param-type ical:param-types)))
+         (name-str (or param-name
+                       ;; set by parse-params for unrecognized params:
+                       (ical:ast-node-meta-get :original-name node))))
+
+    (unless (and name-str (stringp name-str) (not (equal name-str "")))
+      (ical:signal-print-error "No printable parameter name" :node node))
+
+    (let* ((list-sep (get param-type 'ical:list-sep))
+           (val/s (ical:ast-node-value node))
+           (vals (if (and list-sep (listp val/s))
+                     val/s
+                   (list val/s)))
+           ;; any ical:print-error here propagates:
+           (printed (mapcar #'ical:default-value-printer vals))
+           ;; add quotes to each value as needed, even if :quoted
+           ;; does not require it:
+           (must-quote (get param-type 'ical:is-quoted))
+           (quoted (mapcar
+                    (lambda (v) (ical:maybe-quote-param-value v must-quote=
))
+                    printed))
+           (val-str (or (ical:ast-node-meta-get :original-value node)
+                        (string-join quoted list-sep)
+                        quoted)))
+
+      (unless (and (stringp val-str) (not (equal val-str "")))
+        (ical:signal-print-error "Unable to print parameter value" :node n=
ode))
+
+      (format ";%s=3D%s" name-str val-str))))
+
+(defun ical:print-params (param-nodes)
+  "Print the property parameter nodes in PARAM-NODES. Returns the
+printed parameter list as a string."
+  (let (param-strs)
+    (dolist (node param-nodes)
+      (condition-case err
+          (push (ical:print-param-node node) param-strs)
+        (ical:print-error
+         (ical:handle-print-error err))))
+    (apply #'concat (nreverse param-strs))))
+
+;; Parameter definitions in RFC5545:
+
+(ical:define-param ical:altrepparam "ALTREP"
+  "Alternate text representation (URI)"
+  ical:uri
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.1")
+
+(ical:define-param ical:cnparam "CN"
+  "Common Name"
+  ical:param-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.2")
+
+(ical:define-param ical:cutypeparam "CUTYPE"
+  "Calendar User Type"
+  (or "INDIVIDUAL"
+      "GROUP"
+      "RESOURCE"
+      "ROOM"
+      "UNKNOWN"
+      (group-n 5
+        (or ical:x-name ical:iana-token)))
+  :default "INDIVIDUAL"
+  ;; "Applications MUST treat x-name and iana-token values they
+  ;; don't recognize the same way as they would the UNKNOWN
+  ;; value":
+  :unrecognized "UNKNOWN"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.3")
+
+(ical:define-param ical:delfromparam "DELEGATED-FROM"
+  "Delegators.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have delegated their participation to the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.4")
+
+(ical:define-param ical:deltoparam "DELEGATED-TO"
+  "Delegatees.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have been delegated to participate by the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.5")
+
+(ical:define-param ical:dirparam "DIR"
+  "Directory Entry Reference.
+
+This parameter may be specified on properties with a
+`icalendar-cal-address' value type. It is a quoted URI which specifies
+a reference to a directory entry associated with the calendar
+user which is the value of the property."
+   ical:uri
+   :quoted t
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.6")
+
+(ical:define-param ical:encodingparam "ENCODING"
+  "Inline Encoding, either \"8BIT\" (text, default) or \"BASE64\" (binary).
+
+If \"BASE64\", the property value is base64-encoded binary data.
+This parameter must be specified if the `icalendar-valuetypeparam'
+is \"BINARY\"."
+  (or "8BIT" "BASE64")
+  :default "8BIT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.7")
+
+(rx-define ical:mimetype
+  (seq ical:mimetype-regname "/" ical:mimetype-regname))
+
+;; from https://www.rfc-editor.org/rfc/rfc4288#section-4.2:
+(rx-define ical:mimetype-regname
+  (** 1 127 (any "A-Za-z0-9" ?! ?# ?$ ?& ?. ?+ ?- ?^ ?_)))
+
+(ical:define-param ical:fmttypeparam "FMTTYPE"
+  "Format Type (Mimetype per RFC4288)
+
+Specifies the media type of the object referenced in the property value,
+for example \"text/plain\" or \"text/html\".
+Valid media types are defined in RFC4288; see
+URL `https://www.rfc-editor.org/rfc/rfc4288#section-4.2'"
+  ical:mimetype
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.8")
+
+(ical:define-param ical:fbtypeparam "FBTYPE"
+  "Free/Busy Time Type. Default is \"BUSY\".
+
+RFC5545 gives the following meanings to the values:
+
+FREE: the time interval is free for scheduling.
+BUSY: the time interval is busy because one or more events have
+  been scheduled for that interval.
+BUSY-UNAVAILABLE: the time interval is busy and the interval
+  can not be scheduled.
+BUSY-TENTATIVE: the time interval is busy because one or more
+  events have been tentatively scheduled for that interval.
+Other values are treated like BUSY."
+  (or "FREE"
+      "BUSY-UNAVAILABLE"
+      "BUSY-TENTATIVE"
+      "BUSY"
+      ical:x-name
+      ical:iana-token)
+  :default "BUSY"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.9")
+
+;; TODO: see https://www.rfc-editor.org/rfc/rfc5646#section-2.1
+(rx-define ical:rfc5646-lang
+  (one-or-more (any "A-Za-z0-9" ?-)))
+
+(ical:define-param ical:languageparam "LANGUAGE"
+  "Language tag (per RFC5646)
+
+This parameter specifies the language of the property value as a
+language tag, for example \"en-US\" for US English or \"no\" for
+Norwegian. Valid language tags are defined in RFC5646; see
+URL `https://www.rfc-editor.org/rfc/rfc5646'"
+  ical:rfc5646-lang
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.10")
+
+(ical:define-param ical:memberparam "MEMBER"
+  "Group or List Membership.
+
+This is a comma-separated list of quoted `icalendar-cal-address'
+values. These are addresses of groups or lists of which the user
+in the property value is a member."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.11")
+
+(ical:define-param ical:partstatparam "PARTSTAT"
+  "Participation status.
+
+The value specifies the participation status of the calendar user
+in the property value. They have different interpretations
+depending on whether they occur in a VEVENT, VTODO or VJOURNAL
+component. RFC5545 gives the values the following meanings:
+
+NEEDS-ACTION (all): needs action by the user
+ACCEPTED (all): accepted by the user
+DECLINED (all): declined by the user
+TENTATIVE (VEVENT, VTODO): tentatively accepted by the user
+DELEGATED (VEVENT, VTODO): delegated by the user
+COMPLETED (VTODO): completed at the `icalendar-date-time' in the
+  VTODO's `icalendar-completed' property
+IN-PROCESS (VTODO): in the process of being completed"
+  (or "NEEDS-ACTION"
+      "ACCEPTED"
+      "DECLINED"
+      "TENTATIVE"
+      "DELEGATED"
+      "COMPLETED"
+      "IN-PROCESS"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; NEEDS-ACTION value."
+  :default "NEEDS-ACTION"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.12")
+
+(ical:define-param ical:rangeparam "RANGE"
+  "Recurrence Identifier Range.
+
+Specifies the effective range of recurrence instances of the property's va=
lue.
+The value \"THISANDFUTURE\" is the only value compliant with RFC5545;
+legacy applications might also produce \"THISANDPRIOR\"."
+  "THISANDFUTURE"
+  :default "THISANDFUTURE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.13")
+
+(ical:define-param ical:trigrelparam "RELATED"
+  "Alarm Trigger Relationship.
+
+This parameter may be specified on properties whose values give
+an alarm trigger as an `icalendar-duration'. If the parameter
+value is \"START\" (the default), the alarm triggers relative to
+the start of the component; similarly for \"END\"."
+  (or "START" "END")
+  :default "START"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.14")
+
+(ical:define-param ical:reltypeparam "RELTYPE"
+  "Relationship type.
+
+This parameter specifies a hierarchical relationship between the
+calendar component referenced in a `icalendar-related-to'
+property and the calendar component in which it occurs.
+\"PARENT\" means the referenced component is superior to this
+one, \"CHILD\" that the referenced component is subordinate to
+this one, and \"SIBLING\" means they are peers."
+  (or "PARENT"
+      "CHILD"
+      "SIBLING"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values they don't
+  ;; recognize the same way as they would the PARENT value."
+  :default "PARENT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15")
+
+(ical:define-param ical:roleparam "ROLE"
+  "Participation role.
+
+This parameter specifies the participation role of the calendar
+user in the property value. RFC5545 gives the parameter values
+the following meanings:
+CHAIR: chair of the calendar entity
+REQ-PARTICIPANT (default): user's participation is required
+OPT-PARTICIPANT: user's participation is optional
+NON-PARTICIPANT: user is copied for information purposes only"
+  (or "CHAIR"
+      "REQ-PARTICIPANT"
+      "OPT-PARTICIPANT"
+      "NON-PARTICIPANT"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; REQ-PARTICIPANT value."
+  :default "REQ-PARTICIPANT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.16")
+
+(ical:define-param ical:rsvpparam "RSVP"
+  "RSVP expectation.
+
+This parameter is an `icalendar-boolean' which specifies whether
+the calendar user in the property value is expected to reply to
+the Organizer of a VEVENT or VTODO."
+  ical:boolean
+  :default "FALSE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.17")
+
+(ical:define-param ical:sentbyparam "SENT-BY"
+  "Sent by.
+
+This parameter specifies a calendar user that is acting on behalf
+of the user in the property value."
+  ;; "The parameter value MUST be a mailto URI as defined in [RFC2368]"
+  ;; Weirdly, this is the only place in the standard I've seen "mailto:"
+  ;; be *required* for a cal-address. We ignore this requirement because
+  ;; coding around the exception is not worth it: it requires working
+  ;; around the fact that two different types, the looser and the more
+  ;; stringent cal-address, would need to have the same print name.
+  ical:cal-address
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.18")
+
+(ical:define-param ical:tzidparam "TZID"
+  "Time Zone identifier.
+
+This parameter identifies the VTIMEZONE component in the calendar
+which should be used to interpret the time value given in the
+property. The value of this parameter must be equal to the value
+of the TZID property in that VTIMEZONE component; there must be
+exactly one such component for every unique value of this
+parameter in the calendar."
+  ;; TODO: "This parameter MUST be specified on the "DTSTART","DTEND",
+  ;; "DUE", "EXDATE", and "RDATE" properties when either a DATE-TIME
+  ;; or TIME value type is specified and when the value is neither a
+  ;; UTC or a "floating" time."
+  ;; TODO: "The "TZID" property parameter MUST NOT be applied to DATE
+  ;; properties and DATE-TIME or TIME properties whose time values are
+  ;; specified in UTC."
+  (seq (zero-or-one "/") ical:paramtext)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.19")
+
+(defun ical:read-value-type (s)
+  "Read a value type from string S.
+S should contain the printed representation of a value type in a \"VALUE=
=3D...\"
+property parameter. If S represents a known type in `icalendar-value-types=
',
+it is read as the associated type symbol. Otherwise S is returned unchange=
d."
+  (let ((type-assoc (assoc s ical:value-types)))
+    (if type-assoc
+        (cdr type-assoc)
+      s)))
+
+(defun ical:print-value-type (type)
+  "Print a value type TYPE.
+TYPE should be an iCalendar type symbol naming a known value type
+defined with `icalendar-define-type', or a string naming an
+unknown type. If it is a symbol, return the associated printed
+representation for the type from `icalendar-value-types'.
+Otherwise return TYPE."
+  (if (symbolp type)
+      (car (rassq type ical:value-types))
+    type))
+
+(ical:define-type ical:printed-value-type nil
+  "Type to represent values of the `icalendar-valuetypeparam' parameter.
+
+When read, if the type named by the parameter is a known value
+type in `icalendar-value-types', it is represented as a type
+symbol for that value type. If it is an unknown value type, it is
+represented as a string. When printed, a string is returned
+unchanged; a type symbol is printed as the associated name in
+`icalendar-value-types'.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing of the `icalendar-valuetypeparam' parameter."
+  '(or string (satisfies ical:printable-value-type-symbol-p))
+  (or "BINARY"
+      "BOOLEAN"
+      "CAL-ADDRESS"
+      "DATE-TIME"
+      "DATE"
+      "DURATION"
+      "FLOAT"
+      "INTEGER"
+      "PERIOD"
+      "RECUR"
+      "TEXT"
+      "TIME"
+      "URI"
+      "UTC-OFFSET"
+      ;; Note: "Applications MUST preserve the value data for x-name
+      ;; and iana-token values that they don't recognize without
+      ;; attempting to interpret or parse the value data." So in this
+      ;; case we don't specify :default or :unrecognized in the
+      ;; parameter definition, and we don't put the value in group 5;
+      ;; the reader will just preserve whatever string matches here.
+      ical:x-name
+      ical:iana-token)
+  :reader ical:read-value-type
+  :printer ical:print-value-type)
+
+(ical:define-param ical:valuetypeparam "VALUE"
+  "Property value data type.
+
+This parameter is used to specify the value type of the
+containing property's value, if it is not of the default value
+type."
+  ical:printed-value-type
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.20")
+
+(ical:define-param ical:otherparam nil ; don't add to ical:param-types
+  "Parameter with an unknown name.
+
+This is not a parameter type defined by RFC5545; it represents
+parameters with an unknown name (matching rx `icalendar-param-name')
+whose values must be parsed and preserved but not further
+interpreted."
+  ical:param-value)
+
+(rx-define ical:other-param-safe
+  ;; we use this rx to skip params when matching properties and
+  ;; their values. Thus we *don't* capture the param names and param values
+  ;; in numbered groups here, which would clobber the groups of the enclos=
ing
+  ;; expression.
+  (seq ";"
+       (or ical:iana-token ical:x-name)
+       "=3D"
+       (ical:comma-list ical:param-value)))
+
+
+;;; Properties:
+
+(defconst ical:property-types nil ;; populated by ical:define-property
+  "Alist mapping printed property names to type symbols")
+
+(defun ical:read-property-value (type s &optional params)
+  "Read a value for the property type TYPE from a string S.
+
+TYPE should be a type symbol for an iCalendar property type
+defined with `icalendar-define-property'. The property value is
+assumed to be of TYPE's default value type, unless an
+`icalendar-valuetypeparam' parameter appears in PARAMS, in which
+case a value of that type will be read. S should have already
+been matched against TYPE's value regex and the match data should
+be available to this function. Returns a property syntax node of
+type TYPE containing the read value and the list of PARAMS.
+
+If TYPE accepts lists of values, they will be split from S on the
+list separator and read separately."
+  (let* ((value-type (or (ical:value-type-from-params params)
+                         (get type 'ical:default-type)))
+         (list-sep (get type 'ical:list-sep))
+         (unrecognized-val (match-string 5))
+         (raw-val (if unrecognized-val
+                      (get type 'ical:substitute-value)
+                    s))
+         (value (if list-sep
+                    (ical:read-list-of value-type raw-val list-sep)
+                  (ical:read-value-node value-type raw-val))))
+    (ical:make-ast-node type
+                        (list :value value
+                              :original-value unrecognized-val)
+                        params)))
+
+(defun ical:parse-property-value (type limit &optional params)
+  "Parse a value for the property type TYPE from point up to LIMIT.
+This function expects point to be at the start of the value
+expression, after \"PROPERTY-NAME[PARAM...]:\". Returns a syntax
+node of type TYPE containing the parsed value and the list of
+PARAMS."
+  (let ((start (point))
+        (full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+
+    ;; By far the most common invalid data seem to be text values that
+    ;; contain unescaped characters (e.g. commas in addresses in
+    ;; LOCATION). These are harmless as long as the property accepts
+    ;; any text value, accepts no other types of values, and does not
+    ;; expect a list of values. So we treat this as a special case and
+    ;; loosen the regexp to accept any non-control character until eol:
+    (when (and (eq 'ical:text (get type 'ical:default-type))
+               (equal (rx-to-string 'ical:text t)
+                      (rx-to-string (get type 'ical:value-rx) t))
+               (null (get type 'ical:other-types))
+               (not (ical:expects-list-of-values-p type))
+               (not ical:parse-strictly))
+        (setq full-value-regex
+              (rx (group-n 2 (zero-or-more (not (any control))))
+                  line-end)))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (ical:signal-parse-error
+       (format "Unable to parse `%s' property value between %d and %d"
+               type start limit)
+       :restart-at (1+ limit)))
+
+    (when (match-string 3)
+      (ical:signal-parse-error
+       (format "Invalid value for `%s' property: %s" type (match-string 3))
+       :restart-at (1+ limit)))
+
+    (let* ((value-begin (match-beginning 2))
+           (value-end (match-end 2))
+           (end value-end)
+           (node (ical:read-property-value type (match-string 2) params)))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; 'begin must be set by parse-property
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-property-node (node)
+  "Serialize a property syntax node NODE to a string."
+  (setq node (ical:maybe-add-value-param node))
+  (let* ((type (ical:ast-node-type node))
+         (list-sep (get type 'ical:list-sep))
+         (property-name (car (rassq type ical:property-types)))
+         (name-str (or property-name
+                       (ical:ast-node-meta-get :original-name node)))
+         (params (ical:ast-node-children node))
+         (value (ical:ast-node-value node))
+         (value-str
+          (or (ical:ast-node-meta-get :original-value node)
+              ;; any ical:print-error here propagates:
+              (if list-sep
+                  (string-join (mapcar #'ical:default-value-printer value)
+                               list-sep)
+                (ical:default-value-printer value)))))
+
+    (unless (and (stringp name-str) (length> name-str 0))
+      (ical:signal-print-error
+       (format "Unknown property name for type `%s'" type)
+       :node node))
+
+    (concat name-str
+            (ical:print-params params)
+            ":"
+            value-str
+            "\n")))
+
+(defun ical:maybe-add-value-param (property-node)
+  "If the type of PROPERTY-NODE's value is not the same as its
+default-type, check that its parameter list contains an
+`icalendar-valuetypeparam' specifying that type as the type for
+the value. If not, add such a parameter to PROPERTY-NODE's list
+of parameters. Returns the possibly-modified PROPERTY-NODE.
+
+If the parameter list already contains a value type parameter for
+a type other than the property value's type, an
+`icalendar-validation-error' is signaled.
+
+If PROPERTY's value is a list, the type of the first element will
+be assumed to be the type for all the values in the list. If the
+list is empty, no change will be made to PROPERTY's parameters."
+  (catch 'no-value-type
+    (let* ((property-type (ical:ast-node-type property-node))
+           (value/s (ical:ast-node-value property-node))
+           (value (if (and (ical:expects-list-of-values-p property-type)
+                           (listp value/s))
+                      (car value/s)
+                    value/s))
+           (value-type (cond ((stringp value) 'ical:text)
+                             ((ical:ast-node-p value)
+                              (ical:ast-node-type value))
+                             ;; if we can't determine a type from the valu=
e, bail:
+                             (t (throw 'no-value-type property-node))))
+           (params (ical:ast-node-children property-node))
+           (expected-type (ical:value-type-from-params params)))
+
+      (when (not (eq value-type (get property-type 'ical:default-type)))
+        (if expected-type
+            (when (not (eq value-type expected-type))
+              (ical:signal-validation-error
+                (format (concat "Mismatching VALUE parameter. VALUE specif=
ies %s "
+                                "but property value has type %s")
+                        expected-type value-type)
+                :node property-node))
+          ;; the value isn't of the default type, but we didn't find a
+          ;; VALUE parameter, so add one now:
+          (let* ((valuetype-param
+                  (ical:make-ast-node 'ical:valuetypeparam
+                                      (list :value (ical:make-ast-node
+                                                    'ical:printed-value-ty=
pe
+                                                    (list :value value-typ=
e)))))
+                 (new-params (cons valuetype-param
+                                   (ical:ast-node-children property-node))=
))
+            (apply #'ical:ast-node-set-children property-node new-params))=
))
+
+      ;; Return the modified property node:
+      property-node)))
+
+(defun ical:value-type-from-params (params)
+  "If there is an `icalendar-valuetypeparam' in PARAMS, return the
+type symbol associated with the value type it specifies."
+  (catch 'found
+    (dolist (param params)
+      (when (ical:value-param-p param)
+        (let ((type (ical:ast-node-value
+                     (ical:ast-node-value param))))
+          (throw 'found type))))))
+
+(defun ical:parse-property (limit)
+  "Parse the current property, up to LIMIT. Point should be at the
+beginning of a property line; LIMIT should be the position at the
+end of the line.
+
+Returns a syntax node for the property. After parsing, point is at the
+beginning of the next content line."
+  (rx-let ((ical:property-start (seq line-start (group-n 1 ical:name))))
+    (let (line-begin line-end property-name property-type params node)
+      ;; Property name
+      (unless (re-search-forward (rx ical:property-start) limit t)
+        (ical:signal-parse-error
+         "Malformed property: could not match property name"
+         :restart-at (1+ limit)))
+
+      (setq property-name (match-string 1))
+      (setq line-begin (line-beginning-position))
+      (setq line-end (line-end-position))
+
+      ;; Parameters
+      (when (looking-at-p ";")
+        (setq params (ical:parse-params line-end)))
+
+      (unless (looking-at-p ":")
+        (ical:signal-parse-error
+         "Malformed property: parameters did not end at colon"
+         :restart-at (1+ limit)))
+      (forward-char)
+
+      ;; Value
+      (setq property-type (alist-get (upcase property-name)
+                                     ical:property-types
+                                     'ical:other-property
+                                     nil #'equal))
+      (setq node (ical:parse-property-value property-type limit params))
+
+      ;; sanity check, since e.g. invalid base64 data might not
+      ;; match all the way to the end of the line, as test
+      ;; rfc5545-sec3.1.3/2 initially revealed
+      (unless (eql (point) (line-end-position))
+        (ical:signal-parse-error
+         (format "%s property value did not consume line: %s"
+                 property-name
+                 (ical:default-value-printer (ical:ast-node-value node)))
+         :restart-at (1+ limit)))
+
+      ;; value, children are set in ical:read-property-value,
+      ;; value-begin, value-end, end in ical:parse-property-value.
+      ;; begin and original-name are only available here:
+      (ical:ast-node-meta-set node :begin line-begin)
+      (when (eq property-type 'ical:other-property)
+        (ical:ast-node-meta-set node :original-name property-name))
+
+      ;; Set point up for the next property parser.
+      (while (not (bolp))
+        (forward-char))
+
+      ;; Return the syntax node
+      node)))
+
+
+;;;; Section 3.7: Calendar Properties
+(ical:define-property ical:calscale "CALSCALE"
+  "Calendar scale.
+
+This property specifies the time scale of an
+`icalendar-vcalendar' object. The only scale defined by RFC5545
+is \"GREGORIAN\", which is the default."
+  ;; only allowed value:
+  "GREGORIAN"
+  :default "GREGORIAN"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.1")
+
+(ical:define-property ical:method "METHOD"
+  "Method for a scheduling request.
+
+When an `icalendar-vcalendar' is sent in a MIME message, this property
+specifies the semantics of the request in the message: e.g. it is
+a request to publish the calendar object, or a reply to an
+invitation. This property and the MIME message's \"method\"
+parameter value must be the same.
+
+RFC5545 does not define any methods, but RFC5546 does; see
+URL `https://www.rfc-editor.org/rfc/rfc5546.html#section-3.2'"
+  ;; TODO: implement methods in RFC5546?
+  ical:iana-token
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.2")
+
+(ical:define-property ical:prodid "PRODID"
+  "Product Identifier.
+
+This property identifies the program that created an
+`icalendar-vcalendar' object. It must be specified exactly once in a
+calendar object. Its value should be a globally unique identifier for
+the program. RFC5545 suggests using an ISO \"Formal Public Identifier\";
+see URL `https://en.wikipedia.org/wiki/Formal_Public_Identifier'."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.3")
+
+(ical:define-property ical:version "VERSION"
+  "Version (2.0 corresponds to RFC5545).
+
+This property specifies the version number of the iCalendar
+specification to which an `icalendar-vcalendar' object conforms,
+and must be specified exactly once in a calendar object. It is
+either the string \"2.0\" or a string like MIN;MAX specifying
+minimum and maximum versions of future revisions of the
+specification."
+  (or "2.0"
+      ;; minver ";" maxver
+      (seq ical:iana-token ?\; ical:iana-token))
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.4")
+
+
+;;;; Section 3.8:
+;;;;; Section 3.8.1: Descriptive Component Properties
+
+(ical:define-property ical:attach "ATTACH"
+  "Attachment.
+
+This property specifies a file attached to an iCalendar
+component, either via a URI, or as encoded binary data. In
+`icalendar-valarm' components, it is used to specify the
+notification sent by the alarm."
+  ;; Groups 11, 12 are used in ical:uri
+  (or (group-n 13 ical:uri)
+      (group-n 14 ical:binary))
+  :default-type ical:uri
+  :other-types (ical:binary)
+  :child-spec (:zero-or-one (ical:fmttypeparam
+                             ical:valuetypeparam
+                             ical:encodingparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:attach-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1")
+
+(defun ical:attach-validator (node)
+  "Additional validator for an `icalendar-attach' NODE.
+Checks that NODE has a correct `icalendar-encodingparam' and
+`icalendar-valuetypeparam' if its value is an `icalendar-binary'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+ATTACH nodes; it is not normally necessary to call it directly."
+  (let* ((value-node (ical:ast-node-value node))
+         (value-type (ical:ast-node-type value-node))
+         (valtypeparam (ical:ast-node-first-child-of 'ical:valuetypeparam =
node))
+         (encodingparam (ical:ast-node-first-child-of 'ical:encodingparam =
node)))
+
+    (when (eq value-type 'ical:binary)
+      (unless (and (ical:ast-node-p valtypeparam)
+                   (eq 'ical:binary
+                       (ical:ast-node-value ; unwrap inner printed-value-t=
ype
+                        (ical:ast-node-value valtypeparam))))
+        (ical:signal-validation-error
+         "`icalendar-binary' attachment requires 'VALUE=3DBINARY' paramete=
r"
+        :node node))
+      (unless (and (ical:ast-node-p encodingparam)
+                   (equal "BASE64" (ical:ast-node-value encodingparam)))
+        (ical:signal-validation-error
+         "`icalendar-binary' attachment requires 'ENCODING=3DBASE64' param=
eter"
+         :node node)))
+    ;; success:
+    node))
+
+(ical:define-property ical:categories "CATEGORIES"
+  "Categories.
+
+This property lists categories or subtypes of an iCalendar
+component for e.g. searching or filtering. The categories can be
+any `icalendar-text' value."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.2")
+
+(ical:define-property ical:class "CLASS"
+  "(Access) Classification.
+
+This property specifies the scope of access that the calendar
+owner intends for a given component, e.g. public or private."
+  (or "PUBLIC"
+      "PRIVATE"
+      "CONFIDENTIAL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "If not specified in a component that allows this property, the
+  ;; default value is PUBLIC. Applications MUST treat x-name and
+  ;; iana-token values they don't recognize the same way as they would
+  ;; the PRIVATE value."
+  :default "PUBLIC"
+  :unrecognized "PRIVATE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3")
+
+(ical:define-property ical:comment "COMMENT"
+  "Comment to calendar user.
+
+This property can be specified multiple times in calendar components,
+and can contain any `icalendar-text' value."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.4")
+
+(ical:define-property ical:description "DESCRIPTION"
+  "Description.
+
+This property should be a longer, more complete description of
+the calendar component than is contained in the
+`icalendar-summary' property. In a `icalendar-vjournal'
+component, it is used to capture a journal entry, and may be
+specified multiple times. Otherwise it may only be specified
+once. In an `icalendar-valarm' component, it contains the
+notification text for a DISPLAY or EMAIL alarm."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5")
+
+(defun ical:read-geo-coordinates (s)
+  "Read an `icalendar-geo-coordinates' value from string S"
+  (let ((vals (mapcar #'string-to-number (string-split s ";"))))
+    (cons (car vals) (cadr vals))))
+
+(defun ical:print-geo-coordinates (val)
+  "Serialize an `icalendar-geo-coordinates' value to a string"
+  (concat (number-to-string (car val)) ";" (number-to-string (cdr val))))
+
+(defun ical:geo-coordinates-p (val)
+  "Return non-nil if VAL is an `icalendar-geo-coordinates' value"
+  (and (floatp (car val)) (floatp (cdr val))))
+
+(ical:define-type ical:geo-coordinates nil ; don't add to ical:value-types
+  "Type for global positions.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-geo' property. When printed, it
+is represented as a pair of `icalendar-float' values separated by
+a semicolon, like LATITUDE;LONGITUDE. When read, it is a dotted
+pair of Elisp floats (LATITUDE . LONGITUDE)."
+  '(satisfies ical:geo-coordinates-p)
+  (seq ical:float ";" ical:float)
+  :reader ical:read-geo-coordinates
+  :printer ical:print-geo-coordinates)
+
+(ical:define-property ical:geo "GEO"
+  "Global position of a component as a pair LATITUDE;LONGITUDE.
+
+Both values are floats representing a number of degrees. The
+latitude value is north of the equator if positive, and south of
+the equator if negative. The longitude value is east of the prime
+meridian if positive, and west of it if negative."
+  ical:geo-coordinates
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.6")
+
+(ical:define-property ical:location "LOCATION"
+  "Location.
+
+This property describes the intended location or venue of a
+component, e.g. a particular room or building, with an
+`icalendar-text' value. RFC5545 suggests using the
+`icalendar-altrep' parameter on this property to provide more
+structured location information."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7")
+
+;; TODO: type for percentages?
+(ical:define-property ical:percent-complete "PERCENT-COMPLETE"
+  "Percent Complete.
+
+This property describes progress toward the completion of an
+`icalendar-vtodo' component. It can appear at most once in such a
+component. If this TODO is assigned to multiple people, the value
+represents the completion state for each person individually. The
+value should be between 0 and 100 (though this is not currently
+enforced here)."
+  ical:integer
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.8")
+
+;; TODO: type for priority values?
+(ical:define-property ical:priority "PRIORITY"
+  "Priority.
+
+This property describes the priority of a component. 0 means an
+undefined priority. Other values range from 1 (highest priority)
+to 9 (lowest priority). See RFC5545 for suggestions on how to
+represent other priority schemes with this property."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9")
+
+(ical:define-property ical:resources "RESOURCES"
+  "Resources for an activity.
+
+This property is a list of `icalendar-text' values that describe
+any resources required or foreseen for the activity represented
+by a component, e.g. a projector and screen for a meeting."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.10")
+
+(ical:define-type ical:status-keyword nil
+  "Keyword value of a STATUS property.
+
+This is not a real type defined by RFC5545; it is defined here to
+facilitate parsing that property."
+  '(and string (satisfies ical:match-status-keyword-value))
+  ;; Note that this type does NOT allow arbitrary text:
+  (or "TENTATIVE"
+      "CONFIRMED"
+      "CANCELLED"
+      "NEEDS-ACTION"
+      "COMPLETED"
+      "IN-PROCESS"
+      "DRAFT"
+      "FINAL"))
+
+(ical:define-property ical:status "STATUS"
+  "Overall status or confirmation.
+
+This property is a keyword used by an Organizer to inform
+Attendees about the status of a component, e.g. whether an
+`icalendar-vevent' has been cancelled, whether an
+`icalendar-vtodo' has been completed, or whether an
+`icalendar-vjournal' is still in draft form. It can be specified
+at most once on these components."
+  ical:status-keyword
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11")
+
+(ical:define-property ical:summary "SUMMARY"
+  "Short summary.
+
+This property provides a short, one-line description of a
+component for display purposes. In an EMAIL `icalendar-valarm',
+it is used as the subject of the email. A longer description of
+the component can be provided in the `icalendar-description'
+property."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12")
+
+;;;;; Section 3.8.2: Date and Time Component Properties
+
+(ical:define-property ical:completed "COMPLETED"
+  "Time completed.
+
+This property is a timestamp that records the date and time when
+an `icalendar-vtodo' was actually completed. The value must be an
+`icalendar-date-time' with a UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
+
+(ical:define-property ical:dtend "DTEND"
+  "End time of an event or free/busy block.
+
+This property's value specifies when an `icalendar-vevent' or
+`icalendar-freebusy' ends. Its value must be of the same type as
+the value of the component's corresponding `icalendar-dtstart'
+property. The value is a non-inclusive bound, i.e., the value of
+this property must be the first time or date *after* the end of
+the event or free/busy block."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2")
+
+(ical:define-property ical:due "DUE"
+  "Due date.
+
+This property specifies the date (and possibly time) by which an
+`icalendar-todo' item is expected to be completed, i.e., its
+deadline. If the component also has an `icalendar-dtstart'
+property, the two properties must have the same value type, and
+the value of the DTSTART property must be earlier than the value
+of this property."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.3")
+
+(ical:define-property ical:dtstart "DTSTART"
+  "Start time of a component.
+
+This property's value specifies when a component starts. In an
+`icalendar-vevent', it specifies the start of the event. In an
+`icalendar-vfreebusy', it specifies the start of the free/busy
+block. In `icalendar-standard' and `icalendar-daylight'
+sub-components, it defines the start time of a time zone
+specification.
+
+It is required in any component with an `icalendar-rrule'
+property, and in any `icalendar-vevent' component contained in a
+calendar that does not have a `icalendar-method' property.
+
+Its value must be of the same type as the value of the
+component's corresponding `icalendar-dtend' property. In an
+`icalendar-vtodo' component, it must also be of the same type as
+the value of an `icalendar-due' property (if present)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4")
+
+(ical:define-property ical:duration "DURATION"
+  "Duration.
+
+This property specifies a duration of time for a component.
+In an `icalendar-vevent', it can be used to implicitly specify
+the end of the event, instead of an explicit `icalendar-dtend'.
+In an `icalendar-vtodo', it can likewise be used to implicitly specify
+the due date, instead of an explicit `icalendar-due'.
+In an `icalendar-valarm', it used to specify the delay period
+before the alarm repeats.
+
+If a related `icalendar-dtstart' property has an `icalendar-date'
+value, then the duration must be given as a number of weeks or days."
+  ical:dur-value
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.5")
+
+(ical:define-property ical:freebusy "FREEBUSY"
+  "Free/Busy Times.
+
+This property specifies a list of periods of free or busy time in
+an `icalendar-vfreebusy' component. Whether it specifies free or
+busy times is determined by its `icalendar-fbtype' parameter. The
+times in each period must be in UTC format."
+  ical:period
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:fbtypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.6")
+
+(ical:define-property ical:transp "TRANSP"
+  "Time Transparency for free/busy searches.
+
+Note that this property only allows two values: \"TRANSPARENT\"
+or \"OPAQUE\". An OPAQUE value means that the component consumes
+time on a calendar. TRANSPARENT means it does not, and thus is
+invisible to free/busy time searches."
+  ;; Note that this does NOT allow arbitrary text:
+  (or "TRANSPARENT"
+      "OPAQUE")
+  :default "OPAQUE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7")
+
+;;;;; Section 3.8.3: Time Zone Component Properties
+
+(ical:define-property ical:tzid "TZID"
+  "Time Zone Identifier.
+
+This property specifies the unique identifier for a time zone in
+an `icalendar-vtimezone' component, and is a required property of
+that component. This is an identifier that `icalendar-tzidparam'
+parameters in other components may then refer to."
+  (seq (zero-or-one "/") ical:text)
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.1")
+
+(ical:define-property ical:tzname "TZNAME"
+  "Time Zone Name.
+
+This property specifies a customary name for a time zone in
+`icalendar-daylight' and `icalendar-standard' sub-components."
+  ical:text
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.2")
+
+(ical:define-property ical:tzoffsetfrom "TZOFFSETFROM"
+  "Time Zone Offset (prior to observance).
+
+This property specifies the time zone offset that is in use
+*prior to* this time zone observance. It is used to calculate the
+absolute time at which the observance takes place. It is a
+required property of an `icalendar-vtimezone' component. Positive
+numbers indicate time east of the prime meridian (ahead of UTC).
+Negative numbers indicate time west of the prime meridian (behind
+UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.3")
+
+(ical:define-property ical:tzoffsetto "TZOFFSETTO"
+  "Time Zone Offset (in this observance).
+
+This property specifies the time zone offset that is in use *in*
+this time zone observance. It is used to calculate the absolute
+time at which a new observance takes place. It is a required
+property of `icalendar-standard' and `icalendar-daylight'
+components. Positive numbers indicate time east of the prime
+meridian (ahead of UTC). Negative numbers indicate time west of
+the prime meridian (behind UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.4")
+
+(ical:define-property ical:tzurl "TZURL"
+  "Time Zone URL.
+
+This property specifies a URL where updated versions of an
+`icalendar-vtimezone' component are published."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.5")
+
+;;;;; Section 3.8.4: Relationship Component Properties
+
+(ical:define-property ical:attendee "ATTENDEE"
+  "Attendee.
+
+This property specfies a participant in a `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-valarm'. It is required when the
+containing component represents event, task, or notification for
+a *group* of people, but not for components that simply represent
+these items in a single user's calendar (in that case, it should
+not be specified). The property can be specified multiple times,
+once for each participant in the event or task. In an
+EMAIL-category VALARM component, this property specifies the
+address of the user(s) who should receive the notification email.
+
+The parameters `icalendar-roleparam', `icalendar-partstatparam',
+`icalendar-rsvpparam', `icalendar-delfromparam', and
+`icalendar-deltoparam' are especially relevant for further
+specifying the roles of each participant in the containing
+component."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cutypeparam
+                             ical:memberparam
+                             ical:roleparam
+                             ical:partstatparam
+                             ical:rsvpparam
+                             ical:deltoparam
+                             ical:delfromparam
+                             ical:sentbyparam
+                             ical:cnparam
+                             ical:dirparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1")
+
+(ical:define-property ical:contact "CONTACT"
+  "Contact.
+
+This property provides textual contact information relevant to an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy'."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.2")
+
+(ical:define-property ical:organizer "ORGANIZER"
+  "Organizer.
+
+This property specifies the organizer of a group-scheduled
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'.
+It is required in those components if they represent a calendar
+entity with multiple participants. In an `icalendar-vfreebusy'
+component, it used to specify the user requesting free or busy
+time, or the user who published the calendar that the free/busy
+information comes from."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cnparam
+                             ical:dirparam
+                             ical:sentbyparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3")
+
+(ical:define-property ical:recurrence-id "RECURRENCE-ID"
+  "Recurrence ID.
+
+This property is used together with the `icalendar-uid' and
+`icalendar-sequence' properties to identify a specific instance
+of a recurring `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' component. The property value is the
+original value of the `icalendar-dtstart' property of the
+recurrence instance. Its value must have the same type as that
+property's value, and both must specify times in the same way
+(either local or UTC)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam
+                             ical:tzidparam
+                             ical:rangeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.4")
+
+(ical:define-property ical:related-to "RELATED-TO"
+  "Related To (component UID).
+
+This property specifies the `icalendar-uid' value of a different,
+related calendar component. It can be specified on an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. An `icalendar-reltypeparam' can be used to specify the
+relationship type."
+  ical:text
+  :child-spec (:zero-or-one (ical:reltypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.5")
+
+(ical:define-property ical:url "URL"
+  "Uniform Resource Locator.
+
+This property specifies the URL associated with an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy' component."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6")
+
+;; TODO: UID should probably be its own type
+(ical:define-property ical:uid "UID"
+  "Unique Identifier.
+
+This property specifies a globally unique identifier for the
+containing component, and is required in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vfreebusy'
+component.
+
+RFC5545 requires that the program generating the UID guarantee
+that it be unique, and recommends generating it in a format which
+includes a timestamp on the left hand side of an '@' character,
+and the domain name or IP address of the host on the right-hand
+side."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.7")
+
+;;;;; Section 3.8.5: Recurrence Component Properties
+
+(ical:define-property ical:exdate "EXDATE"
+  "Exception Date-Times.
+
+This property defines a list of exceptions to a recurrence rule
+in an `icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component. Together
+with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-rdate' properties, it defines the recurrence set of
+the component."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1")
+
+(ical:define-property ical:rdate "RDATE"
+  "Recurrence Date-Times.
+
+This property defines a list of date-times or dates on which an
+`icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component recurs.
+Together with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-exdate' properties, it defines the recurrence set of
+the component."
+  (or ical:period
+      ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date ical:period)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.2")
+
+(ical:define-property ical:rrule "RRULE"
+  "Recurrence Rule.
+
+This property defines a rule or repeating pattern for the dates
+and times on which an `icalendar-vevent', `icalendar-todo',
+`icalendar-vjournal', `icalendar-standard', or
+`icalendar-daylight' component recurs. Together with the
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate'
+properties, it defines the recurrence set of the component."
+  ical:recur
+  ;; TODO: faces for subexpressions?
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3")
+
+;;;;; Section 3.8.6: Alarm Component Properties
+
+(ical:define-property ical:action "ACTION"
+  "Action (when alarm triggered).
+
+This property defines the action to be taken when the containing
+`icalendar-valarm' component is triggered. It is a required
+property in an alarm component."
+  (or "AUDIO"
+      "DISPLAY"
+      "EMAIL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "Applications MUST ignore alarms with x-name and iana-token values
+  ;; they don't recognize." This substitute is not defined in the
+  ;; standard but is the simplest way to parse such alarms:
+  :unrecognized "IGNORE"
+  :default-type ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.1")
+
+(ical:define-property ical:repeat "REPEAT"
+  "Repeat Count (after initial trigger).
+
+This property specifies the number of times an `icalendar-valarm'
+should repeat after it is initially triggered. This property,
+along with the `icalendar-duration' property, is required if the
+alarm triggers more than once."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.2")
+
+(ical:define-property ical:trigger "TRIGGER"
+  "Trigger.
+
+This property specifies when an `icalendar-valarm' should
+trigger. If the value is an `icalendar-dur-value', it represents
+a time of that duration relative to the start or end of a related
+`icalendar-vevent' or `icalendar-vtodo'. Whether the trigger
+applies to the start time or end time of the related component
+can be specified with the `icalendar-trigrelparam' parameter. A
+positive duration value triggers after the start or end of the
+related component; a negative duration value triggers before.
+
+If the value is an `icalendar-date-time', it must be in UTC
+format, and it triggers at the specified time."
+  (or ical:dur-value
+      ical:date-time)
+  :default-type ical:dur-value
+  :other-types (ical:date-time)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:trigrelparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:trigger-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.3")
+
+(defun ical:trigger-validator (node)
+  "Additional validator for an `icalendar-trigger' NODE.
+Checks that NODE has valid parameters depending on the type of its value.
+
+This function is called by `icalendar-ast-node-valid-p' for
+TRIGGER nodes; it is not normally necessary to call it directly."
+  (let* ((params (ical:ast-node-children node))
+         (value-node (ical:ast-node-value node))
+         (value-type (and value-node (ical:ast-node-type value-node))))
+    (when (eq value-type 'ical:date-time)
+      (let ((expl-type (ical:value-type-from-params params))
+            (dt-value (ical:ast-node-value value-node)))
+        (unless (eq expl-type 'ical:date-time)
+          (ical:signal-validation-error
+           (concat "Explicit `icalendar-valuetypeparam' required in "
+                   "`icalendar-trigger' with non-duration value")
+           :node node))
+        (when (ical:ast-node-first-child-of 'ical:trigrelparam node)
+          (ical:signal-validation-error
+           (concat "`icalendar-trigrelparam' not allowed in "
+                   "`icalendar-trigger' with non-duration value")
+           :node node))
+        (unless (ical:date-time-is-utc-p dt-value)
+          (ical:signal-validation-error
+           (concat "`icalendar-date-time' value of `icalendar-trigger' "
+                   "must be in UTC time")
+           :node node))))
+    ;; success:
+    node))
+
+;;;;; Section 3.8.7: Change Management Component Properties
+
+(ical:define-property ical:created "CREATED"
+  "Date-Time Created.
+
+This property specifies the date and time when the calendar user
+initially created an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' in the calendar database. The value must be
+in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
+
+(ical:define-property ical:dtstamp "DTSTAMP"
+  "Timestamp (of last revision or instance creation).
+
+In an `icalendar-vevent', `icalendar-vtodo',
+`icalendar-vjournal', or `icalendar-vfreebusy', this property
+specifies the date and time when the calendar user last revised
+the component's data in the calendar database. (In this case, it
+is equivalent to the `icalendar-last-modified' property.)
+
+If this property is specified on an `icalendar-vcalendar' object
+which contains an `icalendar-method' property, it specifies the
+date and time when that instance of the calendar object was
+created. In this case, it differs from the `icalendar-creation'
+and `icalendar-last-modified' properties: whereas those specify
+the time the underlying data was created and last modified in the
+calendar database, this property specifies when the calendar
+object *representing* that data was created.
+
+The value must be in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
+
+(ical:define-property ical:last-modified "LAST-MODIFIED"
+  "Last Modified timestamp.
+
+This property specifies when the data in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vtimezone'
+was last modified in the calendar database."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3")
+
+(ical:define-property ical:sequence "SEQUENCE"
+  "Revision Sequence Number.
+
+This property specifies the number of the current revision in a
+sequence of revisions in an `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-vjournal' component. It starts
+at 0 and should be incremented monotonically every time the
+Organizer makes a significant revision to the calendar data that
+component represents."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.4")
+
+;;;;; Section 3.8.8: Miscellaneous Component Properties
+;; IANA and X- properties should be parsed and printed but can be ignored:
+(ical:define-property ical:other-property nil ; don't add to ical:property=
-types
+  "IANA or X-name property.
+
+This property type corresponds to the IANA Properties and
+Non-Standard Properties defined in RFC5545; it represents
+properties with an unknown name (matching rx
+`icalendar-iana-token' or `icalendar-x-name') whose values must
+be parsed and preserved but not further interpreted. Its value
+may be set to any type with the `icalendar-valuetypeparam'
+parameter."
+  ical:value
+  :default-type ical:text
+  ;; "The default value type is TEXT. The value type can be set to any
+  ;; value type." TODO: should we specify :other-types? Without it, a
+  ;; VALUE param will be required to parse anything other than text,
+  ;; but that seems reasonable.
+  :child-spec (:allow-others t)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8")
+
+(defun ical:read-req-status-info (s)
+  "Read a request status value from S.
+S should have been previously matched against `icalendar-request-status-in=
fo'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for n=
ow.
+  (ignore s)
+  (let ((code (match-string 11))
+        (desc (match-string 12))
+        (exdata (match-string 13)))
+    (list code (ical:read-text desc) (when exdata (ical:read-text exdata))=
)))
+
+(defun ical:print-req-status-info (rsi)
+  "Serialize request status info value RSI to a string."
+  (let ((code (car rsi))
+        (desc (cadr rsi))
+        (exdata (caddr rsi)))
+    (if exdata
+        (format "%s;%s;%s" code (ical:print-text desc) (ical:print-text ex=
data))
+      (format "%s;%s" code (ical:print-text desc)))))
+
+(defun ical:req-status-info-p (val)
+  "Return non-nil if VAL is an `icalendar-request-status-info' value."
+  (and (listp val)
+       (length=3D val 3)
+       (stringp (car val))
+       (stringp (cadr val))
+       (cl-typep (caddr val) '(or string null))))
+
+(ical:define-type ical:req-status-info nil
+  "Type for REQUEST-STATUS property values.
+
+When read, a list (CODE DESCRIPTION EXCEPTION). CODE is a hierarchical
+numerical code, represented as a string, with the following meanings:
+  1.xx Preliminary success
+  2.xx Successful
+  3.xx Client Error
+  4.xx Scheduling Error
+DESCRIPTION is a longer description of the request status, also a string.
+EXCEPTION (which may be nil) is textual data describing an error.
+
+When printed, the three elements are separated by semicolons, like
+  CODE;DESCRIPTION;EXCEPTION
+or
+  CODE;DESCRIPTION
+if EXCEPTION is nil.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-request-status' property."
+  '(satisfies ical:req-status-info-p)
+  (seq
+   ;; statcode: hierarchical status code
+   (group-n 11
+     (seq (one-or-more digit)
+          (** 1 2 (seq ?. (one-or-more digit)))))
+   ?\;
+   ;; statdesc: status description
+   (group-n 12 ical:text)
+   ;; exdata: exception data
+   (zero-or-one (seq ?\; (group-n 13 ical:text))))
+  :reader ical:read-req-status-info
+  :printer ical:print-req-status-info
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+(ical:define-property ical:request-status "REQUEST-STATUS"
+  "Request status"
+  ical:req-status-info
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+
+;;; Section 3.6: Calendar Components
+
+(defconst ical:component-types nil ;; populated by ical:define-component
+  "Alist mapping printed component names to type symbols")
+
+(defun ical:parse-component (limit)
+  "Parse an iCalendar component from point up to LIMIT.
+Point should be at the start of the component, i.e., at the start
+of a line that looks like \"BEGIN:[COMPONENT-NAME]\". After parsing,
+point is at the beginning of the next line following the component
+(or end of the buffer). Returns a syntax node representing the component."
+  (let ((begin-pos nil)
+        (body-begin-pos nil)
+        (end-pos nil)
+        (body-end-pos nil)
+        (begin-regex (rx line-start "BEGIN:" (group-n 2 ical:name) line-en=
d)))
+
+    (unless (re-search-forward begin-regex limit t)
+      (ical:signal-parse-error "Not at start of a component"))
+
+    (setq begin-pos (match-beginning 0)
+          body-begin-pos (1+ (match-end 0))) ; start of next line
+
+    (let* ((component-name (match-string 2))
+           (known-type (alist-get (upcase component-name)
+                                  ical:component-types
+                                  nil nil #'equal))
+           (component-type (or known-type 'ical:other-component))
+           child children)
+
+      ;; Find end of component:
+      (save-excursion
+        (if (re-search-forward (concat "^END:" component-name "$") limit t)
+            (setq end-pos (match-end 0)
+                  body-end-pos (1- (match-beginning 0))) ; end of prev. li=
ne
+          (ical:signal-parse-error
+           (format  "Matching 'END:%s' not found between %d and %d"
+                    component-name begin-pos limit)
+           :restart-at (1+ limit))))
+
+      (while (not (bolp))
+        (forward-char))
+
+      ;; Parse the properties and subcomponents of this component:
+      (while (<=3D (point) body-end-pos)
+        (condition-case err
+            (setq child (ical:parse-property-or-component end-pos))
+          (ical:parse-error
+           (ical:handle-parse-error err)
+           (setq child nil)))
+        (when child (push child children)))
+
+      ;; Set point up for the next parser:
+      (goto-char end-pos)
+      (while (and (< (point) (point-max)) (not (bolp)))
+        (forward-char))
+
+      ;; Return the syntax node for the component:
+      (when children
+        (ical:make-ast-node component-type
+                            (list
+                             :original-name
+                             (when (eq component-type 'ical:other-componen=
t)
+                               component-name)
+                             :buffer (current-buffer)
+                             :begin begin-pos
+                             :end end-pos
+                             :value-begin body-begin-pos
+                             :value-end body-end-pos)
+                            (nreverse children))))))
+
+(defun ical:parse-property-or-component (limit)
+  "Parse a component or a property at point.
+Point should be at the beginning of a line which begins a
+component or contains a property."
+  (cond ((looking-at-p (rx line-start "BEGIN:" ical:name line-end))
+         (ical:parse-component limit))
+        ((looking-at-p (rx line-start ical:name))
+         (ical:parse-property (line-end-position)))
+        (t (ical:signal-parse-error
+            "Not at start of property or component"
+            :restart-at ; find start of next content line:
+            (save-excursion
+              (if (re-search-forward (rx line-start ical:name) nil t)
+                  (match-beginning 0)
+                (point-max)))))))
+
+(defun ical:print-component-node (node)
+  "Serialize a component syntax node NODE to a string."
+  (let* ((type (ical:ast-node-type node))
+         (name (or (ical:ast-node-meta-get :original-name node)
+                   (car (rassq type ical:component-types))))
+         (children (ical:ast-node-children node))
+         body)
+
+    (unless name
+      (ical:signal-print-error
+       (format "Unknown component name for type `%s'" type)
+       :node node))
+
+    (dolist (child children)
+      (condition-case err
+          (setq body
+                (concat body (ical:print-property-or-component child)))
+        (ical:print-error
+         (if (ical:ast-node-required-child-p child node)
+             (ical:signal-print-error
+              (format
+               "Unable to print required `%s' %s in `%s' component. Error =
was:\n%s"
+               (ical:ast-node-type child)
+               (if (ical:component-node-p child) "subcomponent" "property")
+               (ical:ast-node-type node)
+               (plist-get (cdr err) :message))
+              :node node)
+           (ical:handle-print-error err)))))
+    (concat
+     (format "BEGIN:%s\n" name)
+     body
+     (format "END:%s\n" name))))
+
+(defun ical:print-property-or-component (node)
+  "Serialize a property or component node NODE to a string."
+  (cond ((ical:property-node-p node)
+         (ical:print-property-node node))
+        ((ical:component-node-p node)
+         (ical:print-component-node node))
+        (t (ical:signal-print-error "Not a component or property node"
+                                    :node node))))
+
+(ical:define-component ical:vevent "VEVENT"
+  "Represents an event.
+
+This component contains properties which describe an event, such
+as its start and end time (`icalendar-dtstart' and
+`icalendar-dtend') and a summary (`icalendar-summary') and
+description (`icalendar-description'). It may also contain
+`icalendar-valarm' components as subcomponents which describe
+reminder notifications related to the event. Event components can
+only be direct children of an `icalendar-vcalendar'; they cannot
+be subcomponents of any other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:dtstart
+                             ;; TODO: dtstart required if METHOD not prese=
nt
+                             ;; in parent calendar
+                             ical:class
+                             ical:created
+                             ical:description
+                             ical:dtend
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:priority
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:transp
+                             ical:url
+                             ical:recurrence-id
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vevent-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1")
+
+(defun ical:rrule-validator (node)
+  "When component NODE has an `icalendar-rrule', validate that its
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate' properties
+satisfy the requirements imposed by this rule."
+  (let* ((rrule (ical:ast-node-first-child-of 'ical:rrule node))
+         (recval (when rrule (ical:ast-node-value rrule)))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node))
+         (start (when dtstart (ical:ast-node-value dtstart)))
+         (rdates (ical:ast-node-children-of 'ical:rdate node))
+         (included (when rdates
+                     (mapcar #'ical:ast-node-value
+                             (apply #'append
+                                    (mapcar #'ical:ast-node-value rdates))=
))))
+    (when rrule
+      (unless dtstart
+        (ical:signal-validation-error
+         "An `icalendar-rrule' requires an `icalendar-dtstart' property"
+         :node node))
+      (when included
+        ;; ""RDATE" in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with local time
+        ;; value, relative to the UTC offset specified in the
+        ;; "TZOFFSETFROM" property."
+        (when (and (memq (ical:ast-node-type node) '(ical:standard ical:da=
ylight)))
+          (unless (ical:list-of-p included 'ical:date-time)
+            (ical:signal-validation-error
+             (format
+              (concat "`icalendar-rdate' values must be `icalendar-date-ti=
me' "
+                      "values in %s components")
+              (ical:ast-node-type node))
+             :node node))
+          (when (seq-some #'decoded-time-zone included)
+            (ical:signal-validation-error
+             (format
+              (concat "`icalendar-rdate' values must be in local (\"floati=
ng\")"
+                      "time in %s components")
+              (ical:ast-node-type node))
+             :node node))))
+
+      (let* ((freq (car (alist-get 'FREQ recval)))
+             (until (car (alist-get 'UNTIL recval))))
+        (when (eq 'ical:date (ical:ast-node-type start))
+          (when (or (memq freq '(HOURLY MINUTELY SECONDLY))
+                    (assq 'BYSECOND recval)
+                    (assq 'BYMINUTE recval)
+                    (assq 'BYHOUR recval))
+            (ical:signal-validation-error
+             (concat "`icalendar-rrule' must not contain time-based "
+                     "rules when `icalendar-dtstart' is a plain date")
+             :node node)))
+        (when until
+          (unless (eq (ical:ast-node-type start)
+                      (ical:ast-node-type until))
+            (ical:signal-validation-error
+             (concat "`icalendar-rrule' UNTIL clause must agree with "
+                     "type of `icalendar-dtstart' property")
+             :node node))
+          (when (eq 'ical:date-time (ical:ast-node-type until))
+            (let ((until-zone
+                   (decoded-time-zone (ical:ast-node-value until)))
+                  (start-zone
+                   (decoded-time-zone (ical:ast-node-value start))))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with local time, then the UNTIL rule part MUST also
+              ;; be specified as a date with local time":
+              (when (and (null start-zone) (not (null until-zone)))
+                (ical:signal-validation-error
+                  (concat "`icalendar-rrule' UNTIL clause must be in "
+                          "local time if `icalendar-dtstart' is")
+                  :node node))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with UTC time or a date with local time and time zone
+              ;; reference, then the UNTIL rule part MUST be specified
+              ;; as a date with UTC time":
+              (when (and (integerp start-zone)
+                         (not (ical:date-time-is-utc-p until)))
+                (ical:signal-validation-error
+                  (concat "`icalendar-rrule' UNTIL clause must be in UTC t=
ime "
+                          "if `icalendar-dtstart' has a defined time zone")
+                  :node node))))
+          (when (memq (ical:ast-node-type node) '(ical:standard ical:dayli=
ght))
+            ;; "In the case of the "STANDARD" and "DAYLIGHT"
+            ;; sub-components the UNTIL rule part MUST always be
+            ;; specified as a date with UTC time":
+            (unless (ical:date-time-is-utc-p until)
+              (ical:signal-validation-error
+               (concat "`icalendar-rrule' UNTIL clause must be in UTC time=
 in "
+                       "`icalendar-standard' and `icalendar-daylight' comp=
onents")
+               :node node))))
+
+        ;; "DTSTART in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with a local
+        ;; time value."
+        (when (memq (ical:ast-node-type node) '(ical:standard ical:dayligh=
t))
+          (unless (eq 'ical:date-time (ical:ast-node-type start))
+            (ical:signal-validation-error
+              (concat "`icalendar-dtstart' must be an `icalendar-date-time=
' in "
+                      "`icalendar-standard' and `icalendar-daylight' compo=
nents")
+              :node node))
+
+          (when (decoded-time-zone (ical:ast-node-value start))
+            (ical:signal-validation-error
+             (concat "`icalendar-dtstart' must be in local (\"floating\") =
time in "
+                     "`icalendar-standard' and `icalendar-daylight' compon=
ents")
+             :node node)))))
+
+    ;; Success:
+    node))
+
+(defun ical:vevent-validator (node)
+  "Additional validator for an `icalendar-vevent' NODE.
+Checks that NODE has does not have both `icalendar-duration' and
+`icalendar-dtend' properties, and
+calls `icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VEVENT nodes; it is not normally necessary to call it directly."
+  (let* ((duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dur-value (when duration (ical:ast-node-value
+                                     (ical:ast-node-value duration))))
+         (dtend (ical:ast-node-first-child-of 'ical:dtend node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and dtend duration)
+      (ical:signal-validation-error
+       (concat "`icalendar-dtend' and `icalendar-duration' properties must=
 "
+               "not appear in the same `icalendar-vevent'")
+       :node node))
+    ;; don't allow time-based durations with dates
+    ;; TODO: check that the standard disallows this...?
+    (when (and dtstart duration
+               (eq 'ical:date (ical:ast-node-type dtstart))
+               (or (not (integerp dur-value))
+                   (decoded-time-hour dur-value)
+                   (decoded-time-minute dur-value)
+                   (decoded-time-second dur-value)))
+      (ical:signal-validation-error
+       (concat "Event with `icalendar-date' value in `icalendar-dtstart' "
+               "cannot have time units in `icalendar-duration'")
+       :node node))
+
+  (ical:rrule-validator node)
+  ;; success:
+  node))
+
+(ical:define-component ical:vtodo "VTODO"
+  "Represents a To-Do item or task.
+
+This component contains properties which describe a to-do item or
+task, such as its due date (`icalendar-due') and a summary
+(`icalendar-summary') and description (`icalendar-description').
+It may also contain `icalendar-valarm' components as
+subcomponents which describe reminder notifications related to
+the task. To-do components can only be direct children of an
+`icalendar-vcalendar'; they cannot be subcomponents of any other
+component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:completed
+                             ical:created
+                             ical:description
+                             ical:dtstart
+                             ical:due
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:percent-complete
+                             ical:priority
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vtodo-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.2")
+
+(defun ical:vtodo-validator (node)
+  "Additional validator for an `icalendar-vtodo' NODE.
+Checks that NODE has conformant `icalendar-due',
+`icalendar-duration', and `icalendar-dtstart' properties, and calls
+`icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTODO nodes; it is not normally necessary to call it directly."
+  (let* ((due (ical:ast-node-first-child-of 'ical:due node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and due duration)
+      (ical:signal-validation-error
+       (concat "`icalendar-due' and `icalendar-duration' properties "
+               "must not appear in the same `icalendar-vtodo'")
+       :node node))
+    (when (and duration (not dtstart))
+      (ical:signal-validation-error
+       (concat "`icalendar-duration' requires `icalendar-dtstart' "
+               "property in the same `icalendar-vtodo'")
+       :node node)))
+  (ical:rrule-validator node)
+  ;; success:
+  node)
+
+(ical:define-component ical:vjournal "VJOURNAL"
+  "Represents a journal entry.
+
+This component contains properties which describe a journal
+entry, which might be any longer-form data (e.g., meeting notes,
+a diary entry, or information needed to complete a task). It can
+be associated with an `icalendar-vevent' or `icalendar-vtodo' via
+the `icalendar-related-to' property. A journal entry does not
+take up time in a calendar, and plays no role in searches for
+free or busy time. Journal components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:created
+                             ical:dtstart
+                             ical:last-modified
+                             ical:organizer
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:description
+                              ical:exdate
+                              ical:related-to
+                              ical:rdate
+                              ical:request-status
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3")
+
+(ical:define-component ical:vfreebusy "VFREEBUSY"
+  "Represents a published set of free/busy time blocks, or a request
+or response for such blocks.
+
+The free/busy information is represented by the
+`icalendar-freebusy' property (which may be given more than once)
+and the related `icalendar-fbtype' parameter. Note that
+recurrence properties (`icalendar-rrule', `icalendar-rdate', and
+`icalendar-exdate') are NOT permitted in this component.
+
+When used to publish blocks of free/busy time in a user's
+schedule, the `icalendar-organizer' property specifies the user.
+
+When used to request free/busy time in a user's schedule, or to
+respond to such a request, the `icalendar-attendee' property
+specifies the user whose time is being requested, and the
+`icalendar-organizer' property specifies the user making the
+request.
+
+Free/busy components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component, and cannot contain subcomponents."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:contact
+                             ical:dtstart
+                             ical:dtend
+                             ical:organizer
+                             ical:url)
+               :zero-or-more (ical:attendee
+                              ical:comment
+                              ical:freebusy
+                              ical:request-status
+                              ical:other-property))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.4")
+
+;; TODO: RFC7808 defines additional properties that are relevant here:
+;; https://www.rfc-editor.org/rfc/rfc7808.html#section-7
+(ical:define-component ical:vtimezone "VTIMEZONE"
+  "Represents a time zone.
+
+A time zone is identified by an `icalendar-tzid' property, which
+is required in this component. Times in other calendar components
+can be specified in local time in this time zone with the
+`icalendar-tzidparam' parameter. An `icalendar-vcalendar' object
+must contain exactly one `icalendar-vtimezone' component for each
+unique time zone identifier used in the calendar.
+
+Besides the time zone identifier, a time zone component must
+contain at least one `icalendar-standard' or `icalendar-daylight'
+subcomponent, which describe the observance of standard or
+daylight time in the time zone, including the dates of the
+observance and the relevant offsets from UTC time."
+  :child-spec (:one (ical:tzid)
+               :zero-or-one (ical:last-modified
+                             ical:tzurl)
+               :zero-or-more (ical:standard
+                              ical:daylight
+                              ical:other-property))
+  :other-validator ical:vtimezone-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(defun ical:vtimezone-validator (node)
+  "Additional validator for an `icalendar-vtimezone' NODE.
+Checks that NODE has at least one `icalendar-standard' or
+`icalendar-daylight' child.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTIMEZONE nodes; it is not normally necessary to call it directly."
+  (let ((child-counts (ical:count-children-by-type node)))
+    (when (and (=3D 0 (alist-get 'ical:standard child-counts 0))
+               (=3D 0 (alist-get 'ical:daylight child-counts 0)))
+      (ical:signal-validation-error
+       (concat "`icalendar-vtimezone' must have at least one "
+               "`icalendar-standard' or `icalendar-daylight' child")
+       :node node)))
+
+  ;; success:
+  node)
+
+(ical:define-component ical:standard "STANDARD"
+  "Represents a Standard Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other explanatory
+comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:daylight "DAYLIGHT"
+  "Represents a Daylight Savings Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other
+explanatory comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:valarm "VALARM"
+  "Represents an alarm.
+
+An alarm is a notification or reminder for an event or task. The
+type of notification is determined by this component's
+`icalendar-action' property: it may be an AUDIO, DISPLAY, or
+EMAIL notification.
+If it is an audio alarm, it can include an
+`icalendar-attach' property specifying the audio to be rendered.
+If it is a DISPLAY alarm, it must include an `icalendar-description'
+property containing the text to be displayed.
+If it is an EMAIL alarm, it must include both an
+`icalendar-summary' and an `icalendar-description', which specify
+the subject and body of the email, and one or more
+`icalendar-attendee' properties, which specify the recipients.
+
+The required `icalendar-trigger' property specifies when the
+alarm triggers. If the alarm repeats, then `icalendar-duration'
+and `icalendar-repeat' properties are also both required.
+
+This component must occur as a direct child of an
+`icalendar-vevent' or `icalendar-vtodo' component, and cannot
+contain any subcomponents."
+  :child-spec (:one (ical:action ical:trigger)
+               :zero-or-one (ical:duration ical:repeat)
+               :zero-or-more (ical:summary
+                              ical:description
+                              ical:attendee
+                              ical:attach
+                              ical:other-property))
+  :other-validator ical:valarm-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.6")
+
+(defun ical:valarm-validator (node)
+  "Additional validator function for `icalendar-valarm' components.
+Checks that NODE has the right properties corresponding to its
+`icalendar-action' type, e.g., that an EMAIL alarm has a
+subject (`icalendar-summary') and recipients (`icalendar-attendee').
+
+This function is called by `icalendar-ast-node-valid-p' for
+VALARM nodes; it is not normally necessary to call it directly."
+  (let* ((action (ical:ast-node-first-child-of 'ical:action node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (repeat (ical:ast-node-first-child-of 'ical:repeat node))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when (and duration (not repeat))
+      (ical:signal-validation-error
+       (concat "`icalendar-valarm' node with `icalendar-duration' "
+               "must also have `icalendar-repeat' property")
+       :node node))
+
+    (when (and repeat (not duration))
+      (ical:signal-validation-error
+       (concat "`icalendar-valarm' node with `icalendar-repeat' "
+               "must also have `icalendar-duration' property")
+       :node node))
+
+    (let ((action-str (upcase (ical:text-to-string
+                               (ical:ast-node-value action)))))
+      (cond ((equal "AUDIO" action-str)
+             (unless (<=3D (alist-get 'ical:attach child-counts 0) 1)
+               (ical:signal-validation-error
+                (concat "AUDIO `icalendar-valarm' may not have "
+                        "more than one `icalendar-attach'")
+                :node node))
+             node)
+
+            ((equal "DISPLAY" action-str)
+             (unless (=3D 1 (alist-get 'ical:description child-counts 0))
+               (ical:signal-validation-error
+                (concat "DISPLAY `icalendar-valarm' must have "
+                        "exactly one `icalendar-description'")
+                :node node))
+             node)
+
+            ((equal "EMAIL" action-str)
+             (unless (=3D 1 (alist-get 'ical:summary child-counts 0))
+               (ical:signal-validation-error
+                (concat "EMAIL `icalendar-valarm' must have "
+                        "exactly one `icalendar-summary'")
+                :node node))
+             (unless (=3D 1 (alist-get 'ical:description child-counts 0))
+               (ical:signal-validation-error
+                (concat "EMAIL `icalendar-valarm' must have "
+                        "exactly one `icalendar-description'")
+                :node node))
+             (unless (<=3D 1 (alist-get 'ical:attendee child-counts 0))
+               (ical:signal-validation-error
+                (concat "EMAIL `icalendar-valarm' must have "
+                        "at least one `icalendar-attendee'")
+                :node node))
+             node)
+
+            (t
+             ;; "Applications MUST ignore alarms with x-name and iana-token
+             ;; values they don't recognize." So this is not a validation-=
error:
+             (ical:warn
+              (format "Unknown ACTION value in VALARM: %s" action-str)
+              :buffer (ical:ast-node-meta-get node :buffer)
+              :position (ical:ast-node-meta-get node :value-begin))
+             node)))))
+
+(ical:define-component ical:other-component nil
+  "Component type for unrecognized component names.
+
+This component type corresponds to the IANA and X-name components
+allowed by RFC5545 sec. 3.6; it represents components with an
+unknown name (matching rx `icalendar-iana-token' or
+`icalendar-x-name') which must be parsed and preserved but not
+further interpreted."
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6")
+
+;; Technically VCALENDAR is not a "component", but for the
+;; purposes of parsing and syntax highlighting, it looks just like
+;; one, so we define it as such here.
+;; (If this becomes a problem, modify `ical:component-node-p'
+;; to return nil for VCALENDAR components.)
+(ical:define-component ical:vcalendar "VCALENDAR"
+  "Calendar Object.
+
+This is the top-level data structure defined by RFC5545. A
+VCALENDAR must contain the calendar properties `icalendar-prodid'
+and `icalendar-version', and may contain the calendar properties
+`icalendar-method' and `icalendar-calscale'.
+
+It must also contain at least one VEVENT, VTODO, VJOURNAL,
+VFREEBUSY, or other component, and for every unique
+`icalendar-tzidparam' value appearing in a property within these
+components, the calendar object must contain an
+`icalendar-vtimezone' defining a time zone with that TZID."
+  :child-spec (:one (ical:prodid ical:version)
+               :zero-or-one (ical:calscale ical:method)
+               :zero-or-more (ical:other-property
+                              ical:vevent
+                              ical:vtodo
+                              ical:vjournal
+                              ical:vfreebusy
+                              ical:vtimezone
+                              ical:other-component))
+  :other-validator ical:vcalendar-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.4")
+
+(defun ical:all-tzidparams-in (node)
+  "Recursively search NODE for `icalendar-tzidparam' nodes and
+return a list of their values"
+  (cond ((ical:tzid-param-p node)
+         (list (ical:ast-node-value node)))
+        ((ical:param-node-p node)
+         nil)
+        (t ;; TODO: could prune search here when properties don't allow tz=
idparam
+         (seq-uniq (mapcan #'ical:all-tzidparams-in
+                           (ical:ast-node-children node))))))
+
+(defun ical:vcalendar-validator (node)
+  "Additional validator for `icalendar-vcalendar' NODE. Checks that
+NODE has at least one component child and that all of the
+`ical-tzidparam' values appearing in subcomponents have a
+corresponding `icalendar-vtimezone' definition.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VCALENDAR nodes; it is not normally necessary to call it directly."
+  (let* ((children (ical:ast-node-children node))
+         (comp-children (seq-filter #'ical:component-node-p children))
+         (tz-children (seq-filter #'ical:vtimezone-component-p children))
+         (defined-tzs
+          (mapcar
+           (lambda (tz)
+             ;; ensure vtimezone component has a TZID property and
+             ;; extract its string value:
+             (when (ical:ast-node-valid-p tz)
+               (ical:with-component tz ((ical:tzid :value-node tzid-text))
+                 (ical:text-to-string tzid-text))))
+           tz-children))
+         (appearing-tzids (ical:all-tzidparams-in node)))
+    (unless comp-children
+      (ical:signal-validation-error
+       "`icalendar-vcalendar' must contain at least one component"
+       :node node))
+
+    (let ((seen nil))
+      (dolist (tzid appearing-tzids)
+        (unless (member tzid seen)
+          (unless (member tzid defined-tzs)
+            (ical:signal-validation-error
+             (format "No `icalendar-vtimezone' with TZID '%s' in calendar"=
 tzid)
+             :node node)))
+        (push tzid seen)))
+
+    ;; success:
+    node))
+
+(declare-function icr:tz-set-zones-in "icalendar-recur")
+
+(defun ical:contains-vcalendar-p (&optional buffer)
+  "Determine whether BUFFER (default: current buffer) contains
+\"BEGIN:VCALENDAR\" and is therefore a candidate for parsing with, e.g.,
+`icalendar-parse-calendar'.  Returns the position where parsing should
+start, or nil."
+  (with-current-buffer (or buffer (current-buffer))
+    (save-excursion
+      (goto-char (point-min))
+      (when (re-search-forward "^BEGIN:VCALENDAR" nil t)
+        (beginning-of-line)
+        (point)))))
+
+;; `icalendar-parse-component' is sufficient to parse all the syntax in
+;; a calendar, but a calendar-level parsing function is needed to add
+;; support for time zones. This function ensures that every
+;; `icalendar-tzidparam' in the calendar has a corresponding
+;; `icalendar-vtimezone' component, and modifies the zone information of
+;; the parsed date-time according to the offset in that time zone.
+(defun ical:parse-calendar (limit)
+  "Parse an `icalendar-vcalendar' object from point up to LIMIT.
+Point should be at the start of the calendar object, i.e., at the start
+of a line that looks like \"BEGIN:VCALENDAR\". After parsing, point is
+at the beginning of the next line following the calendar (or end of the
+buffer). Returns a syntax node representing the calendar."
+  (require 'icalendar-recur) ; for icr:tz-set-zones-in; avoids circular re=
quire
+  (unless (looking-at-p "^BEGIN:VCALENDAR")
+    (ical:signal-parse-error "Not at start of VCALENDAR"))
+  (let ((cal-node (ical:parse-component limit)))
+      ;(when (ical:ast-node-valid-p cal-node t)
+      (ical:with-component cal-node
+          ((ical:vtimezone :all tzs))
+        ;; After parsing the whole calendar, set the zone and dst slots
+        ;; in all date-times which are relative to a time zone defined
+        ;; in the calendar:
+        ;; (TODO: if this proves too slow in general, we could instead
+        ;; do it lazily when individual components are queried somehow.
+        ;; But I'm not convinced that will actually save any time, because
+        ;; if we're parsing, we're probably already in the middle of a
+        ;; function that will immediately query all these times, e.g.
+        ;; `diary-icalendar-import-buffer'.)
+        (dolist (comp (ical:ast-node-children cal-node))
+          (unless (ical:vtimezone-component-p comp)
+            (icr:tz-set-zones-in tzs comp))));)
+      cal-node))
+
+;; TODO: should we do anything to *create* VTIMEZONE nodes in VCALENDAR
+;; when they're required but don't exist?
+(defun ical:print-calendar-node (vcalendar)
+  "Serialize an `icalendar-vcalendar' VCALENDAR to a string.
+
+If VCALENDAR is not a valid `icalendar-vcalendar', an
+`icalendar-validation-error' will be signaled. Any errors that arise
+during printing will be logged in the buffer returned by
+`icalendar-error-buffer'."
+  (when (ical:ast-node-valid-p vcalendar t)
+    (condition-case err
+        (ical:print-component-node vcalendar)
+      (ical:print-error
+       (ical:handle-print-error err)))))
+
+
+;;; High-level parsing and printing functions.
+(defun ical:parse (&optional buffer)
+  "Parse an `icalendar-vcalendar' object in BUFFER (default: current buffe=
r).
+
+An unfolded copy of BUFFER (see `icalendar-unfolded-buffer-from-buffer')
+will first be obtained if necessary. Parsing will begin at the first
+occurrence of \"BEGIN:VCALENDAR\" in the unfolded buffer.
+
+The buffer may be tidied up by user functions before parsing begins; see
+`icalendar-pre-unfolding-hook' and `icalendar-pre-parsing-hook'.
+
+If parsing is successful, the VCALENDAR object is returned. Otherwise,
+nil is returned, a warning is issued, and errors are logged in the
+buffer returned by `icalendar-error-buffer'."
+  (let* ((buf (or buffer (current-buffer)))
+         (unfolded (cond ((ical:unfolded-p buf) buf)
+                         ((buffer-file-name buf)
+                          (ical:unfolded-buffer-from-file (buffer-file-nam=
e buf)))
+                         (t (ical:unfolded-buffer-from-buffer buf)))))
+    (ical:init-error-buffer)
+    (with-current-buffer unfolded
+      (run-hooks 'ical:pre-parsing-hook)
+      (let ((cal-start (ical:contains-vcalendar-p))
+            vcalendar)
+        (unless cal-start
+          (ical:signal-parse-error "Buffer does not contain \"BEGIN:VCALEN=
DAR\""))
+        (save-excursion
+          (goto-char cal-start)
+          (ical:condition-case err
+              (setq vcalendar (ical:parse-calendar (point-max)))
+            (ical:parse-error
+             (ical:handle-parse-error err)
+             (warn "Errors while parsing %s; see buffer %s"
+                   buffer (buffer-name (ical:error-buffer))))))
+        vcalendar))))
+
+(defun ical:print (vcalendar &optional buffer pos)
+  "Serialize an `icalendar-vcalendar' VCALENDAR to a string and insert it
+at position POS (default: point) in BUFFER (default: current buffer).
+
+If printing is successful, VCALENDAR is returned. Otherwise, nil is
+returned, a warning is issued, and errors are logged in the buffer
+returned by `icalendar-error-buffer'."
+  ;; TODO: This is not really useful yet.
+  ;; Feels like it's needed for completeness but interface needs more thou=
ght.
+  ;; Should this instead be a generic function that prints any
+  ;; kind of node at point? at a given marker?
+  (with-current-buffer (or buffer (current-buffer))
+    (when pos (goto-char pos))
+    (condition-case err
+        (insert (ical:print-calendar-node vcalendar))
+      (ical:print-error
+       (ical:handle-print-error err)
+       (setq vcalendar nil) ; return
+       (warn "Errors while printing; see buffer %s"
+             (buffer-name (ical:error-buffer)))))
+    vcalendar))
+
+
+;;; Caching and indexing parse trees
+;;
+;; The following functions provide a simple in-memory cache and index
+;; for faster access to parsed iCalendar data by date, UID, and other
+;; fields of interest. The index and parse tree are stored in a
+;; buffer-local variable of the parsed buffer and not recomputed if the
+;; buffer hasn't changed. Most users of the library should just call
+;; `icalendar-parse-and-index' to get both the parse tree and a
+;; reference to the index, and get objects of interest from them
+;; with `icalendar-index-get'.
+(defun ical:make-index ()
+  "Create an empty index of iCalendar components."
+  (list :bydate (make-hash-table :test #'equal) ;; date =3D> list of compo=
nents
+        :byuid (make-hash-table :test #'equal)  ;; UID =3D> component
+        :bytzid (make-hash-table :test #'equal) ;; tzid =3D> vtimezone
+        :recurring (list))) ;; list of components
+
+(defun ical:index-insert-tz (index vtimezone)
+  "Insert VTIMEZONE into INDEX."
+  (ical:with-component vtimezone
+      ((ical:tzid :value tzid))
+    (let ((tzid-index (plist-get index :bytzid)))
+      (puthash tzid vtimezone tzid-index)
+      ;; Update and return the index:
+      (plist-put index :bytzid tzid-index))))
+
+(declare-function icr:recurrences-to-count "icalendar-recur")
+
+(defun ical:index-insert (index component)
+  "Insert COMPONENT into INDEX."
+  (require 'icalendar-recur) ; avoid circular import
+  (ical:with-component component
+    ((ical:dtstart :first dtstart-node :value dtstart)
+     (ical:dtend :first dtend-node :value dtend)
+     (ical:due :value due)
+     (ical:duration :value duration)
+     (ical:rrule :value recur-value)
+     (ical:rdate :all rdate-nodes)
+     (ical:exdate :all exdate-nodes)
+     (ical:uid :value uid))
+    (let ((date-index (plist-get index :bydate))
+          (uid-index (plist-get index :byuid))
+          (tzid-index (plist-get index :bytzid))
+          (recurring (plist-get index :recurring))
+          (rdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append (mapcar #'ical:ast-node-value rdate-nod=
es))))
+          (exdates
+           (mapcar #'ical:ast-node-value
+                   (apply #'append (mapcar #'ical:ast-node-value exdate-no=
des))))
+          dates)
+      ;; Everything with a UID goes into the uid-index:
+      (when uid
+        (puthash uid component uid-index))
+      ;; For all top-level components, we gather a list of dates on which
+      ;; they recur for date-index, or put them in the recurring list:
+      (when dtstart
+        (cond
+         ;; If the component has an RRULE that specifies a fixed number
+         ;; of recurrences, compute them now and index them for each date
+         ;; in each recurrence:
+         ((and recur-value (ical:recur-count recur-value))
+          (let* ((tz (gethash (ical:with-param-of dtstart-node 'ical:tzidp=
aram)
+                              tzid-index))
+                 (recs (cons dtstart (icr:recurrences-to-count component t=
z))))
+            (dolist (rec recs)
+              (let ((end-time
+                     (when duration (ical:date/time-add-duration rec durat=
ion))))
+                (setq dates
+                      (append dates
+                              (if end-time (ical:dates-until rec end-time =
t)
+                                (list (ical:date/time-to-date
+                                       (ical:date/time-to-local rec)))))))=
)))
+         ;; Same with RDATEs when there's no RRULE:
+         ((and rdates (not recur-value))
+          (dolist (rec (cons dtstart rdates))
+            (unless (or (cl-typep rec 'ical:period) (member rec exdates))
+              (let ((end-time
+                     (when duration
+                       (ical:date/time-add-duration rec duration))))
+                (setq dates
+                      (append dates
+                              (if end-time (ical:dates-until rec end-time =
t)
+                                (list (ical:date/time-to-date
+                                       (ical:date/time-to-local rec))))))))
+            (when (cl-typep rec 'ical:period)
+              (let* ((start (ical:period-start rec))
+                     (end (or (ical:period-end rec)
+                              (ical:date/time-add-duration
+                               start (ical:period-dur-value rec)))))
+                (setq dates (append dates (ical:dates-until start end t)))=
))))
+         ;; A non-recurring event also gets an index entry for each date
+         ;; until its end time:
+         ((not recur-value)
+          (let ((end-time
+                 (or dtend due
+                     (when duration
+                       (ical:date/time-add-duration dtstart duration)))))
+            (setq dates (if end-time (ical:dates-until dtstart end-time t)
+                          (list
+                           (ical:date/time-to-date
+                            (ical:date/time-to-local dtstart)))))))
+         ;; Otherwise, we put off the computation of recurrences until que=
ried:
+         (t (push component recurring)))
+
+        (dolist (date (seq-uniq dates))
+          (let ((others (gethash date date-index)))
+            ;; TODO: wonder if we should normalize, and instead store UIDs
+            ;; in the date index, then look them up by UID when queried.
+            (puthash date (cons component others) date-index))))
+
+      ;; Return the updated index:
+      (setq index (plist-put index :byuid uid-index))
+      (setq index (plist-put index :bytzid tzid-index))
+      (setq index (plist-put index :bydate date-index))
+      (setq index (plist-put index :recurring recurring))
+      index)))
+
+(defun ical:index-populate-from-calendar (index vcalendar)
+  "Insert all components in VCALENDAR into INDEX"
+  (let* ((tzs (ical:ast-node-children-of 'ical:vtimezone vcalendar))
+         (vevents (ical:ast-node-children-of 'ical:vevent vcalendar))
+         (vjournals (ical:ast-node-children-of 'ical:vjournal vcalendar))
+         (vtodos (ical:ast-node-children-of 'ical:vtodo vcalendar))
+         ;; TODO: customizable selection? what about valarms?
+         (to-index (append vevents vjournals vtodos)))
+
+    ;; First insert the tzs, so that they're available when inserting
+    ;; the others by date:
+    (dolist (tz tzs)
+      (setq index (ical:index-insert-tz index tz)))
+
+    (dolist (component to-index)
+      (setq index (ical:index-insert index component)))
+    index))
+
+(declare-function icr:find-interval "icalendar-recur")
+(declare-function icr:recurrences-in-interval "icalendar-recur")
+
+(cl-defun ical:index-get (index &rest args &key date uid tzid)
+  "Get an iCalendar component from INDEX by date, UID, or TZID.
+
+INDEX should be a reference to a parse tree index as returned by
+`icalendar-parse-and-index', which see. The index can be queried by:
+
+:uid UID (string, see `icalendar-uid') - returns the component with that
+  UID.
+
+:tzid TZID (string, see `icalendar-tzid' and `icalendar-tzidparam') -
+  returns the `icalendar-vtimezone' component with that TZID.
+
+:date DT (an `icalendar-date', i.e. a list (M D Y)) - returns a list of
+  the components occurring (or recurring) on that date.
+
+Only one keyword argument can be queried at a time."
+  (require 'icalendar-recur) ; avoid circular import
+  (when (length> args 2)
+    (error "Only one keyword argument can be queried"))
+  (cond (uid (gethash uid (plist-get index :byuid)))
+        (tzid (gethash tzid (plist-get index :bytzid)))
+        (date
+         (let ((computed (gethash date (plist-get index :bydate)))
+               (recurring (plist-get index :recurring)))
+           (dolist (component recurring)
+             (ical:with-component component
+                 ((ical:dtstart :first dtstart-node :value dtstart)
+                  (ical:rrule :value recur-value)
+                  (ical:rdate :all rdate-nodes)
+                  (ical:duration :value duration))
+               (unless (ical:date/time<=3D date dtstart)
+                 (let* ((tz (ical:with-param-of dtstart-node 'ical:tzidpar=
am nil
+                              (gethash value (plist-get index :bytzid))))
+                        (int (icr:find-interval date dtstart recur-value t=
z))
+                        (recs (icr:recurrences-in-interval int component t=
z)))
+                   (catch 'found
+                     (dolist (rec recs)
+                       (let* ((local-rec (ical:date/time-to-local rec))
+                              (end
+                               (when duration
+                                 (ical:date/time-add-duration local-rec du=
ration)))
+                              (rec-dates
+                               (if end (ical:dates-until local-rec end t)
+                                 (list (ical:date/time-to-date local-rec))=
)))
+                         (when (member date rec-dates)
+                           (push component computed)
+                           (throw 'found nil))))
+                     (dolist (node rdate-nodes)
+                       ;; normal RDATE recurrences have already been
+                       ;; checked above, but we check whether `date'
+                       ;; occurs in any RDATE period values here:
+                       (when (eq 'ical:period
+                                 (ical:value-type-from-params
+                                  (ical:ast-node-children node)))
+                         (let* ((tz
+                                 (ical:with-param-of node 'ical:tzidparam =
nil
+                                   (gethash value (plist-get index :bytzid=
)))))
+                           (ical:with-property node nil
+                             (dolist (period values)
+                               (when (ical:date/time-in-period-p date peri=
od tz)
+                                 (push component computed)
+                                 (throw 'found nil))))))))))))
+           computed))
+        (t (error "At least one of :uid, :tzid, or :date is required"))))
+
+;; Buffer local variable to cache the index and parse tree.
+;; Format: (TICKS VCALENDAR INDEX)
+;; TICKS is the value of (buffer-modified-tick) at last parse
+(defvar-local ical:-parsed-calendar-and-index '(0 nil nil))
+
+(defun ical:parse-and-index (&optional buffer-or-file)
+  "Parse the first iCalendar VCALENDAR object in BUFFER-OR-FILE (default:
+current buffer) and build an index of its components. Returns a
+list (VCALENDAR INDEX). The index can then be queried to retrieve
+calendar components by UID, TZID, or date; see `icalendar-index-get'.
+
+BUFFER-OR-FILE may be a buffer or a string containing a filename.  If it
+is a filename, an unfolded buffer containing its data will be found, or
+created if necessary (see `icalendar-unfolded-buffer-from-file').  The
+resulting buffer must contain an iCalendar VCALENDAR object, which will
+be parsed and indexed.
+
+The results of parsing and indexing are cached in buffer-local
+variables, and subsequent calls with the same BUFFER-OR-FILE will return
+the cached results as long as the buffer has not been modified in the
+meantime."
+  (let* ((buffer (cond ((null buffer-or-file) (current-buffer))
+                       ((bufferp buffer-or-file) buffer-or-file)
+                       ((and (stringp buffer-or-file)
+                             (file-exists-p buffer-or-file))
+                        (find-buffer-visiting buffer-or-file))))
+         (file-name (cond (buffer (buffer-file-name buffer))
+                          ((and (stringp buffer-or-file)
+                                (file-exists-p buffer-or-file))
+                           (expand-file-name buffer-or-file))))
+         (unfolded (cond ((and buffer (ical:unfolded-p buffer))
+                          buffer)
+                         (file-name
+                          (or (ical:find-unfolded-buffer-visiting file-nam=
e)
+                              (ical:unfolded-buffer-from-file file-name)))
+                         (buffer
+                          (ical:unfolded-buffer-from-buffer buffer))
+                         (t
+                          (error "Unable to get unfolded buffer for '%s'"
+                                 buffer-or-file)))))
+    (with-current-buffer unfolded
+      (when (ical:contains-vcalendar-p)
+        (if (eql (car ical:-parsed-calendar-and-index) (buffer-modified-ti=
ck))
+            (cdr ical:-parsed-calendar-and-index)
+          (message "Parsing and indexing iCalendar data in %s..." (buffer-=
name))
+          (let ((vcalendar (ical:parse)))
+            (when vcalendar
+              (setq ical:-parsed-calendar-and-index
+                    (list
+                     (buffer-modified-tick)
+                     vcalendar
+                     (ical:index-populate-from-calendar (ical:make-index)
+                                                         vcalendar)))
+              (message "Parsing and indexing iCalendar data in %s...Done."
+                       (buffer-name))
+              (cdr ical:-parsed-calendar-and-index))))))))
+
+
+
+;;; Documentation for all of the above via `describe-symbol':
+(defun ical:documented-symbol-p (sym)
+  "iCalendar symbol predicate for `describe-symbol-backends'"
+  (or (get sym 'icalendar-type-documentation)
+      ;; grammatical categories defined with rx-define, but with no
+      ;; other special icalendar docs:
+      (and (get sym 'rx-definition)
+           (length> (symbol-name sym) 10)
+           (equal "icalendar-" (substring (symbol-name sym) 0 10)))))
+
+(defun ical:documentation (sym buf frame)
+  "iCalendar documentation backend for `describe-symbol-backends'"
+  (ignore buf frame) ; Silence the byte compiler
+  (with-help-window (help-buffer)
+    (with-current-buffer standard-output
+      (let* ((type-doc (get sym 'icalendar-type-documentation))
+             (link (get sym 'icalendar-link))
+             (rx-def (get sym 'rx-definition))
+             (rx-doc (when rx-def
+                       (with-output-to-string
+                         (pp rx-def))))
+             (value-rx-def (get sym 'ical:value-rx))
+             (value-rx-doc (when value-rx-def
+                             (with-output-to-string
+                               (pp value-rx-def))))
+             (values-rx-def (get sym 'ical:values-rx))
+             (values-rx-doc (when values-rx-def
+                             (with-output-to-string
+                               (pp values-rx-def))))
+
+             (full-doc
+              (concat
+               (when type-doc
+                 (format "`%s' is an iCalendar type:\n\n%s\n\n"
+                         sym type-doc))
+               (when link
+                 (format "For further information see\nURL `%s'\n\n" link))
+               ;; FIXME: this is probably better done in rx.el!
+               ;; TODO: could also generalize this to recursively
+               ;; search rx-def for any symbol that starts with "icalendar=
-"...
+               (when rx-def
+                 (format "`%s' is an iCalendar grammar category.
+Its `rx' definition is:\n\n%s%s%s"
+                         sym
+                         rx-doc
+                         (if value-rx-def
+                             (format "\nIndividual values must match:\n%s"
+                                      value-rx-doc)
+                           "")
+                         (if values-rx-def
+                             (format "\nLists of values must match:\n%s"
+                                      values-rx-doc)
+                           "")))
+               "\n")))
+
+        (insert full-doc)
+        full-doc))))
+
+
+(defconst ical:describe-symbol-backend
+  '(nil icalendar-documented-symbol-p icalendar-documentation)
+  "Entry for icalendar documentation in `describe-symbol-backends'")
+
+(push ical:describe-symbol-backend describe-symbol-backends)
+
+;; Unloading:
+(defun ical:parser-unload-function ()
+  "Unload function for `icalendar-parser'."
+  (mapatoms
+   (lambda (sym)
+     (when (string-match "^icalendar-" (symbol-name sym))
+       (makunbound sym)
+       (fmakunbound sym))))
+
+  (setq describe-symbol-backends
+        (remq ical:describe-symbol-backend describe-symbol-backends))
+  ;; Proceed with normal unloading:
+  nil)
+
+(provide 'icalendar-parser)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-=
recur-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/icalendar-recur.el b/lisp/calendar/icalendar-rec=
ur.el
new file mode 100644
index 00000000000..5f8158ddecf
--- /dev/null
+++ b/lisp/calendar/icalendar-recur.el
@@ -0,0 +1,2070 @@
+;;; icalendar-recur.el --- Support for iCalendar recurrences and time zone=
s -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: December 2024
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This is a sub-library for working with recurrence rules and time
+;; zones, as defined by RFC5545 (see especially Secs. 3.3.10 and
+;; 3.8.5.3, which are required reading before you make any changes to
+;; the code below) and related standards (especially RFC8984 Sec. 4.3,
+;; also strongly recommended reading). Recurrence rules and time zones
+;; are mutually dependent: to calculate the date and time of future
+;; instances of a recurring event, you must be able to apply time zone
+;; rules; and to apply time zone rules, you must be able to calculate
+;; the date and time of recurring events, namely the shifts between
+;; observances of standard and daylight savings time. For example, an
+;; event that occurs "on the last Friday of every month at 11AM" in a
+;; given time zone should recur at 11AM daylight savings time in July,
+;; but 11AM standard time in January, for a typical time zone that
+;; shifts from standard to DST and back once each year. These shifts
+;; occur at, say, "the last Sunday in March at 2AM" and "the first
+;; Sunday in November at 2AM". So to calculate an absolute time for a
+;; given instance of the original event, you first have to calculate the
+;; nearest instance of the shift between standand and daylight savings
+;; time, which itself involves applying a recurrence rule of the same
+;; form.
+;;
+;; This mutual dependence between recurrence rules and time zones is not
+;; a *vicious* circle, because the shifts between time zone observances
+;; have fixed offsets from UTC time which are made explicit in iCalendar
+;; data. But it does make things complicated. RFC5545 focuses on making
+;; recurrence rules expressive enough to cover existing practices,
+;; including time zone observance shifts, rather than on being easy to
+;; implement.
+;;
+;; So be forewarned: here be dragons. The code here was difficult to get
+;; working, in part because this mutual dependence means it is difficult
+;; to implement anything less than the whole system, in part because
+;; recurrence rules are very flexible in order to cover as many
+;; practical uses as possible, in part because time zone practices are
+;; themselves complicated, and in part because there are a *lot* of edge
+;; cases to worry about. Much of it is tedious and repetitive but
+;; doesn't lend itself to further simplification or abstraction. If you
+;; need to make changes, make them slowly, and use the tests in
+;; test/lisp/calendar/icalendar-recur-tests.el to make sure they don't
+;; break anything.
+;;
+;; Notation: `date/time' with a slash in symbol names means "`date' or
+;; `date-time'", i.e., is a way of indicating that a function can
+;; accept either type of value, and `dt' is typically used for an
+;; argument of either type. `date-time' should always refer to *just*
+;; date-time values, not plain (calendar-style) dates.
+
+(require 'icalendar-ast)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'cl-lib)
+(require 'calendar)
+(require 'simple)
+(require 'seq)
+(eval-when-compile '(require 'icalendar-macs))
+
+;; FIXME: this function, or something similar, should probably be in
+;; calendar.el. It is the inverse of `calendar-day-number',
+;; extracted from `calendar-goto-day-of-year'.
+(defun ical:calendar-date-from-yearday-number (year dayno)
+  "Return the date of the DAYNO-th day in YEAR. DAYNO must be an
+integer between -366 and 366."
+  (calendar-gregorian-from-absolute
+   (+ (if (< dayno 0)
+          (+ 1 dayno (if (calendar-leap-year-p year) 366 365))
+        dayno)
+      (calendar-absolute-from-gregorian (list 12 31 (1- year))))))
+
+
+;; Recurrence Intervals
+;;
+;; Two important ideas in the following:
+;;
+;; 1) Because recurrence sets are potentially infinite, we always
+;; calculate recurrences within certain upper and lower bounds. These
+;; bounds might be determined by a user interface (e.g. the week or
+;; month displayed in a calendar) or might be derived from the logic of
+;; the recurrence rule itself. In the former case, where the bounds can
+;; be arbitrary, it's called a 'window' here (as in "window of
+;; time"). In the latter case, it's called an 'interval' here (after the
+;; "INTERVAL=3D..." clause in recurrence rules).
+;;
+;; Unlike a window, an interval must be synced up with the recurrence
+;; rule: its bounds must fall at successive integer multiples of the
+;; product of the recurrence rule's FREQ and INTERVAL values, relative
+;; to a starting date/time. For example, a recurrence rule with a
+;; MONTHLY frequency and INTERVAL=3D3 will have an interval that is three
+;; months long. If its start date is, e.g., in November, then the first
+;; interval runs from November to February, the next from February to
+;; May, and so on. Because intervals depend only on the starting
+;; date/time, the frequency, and the interval length, it is relatively
+;; straightforward to compute the bounds of the interval surrounding an
+;; arbitrary point in time (without enumerating them successively from
+;; the start time); see `icalendar-recur-find-interval', which calls
+;; this arbitrary point in time the 'target'.
+;;
+;; 2) An interval is the smallest unit of time for which we compute
+;; values of the recurrence set. This is because the "BYSETPOS=3D..."
+;; clause in a recurrence rule operates on the sequence of recurrences
+;; in a single interval. Since it selects recurrences by their index in
+;; this sequence, the sequence must have a determinate length and known
+;; bounds. The function `icalendar-recur-recurrences-in-interval' is the
+;; main function to compute recurrences in a given interval.
+;;
+;; The way to compute the recurrences in an arbitrary *window* is thus
+;; to find the interval bounds which are closest to the window's lower
+;; and upper bound, and then compute the recurrences for all the
+;; intervals in between, i.e., that "cover" the window. This is what the
+;; function `icalendar-recur-recurrences-in-window' does.
+;;
+;; Note that the recurrence set for a recurrence rule with a COUNT
+;; clause cannot be computed for an arbitrary interval (or window);
+;; instead, the set must be enumerated from the beginning, so that the
+;; enumeration can stop after a fixed number of recurrences. This is
+;; what the function `icalendar-recur-recurrences-to-count' does. But
+;; also in this case, recurrences are generated for one interval at a
+;; time, because a BYSETPOS clause might apply.
+;;
+;; An interval is represented as a list (LOW HIGH NEXT-LOW) of decoded
+;; times. The length of time between LOW and HIGH corresponds to the
+;; FREQ rule part: they are one year apart for a 'YEARLY rule, a month
+;; apart for a 'MONTHLY rule, etc. NEXT-LOW is the upper bound of the
+;; interval: it is equal to LOW in the subsequent interval. When the
+;; INTERVAL rule part is equal to 1 (the default), HIGH and NEXT-LOW are
+;; the same, but if it is > 1, NEXT-LOW is equal to LOW + INTERVAL *
+;; FREQ.  For example, in a 'MONTHLY rule where INTERVAL=3D3, which means
+;; "every three months", LOW and HIGH bound the first month, while HIGH
+;; and NEXT-LOW bound the following two months.
+;;
+;; The times between LOW and HIGH are candidates for recurrences.  LOW
+;; is an inclusive lower bound, and HIGH is an exclusive upper bound:
+;; LOW <=3D R < HIGH for each recurrence R in the interval. The times
+;; between HIGH and NEXT-LOW are not candidates for recurrences.
+;;
+;; The following functions deal with constructing intervals, given a
+;; target, a start date/time, and intervalsize, and optionally a time
+;; zone.  The main entry point is `icalendar-recur-find-interval'.
+
+;; Look, dragons already:
+(defun icr:find-absolute-interval (target dtstart intervalsize freqs
+                                   &optional vtimezone)
+  "Find a recurrence interval based on a fixed number of seconds.
+
+INTERVALSIZE should be the total size of the interval in seconds. FREQS
+should be the number of seconds between the lower bound of the interval
+and the upper bound for candidate recurrences; it is the number of
+seconds in the unit of time in a recurrence rule's FREQ part.  The
+returned interval looks like (LOW LOW+FREQS LOW+INTERVALSIZE).  See
+`icalendar-recur-find-interval' for other arguments' meanings."
+  ;; We assume here that the interval needs to be calculated using
+  ;; absolute times for SECONDLY, MINUTELY, and HOURLY rules.
+  ;; There are two reasons for this:
+  ;;
+  ;; 1) Time zone shifts. If we don't use absolute times, and instead
+  ;;    find interval boundaries using local clock times with e.g.
+  ;;    `ical:date/time-add' (as we do with time units of a day or
+  ;;    greater below), we have to adjust for clock time changes.  Using
+  ;;    absolute times is simpler.
+  ;; 2) More problematically, using local clock times, at least in its
+  ;;    most straightforward implementation, has pathological results
+  ;;    when `intervalsize' is relatively prime with 60 (for a SECONDLY
+  ;;    rule, similarly for the others): intervals generated by
+  ;;    successive enumeration from one target value will not in general
+  ;;    align with intervals generated from a different, but nearby,
+  ;;    target value.  (So going this route seems to mean giving up on
+  ;;    the idea that intervals can be calculated just from `target',
+  ;;    `dtstart' and `intervalsize', and instead always enumerating
+  ;;    them from the beginning.)
+  ;;
+  ;; In effect, we are deciding that a rule like "every 3 hours" always
+  ;; means every 3 * 60 * 60 =3D 10800 seconds after `dtstart', and not
+  ;; "every 10800 seconds, except when there's a time zone observance
+  ;; change".  People who want the latter have another option: use a
+  ;; DAILY rule and specify the (local) times for the hours they want in
+  ;; the BYHOUR clause, etc. (People who want it for a number of hours,
+  ;; e.g. 7, which does not divide 24, unfortunately do *not* have this
+  ;; option, but anyone who wants that but does not want to understand
+  ;; "7 hours" as a fixed number of seconds has a pathology that I
+  ;; cannot cure here.)
+  ;;
+  ;; RFC5545 does not seem to pronounce one way or the other on whether
+  ;; this decision is correct: there are no examples of SECONDLY rules
+  ;; to go on, and the few examples for MINUTELY and HOURLY rules only
+  ;; use "nice" values in the INTERVAL clause (real-life examples
+  ;; probably(?)  will too).  Our assumption has some possibly
+  ;; unintuitive consequences for `intervalsize' values that are not
+  ;; "nice" (basically, whenever intervalsize and either 60 or 24 are
+  ;; relatively prime), and for how interval boundaries behave at the
+  ;; shifts between time zone observances (since local clock times in
+  ;; the interval bounds will shift from what they would have been
+  ;; before the observance change -- arguably correct but possibly
+  ;; surprising, depending on the case). But the alternative seems
+  ;; worse, so until countervailing evidence emerges, this approach
+  ;; seems reasonable.
+  (let* ((given-start-zone (decoded-time-zone dtstart))
+         (start-w/zone (cond (given-start-zone dtstart)
+                             ((ical:vtimezone-component-p vtimezone)
+                              (ical:date-time-variant dtstart :tz vtimezon=
e))
+                             (t
+                              ;; "Floating" time should be interpreted in =
user's
+                              ;; current time zone; see RFC5545 Sec 3.3.5
+                              (ical:date-time-variant
+                               dtstart :zone (car (current-time-zone))))))
+         (start-abs (ignore-errors
+                      (time-convert (encode-time start-w/zone) 'integer)))
+         (given-target-zone (decoded-time-zone target))
+         (target-w/zone (cond (given-target-zone target)
+                              (vtimezone
+                               (ical:date-time-variant target :tz vtimezon=
e))
+                              (t
+                               (ical:date-time-variant
+                                target :zone (car (current-time-zone))))))
+         (target-abs (ignore-errors
+                         (time-convert (encode-time target-w/zone) 'intege=
r)))
+         low-abs low high next-low)
+
+    (unless (zerop (mod intervalsize freqs))
+      ;; Bad things will happen if intervalsize is not an integer
+      ;; multiple of freqs
+      (error "FREQS=3D%d does not divide INTERVALSIZE=3D%d" freqs interval=
size))
+    (unless (and start-abs target-abs)
+      (when (not start-abs)
+        (error "Could not determine an offset for DTSTART=3D%s" dtstart))
+      (when (not target-abs)
+        (error "Could not determine an offset for TARGET=3D%s" target)))
+
+    ;; Find the lower bound below target that is the closest integer
+    ;; multiple of intervalsize seconds from dtstart
+    (setq low-abs (- target-abs
+                     (mod (- target-abs start-abs) intervalsize)))
+
+    (if vtimezone
+        (setq low (icr:tz-decode-time low-abs vtimezone)
+              high (icr:tz-decode-time (+ low-abs freqs) vtimezone)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) vtimez=
one))
+      ;; best we can do is decode into target's zone:
+      (let ((offset (decoded-time-zone target-w/zone)))
+        (setq low (icr:tz-decode-time low-abs offset)
+              high (icr:tz-decode-time (+ low-abs freqs) offset)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) offset=
))))
+
+    (unless (and given-start-zone given-target-zone)
+      ;; but if we started with floating times, we should return floating =
times:
+      (setf (decoded-time-zone low) nil)
+      (setf (decoded-time-dst low) -1)
+      (setf (decoded-time-zone high) nil)
+      (setf (decoded-time-dst high) -1)
+      (setf (decoded-time-zone next-low) nil)
+      (setf (decoded-time-dst next-low) -1))
+
+    (list low high next-low)))
+
+(defun icr:find-secondly-interval (target dtstart intervalsize &optional v=
timezone)
+  "Find a SECONDLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   dtstart
+   intervalsize
+   1
+   vtimezone))
+
+(defun icr:find-minutely-interval (target dtstart intervalsize &optional v=
timezone)
+  "Find a MINUTELY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; A MINUTELY interval always runs from the beginning of a minute to
+   ;; the beginning of the next minute:
+   (ical:date-time-variant dtstart :second 0 :tz 'preserve)
+   (* 60 intervalsize)
+   60
+   vtimezone))
+
+(defun icr:find-hourly-interval (target dtstart intervalsize &optional vti=
mezone)
+  "Find an HOURLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; An HOURLY interval always runs from the beginning of an hour to
+   ;; the beginning of the next hour:
+   (ical:date-time-variant dtstart :minute 0 :second 0 :tz 'preserve)
+   (* 60 60 intervalsize)
+   (* 60 60)
+   vtimezone))
+
+(defun icr:find-daily-interval (target dtstart intervalsize &optional vtim=
ezone)
+  "Find a DAILY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-absdate (calendar-absolute-from-gregorian
+                         (ical:date/time-to-date dtstart)))
+         (target-absdate (calendar-absolute-from-gregorian
+                          (ical:date/time-to-date target)))
+         ;; low-absdate is the closest absolute date below target that
+         ;; is an integer multiple of intervalsize days from dtstart
+         (low-absdate (- target-absdate
+                         (mod (- target-absdate start-absdate) intervalsiz=
e)))
+         (high-absdate (1+ low-absdate))
+         (next-low-absdate (+ low-absdate intervalsize)))
+
+    (let* ((low-dt (ical:date-to-date-time
+                     (calendar-gregorian-from-absolute low-absdate)))
+           (high-dt (ical:date-to-date-time
+                      (calendar-gregorian-from-absolute high-absdate)))
+           (next-low-dt (ical:date-to-date-time
+                          (calendar-gregorian-from-absolute next-low-absda=
te))))
+
+      (when vtimezone
+        (icr:tz-set-zone low-dt vtimezone)
+        (icr:tz-set-zone high-dt vtimezone)
+        (icr:tz-set-zone next-low-dt vtimezone))
+
+      ;; Return the bounds:
+      (list low-dt high-dt next-low-dt))))
+
+(defun icr:find-weekly-interval (target dtstart intervalsize
+                                 &optional weekstart vtimezone)
+  "Find a WEEKLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((target-date (ical:date/time-to-date target))
+         (start-date (ical:date/time-to-date dtstart))
+         ;; the absolute dates of the week start before target and
+         ;; dtstart; these are always a whole number of weeks apart:
+         (target-week-abs (calendar-nth-named-absday
+                           -1
+                           (or weekstart 1)
+                           (calendar-extract-month target-date)
+                           (calendar-extract-year target-date)
+                           (calendar-extract-day target-date)))
+         (start-abs (calendar-nth-named-absday
+                     -1
+                     (or weekstart 1)
+                     (calendar-extract-month start-date)
+                     (calendar-extract-year start-date)
+                     (calendar-extract-day start-date)))
+         (intsize-days (* 7 intervalsize))
+         ;; the absolute date of the week start before target which is
+         ;; an integer multiple of intervalsize weeks from dtstart:
+         (low-abs (- target-week-abs
+                  (mod (- target-week-abs start-abs) intsize-days)))
+         ;; then use this to find the interval bounds:
+         (low (ical:date-to-date-time
+               (calendar-gregorian-from-absolute low-abs)))
+         (high (ical:date-to-date-time
+               (calendar-gregorian-from-absolute (+ 7 low-abs))))
+         (next-low (ical:date-to-date-time
+                    (calendar-gregorian-from-absolute (+ intsize-days low-=
abs)))))
+
+    (when vtimezone
+      (icr:tz-set-zone low vtimezone)
+      (icr:tz-set-zone high vtimezone)
+      (icr:tz-set-zone next-low vtimezone))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-monthly-interval (target dtstart intervalsize &optional vt=
imezone)
+  "Find a MONTHLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-month (ical:date/time-month dtstart))
+         (start-year (ical:date/time-year dtstart))
+         ;; we calculate in "absolute months", i.e., number of months
+         ;; since the beginning of the Gregorian calendar, to make
+         ;; finding the lower bound easier:
+         (start-abs-months (+ (* 12 (1- start-year)) (1- start-month)))
+         (target-month (ical:date/time-month target))
+         (target-year (ical:date/time-year target))
+         (target-abs-months (+ (* 12 (1- target-year)) (1- target-month)))
+         ;; number of "absolute months" between start of dtstart's month
+         ;; and start of target's month:
+         (nmonths (- target-abs-months start-abs-months))
+         ;; the number of months after dtstart that is the closest integer
+         ;; multiple of intervalsize months before target:
+         (lmonths (- nmonths (mod nmonths intervalsize)))
+         ;; convert these "absolute months" back to Gregorian month and ye=
ar:
+         (mod-month (mod (+ start-month lmonths) 12))
+         (low-month (if (zerop mod-month) 12 mod-month))
+         (low-year (+ (/ lmonths 12) start-year
+                      ;; iff we cross a year boundary moving forward in
+                      ;; time from start-month to target-month, we need
+                      ;; to add one to the year:
+                      (if (<=3D start-month target-month) 0 1)))
+         ;; and now we can use these to calculate the interval bounds:
+         (low (ical:make-date-time :year low-year :month low-month :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezo=
ne))
+         (high (ical:date/time-add low :month 1 vtimezone))
+         (next-low (ical:date/time-add low :month intervalsize vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-yearly-interval (target dtstart intervalsize &optional vti=
mezone)
+  "Find a YEARLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-year (ical:date/time-year dtstart))
+         (target-year (ical:date/time-year target))
+         ;; The year before target that is the closest integer multiple
+         ;; of intervalsize years after dtstart:
+         (low-year (- target-year
+                      (mod (- target-year start-year) intervalsize)))
+         (low (ical:make-date-time :year low-year :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezo=
ne))
+         (high (ical:make-date-time :year (1+ low-year) :month 1 :day 1
+                                    :hour 0 :minute 0 :second 0 :tz vtimez=
one))
+         (next-low (ical:make-date-time :year (+ low-year intervalsize)
+                                        :month 1 :day 1 :hour 0 :minute 0 =
:second 0
+                                        :tz vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-interval (target dtstart recur-value &optional vtimezone)
+  "Return the recurrence interval around TARGET.
+
+TARGET and DTSTART should be `icalendar-date' or `icalendar-date-time'
+values. RECUR-VALUE should be an `icalendar-recur'.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which
+represents the lower and upper bounds of a recurrence interval around
+TARGET. For some N, LOW is equal to START + N*INTERVALSIZE units, HIGH
+is equal to START + (N+1)*INTERVALSIZE units, and LOW <=3D TARGET < HIGH.
+START here is a time derived from DTSTART depending on RECUR-VALUE's
+FREQ part: the first day of the year for a \\=3D'YEARLY rule, first day
+of the month for a \\=3D'MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\=3D'MONTHLY, etc.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (let ((freq (ical:recur-freq recur-value))
+        (intsize (ical:recur-interval-size recur-value))
+        (weekstart (ical:recur-weekstart recur-value)))
+    (cl-case freq
+      (SECONDLY (icr:find-secondly-interval target dtstart intsize vtimezo=
ne))
+      (MINUTELY (icr:find-minutely-interval target dtstart intsize vtimezo=
ne))
+      (HOURLY (icr:find-hourly-interval target dtstart intsize vtimezone))
+      (DAILY (icr:find-daily-interval target dtstart intsize vtimezone))
+      (WEEKLY (icr:find-weekly-interval target dtstart intsize
+                                        weekstart vtimezone))
+      (MONTHLY (icr:find-monthly-interval target dtstart intsize vtimezone=
))
+      (YEARLY (icr:find-yearly-interval target dtstart intsize vtimezone))=
)))
+
+(defun icr:nth-interval (n dtstart recur-value &optional vtimezone)
+  "Return the Nth recurrence interval after DTSTART.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which represent the Nth
+recurrence interval after DTSTART.  LOW is equal to START +
+N*INTERVALSIZE units, HIGH is equal to START + (N+1)*INTERVALSIZE units,
+and LOW <=3D TARGET < HIGH.  START here is a time derived from DTSTART
+depending on RECUR-VALUE's FREQ part: the first day of the year for a
+\\=3D'YEARLY rule, first day of the month for a \\=3D'MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\=3D'MONTHLY, etc.
+
+N should be a non-negative integer. Interval 0 is the interval
+containing DTSTART.  DTSTART should be an `icalendar-date' or
+`icalendar-date-time' value.  RECUR-VALUE should be an
+`icalendar-recur'.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (when (< n 0) (error "Recurrence interval undefined for negative N"))
+  (let* ((start-dt (if (cl-typep dtstart 'ical:date)
+                       (ical:date-to-date-time dtstart :tz vtimezone)
+                     dtstart))
+         (freq (ical:recur-freq recur-value))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (target (ical:date/time-add start-dt unit (* n intervalsize) vtim=
ezone)))
+    (icr:find-interval target dtstart recur-value vtimezone)))
+
+(defun icr:next-interval (interval recur-value &optional vtimezone)
+  "Given a recurrence INTERVAL (LOW HIGH NEXT), return the next interval
+(NEXT HIGHER HIGHER-NEXT), where HIGHER and HIGHER-NEXT are determined
+by the frequency and interval sizes of RECUR-VALUE."
+  (let* ((new-low (caddr interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone))
+         (new-next (ical:date/time-add new-low unit intervalsize vtimezone=
)))
+
+    (when vtimezone
+      (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      ;; (icr:tz-set-zone new-next vtimezone)
+      )
+
+    (list new-low new-high new-next)))
+
+(defun icr:previous-interval (interval recur-value dtstart &optional vtime=
zone)
+  "Given a recurrence INTERVAL, return the previous interval.
+
+For an interval (LOW HIGH NEXT-LOW), the previous interval is
+(PREV-LOW PREV-HIGH LOW), where PREV-LOW and PREV-HIGH are determined by
+the frequency and interval sizes of RECUR-VALUE (see
+`icalendar-recur-find-interval').  If the resulting period of time
+between PREV-LOW and PREV-HIGH occurs entirely before DTSTART, then the
+interval does not exist; in this case nil is returned."
+  (let* ((upper (car interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-low (ical:date/time-add upper unit (* -1 intervalsize) vtime=
zone))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone)))
+
+    (when vtimezone
+      ;; (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      (icr:tz-set-zone upper vtimezone))
+
+    (unless (ical:date-time< new-high dtstart)
+      (list new-low new-high upper))))
+
+
+
+;; Refining intervals into subintervals
+;;
+;; For a given interval, the various BY*=3D... clauses in a recurrence
+;; rule specify the recurrences in that interval.
+;;
+;; RFC5545 unfortunately has an overly-complicated conceptual model for
+;; how recurrences are to be calculated which is based on "expanding" or
+;; "limiting" the recurrence set for each successive clause. This model
+;; is difficult to think about and implement, and the text of the
+;; standard is ambiguous. I did not succeed in producing a working
+;; implementation based on the description in the standard, and the
+;; existing implementations don't seem to agree on how it's to be
+;; implemented anyway.
+;;
+;; Fortunately, RFC8984 (JSCalendar) is a forthcoming standard which
+;; attempts to resolve the ambiguities while being semantically
+;; backward-compatible with RFC5545. It provides a much cleaner
+;; conceptual model: the recurrence set is generated by starting with a
+;; list of candidates, which consist of every second in (what is here
+;; called) an interval, and then filtering out any candidates which do
+;; not match the rule's clauses. The most straightforward implementation
+;; of this model, however, is unusably slow in typical cases. Consider
+;; for example the case of calculating the onset of daylight savings
+;; time in a given year: the interval is a year long, so it consists of
+;; over 31 million seconds. Although it's easy to generate Lisp
+;; timestamps for each of those seconds, filtering them through the
+;; various BY* clauses means decoding each of those timestamps, which
+;; means doing a fairly expensive computation over 31 million times, and
+;; then throwing away the result in all but one case. When I implemented
+;; this model, I was not patient enough to sit through the calculations
+;; for even MONTHLY rules (which on my laptop took minutes).
+;;
+;; So instead of implementing RFC8984's model directly, the strategy
+;; here is to do something equivalent but much more efficient: rather
+;; than thinking of an interval as consisting of a set of successive
+;; seconds, we think of it as described by its bounds; and for each BY*
+;; clause, we *refine* the interval into subintervals by computing the
+;; bounds of each subinterval corresponding to the value(s) in that
+;; clause. For example, in a YEARLY rule, the initial interval is one
+;; year long, say all of 2025. If it has a "BYMONTH=3D4,10" clause, then
+;; we refine this interval into two subintervals, each one month long:
+;; one for April 2025 and one for October 2025.  This is much more
+;; efficient in the typical case, because the number of bounds which
+;; describe the final set of subintervals is usually *much* smaller than
+;; the number of seconds in the original interval.
+;;
+;; The following functions are responsible for computing these
+;; refinements. The main entry point here is
+;; `icalendar-recur-refine-from-clauses', which takes care of
+;; successively refining the interval both by the explicit values in the
+;; rule's clauses and by the implicit values in DTSTART. (There, too,
+;; RFC8984 is helpful: it gives a much more explicit description of how
+;; the information in DTSTART interacts with the BY* clauses to further
+;; refine the subintervals.)
+
+(defun icr:refine-byyearday (interval yeardays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching YEARDAYS.
+
+YEARDAYS should be a list of values from a recurrence rule's
+BYYEARDAY=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-ydays (sort yeardays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 366 a)=
))
+                                            (pos-b (if (< 0 b) b (+ 366 b)=
)))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<=3D start-year end-year)
+      ;; For each year in the interval...
+      (dolist (n sorted-ydays)
+        ;; ...the subinterval is one day long on the nth yearday
+        (let* ((nthday (ical:calendar-date-from-yearday-number start-year =
n))
+               (low (ical:make-date-time :year start-year
+                                         :month (calendar-extract-month nt=
hday)
+                                         :day (calendar-extract-day nthday)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval.  Careful! This clipping can lead to high <=3D low,
+          ;; so need to check it is still the case that low < high
+          ;; before pushing the subinterval
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<=3D interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<=3D high interval-end))
+            (push (list low high) subintervals))))
+
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byweekno (interval weeknos &optional weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching WEEKNOS.
+
+WEEKNOS should be a list of values from a recurrence rule's
+BYWEEKNO=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-weeknos (sort weeknos
+                               :lessp (lambda (a b)
+                                        (let ((pos-a (if (< 0 a) a (+ 53 a=
)))
+                                              (pos-b (if (< 0 b) b (+ 53 b=
))))
+                                          (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<=3D start-year end-year)
+      ;; For each year in the interval...
+      (dolist (wn sorted-weeknos)
+        ;; ...the subinterval is one week long in the wn-th week
+        (let* ((nth-wstart (ical:start-of-weekno wn start-year weekstart))
+               (low (ical:make-date-time :year (calendar-extract-year nth-=
wstart)
+                                         :month (calendar-extract-month nt=
h-wstart)
+                                         :day (calendar-extract-day nth-ws=
tart)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 7 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval, as above. This can happen often here because week
+          ;; boundaries generally do not align with year boundaries.
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<=3D interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<=3D high interval-end))
+              (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonth (interval months &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHS.
+
+MONTHS should be a list of values from a recurrence rule's
+BYMONTH=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-months (sort months))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<=3D start-year end-year)
+      ;; For each year in the interval...
+      (dolist (m sorted-months)
+        ;; ...the subinterval is from the first day of the given month
+        ;; to the first day of the next
+        (let* ((low (ical:make-date-time :year start-year :month m :day 1
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :month 1 vtimezone)))
+
+          ;; Clip the subinterval bounds, as above
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<=3D interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<=3D high interval-end))
+            (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonthday (interval monthdays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHDAYS.
+
+MONTHDAYS should be a list of values from a recurrence rule's
+BYMONTHDAY=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-mdays (sort monthdays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 31 a)))
+                                            (pos-b (if (< 0 b) b (+ 31 b))=
))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<=3D interval-start interval-end)
+      ;; For each month in the interval...
+      (dolist (m sorted-mdays)
+        ;; ...the subinterval is one day long on the given monthday
+        (let* ((month (ical:date/time-month interval-start))
+               (year (ical:date/time-year interval-start))
+               (monthday (if (< 0 m) m
+                           (+ m 1 (calendar-last-day-of-month month year))=
))
+               (low (ical:date-time-variant interval-start :day monthday
+                                            :hour 0 :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+
+          (ignore-errors ; ignore invalid dates, e.g. 2025-02-29
+            ;; Clip subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<=3D high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start
+            (ical:date/time-add interval-start :month 1 vtimezone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byday (interval weekdays &optional in-month vtimezone)
+  "Refine INTERVAL to days matching the given WEEKDAYS.
+
+WEEKDAYS should be a list of values from a recurrence rule's
+BYDAY=3D... clause; see `icalendar-recur' for the possible values.
+
+If WEEKDAYS contains pairs (DOW . OFFSET), then IN-MONTH indicates
+whether OFFSET is relative to the month of the start of the interval. If
+it is nil, OFFSET will be relative to the year, rather than the month."
+  (let* ((sorted-weekdays (sort (seq-filter #'natnump weekdays)))
+         (with-offsets (sort (seq-filter #'consp weekdays)
+                             :lessp (lambda (w1 w2) (and (< (car w1) (car =
w2))))))
+         (interval-start (car interval))
+         (start-abs (calendar-absolute-from-gregorian
+                     (ical:date-time-to-date interval-start)))
+         (interval-end (cadr interval))
+         (end-abs (calendar-absolute-from-gregorian
+                   (ical:date-time-to-date interval-end)))
+         (subintervals nil))
+
+    ;; For days where an offset was given, the subinterval is a single
+    ;; weekday relative to the month or year of interval-start:
+    (dolist (wo with-offsets)
+      (let* ((dow (car wo))
+             (offset (cdr wo))
+             (low-date
+              (ical:nth-weekday-in offset dow
+                                   (ical:date/time-year interval-start)
+                                   (when in-month
+                                     (ical:date/time-month interval-start)=
)))
+             (low (ical:date-to-date-time low-date :tz vtimezone))
+             (high (ical:date/time-add low :day 1 vtimezone)))
+        (when (ical:date/time< low interval-start)
+          (setq low interval-start))
+        (when (ical:date/time< interval-end high)
+          (setq high interval-end))
+        (when vtimezone
+          (icr:tz-set-zone low vtimezone)
+          (icr:tz-set-zone high vtimezone))
+        (when (and (ical:date/time<=3D interval-start low)
+                   (ical:date/time<=3D high interval-end)
+                   (ical:date/time< low high))
+          (push (list low high) subintervals))))
+
+    ;; When no offset was given, for each day in the interval...
+    (while (and (<=3D start-abs end-abs)
+                sorted-weekdays)
+      ;; ...the subinterval is one day long on matching weekdays.
+      (let* ((gdate (calendar-gregorian-from-absolute start-abs)))
+        (when (memq (calendar-day-of-week gdate) sorted-weekdays)
+          (let* ((low (ical:date-to-date-time gdate))
+                 (high (ical:date/time-add low :day 1 vtimezone)))
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when vtimezone
+              (icr:tz-set-zone low vtimezone)
+              (icr:tz-set-zone high vtimezone))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time<=3D high interval-end)
+                       (ical:date/time< low high))
+              (push (list low high) subintervals)))))
+      (setq start-abs (1+ start-abs)))
+
+    ;; Finally, sort and return all subintervals:
+    (sort subintervals
+          :lessp (lambda (int1 int2)
+                   (ical:date-time< (car int1) (car int2)))
+          :in-place t)))
+
+(defun icr:refine-byhour (interval hours &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching HOURS.
+
+HOURS should be a list of values from a recurrence rule's
+BYHOUR=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-hours (sort hours))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<=3D interval-start interval-end)
+      ;; For each day in the interval...
+      (dolist (h sorted-hours)
+        ;; ...the subinterval is one hour long in the given hour
+        (let* ((low (ical:date-time-variant interval-start
+                                            :hour h :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :hour 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting ti=
mes
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<=3D high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start (ical:date/time-add interval-start :day 1 vtime=
zone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byminute (interval minutes &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MINUTES.
+
+MINUTES should be a list of values from a recurrence rule's
+BYMINUTE=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-minutes (sort minutes))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<=3D low-ts end-ts)
+      ;; For each hour in the interval...
+      (dolist (m sorted-minutes)
+        ;; ...the subinterval is one minute long in the given minute
+        (let* ((low (ical:date-time-variant interval-start :minute m :seco=
nd 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :minute 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting ti=
mes
+            ;; Clip the subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<=3D interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<=3D high interval-end))
+              (push (list low high) subintervals)))))
+      (setq low-ts (+ low-ts (* 60 60))
+            interval-start (if vtimezone (icr:tz-decode-time low-ts vtimez=
one)
+                             (ical:date/time-add interval-start :hour 1))))
+    (nreverse subintervals)))
+
+(defun icr:refine-bysecond (interval seconds &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching SECONDS.
+
+SECONDS should be a list of values from a recurrence rule's
+BYSECOND=3D... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-seconds (sort seconds))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<=3D low-ts end-ts)
+      ;; For each minute in the interval...
+      (dolist (s sorted-seconds)
+        ;; ...the subinterval is one second long: the given second
+        (let* ((low (ical:date-time-variant interval-start :second s
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :second 1 vtimezone)))
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<=3D interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<=3D high interval-end))
+            (push (list low high) subintervals))))
+      (setq low-ts (+ low-ts 60)
+            interval-start (if vtimezone
+                               (icr:tz-decode-time low-ts vtimezone)
+                             (ical:date/time-add interval-start :minute 1)=
)))
+    (nreverse subintervals)))
+
+;; TODO: should this just become a generic function, with the above
+;; refine-by* functions becoming its methods?
+(defun icr:refine-by (unit interval values
+                      &optional byday-inmonth weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching VALUES for UNIT."
+  (cl-case unit
+    (BYYEARDAY (icr:refine-byyearday interval values vtimezone))
+    (BYWEEKNO (icr:refine-byweekno interval values weekstart vtimezone))
+    (BYMONTH (icr:refine-bymonth interval values vtimezone))
+    (BYMONTHDAY (icr:refine-bymonthday interval values vtimezone))
+    (BYDAY (icr:refine-byday interval values byday-inmonth vtimezone))
+    (BYHOUR (icr:refine-byhour interval values vtimezone))
+    (BYMINUTE (icr:refine-byminute interval values vtimezone))
+    (BYSECOND (icr:refine-bysecond interval values vtimezone))))
+
+(defun icr:make-bysetpos-filter (setpos)
+  "Return a filter on values for the indices in SETPOS.
+
+SETPOS should be a list of positive or negative integers between -366
+and 366, indicating a fixed index in a set of recurrences for *one
+interval* of a recurrence set, as found in the BYSETPOS=3D...  clause of
+an `icalendar-recur'. For example, in a YEARLY recurrence rule with an
+INTERVAL of 1, the SETPOS represent indices in the recurrence instances
+generated for a single year.
+
+The returned value is a closure which can be called on the list of
+recurrences for one interval to filter it by index."
+  (lambda (dts)
+    (let* ((len (length dts))
+           (keep-indices (mapcar
+                          (lambda (pos)
+                            ;; sequence indices are 0-based, POS's are 1-b=
ased:
+                            (if (< pos 0)
+                                (+ pos len)
+                              (1- pos)))
+                          setpos)))
+      (delq nil
+        (seq-map-indexed
+         (lambda (dt index)
+           (when (memq index keep-indices)
+                 dt))
+         dts)))))
+
+(defun icr:refine-from-clauses (interval recur-value dtstart
+                                &optional vtimezone)
+  "Resolve INTERVAL into subintervals based on the clauses in RECUR-VALUE.
+
+The resulting list of subintervals represents all times in INTERVAL
+which match the BY* clauses of RECUR-VALUE except BYSETPOS, as well as
+the constraints implicit in DTSTART. (For example, if there is no
+BYMINUTE clause, subintervals will have the same minute value as
+DTSTART.)
+
+If specified, VTIMEZONES should be a list of `icalendar-vtimezone'
+components and TZID should be the `icalendar-tzid' property value of one
+of those timezones. In this case, TZID states the time zone of DTSTART,
+and the offsets effective in that time zone on the dates and times of
+recurrences will be local to that time zone."
+  (let ((freq (ical:recur-freq recur-value))
+        (weekstart (ical:recur-weekstart recur-value))
+        (subintervals (list interval)))
+
+    (dolist (byunit (list 'BYMONTH 'BYWEEKNO
+                          'BYYEARDAY 'BYMONTHDAY 'BYDAY
+                          'BYHOUR 'BYMINUTE 'BYSECOND))
+      (let ((values (ical:recur-by* byunit recur-value))
+            (in-month nil))
+        ;; When there is no explicit BY* clause, use the value implicit
+        ;; in DTSTART. (These conditions are adapted from RFC8984:
+        ;;   https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.3.1-4=
.3.1
+        ;; Basically, the conditions are somewhat complicated because
+        ;; the meanings of various BY* clauses are not independent and
+        ;; so we have to be careful about the information we take to be
+        ;; implicit in DTSTART, especially with MONTHLY and YEARLY
+        ;; rules. For example, we *do* want to take the weekday of
+        ;; DTSTART as an implicit constraint if a BYWEEKNO clause is
+        ;; present, but not if an explicit BYDAY or BYMONTHDAY clause is
+        ;; also present, since they might contain conflicting
+        ;; constraints.)
+        (when (and (eq byunit 'BYSECOND)
+                   (not (eq freq 'SECONDLY))
+                   (not values))
+          (setq values (list (ical:date/time-second dtstart))))
+        (when (and (eq byunit 'BYMINUTE)
+                   (not (memq freq '(SECONDLY MINUTELY)))
+                   (not values))
+          (setq values (list (ical:date/time-minute dtstart))))
+        (when (and (eq byunit 'BYHOUR)
+                   (not (memq freq '(SECONDLY MINUTELY HOURLY)))
+                   (not values))
+          (setq values (list (ical:date/time-hour dtstart))))
+        (when (and (eq byunit 'BYDAY)
+                   (eq freq 'WEEKLY)
+                   (not values))
+          (setq values (list (ical:date/time-weekday dtstart))))
+        (when (and (eq byunit 'BYMONTHDAY)
+                   (eq freq 'MONTHLY)
+                   (not (ical:recur-by* 'BYDAY recur-value))
+                   (not values))
+          (setq values (list (ical:date/time-monthday dtstart))))
+        (when (and (eq freq 'YEARLY)
+                   (not (ical:recur-by* 'BYYEARDAY recur-value)))
+          (when (and (eq byunit 'BYMONTH)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (or (ical:recur-by* 'BYMONTHDAY recur-value)
+                         (not (ical:recur-by* 'BYDAY recur-value))))
+            (setq values (list (ical:date/time-month dtstart))))
+          (when (and (eq byunit 'BYMONTHDAY)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (not (ical:recur-by* 'BYDAY recur-value)))
+            (setq values (list (ical:date/time-monthday dtstart))))
+          (when (and (eq byunit 'BYDAY)
+                     (not values)
+                     (ical:recur-by* 'BYWEEKNO recur-value)
+                     (not (ical:recur-by* 'BYMONTHDAY recur-value)))
+            (setq values (list (ical:date/time-weekday dtstart)))))
+
+        ;; Handle offsets in a BYDAY clause:
+        ;; "If present, this [offset] indicates the nth occurrence of a
+        ;; specific day within the MONTHLY or YEARLY "RRULE".  For
+        ;; example, within a MONTHLY rule, +1MO (or simply 1MO)
+        ;; represents the first Monday within the month, whereas -1MO
+        ;; represents the last Monday of the month.  The numeric value
+        ;; in a BYDAY rule part with the FREQ rule part set to YEARLY
+        ;; corresponds to an offset within the month when the BYMONTH
+        ;; rule part is present"
+        (when (and (eq byunit 'BYDAY)
+                   (or (eq freq 'MONTHLY)
+                       (and (eq freq 'YEARLY)
+                            (ical:recur-by* 'BYMONTH recur-value))))
+          (setq in-month t))
+
+        ;; On each iteration of the loop, we refine the subintervals
+        ;; with these explicit or implicit values:
+        (when values
+          (setq subintervals
+                (delq nil
+                      (mapcan (lambda (in)
+                                (icr:refine-by byunit in values in-month
+                                               weekstart vtimezone))
+                              subintervals))))))
+
+    ;; Finally return the refined subintervals after we've looked at all
+    ;; clauses:
+    subintervals))
+
+;; Once we have refined an interval into a final set of subintervals, we
+;; need to convert those subintervals into a set of recurrences. For a
+;; recurrence set where DTSTART and the recurrences are date-times, the
+;; recurrence set (in this interval) consists of every date-time
+;; corresponding to each second of any subinterval. When DTSTART and the
+;; recurrences are plain dates, the recurrence set consists of each
+;; distinct date in any subinterval.
+(defun icr:subintervals-to-date-times (subintervals &optional vtimezone)
+  "Transform SUBINTERVALS into a list of `icalendar-date-time' recurrences.
+
+The returned list of recurrences contains one date-time value for each
+second of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (dt start)
+             ;; use absolute times for the loop in case the subinterval
+             ;; crosses the boundary between two observances
+             ;; TODO: what if we only have floating times?
+             (end (time-convert (encode-time (cadr int)) 'integer))
+             (tick (time-convert (encode-time start) 'integer)))
+        (while (time-less-p tick end)
+          (push dt recurrences)
+          (setq tick (1+ tick)
+                dt (if vtimezone (icr:tz-decode-time tick vtimezone)
+                     (ical:date/time-add dt :second 1))))))
+    (nreverse recurrences)))
+
+(defun icr:subintervals-to-dates (subintervals)
+  "Transform SUBINTERVALS into a list of `icalendar-date' recurrences.
+
+The returned list of recurrences contains one date value for each
+day of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (start-abs (calendar-absolute-from-gregorian
+                         (ical:date-time-to-date start)))
+             (end (cadr int))
+             (end-abs (calendar-absolute-from-gregorian
+                       (ical:date-time-to-date end)))
+             ;; end is an exclusive upper bound, but number-sequence
+             ;; needs an *inclusive* upper bound, so if end is at
+             ;; midnight, the bound is the previous day:
+             (bound (if (zerop (+ (decoded-time-hour end)
+                                  (decoded-time-minute end)
+                                  (decoded-time-second end)))
+                        (1- end-abs)
+                      end-abs)))
+        (setq recurrences
+              (append recurrences
+                      (mapcar #'calendar-gregorian-from-absolute
+                              (number-sequence start-abs bound))))))
+    recurrences))
+
+(defun icr:subintervals-to-recurrences (subintervals dtstart &optional vti=
mezone)
+  "Transform SUBINTERVALS into a list of recurrences.
+
+The returned list of recurrences contains all distinct values in each
+subinterval of the same type as DTSTART."
+  (if (cl-typep dtstart 'ical:date)
+      (icr:subintervals-to-dates subintervals)
+    (icr:subintervals-to-date-times subintervals vtimezone)))
+
+
+;; Calculating recurrences in a given interval or window
+;;
+;; We can now put all of the above together to compute the set of
+;; recurrences in a given interval (`icr:recurrences-in-interval'), and
+;; thereby in a given window (`icr:recurences-in-window'); or, if the
+;; rule describing the set has a COUNT clause, we can enumerate the
+;; recurrences in each interval starting from the beginning of the set
+;; (`icr:recurrences-to-count').
+(defun icr:recurrences-in-interval (interval component &optional vtimezone=
 nmax)
+  "Return a list of the recurrences of COMPONENT in INTERVAL.
+
+INTERVAL should be a list (LOW HIGH NEXT) of date-times which bound a
+single recurrence interval, as returned e.g. by
+`icalendar-recur-find-interval'. (To find the recurrences in an
+arbitrary window of time, rather than between interval boundaries, see
+`icalendar-recur-recurrences-in-window'.)
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and either an
+`icalendar-rrule' or `icalendar-rdate' property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone.
+
+If specified, NMAX should be a positive integer containing a maximum
+number of recurrences to return from this interval. In this case, if the
+interval contains more than NMAX recurrences, only the first NMAX
+recurrences will be returned; otherwise all recurrences in the interval
+are returned. (The NMAX argument mainly exists to support recurrence
+rules with a COUNT clause; see `icalendar-recur-recurrences-to-count'.)
+
+The returned list is a list of `icalendar-date' or `icalendar-date-time'
+values representing the start times of recurrences.  Note that any
+values of type `icalendar-period' in COMPONENT's `icalendar-rdate'
+property (or properties) will NOT be included in the list; it is the
+callee's responsibility to handle any such values separately.
+
+The computed recurrences for INTERVAL are cached in COMPONENT and
+retrieved on subsequent calls with the same arguments."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :all rdate-nodes) ;; TODO: these can also be ical:perio=
d values
+       (ical:exdate :all exdate-nodes))
+    (unless (or recur-value rdate-nodes)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylig=
ht))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (cl-labels ((get-interval (apply-partially #'icr:-set-get-interval com=
ponent))
+                (put-interval (apply-partially #'icr:-set-put-interval com=
ponent)))
+      (let ((cached (get-interval interval)))
+        (cond ((eq cached :none) nil)
+              (cached cached)
+              (t
+               (let* (;; Start by generating all the recurrences matching =
the
+                      ;; BY* clauses except for BYSETPOS:
+                      (subs (icr:refine-from-clauses interval recur-value =
dtstart
+                                                     vtimezone))
+                      (sub-recs (icr:subintervals-to-recurrences subs dtst=
art
+                                                                 vtimezone=
))
+                      ;; Apply any BYSETPOS clause to this set:
+                      (keep-indices (ical:recur-by* 'BYSETPOS recur-value))
+                      (pos-recs
+                       (if keep-indices
+                           (funcall (icr:make-bysetpos-filter keep-indices)
+                                    sub-recs)
+                         sub-recs))
+                      ;; Remove any recurrences before DTSTART or after UN=
TIL
+                      ;; (both of which are inclusive bounds):
+                      (until (ical:recur-until recur-value))
+                      (until-recs
+                       (seq-filter
+                        (lambda (rec) (and (ical:date/time<=3D dtstart rec)
+                                           (or (not until)
+                                               (ical:date/time<=3D rec unt=
il))))
+                        pos-recs))
+                      ;; Include any values in the interval from the
+                      ;; RDATE property:
+                      (low (car interval))
+                      (high (cadr interval))
+                      (rdates
+                       (mapcar #'ical:ast-node-value
+                               (apply #'append
+                                      (mapcar #'ical:ast-node-value rdate-=
nodes))))
+                      (interval-rdates
+                       (seq-filter
+                        (lambda (rec)
+                          ;; only include ical:date and ical:date-time
+                          ;; values from RDATE; callee is responsible
+                          ;; for handling ical:period values
+                          (unless (cl-typep rec 'ical:period)
+                             (and (ical:date/time<=3D low rec)
+                                  (ical:date/time< high rec))))
+                        rdates))
+                      (included-recs (append until-recs interval-rdates))
+                      ;; Exclude any values from the EXDATE property; this
+                      ;; gives us the complete set of recurrences in this =
interval:
+                      (exdates
+                       (mapcar #'ical:ast-node-value
+                               (append
+                                (mapcar #'ical:ast-node-value exdate-nodes=
))))
+                      (all-recs
+                       (if exdates
+                           (seq-filter
+                            (lambda (rec) (not (member rec exdates)))
+                            included-recs)
+                         included-recs))
+                      ;; Limit to the first NMAX recurrences if requested.
+                      ;; `icr:recurrences-to-count' provides NMAX so as no=
t to
+                      ;; store more recurrences in the final interval than=
 the
+                      ;; COUNT clause allows:
+                      (nmax-recs
+                       (if nmax (seq-take all-recs nmax)
+                         all-recs)))
+                 ;; Store and return the computed recurrences:
+                 (put-interval interval (or nmax-recs :none))
+                 nmax-recs)))))))
+
+(defun icr:recurrences-in-window (lower upper component &optional vtimezon=
e)
+  "Return the recurrences of COMPONENT in the window between LOWER and UPP=
ER.
+
+LOWER and UPPER may be arbitrary `icalendar-date' or
+`icalendar-date-time' values. COMPONENT should be an iCalendar component
+node representing a recurring event: it should contain at least an
+`icalendar-dtstart' and either an `icalendar-rrule' or `icalendar-rdate'
+property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :all rdate-nodes))
+    (unless (or recur-value rdate-nodes)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylig=
ht))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+
+    (let* (;; don't look for nonexistent intervals:
+           (low-start (if (ical:date/time< lower dtstart) dtstart lower))
+           (until (ical:recur-until recur-value))
+           (high-end (if (and until (ical:date/time< until upper)) until u=
pper))
+           (curr-interval (icr:find-interval low-start dtstart recur-value
+                                             vtimezone))
+           (high-interval (icr:find-interval high-end dtstart recur-value
+                                             vtimezone))
+           (high-intbound (cadr high-interval))
+           (recurrences nil))
+
+      (while (ical:date-time< (car curr-interval) high-intbound)
+        (setq recurrences
+              (append
+               (icr:recurrences-in-interval curr-interval component vtimez=
one)
+               recurrences))
+        (setq curr-interval (icr:next-interval curr-interval recur-value
+                                               vtimezone)))
+
+      ;; exclude any recurrences inside the first and last intervals but
+      ;; outside the window before returning:
+      (seq-filter
+       (lambda (dt)
+         (and (ical:date/time<=3D lower dt)
+              (ical:date/time< dt upper)))
+       recurrences))))
+
+(defun icr:recurrences-in-window-w/end-times
+    (lower upper component &optional vtimezone)
+  "Like `icalendar-recurrences-in-window', but returns a list of
+(START END) pairs representing the start and end time of each recurrence
+of COMPONENT in the window.
+
+In the returned pairs, START and END are both `icalendar-date' or
+`icalendar-date-time' values of the same type as COMPONENT's
+`icalendar-dtstart'. Each END time is computed by adding COMPONENT's
+`icalendar-duration' value to START for each recurrence START between
+LOWER and UPPER. Or, if the recurrence is given by an `icalendar-period'
+value in an `icalendar-rdate' property, START and END are determined by
+the period."
+  (ical:with-component component
+    ((ical:duration :value duration)
+     (ical:rdate :all rdate-nodes))
+    ;; TODO: for higher-level applications showing a schedule, it might
+    ;; be useful to include recurrences which start outside the window,
+    ;; but end inside it. This would mean we can't simply use
+    ;; recurrences-in-window like this.
+    (let ((starts (icr:recurrences-in-window lower upper component vtimezo=
ne))
+          (periods (seq-filter
+                    (lambda (vnode)
+                      (when (eq 'ical:period (ical:ast-node-type vnode))
+                        (ical:ast-node-value vnode)))
+                    (append
+                     (mapcar #'ical:ast-node-value rdate-nodes)))))
+      (when (or starts periods)
+        (seq-uniq
+         (append (mapcar
+                  (lambda (dt) (list dt (ical:date/time-add-duration
+                                         dt duration vtimezone)))
+                  starts)
+                 (mapcar
+                  (lambda (p)
+                    (let ((start (ical:period-start p)))
+                      (list start
+                            (or (ical:period-end p)
+                                (ical:date/time-add-duration
+                                 start (ical:period-dur-value p) vtimezone=
)))))
+                  periods)))))))
+
+(defun icr:recurrences-to-count (component &optional vtimezone)
+  "Return all the recurrences in COMPONENT up to COUNT in its recurrence r=
ule.
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and an
+`icalendar-rrule', which must contain a COUNT=3D... clause.
+
+Warning: this function finds *all* the recurrences in COMPONENT's
+recurrence set. If the value of COUNT is large, this can be slow.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :all rdate-nodes))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylig=
ht))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (unless (or recur-value rdate-nodes)
+      (error "No recurrence data in component: %s" component))
+    (unless (ical:recur-count recur-value)
+      (error "Recurrence rule has no COUNT clause"))
+    (let ((count (ical:recur-count recur-value))
+          (int (icr:nth-interval 0 dtstart recur-value vtimezone))
+          recs)
+      (while (length< recs count)
+        (setq recs
+              (append recs (icr:recurrences-in-interval int component vtim=
ezone
+                                                        (- count (length r=
ecs)))))
+        (setq int (icr:next-interval int recur-value vtimezone)))
+      recs)))
+
+
+
+;; Recurrence set representation
+;;
+;; We represent a recurrence set as a map from intervals to the
+;; recurrences in that interval. The primary purpose of this
+;; representation is to memoize the computation of recurrences, since
+;; the computation is relatively expensive and the results are needed
+;; repeatedly, particularly for time zone observances. The map is stored
+;; in the `:recurrence-set' property of the iCalendar component which
+;; represents the recurring event.
+;;
+;; The macro `icalendar-recur-with-recurrence-set' makes it easy to work
+;; with these maps. Given a component representing a recurring event, it
+;; binds the values for all the recurrence-related properties in the
+;; component, as well as two functions to store and retrieve recurrences
+;; by interval.
+;;
+;; Internally, we use a hash table for the map, since the set can grow
+;; quite large. We use the start date-times of intervals as the keys,
+;; since these uniquely identify intervals within a given component; we
+;; ignore the weekday, zone and dst fields in the keys, mostly to avoid
+;; cache misses during time zone observance lookups, which must generate
+;; intervals with different zone values.
+;;
+;; In order to avoid repeating the computation of recurrences, we store
+;; the keyword `:none' as the value when there are no recurrences in a
+;; given interval. This distinguishes the value from nil, so that,
+;; whereas (gethash some-key the-map) =3D> nil means "We haven't computed
+;; recurrences yet for this interval", (gethash some-key the-map) =3D>
+;; :none means "We've computed that there are no recurrences in this
+;; interval", and can skip the computation of recurrences. See
+;; `icalendar-recur-recurrences-in-interval', which performs the check.
+
+(defun icr:-make-set ()
+  (make-hash-table :test #'equal))
+
+(defsubst icr:-key-from-interval (interval)
+  (take 6 (car interval))) ; (secs mins hours day month year)
+
+(defun icr:-set-get-interval (component interval)
+  (let ((set (ical:ast-node-meta-get :recurrence-set component))
+        (key (icr:-key-from-interval interval)))
+    (when (hash-table-p set)
+      (gethash key set))))
+
+(defun icr:-set-put-interval (component interval recurrences)
+  (let ((set (or (ical:ast-node-meta-get :recurrence-set component)
+                 (icr:-make-set)))
+        (key (icr:-key-from-interval interval)))
+    (setf (gethash key set) recurrences)
+    (ical:ast-node-meta-set component :recurrence-set set)))
+
+(defun icr:-set-complete-p (component)
+  (let* ((set (ical:ast-node-meta-get :recurrence-set component))
+         (recur-value (ical:with-property-of component 'ical:recur nil val=
ue))
+         (count (ical:recur-count recur-value))
+         (n 0)
+         (until (ical:recur-until recur-value))
+         (has-until nil))
+    (when (hash-table-p set)
+      (dolist (recs (hash-table-values set))
+        (cl-incf n (length recs))
+        ;; TODO: This isn't right; the set is only complete if it has
+        ;; recurrences for *all* intervals *up to* UNTIL. May not be
+        ;; worth computing this.
+        (when (and (listp recs) (member until recs))
+          (setq has-until t)))
+      (cond (count (=3D count n))
+            (until has-until)
+            (t nil)))))
+
+;; TODO: this needs more thought.
+;; The byte compiler doesn't like all these implicit bindings.
+;; Instead, with-component should offer the option to bind
+;; get-interval and put-interval; or these
+;; TODO: get/put-interval might need better names, and should be bound with
+;; cl-labels instead of let, so that they can be put directly in function =
position
+(defmacro icr:with-recurrence-set (component &rest body)
+  "Execute BODY with bindings for recurrence set properties in COMPONENT.
+
+This macro facilitates memoized computations of the values in
+COMPONENT's recurrence set.
+
+Within BODY, the following symbols are bound as follows:
+ dtstart - the value in COMPONENT's `icalendar-dtstart' property.
+   In `icalendar-standard' and `icalendar-daylight' components,
+   this value includes the UTC offset in the `icalendar-tzoffsetfrom' prop=
erty.
+ recur-value - the value in COMPONENT's `icalendar-rrule' property.
+ rdates - the values in COMPONENT's `icalendar-rdate' property.
+ exdates - the values in COMPONENT's `icalendar-exdate' property.
+ get-interval - a function which, given an interval, returns the
+   recurrences in that interval cached in COMPONENT's recurrence set.
+   It should be called like:
+   (funcall get-interval INTERVAL)
+ put-interval - a function which, given an interval and a list of recurren=
ces
+   in that interval, caches those recurrences in COMPONENT's recurrence se=
t.
+   It should be called like:
+   (funcall put-interval INTERVAL RECURRENCES)
+"
+  (let ((comp (gensym "icalendar-component"))
+        (offset (gensym "tzoffset"))
+        (recset (gensym "recurrence-set")))
+  `(let ((,comp ,component))
+     (ical:with-component ,comp
+       ((ical:dtstart :value dtstart :value-type dtstart-type)
+        (ical:tzoffsetfrom :value ,offset)
+        (ical:rrule :value recur-value)
+        (ical:rdate :all rdate-nodes))
+       (when (memq (ical:ast-node-type ,comp) '(ical:standard ical:dayligh=
t))
+         ;; in time zone observances, set the zone field in dtstart
+         ;; from the TZOFFSETFROM property:
+         (setq dtstart (ical:date-time-variant dtstart :zone ,offset
+                                               :dst (not (ical:daylight-co=
mponent-p ,comp)))))
+       (if (or recur-value rdate-nodes)
+         (let ((,recset (ical:ast-node-meta-get :recurrence-set ,comp)))
+           (unless ,recset
+             (setq ,recset (icr:-make-set))
+             (ical:ast-node-meta-set ,comp :recurrence-set ,recset))
+           (let ((get-interval (apply-partially #'icr:-set-get-interval ,c=
omp))
+                 (put-interval (apply-partially #'icr:-set-put-interval ,c=
omp)))
+             ;; TODO: further functions to test for membership, query by
+             ;; date, etc.?
+             ,@body))
+         ;; TODO: what's the most sensible thing when there's no RRULE or =
RDATE?
+         ;; Should we still execute body?
+         (error "No recurrence data in this component"))))))
+
+
+;; Timezones:
+
+(define-error 'ical:tz-nonexistent-time "Date-time does not exist" 'ical:e=
rror)
+
+(define-error 'ical:tz-no-observance "No observance found for date-time"
+              'ical:error)
+
+;; In RFC5545 Section 3.3.10, we read: "If the computed local start time
+;; of a recurrence instance does not exist ... the time of the
+;; recurrence instance is interpreted in the same manner as an explicit
+;; DATE-TIME value describing that date and time, as specified in
+;; Section 3.3.5." which in turn says:
+;; "If, based on the definition of the referenced time zone, the local
+;; time described occurs more than once (when changing from daylight to
+;; standard time), the DATE-TIME value refers to the first occurrence of
+;; the referenced time.  Thus, TZID=3DAmerica/New_York:20071104T013000
+;; indicates November 4, 2007 at 1:30 A.M. EDT (UTC-04:00).  If the
+;; local time described does not occur (when changing from standard to
+;; daylight time), the DATE-TIME value is interpreted using the UTC
+;; offset before the gap in local times. Thus,
+;; TZID=3DAmerica/New_York:20070311T023000 indicates March 11, 2007 at
+;; 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST (UTC-05:00)."
+
+;; TODO: verify that these functions are correct for time zones other
+;; than US Eastern.
+(defun icr:nonexistent-date-time-p (dt obs-onset observance)
+  "Return non-nil if DT does not exist in a given OBSERVANCE.
+
+Some local date-times do not exist in a given time zone.  When switching
+from standard to daylight savings time, the local clock time jumps over
+a certain range of times. This function tests whether DT is one of those
+non-existent local times.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted using the UTC offset in effect prior to the onset of
+OBSERVANCE. For example, at the switch from Standard to Daylight Savings
+time in US Eastern, the nonexistent time 2:30AM (Standard) must be
+re-interpreted as 3:30AM DST."
+  (when (ical:daylight-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (=3D (decoded-time-year dt) (decoded-time-year obs-onset))
+           (=3D (decoded-time-month dt) (decoded-time-month obs-onset))
+           (=3D (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (jumped (abs (- offset-from offset-to)))
+                  (after-jumped (+ onset-secs jumped)))
+             (and
+              (<=3D onset-secs dt-secs)
+              (< dt-secs after-jumped)))))))
+
+(defun icr:date-time-occurs-twice-p (dt obs-onset observance)
+  "Return non-nil if DT represents a local clock time that occurs twice in
+a given observance.
+
+Some local date-times occur twice in a given time zone.  When switching
+from daylight savings to standard time time, the local clock time is
+typically set back, so that a certain range of clock times occurs twice,
+once in daylight savings time and once in standard time. This function
+tests whether DT is one of those local times which occur twice.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the relevant onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted as the first occurrence of this clock time, i.e., in
+daylight savings time, prior to OBS-ONSET."
+  (when (ical:standard-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (=3D (decoded-time-year dt) (decoded-time-year obs-onset))
+           (=3D (decoded-time-month dt) (decoded-time-month obs-onset))
+           (=3D (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (repeated (abs (- offset-from offset-to)))
+                  (start-repeateds (- onset-secs repeated)))
+             (and
+              (<=3D start-repeateds dt-secs)
+              (< dt-secs onset-secs)))))))
+
+(defun icr:tz--get-updated-in (dt obs-onset observance)
+  "Determine how to update DT's zone and dst slots from OBSERVANCE.
+
+DT should be an `icalendar-date-time', OBSERVANCE an
+`icalendar-standard' or `icalendar-daylight', and OBS-ONSET the nearest
+onset of OBSERVANCE before DT. Returns an `icalendar-date-time' that can
+be used to update DT.
+
+In most cases, the return value will contain a zone offset equal to
+OBSERVANCE's `icalendar-tzoffsetto' value.
+
+However, when DT falls within a range of nonexistent times after
+OBS-ONSET, or a range of local times that occur twice (see
+`icalendar-recur-nonexistent-date-time-p' and
+`icalendar-recur-date-time-occurs-twice-p'), it needs to be interpreted
+with the UTC offset in effect prior to the OBS-ONSET of OBSERVANCE (see
+RFC5545 Sec. 3.3.5).  So e.g. at the switch from Standard to Daylight in
+US Eastern, 2:30AM EST (a nonexistent time) becomes 3:30AM EDT, and at
+the switch from Daylight to Standard, 1:30AM (which occurs twice)
+becomes 1:30AM EDT, the first occurence."
+  (ical:with-component observance
+      ((ical:tzoffsetfrom :value offset-from)
+       (ical:tzoffsetto :value offset-to))
+    (let* ((is-daylight (ical:daylight-component-p observance))
+           (to-dt (ical:date-time-variant dt :dst is-daylight :zone offset=
-to))
+           (from-dt (ical:date-time-variant dt :dst (not is-daylight)
+                                            :zone offset-from))
+          updated)
+      (cond ((icr:nonexistent-date-time-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we take the same
+             ;; point in absolute time as from-dt, but re-decode it into
+             ;; to-dt's zone:
+             (setq updated (decode-time (encode-time from-dt) offset-to))
+             (setf (decoded-time-dst updated) is-daylight))
+            ((icr:date-time-occurs-twice-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we interpret dt as
+             ;; from-dt, since that is the first occurrence of the clock
+             ;; time in the zone:
+             (setq updated from-dt))
+            (t
+             ;; Otherwise we interpret dt as to-dt, i.e., with the
+             ;; offset effective within the observance:
+             (setq updated to-dt)))
+      updated)))
+
+(defun icr:tz-for (tzid vtimezones)
+  "Return the `icalendar-vtimezone' for the TZID.
+
+VTIMEZONES should be a list of `icalendar-vtimezone' components.  TZID
+should be a time zone identifier, as found e.g. in an
+`icalendar-tzidparam' parameter. The first time zone in VTIMEZONES whose
+`icalendar-tzid' value matches this parameter's value is returned."
+  (catch 'found
+    (dolist (tz vtimezones)
+      (ical:with-component tz
+          ((ical:tzid :value tzidval))
+        (when (equal tzidval tzid)
+          (throw 'found tz))))))
+
+;; DRAGONS DRAGONS DRAGONS
+(defun icr:tz-observance-on (dt vtimezone &optional update nonexisting)
+  "Return the time zone observance in effect on DT in VTIMEZONE.
+
+If there is such an observance, the returned value is a list (OBSERVANCE
+ONSET). OBSERVANCE is an `icalendar-standard' or `icalendar-daylight'
+component node. ONSET is the recurrence of OBSERVANCE (an
+`icalendar-date-time') which occurs closest in time, but before, DT.
+
+If there is no such observance in VTIMEZONE, the returned value is nil.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node.
+
+DT may be an an `icalendar-date-time' or a Lisp timestamp. If it is a
+date-time, it represents a local time assumed to be in VTIMEZONE. Any
+existing offset in DT is ignored, and DT is compared with the local
+clock time at the start of each observance in VTIMEZONE to determine the
+correct observance and onset. (This is so that the correct observance
+can be found for clock times generated during recurrence rule
+calculations.)
+
+If UPDATE is non-nil, the observance found will be used to update the
+offset value in DT (as a side effect) before returning the observance
+and onset.
+
+If UPDATE is non-nil, NONEXISTING specifies how to handle clock times
+that do not exist in the observance (see
+`icalendar-recur-tz-nonexistent-date-time-p').  The keyword `:error'
+means to signal an \\=3D'icalendar-tz-nonexistent-time error, without
+modifying any of the fields in DT.  Otherwise, the default is to
+interpret DT using the offset from UTC before the onset of the found
+observance, and then reset the clock time in DT to the corresponding
+existing time after the onset of the observance.  For example, the
+nonexisting time 2:30AM in Standard time on the day of the switch to
+Daylight time in the US Eastern time zone will be reset to 3:30AM
+Eastern Daylight time.
+
+If DT is a Lisp timestamp, it represents an absolute time and
+comparisons with the onsets in VTIMEZONE are performed with absolute
+times. UPDATE and NONEXISTING have no meaning in this case and are
+ignored."
+  (ical:with-component vtimezone
+    ((ical:standard :all stds)
+     (ical:daylight :all dls))
+    (let (given-abs-time     ;; =3D `dt', if given a Lisp timestamp
+          given-clock-time   ;; =3D `dt', if given a decoded time
+          nearest-observance ;; the observance we're looking for
+          nearest-onset      ;; latest onset of this observance before `dt'
+          updated)           ;; stores how `dt's fields should be updated
+                             ;; in line with this observance, if requested
+
+      (if (cl-typep dt 'ical:date-time)
+          ;; We were passed a date-time with local clock time, not an
+          ;; absolute time; in this case, we must make local clock time
+          ;; comparisons with the observance onset start and recurrences
+          ;; (in order to determine the correct offset for it within the
+          ;; zone)
+          (setq given-clock-time dt
+                given-abs-time nil)
+        ;; We were passed an absolute time, not a date-time; in this
+        ;; case, we can make comparisons in absolute time with
+        ;; observance onset start and recurrences (in order to determine
+        ;; the correct offset for decoding it)
+        (setq given-abs-time dt
+              given-clock-time nil))
+
+      (dolist (obs (append stds dls))
+        (ical:with-component obs
+          ((ical:dtstart :value start)
+           (ical:rrule :value recur-value)
+           (ical:rdate :all rdate-nodes)
+           (ical:tzoffsetfrom :value offset-from))
+          ;; DTSTART of the observance must be given as local time, and is
+          ;; combined with TZOFFSETFROM to define the effective onset
+          ;; for the observance in absolute time.
+          (let* ((is-daylight (ical:daylight-component-p obs))
+                 (effective-start
+                  (ical:date-time-variant start :zone offset-from
+                                          :dst (not is-daylight)))
+                 (observance-might-apply
+                  (if given-clock-time
+                      (ical:date-time-locally<=3D effective-start given-cl=
ock-time)
+                    (ical:time<=3D (encode-time effective-start) given-abs=
-time))))
+
+            (when observance-might-apply
+              ;; Initialize our return values on the first iteration
+              ;; where an observance potentially applies:
+              (unless nearest-onset
+                (setq nearest-onset effective-start
+                      nearest-observance obs)
+                (when (and update given-clock-time)
+                  (setq updated
+                        (icr:tz--get-updated-in given-clock-time
+                                                effective-start obs))))
+
+              ;; We first check whether any RDATEs in the observance are
+              ;; the relevant onset:
+              (let ((rdates
+                     (mapcar #'ical:ast-node-value
+                             (apply #'append
+                                    (mapcar #'ical:ast-node-value rdate-no=
des)))))
+                (dolist (rd rdates)
+                  (let* ((effective-rd
+                          ;; N.B.: we don't have to worry about rd being
+                          ;; an ical:period or ical:date here because in
+                          ;; time zone observances, RDATE values are
+                          ;; *only* allowed to be local date-times; see
+                          ;; https://www.rfc-editor.org/rfc/rfc5545#sectio=
n-3.6.5
+                          ;; and `ical:rrule-validator'
+                          (ical:date-time-variant rd :zone offset-from
+                                                  :dst (not is-daylight)))
+                         (onset-applies
+                          (if given-clock-time
+                              (ical:date-time-locally<=3D effective-rd
+                                                        given-clock-time)
+                            (ical:time<=3D (encode-time effective-rd)
+                                         given-abs-time))))
+
+                    (when (and onset-applies nearest-onset
+                               (ical:date-time< nearest-onset effective-rd=
))
+                      (setq nearest-onset effective-rd
+                            nearest-observance obs)
+
+                      (when (and update given-clock-time)
+                        (setq updated
+                              (icr:tz--get-updated-in given-clock-time
+                                                      effective-rd obs))))=
)))
+
+              ;; If the observance has a recurrence value, it's the
+              ;; relevant observance if it:
+              ;; (1) has a recurrence which starts before dt
+              ;; (2) that recurrence is the nearest in the zone
+              ;;     which starts before dt
+              ;; Note that we intentionally do *not* pass `vtimezone'
+              ;; through here to find-interval, recurrences-in-interval,
+              ;; etc. so as not to cause infinite recursion. Instead we
+              ;; directly pass `offset-from' (the offset from UTC at the
+              ;; start of each observance onset), which
+              ;; `icr:tz-set-zone' knows to handle specially without
+              ;; calling this function.
+              (when recur-value
+                (let* ((target (or given-clock-time
+                                   (decode-time given-abs-time offset-from=
)))
+                       (int (icr:find-interval
+                             target effective-start recur-value offset-fro=
m))
+                       (int-recs (icr:recurrences-in-interval
+                                  int obs offset-from))
+                       ;; The closest observance onset before `dt' might
+                       ;; actually be in the previous interval, e.g.
+                       ;; if `dt' is in January after an annual change to
+                       ;; Standard Time in November. So check that as well.
+                       (prev-int (icr:previous-interval int recur-value
+                                                        effective-start
+                                                        offset-from))
+                       (prev-recs (when prev-int
+                                    (icr:recurrences-in-interval
+                                     prev-int obs offset-from)))
+                       (recs (append prev-recs int-recs))
+                       (keep-recs<=3Dgiven
+                        (if given-clock-time
+                            (lambda (rec)
+                              (ical:date-time-locally<=3D rec given-clock-=
time))
+                          (lambda (rec)
+                            (ical:time<=3D (encode-time rec) given-abs-tim=
e))))
+                       (srecs (sort (seq-filter ; (1)
+                                     keep-recs<=3Dgiven
+                                     recs)
+                                    :lessp #'ical:date-time<
+                                    :in-place t :reverse t))
+                       (latest-rec (car srecs)))
+
+                  (when (and latest-rec
+                             (ical:date-time< nearest-onset latest-rec)) ;=
 (2)
+                    (setf (decoded-time-dst latest-rec)
+                          ;; if obs is a DAYLIGHT observance, latest-rec
+                          ;; represents the last moment of standard time, =
and
+                          ;; vice versa
+                          (not is-daylight))
+                    (setq nearest-onset latest-rec
+                          nearest-observance obs)
+                    (when (and update given-clock-time)
+                      (setq updated
+                            (icr:tz--get-updated-in given-clock-time
+                                                    latest-rec obs))))))))=
))
+
+      ;; We've now found the nearest observance, if there was one.
+      ;; Update `dt' as a side effect if requested.  This saves
+      ;; repeating a lot of the above in a separate function.
+      (when (and update given-clock-time nearest-observance updated)
+        ;; signal an error when `dt' does not exist if requested, so the
+        ;; nonexistence can be handled further up the stack:
+        (when (and (eq :error nonexisting)
+                   (not (ical:date-time-locally-simultaneous-p dt updated)=
))
+          ;; TODO: write icr:signal-tz-nonexistent-time and use same error=
 format
+          (signal 'ical:tz-nonexistent-time
+                  (list (format "%d-%02d-%02d %02d:%02d:%02d does not exis=
t in %s"
+                                (decoded-time-year dt)
+                                (decoded-time-month dt)
+                                (decoded-time-day dt)
+                                (decoded-time-hour dt)
+                                (decoded-time-minute dt)
+                                (decoded-time-second dt)
+                                (or
+                                 (ical:with-property-of nearest-observance
+                                                        'ical:tzname nil v=
alue)
+                                 "time zone observance"))
+                        dt nearest-observance)))
+        ;; otherwise we copy `updated' over to `dt', which resets the
+        ;; clock time in `dt' if it did not exist:
+        (setf (decoded-time-zone dt) (decoded-time-zone updated))
+        (setf (decoded-time-dst dt) (decoded-time-dst updated))
+        (setf (decoded-time-second dt) (decoded-time-second updated))
+        (setf (decoded-time-minute dt) (decoded-time-minute updated))
+        (setf (decoded-time-hour dt) (decoded-time-hour updated))
+        (setf (decoded-time-day dt) (decoded-time-day updated))
+        (setf (decoded-time-month dt) (decoded-time-month updated))
+        (setf (decoded-time-year dt) (decoded-time-year updated))
+        (setf (decoded-time-weekday dt)
+              (calendar-day-of-week (ical:date-time-to-date updated))))
+
+      ;; Return the observance and onset if found, nil if not:
+      (when nearest-observance
+        (list nearest-observance nearest-onset)))))
+
+(defun icr:tz-offset-in (observance)
+  "Return the offset (in seconds) from UTC in effect during OBSERVANCE.
+
+OBSERVANCE should be an `icalendar-standard' or `icalendar-daylight'
+subcomponent of a particular `icalendar-vtimezone'. The returned value
+is the value of its `icalendar-tzoffsetto' property."
+  (ical:with-property-of observance 'ical:tzoffsetto nil value))
+
+(defun icr:tz-decode-time (ts vtimezone)
+  "Decode Lisp timestamp TS with the appropriate offset in VTIMEZONE.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node. The correct
+observance for TS will be looked up in VTIMEZONE, TS will be decoded
+with the UTC offset of that observance, and its dst slot will be set
+based on whether the observance is an `icalendar-standard' or
+`icalendar-daylight' component.  If VTIMEZONE does not have an
+observance that applies to TS, it is decoded into UTC time.
+
+VTIMEZONE may also be an `icalendar-utc-offset'. In this case TS is
+decoded directly into this UTC offset, and its dst slot is set to -1."
+  (let* ((observance (when (ical:vtimezone-component-p vtimezone)
+                       (car (icr:tz-observance-on ts vtimezone))))
+         (offset (cond (observance (icr:tz-offset-in observance))
+                       ((cl-typep vtimezone 'ical:utc-offset)
+                        vtimezone)
+                       (t 0))))
+
+    (ical:date-time-variant ; ensures weekday gets set, too
+     (decode-time ts offset)
+     :zone offset
+     :dst (if observance (ical:daylight-component-p observance)
+            -1))))
+
+(defun icr:tz-set-zone (dt vtimezone &optional nonexisting)
+  "Set the time zone offset and dst flag in DT based on VTIMEZONE.
+
+DT should be an `icalendar-date-time' and VTIMEZONE should be an
+`icalendar-vtimezone'. VTIMEZONE can also be an `icalendar-utc-offset',
+in which case this value is directly set in DT's zone field (without
+changing its dst flag). The updated DT is returned.
+
+This function generally sets only the zone and dst slots of DT, without
+changing the other slots; its main purpose is to adjust date-times
+generated from other date-times during recurrence rule calculations,
+where a different time zone observance may be in effect in the original
+date-time. It cannot be used to re-decode a fixed point in time into a
+different time zone; for that, see `icalendar-recur-tz-decode-time'.
+
+If given, NONEXISTING is a keyword that specifies what to do if DT
+represents a clock time that does not exist according to the relevant
+observance in VTIMEZONE. The value :error means to signal an
+\\=3D'icalendar-tz-nonexistent-time error, and nil means to reset the
+clock time in DT to an existing one; see
+`icalendar-recur-tz-observance-on'."
+  (if (cl-typep vtimezone 'ical:utc-offset)
+      ;; This is where the recurrence rule/time zone mutual dependence
+      ;; bottoms out; don't remove this conditional!
+      (setf (decoded-time-zone dt) vtimezone)
+
+    ;; Otherwise, if there's already zone information in dt, trust it
+    ;; without looking up the observance.  This is partly a performance
+    ;; optimization (because the lookup is expensive) and partly about
+    ;; avoiding problems: looking up the observance uses the clock time
+    ;; in dt without considering the zone information, and doing this
+    ;; when dt has already been adjusted to contain valid zone
+    ;; information can invalidate that information.
+    ;;
+    ;; It's reliable to skip the lookup when dt already contains zone
+    ;; information only because `icalendar-make-date-time',
+    ;; `icalendar-date/time-add', and in particular
+    ;; `icalendar-date-time-variant' are careful to remove the UTC
+    ;; offset and DST information in the date-times they construct,
+    ;; unless provided with enough information to fill those slots.
+    (unless (and (cl-typep dt 'ical:date-time)
+                 (decoded-time-zone dt)
+                 (booleanp (decoded-time-dst dt)))
+      ;; This updates the relevant slots in dt as a side effect:
+      ;; TODO: if no observance is found, is it ever sensible to signal an=
 error,
+      ;; instead of just leaving the zone slot unset?
+      (icr:tz-observance-on dt vtimezone t nonexisting)))
+    dt)
+
+(defun icr:tz-set-zones-in (vtimezones node)
+  "Recursively set time zone offset and dst flags in times in NODE.
+
+VTIMEZONES should be a list of the `icalendar-vtimezone' components in
+the calendar containing NODE. NODE can be any iCalendar syntax node. If
+NODE is a property node with an `icalendar-tzidparam' parameter and an
+`icalendar-date-time' or `icalendar-period' value, the appropriate time
+zone observance for its value is looked up in VTIMEZONES, and used to
+the set the zone and dst slots in its value. Otherwise, the function is
+called recursively on NODE's children."
+  (cond
+   ((ical:property-node-p node)
+    (ical:with-property node
+      ((ical:tzidparam :value tzid))
+      (when (and tzid (eq value-type 'ical:date-time))
+        (let* ((tz (icr:tz-for tzid vtimezones))
+               updated)
+          (cond ((eq value-type 'ical:date-time)
+                 (setq updated (icr:tz-set-zone value tz)))
+                ((eq value-type 'ical:period)
+                 (setq updated
+                       (ical:make-period
+                        (icr:tz-set-zone (ical:period-start value) tz)
+                        :end
+                        (if (ical:period--defined-end value)
+                            (icr:tz-set-zone (ical:period--defined-end val=
ue) tz)
+                          (ical:period-end value tz))
+                        :duration (ical:period-dur-value value)))))
+          (ical:ast-node-set-value value-node updated)))))
+   ((ical:component-node-p node) ; includes VCALENDAR nodes
+    (mapc (apply-partially #'icr:tz-set-zones-in vtimezones)
+          (ical:ast-node-children node)))
+   (t nil)))
+
+(defun icr:tzname-on (dt vtimezone)
+  "Return the name of the time zone observance in effect on DT in VTIMEZON=
E.
+
+DT should be an `icalendar-date' or `icalendar-date-time'. VTIMEZONE
+should be the `icalendar-vtimezone' component in which to interpret DT.
+
+The observance in effect on DT within VTIMEZONE is computed. The
+returned value is the value of the `icalendar-tzname' property of this
+observance."
+  (when-let* ((obs/onset (icr:tz-observance-on dt vtimezone))
+              (observance (car obs/onset)))
+    (ical:with-property-of observance 'ical:tzname)))
+
+
+
+(provide 'icalendar-recur)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-=
recur-"))
+;; End:
+;;; icalendar-recur.el ends here
diff --git a/lisp/calendar/icalendar-utils.el b/lisp/calendar/icalendar-uti=
ls.el
new file mode 100644
index 00000000000..54d520cf5fc
--- /dev/null
+++ b/lisp/calendar/icalendar-utils.el
@@ -0,0 +1,712 @@
+;;; icalendar-utils.el --- iCalendar utility functions  -*- lexical-bindin=
g: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: January 2025
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+(require 'cl-lib)
+(require 'calendar)
+(require 'icalendar-macs)
+(require 'icalendar-parser)
+
+;; Accessors for commonly used properties
+
+(defun ical:component-dtstart (component)
+  "Return the value of the `icalendar-dtstart' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtstart nil value))
+
+(defun ical:component-dtend (component)
+  "Return the value of the `icalendar-dtend' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtend nil value))
+
+(defun ical:component-rdate (component)
+  "Return the value of the `icalendar-rdate' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:rdate nil value))
+
+(defun ical:component-summary (component)
+  "Return the value of the `icalendar-summary' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:summary nil value))
+
+(defun ical:component-description (component)
+  "Return the value of the `icalendar-description' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:description nil value))
+
+(defun ical:component-tzname (component)
+  "Return the value of the `icalendar-tzname' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:tzname nil value))
+
+(defun ical:component-uid (component)
+  "Return the value of the `icalendar-uid' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:uid nil value))
+
+(defun ical:component-url (component)
+  "Return the value of the `icalendar-url' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:url nil value))
+
+(defun ical:property-tzid (property)
+  "Return the value of the `icalendar-tzid' parameter of PROPERTY."
+  (ical:with-param-of property 'ical:tzidparam nil value))
+
+;; Date/time
+
+;; N.B. Notation: "date/time" is used in function names when a function
+;; can accept either `icalendar-date' or `icalendar-date-time' values;
+;; in contrast, "date-time" means it accepts *only*
+;; `icalendar-date-time' values, not plain dates.
+;; TODO: turn all the 'date/time' functions into methods dispatched by
+;; type?
+
+(defun ical:date-time-to-date (dt)
+  "Convert an `icalendar-date-time' value DT to an `icalendar-date'"
+  (list (decoded-time-month dt)
+        (decoded-time-day dt)
+        (decoded-time-year dt)))
+
+(cl-defun ical:date-to-date-time (dt &key (hour 0) (minute 0) (second 0) (=
tz nil))
+  "Convert an `icalendar-date' value DT to an `icalendar-date-time'.
+
+The following keyword arguments are accepted:
+  :hour, :minute, :second - integers representing a local clock time on da=
te DT
+  :tz - an `icalendar-vtimezone' in which to interpret this clock time
+
+If these arguments are all unspecified, the hour, minute, and second
+slots of the returned date-time will be zero, and it will contain no
+time zone information. See `icalendar-make-date-time' for more on these
+arguments."
+  (ical:make-date-time
+   :year (calendar-extract-year dt)
+   :month (calendar-extract-month dt)
+   :day (calendar-extract-day dt)
+   :hour hour
+   :minute minute
+   :second second
+   :tz tz))
+
+(defun ical:date/time-to-date (dt)
+  "Extract a Gregorian date from DT.  An `icalendar-date'
+value is returned unchanged; an `icalendar-date-time' value is
+converted to an `icalendar-date'."
+  (if (cl-typep dt 'ical:date)
+      dt
+    (ical:date-time-to-date dt)))
+
+;; Type-aware accessors for date/time slots that work for both
+;; ical:date and ical:date-time:
+;; NOTE: cl-typecase ONLY works here if dt is a valid decoded-time with al=
l slots!
+;; May need to adjust this if it's necessary to work with incomplete decod=
ed-times
+(defun ical:date/time-year (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-year dt))
+    (ical:date-time (decoded-time-year dt))))
+
+(defun ical:date/time-month (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-month dt))
+    (ical:date-time (decoded-time-month dt))))
+
+(defun ical:date/time-monthday (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-day dt))
+    (ical:date-time (decoded-time-day dt))))
+
+(defun ical:date/time-weekno (dt &optional weekstart)
+  ;; TODO: Add support for weekstart.
+  ;; calendar-iso-from-absolute doesn't support this yet.
+  (when (and weekstart (not (=3D weekstart 1)))
+    (error "Support for WEEKSTART other than 1 (=3DMonday) not implemented=
 yet"))
+  (let* ((gdate (ical:date/time-to-date dt))
+         (isodate (calendar-iso-from-absolute
+                   (calendar-absolute-from-gregorian gdate)))
+         (weekno (car isodate)))
+    weekno))
+
+(defun ical:date/time-weekday (dt)
+  (cl-typecase dt
+    (ical:date (calendar-day-of-week dt))
+    (ical:date-time
+     (or (decoded-time-weekday dt)
+         ;; compensate for possibly-nil weekday slot if the date-time
+         ;; has been constructed by `make-decoded-time'; cf. comment
+         ;; in `icalendar--decoded-date-time-p':
+         (calendar-day-of-week (ical:date-time-to-date dt))))))
+
+(defun ical:date/time-hour (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-hour dt)))
+
+(defun ical:date/time-minute (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-minute dt)))
+
+(defun ical:date/time-second (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-second dt)))
+
+(defun ical:date/time-zone (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-zone dt)))
+
+;;; Date/time comparisons and arithmetic:
+(defun ical:date< (dt1 dt2)
+  "Return non-nil if date DT1 is strictly earlier than date DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YE=
AR)."
+  (< (calendar-absolute-from-gregorian dt1)
+     (calendar-absolute-from-gregorian dt2)))
+
+(defun ical:date<=3D (dt1 dt2)
+  "Return non-nil if date DT1 is earlier than or the same date as DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YE=
AR)."
+  (or (calendar-date-equal dt1 dt2) (ical:date< dt1 dt2)))
+
+(defun ical:date-time-locally-earlier (dt1 dt2 &optional or-equal)
+  "Return non-nil if date-time DT1 is locally earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information.
+
+If OR-EQUAL is non-nil, this function acts like `<=3D' rather than `<':
+it will return non-nil if DT1 and DT2 are locally the same time."
+  (let ((year1 (decoded-time-year dt1))
+        (year2 (decoded-time-year dt2))
+        (month1 (decoded-time-month dt1))
+        (month2 (decoded-time-month dt2))
+        (day1 (decoded-time-day dt1))
+        (day2 (decoded-time-day dt2))
+        (hour1 (decoded-time-hour dt1))
+        (hour2 (decoded-time-hour dt2))
+        (minute1 (decoded-time-minute dt1))
+        (minute2 (decoded-time-minute dt2))
+        (second1 (decoded-time-second dt1))
+        (second2 (decoded-time-second dt2)))
+    (or (< year1 year2)
+        (and (=3D year1 year2)
+             (or (< month1 month2)
+                 (and (=3D month1 month2)
+                      (or (< day1 day2)
+                          (and (=3D day1 day2)
+                               (or (< hour1 hour2)
+                                   (and (=3D hour1 hour2)
+                                        (or (< minute1 minute2)
+                                            (and (=3D minute1 minute2)
+                                                 (if or-equal
+                                                     (<=3D second1 second2)
+                                                   (< second1 second2)))))=
)))))))))
+
+(defun ical:date-time-locally< (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally strictly earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 nil))
+
+(defun ical:date-time-locally<=3D (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally earlier than, or equal to, D=
T2.
+
+Unlike `icalendar-date-time<=3D', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 t))
+
+(defun ical:date-time< (dt1 dt2)
+  "Return non-nil if date-time DT1 is strictly earlier than DT2.
+
+DT1 and DT2 must both be decoded times, and either both or neither
+should have time zone information.
+
+If one has a time zone offset and the other does not, the offset
+returned from `current-time-zone' is used as the missing offset; if
+`current-time-zone' cannot provide this information, an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-less-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (ical:date-time-locally< dt1 dt2))
+          (t
+           ;; Cf. RFC5545 Sec. 3.3.5:
+           ;; "The recipient of an iCalendar object with a property value
+           ;; consisting of a local time, without any relative time zone
+           ;; information, SHOULD interpret the value as being fixed to wh=
atever
+           ;; time zone the "ATTENDEE" is in at any given moment.  This me=
ans
+           ;; that two "Attendees", in different time zones, receiving the=
 same
+           ;; event definition as a floating time, may be participating in=
 the
+           ;; event at different actual times.  Floating time SHOULD only =
be
+           ;; used where that is the reasonable behavior."
+           ;; I'm interpreting this to mean that if we get here, where
+           ;; one date-time has zone information and the other doesn't,
+           ;; we should use the offset from (current-time-zone).
+           (let* ((user-tz (current-time-zone))
+                  (user-offset (car user-tz))
+                  (dt1z (ical:date-time-variant dt1 :zone (or zone1 user-o=
ffset)))
+                  (dt2z (ical:date-time-variant dt2 :zone (or zone2 user-o=
ffset))))
+             (if user-offset
+                 (time-less-p (encode-time dt1z) (encode-time dt2z))
+               (error "Too little zone information for comparison: %s %s"
+                      dt1 dt2)))))))
+
+;; Two different notions of equality are relevant to decoded times:
+;; strict equality (`icalendar-date-time=3D') of all slots, or
+;; simultaneity (`icalendar-date-time-simultaneous-p').
+;; Most tests probably want the strict notion, because it distinguishes
+;; between simultaneous events decoded into different time zones,
+;; whereas most user-facing functions (e.g. sorting events by date and tim=
e)
+;; probably want simultaneity.
+(defun ical:date-time=3D (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are decoded-times with identical slot val=
ues.
+
+Note that this function returns nil if DT1 and DT2 represent times in
+different time zones, even if they are simultaneous. For the latter, see
+`icalendar-date-time-simultaneous-p'."
+  (equal dt1 dt2))
+
+(defun ical:date-time-locally-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are locally simultaneous date-times.
+Note that this function ignores zone information in dt1 and dt2. It
+returns non-nil if DT1 and DT2 represent the same clock time in
+different time zones, even if they encode to different absolute times."
+  (and (eq (decoded-time-year dt1)   (decoded-time-year dt2))
+       (eq (decoded-time-month dt1)  (decoded-time-month dt2))
+       (eq (decoded-time-day dt1)    (decoded-time-day dt2))
+       (eq (decoded-time-hour dt1)   (decoded-time-hour dt2))
+       (eq (decoded-time-minute dt1) (decoded-time-minute dt2))
+       (eq (decoded-time-second dt1) (decoded-time-second dt2))))
+
+(defun ical:date-time-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are simultaneous date-times.
+
+This function returns non-nil if DT1 and DT2 encode to the same Lisp
+timestamp. Thus they can count as simultaneous even if they represent
+times in different timezones. If both date-times lack an offset from
+UTC, they are treated as simultaneous if they encode to the same
+timestamp in UTC.
+
+If only one date-time has an offset, they are treated as
+non-simultaneous if they represent different clock times according to
+`icalendar-date-time-locally-simultaneous-p'.  Otherwise an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-equal-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0))
+                         (encode-time (ical:date-time-variant dt2 :zone 0)=
)))
+          (t
+           ;; Best effort:
+           ;; TODO: I'm not convinced this is the right thing to do yet.
+           ;; Might want to be stricter here and fix the problem of compar=
ing
+           ;; times with and without zone information elsewhere.
+           (if (ical:date-time-locally-simultaneous-p dt1 dt2)
+               (error "Missing zone information: %s %s" dt1 dt2)
+             nil)))))
+
+(defun ical:date-time<=3D (dt1 dt2)
+  "Return non-nil if DT1 is earlier than, or simultaneous with, DT2.
+DT1 and DT2 must both be decoded times, and either both or neither must ha=
ve
+time zone information."
+  (or (ical:date-time< dt1 dt2)
+      (ical:date-time-simultaneous-p dt1 dt2)))
+
+(defun ical:date/time< (dt1 dt2)
+  "Return non-nil if DT1 is strictly earlier than DT2.
+DT1 and DT2 must be either `icalendar-date' or `icalendar-date-time'
+values. If they are not of the same type, only the date in the
+`icalendar-date-time' value will be considered."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date< dt1 dt2)
+       (ical:date< dt1 (ical:date-time-to-date dt2))))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time< dt1 dt2)
+       (ical:date< (ical:date-time-to-date dt1) dt2)))))
+
+(defun ical:date/time<=3D (dt1 dt2)
+  "Return non-nil if DT1 is earlier than or simultaneous to DT2.
+DT1 and DT2 must be either `icalendar-date' or `icalendar-date-time'
+values. If they are not of the same type, only the date in the
+`icalendar-date-time' value will be considered."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date<=3D dt1 dt2)
+       (ical:date<=3D dt1 (ical:date-time-to-date dt2))))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time<=3D dt1 dt2)
+       (ical:date<=3D (ical:date-time-to-date dt1) dt2)))))
+
+(defun ical:date/time-min (&rest dts)
+  "Return the earliest date or date-time among DTS.
+
+The DTS may be any `icalendar-date' or `icalendar-date-time' values, and
+will be ordered by `icalendar-date/time<=3D'."
+  (car (sort dts :lessp #'ical:date/time<=3D)))
+
+(defun ical:date/time-max (&rest dts)
+  "Return the latest date or date-time among DTS.
+
+The DTS may be any `icalendar-date' or `icalendar-date-time' values, and
+will be ordered by `icalendar-date/time<=3D'."
+  (car (sort dts :reverse t :lessp #'ical:date/time<=3D)))
+
+(defun ical:date-add (date unit n)
+  "Add N UNITs to DATE.
+
+UNIT should be `:year', `:month', `:week', or `:day'; time units will be
+ignored. N may be a positive or negative integer."
+  (if (memq unit '(:hour :minute :second))
+      date
+    (let* ((dt (ical:make-date-time :year (calendar-extract-year date)
+                                    :month (calendar-extract-month date)
+                                    :day (calendar-extract-day date)))
+           (delta (if (eq unit :week)
+                      (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n)))
+           (new-dt (decoded-time-add dt delta)))
+      (ical:date-time-to-date new-dt))))
+
+(declare-function icalendar-recur-tz-decode-time "icalendar-recur")
+
+(defun ical:date-time-add (dt delta &optional vtimezone)
+  "Like `decoded-time-add', but also updates weekday and time zone slots.
+
+DT and DELTA should be `icalendar-date-time' values (decoded times), as
+in `decoded-time-add'.  VTIMEZONE, if given, should be an
+`icalendar-vtimezone'. The resulting date-time will be given the offset
+determined by VTIMEZONE at the local time determined by adding DELTA to
+DT.
+
+This function assumes that time units in DELTA larger than an hour
+should not affect the local clock time in the result, even when crossing
+an observance boundary in VTIMEZONE. This means that e.g. if DT is at
+9AM daylight savings time on the day before the transition to standard
+time, then the result of adding a DELTA of two days will be at 9AM
+standard time, even though this is not exactly 48 hours later. Adding a
+DELTA of 48 hours, on the other hand, will result in a time exactly 48
+hours later, but at a different local time."
+  (require 'icalendar-recur) ; for icr:tz-decode-time; avoids circular req=
uires
+  (if (not vtimezone)
+      ;; the simple case: we have no time zone info, so just use
+      ;; `decoded-time-add':
+      (let ((sum (decoded-time-add dt delta)))
+        (ical:date-time-variant sum))
+    ;; `decoded-time-add' does not take time zone shifts into account,
+    ;; so we need to do the adjustment ourselves. We first add the units
+    ;; larger than an hour using `decoded-time-add', holding the clock
+    ;; time fixed, as described in the docstring. Then we add the time
+    ;; units as a fixed number of seconds and re-decode the resulting
+    ;; absolute time into the time zone.
+    (let* ((cal-delta (make-decoded-time :year (or (decoded-time-year delt=
a) 0)
+                                         :month (or (decoded-time-month de=
lta) 0)
+                                         :day (or (decoded-time-day delta)=
 0)))
+           (cal-sum (decoded-time-add dt cal-delta))
+           (dt-w/zone (ical:date-time-variant cal-sum
+                                              :tz vtimezone))
+           (secs-delta (+ (or (decoded-time-second delta) 0)
+                          (* 60 (or (decoded-time-minute delta) 0))
+                          (* 60 60 (or (decoded-time-hour delta) 0))))
+           (sum-ts (time-add (encode-time dt-w/zone) secs-delta)))
+      (icalendar-recur-tz-decode-time sum-ts vtimezone))))
+
+;; TODO: rework so that it's possible to add dur-values to plain dates.
+;; Perhaps rename this to "date/time-inc" or so, or use kwargs to allow
+;; multiple units, or...
+(defun ical:date/time-add (dt unit n &optional vtimezone)
+  "Add N UNITs to DT.
+
+DT should be an `icalendar-date' or `icalendar-date-time'. UNIT should
+be `:year', `:month', `:week', `:day', `:hour', `:minute', or `:second';
+time units will be ignored if DT is an `icalendar-date'. N may be a
+positive or negative integer."
+  (cl-typecase dt
+    (ical:date-time
+     (let ((delta (if (eq unit :week) (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n))))
+       (ical:date-time-add dt delta vtimezone)))
+    (ical:date (ical:date-add dt unit n))))
+
+(defun ical:date/time-add-duration (start duration &optional vtimezone)
+  "Return the end date(-time) which is a length of DURATION after START.
+
+START should be an `icalendar-date' or `icalendar-date-time'; the
+returned value will be of the same type as START. DURATION should be an
+`icalendar-dur-value'.  VTIMEZONE, if specified, should be the
+`icalendar-vtimezone' representing the time zone of START."
+  (if (integerp duration)
+      ;; number of weeks:
+      (setq duration (make-decoded-time :day (* 7 duration))))
+  (cl-typecase start
+    (ical:date
+     (ical:date-time-to-date
+      (ical:date-time-add (ical:date-to-date-time start) duration)))
+    (ical:date-time
+     (ical:date-time-add start duration vtimezone))))
+
+(defun ical:duration-between (start end)
+  "Return the duration between START and END.
+
+START should be an `icalendar-date' or `icalendar-date-time'; END must
+be of the same type as START. The returned value is an
+`icalendar-dur-value', i.e., a time delta in the sense of
+`decoded-time-add'."
+  (cl-typecase start
+    (ical:date
+     (make-decoded-time :day (- (calendar-absolute-from-gregorian end)
+                                (calendar-absolute-from-gregorian start))))
+    (ical:date-time
+     (let* ((start-abs (time-convert (encode-time start) 'integer))
+            (end-abs (time-convert (encode-time end) 'integer))
+            (dur-secs (- end-abs start-abs))
+            (days (/ dur-secs (* 60 60 24)))
+            (dur-nodays (mod dur-secs (* 60 60 24)))
+            (hours (/ dur-nodays (* 60 60)))
+            (dur-nohours (mod dur-nodays (* 60 60)))
+            (minutes (/ dur-nohours 60))
+            (seconds (mod dur-nohours 60)))
+       (make-decoded-time :day days
+                          :hour hours :minute minutes :second seconds)))))
+
+(defun ical:date/time-to-local (dt)
+  "If DT is an `icalendar-date-time', encode and re-decode it into Emacs
+local time. If DT is an `icalendar-date', return it unchanged."
+  (cl-typecase dt
+    (ical:date dt)
+    (ical:date-time
+     (ical:date-time-variant ; ensure weekday is present too
+      (decode-time (encode-time dt))))))
+
+(declare-function icalendar-recur-subintervals-to-dates "icalendar-recur")
+
+(defun ical:dates-until (start end &optional locally)
+  "Return a list of `icalendar-date' values between START and END.
+
+START and END may be either `icalendar-date' or `icalendar-date-time'
+values.  START is an inclusive lower bound, and END is an exclusive
+upper bound. (Note, however, that if END is a date-time and its time is
+after midnight, then its date will be included in the returned list.)
+
+If LOCALLY is non-nil and START and END are date-times, these will be
+interpreted into Emacs local time, so that the dates returned are valid
+for the local time zone."
+  (require 'icalendar-recur)
+  (when locally
+    (when (cl-typep start 'ical:date-time)
+      (setq start (ical:date/time-to-local start)))
+    (when (cl-typep end 'ical:date-time)
+      (setq end (ical:date/time-to-local end))))
+  (cl-typecase start
+    (ical:date
+     (cl-typecase end
+       (ical:date
+        (icalendar-recur-subintervals-to-dates
+         (list (list (ical:date-to-date-time start)
+                     (ical:date-to-date-time end)))))
+       (ical:date-time
+        (icalendar-recur-subintervals-to-dates
+         (list (list (ical:date-to-date-time start) end))))))
+    (ical:date-time
+     (cl-typecase end
+       (ical:date
+        (icalendar-recur-subintervals-to-dates
+         (list (list start (ical:date-to-date-time end)))))
+       (ical:date-time
+        (icalendar-recur-subintervals-to-dates (list (list start end))))))=
))
+
+
+(cl-defun ical:make-date-time (&key second minute hour day month year
+                                    (dst -1 given-dst) zone tz)
+  "Make an `icalendar-date-time' from the given keyword arguments.
+
+This function is like `make-decoded-time', except that it automatically
+sets the weekday slot set based on the date arguments, and it accepts an
+additional keyword argument: `:tz'. If provided, its value should be an
+`icalendar-vtimezone', and the `:zone' and `:dst' arguments should not
+be provided.  In this case, the zone and dst slots in the returned
+date-time will be adjusted to the correct values in the given time zone
+for the local time represented by the remaining arguments."
+  (when (and tz (or zone given-dst))
+    (error "Possibly conflicting time zone data in args"))
+  (apply #'ical:date-time-variant (make-decoded-time)
+         `(:second ,second :minute ,minute :hour ,hour
+           :day ,day :month ,month :year ,year
+           ;; Don't pass these keywords unless they were given explicitly.
+           ;; TODO: is there a cleaner way to write this?
+           ,@(when tz (list :tz tz))
+           ,@(when given-dst (list :dst dst))
+           ,@(when zone (list :zone zone)))))
+
+(declare-function icalendar-recur-tz-set-zone "icalendar-recur")
+
+(cl-defun ical:date-time-variant (dt &key second minute hour
+                                          day month year
+                                          (dst -1 given-dst)
+                                          (zone nil given-zone)
+                                          tz)
+  "Return a variant of DT with slots modified as in the given arguments.
+
+DT should be an `icalendar-date-time'; the keyword arguments have the
+same meanings as in `make-decoded-time'.  The returned variant will have
+slot values as specified by the arguments or copied from DT, except that
+the weekday slot will be updated if necessary, and the zone and dst
+fields will not be set unless given explicitly (because varying the date
+and clock time generally invalidates the time zone information in DT).
+
+One additional keyword argument is accepted: `:tz'. If provided, its
+value should be an `icalendar-vtimezone', an `icalendar-utc-offset', or
+the symbol \\=3D'preserve.  If it is a time zone component, the zone and
+dst slots in the returned variant will be adjusted to the correct
+values in the given time zone for the local time represented by the
+variant. If it is a UTC offset, the variant's zone slot will contain
+this value, but its dst slot will not be adjusted.  If it is the symbol
+\\=3D'preserve, then both the zone and dst fields are copied from DT into
+the variant."
+  (require 'icalendar-recur) ; for icr:tz-set-zone; avoids circular requir=
es
+  (let ((variant
+         (make-decoded-time :second (or second (decoded-time-second dt))
+                            :minute (or minute (decoded-time-minute dt))
+                            :hour (or hour (decoded-time-hour dt))
+                            :day (or day (decoded-time-day dt))
+                            :month (or month (decoded-time-month dt))
+                            :year (or year (decoded-time-year dt))
+                            ;; For zone and dst slots, trust the value
+                            ;; if explicitly specified or explicitly
+                            ;; requested to preserve, but not otherwise
+                            :dst (cond (given-dst dst)
+                                       ((eq 'preserve tz) (decoded-time-ds=
t dt))
+                                       (t -1))
+                            :zone (cond (given-zone zone)
+                                        ((eq 'preserve tz) (decoded-time-z=
one dt))
+                                        (t nil)))))
+    ;; update weekday slot when possible, since it depends on the date
+    ;; slots, which might have changed. (It's not always possible,
+    ;; because pure time values are also represented as decoded-times,
+    ;; with empty date slots.)
+    (unless (or (null (decoded-time-year variant))
+                (null (decoded-time-month variant))
+                (null (decoded-time-day variant)))
+      (setf (decoded-time-weekday variant)
+            (calendar-day-of-week (ical:date-time-to-date variant))))
+    ;; if given a time zone or UTC offset, update zone and dst slots,
+    ;; which also might have changed:
+    (when (and tz (not (eq 'preserve tz)))
+      (icalendar-recur-tz-set-zone variant tz))
+    variant))
+
+(defun ical:date/time-in-period-p (dt period &optional vtimezone)
+  "Return non-nil if DT occurs within PERIOD.
+
+DT can be an `icalendar-date' or `icalendar-date-time' value.  PERIOD
+should be an `icalendar-period' value.  VTIMEZONE, if given, is passed
+to `icalendar-period-end' to compute the end time of the period if it
+was not specified explicitly."
+  (and (ical:date/time<=3D (ical:period-start period) dt)
+       (ical:date/time< dt (ical:period-end period vtimezone))))
+
+;; TODO: surely this exists already?
+(defun ical:time<=3D (a b)
+  "Compare two lisp timestamps A and B: is A <=3D B?"
+  (or (time-equal-p a b)
+      (time-less-p a b)))
+
+(defun ical:number-of-weeks (year &optional weekstart)
+  "Return the number of weeks in (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (=3D 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (=3D Sunday)
+and 6 (=3D Saturday)."
+  ;; There are 53 weeks in a year if Jan 1 is the fourth day after
+  ;; WEEKSTART, e.g. if the week starts on Monday and Jan 1 is a
+  ;; Thursday, or in a leap year if Jan 1 is the third day after WEEKSTART
+  (let* ((jan1wd (calendar-day-of-week (list 1 1 year)))
+         (delta (mod (- jan1wd (or weekstart 1)) 7)))
+    (if (or (=3D 4 delta)
+            (and (=3D 3 delta) (calendar-leap-year-p year)))
+        53
+      52)))
+
+(defun ical:start-of-weekno (weekno year &optional weekstart)
+  "Return the start of the WEEKNOth week in the (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (=3D 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (=3D Sunday)
+and 6 (=3D Saturday). The returned value is an `icalendar-date'.
+
+If WEEKNO is negative, it refers to the WEEKNOth week before the end of
+the year: -1 is the last week of the year, -2 second to last, etc."
+  (calendar-gregorian-from-absolute
+   (+
+    (* 7 (if (< 0 weekno)
+             (1- weekno)
+           (+ 1 weekno (ical:number-of-weeks year weekstart))))
+    (calendar-dayname-on-or-before
+     (or weekstart 1)
+     ;; Three days after Jan 1. gives us the nearest occurrence;
+     ;; see `calendar-dayname-on-or-before'
+     (+ 3 (calendar-absolute-from-gregorian (list 1 1 year)))))))
+
+(defun ical:nth-weekday-in (n weekday year &optional month)
+  "Return the Nth WEEKDAY in YEAR or MONTH.
+
+If MONTH is specified, it refers to MONTH in YEAR, and N acts as an
+index for WEEKDAYs within the month. Otherwise, N acts as an index for
+WEEKDAYs within the entire YEAR.
+
+N should be an integer. If N<0, it counts from the end of the month or
+year: if N=3D-1, it refers to the last WEEKDAY in the month or year, if
+N=3D-2 the second to last, and so on."
+  (if month
+      (calendar-nth-named-day n weekday month year)
+    (let* ((jan1 (calendar-absolute-from-gregorian (list 1 1 year)))
+           (dec31 (calendar-absolute-from-gregorian (list 12 31 year))))
+      ;; Adapted from `calendar-nth-named-absday'.
+      ;; TODO: we could generalize that function to make month an optional
+      ;; argument, but that would mean changing its interface.
+      (calendar-gregorian-from-absolute
+       (if (> n 0)
+           (+ (* 7 (1- n))
+              (calendar-dayname-on-or-before
+               weekday
+               (+ 6 jan1)))
+         (+ (* 7 (1+ n))
+            (calendar-dayname-on-or-before
+             weekday
+             dec31)))))))
+
+(provide 'icalendar-utils)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-utils.el ends here
diff --git a/lisp/calendar/icalendar.el b/lisp/calendar/icalendar.el
index b3334e483c1..bcb4689725a 100644
--- a/lisp/calendar/icalendar.el
+++ b/lisp/calendar/icalendar.el
@@ -26,6 +26,11 @@
=20
 ;;; Commentary:
=20
+;; Most of the code in this file is now obsolete and has been marked as su=
ch.
+;; For the new implementation of diary import/export, see diary-icalendar.=
el.
+;; Error handling code, global variables, and user options relevant for the
+;; entire iCalendar library remain in this file.
+
 ;; This package is documented in the Emacs Manual.
=20
 ;;   Please note:
@@ -73,39 +78,10 @@
 ;;  0.01: (2003-03-21)
 ;;  - First published version.  Trial version.  Alpha version.
=20
-;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
-;; To Do:
-
-;;  * Import from ical to diary:
-;;    + Need more properties for icalendar-import-format
-;;      (added all that Mozilla Calendar uses)
-;;      From iCal specifications (RFC2445: 4.8.1), icalendar.el lacks
-;;      ATTACH, CATEGORIES, COMMENT, GEO, PERCENT-COMPLETE (VTODO),
-;;      PRIORITY, RESOURCES) not considering date/time and time-zone
-;;    + check vcalendar version
-;;    + check (unknown) elements
-;;    + recurring events!
-;;    + works for european style calendars only! Does it?
-;;    + alarm
-;;    + exceptions in recurring events
-;;    + the parser is too soft
-;;    + error log is incomplete
-;;    + nice to have: #include "webcal://foo.com/some-calendar.ics"
-;;    + timezones probably still need some improvements.
-
-;;  * Export from diary to ical
-;;    + diary-date, diary-float, and self-made sexp entries are not
-;;      understood
-
-;;  * Other things
-;;    + clean up all those date/time parsing functions
-;;    + Handle todo items?
-;;    + Check iso 8601 for datetime and period
-;;    + Which chars to (un)escape?
-
-
 ;;; Code:
=20
+(eval-when-compile (require 'compile))
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; Customizables
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
@@ -138,6 +114,12 @@ icalendar-import-format
           (function :tag "Function"))
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-summary
   "%s"
   "Format string defining how the summary element is formatted.
@@ -146,6 +128,12 @@ icalendar-import-format-summary
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-summary
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-description
   "\n Desc: %s"
   "Format string defining how the description element is formatted.
@@ -154,6 +142,12 @@ icalendar-import-format-description
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-description
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-location
   "\n Location: %s"
   "Format string defining how the location element is formatted.
@@ -162,6 +156,12 @@ icalendar-import-format-location
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-location
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-organizer
   "\n Organizer: %s"
   "Format string defining how the organizer element is formatted.
@@ -170,6 +170,12 @@ icalendar-import-format-organizer
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-organizer
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-url
   "\n URL: %s"
   "Format string defining how the URL element is formatted.
@@ -178,6 +184,12 @@ icalendar-import-format-url
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-url
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-uid
   "\n UID: %s"
   "Format string defining how the UID element is formatted.
@@ -187,6 +199,12 @@ icalendar-import-format-uid
   :version "24.3"
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-uid
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-status
   "\n Status: %s"
   "Format string defining how the status element is formatted.
@@ -195,6 +213,12 @@ icalendar-import-format-status
   :type 'string
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-import-format-status
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
+
 (defcustom icalendar-import-format-class
   "\n Class: %s"
   "Format string defining how the class element is formatted.
@@ -203,68 +227,65 @@ icalendar-import-format-class
   :type 'string
   :group 'icalendar)
=20
-(defcustom icalendar-recurring-start-year
-  2005
-  "Start year for recurring events.
-Some calendar browsers only propagate recurring events for
-several years beyond the start time.  Set this string to a year
-just before the start of your personal calendar."
-  :type 'integer
-  :group 'icalendar)
+(make-obsolete-variable
+ 'icalendar-import-format-class
+ "please use `diary-icalendar-vevent-skeleton-command' for import
+formatting instead."
+ "32.1")
=20
-(defcustom icalendar-export-hidden-diary-entries
-  t
-  "Determines whether hidden diary entries are exported.
-If non-nil hidden diary entries (starting with `&') get exported,
-if nil they are ignored."
-  :type 'boolean
-  :group 'icalendar)
+(define-obsolete-variable-alias
+ 'icalendar-recurring-start-year
+ 'diary-icalendar-recurring-start-year
+ "32.1")
+
+(define-obsolete-variable-alias
+ 'icalendar-export-hidden-diary-entries
+ 'diary-icalendar-export-nonmarking-entries
+ "32.1")
+
+(defcustom ical:uid-format
+  "%h"
+  "Format string for unique ID (UID) values for iCalendar components.
+
+This string is used by `icalendar-make-uid' to generate UID values when
+creating iCalendar components.
=20
-(defcustom icalendar-uid-format
-  "emacs%t%c"
-  "Format of unique ID code (UID) for each iCalendar object.
 The following specifiers are available:
 %c COUNTER, an integer value that is increased each time a uid is
    generated.  This may be necessary for systems which do not
    provide time-resolution finer than a second.
-%h HASH, a hash value of the diary entry,
-%s DTSTART, the start date (excluding time) of the diary entry,
+%h HASH, a hash value of the component's contents or system information,
 %t TIMESTAMP, a unique creation timestamp,
-%u USERNAME, the variable `user-login-name'.
+%u USERNAME, the value of `user-login-name'.
+%s (obsolete, ignored)
=20
-For example, a value of \"%s_%h@HIDDEN\" will generate a
-UID code for each entry composed of the time of the event, a hash
-code for the event, and your personal domain name."
+For example, a value of \"%h%t@HIDDEN\" will generate a UID code
+for each entry composed of a hash of the event data, a creation
+timestamp, and your personal domain name."
   :type 'string
   :group 'icalendar)
=20
-(defcustom icalendar-export-sexp-enumeration-days
-  14
-  "Number of days over which a sexp diary entry is enumerated.
-In general sexp entries cannot be translated to icalendar format.
-They are therefore enumerated, i.e. explicitly evaluated for a
-certain number of days, and then exported.  The enumeration starts
-on the current day and continues for the number of days given here.
-
-See `icalendar-export-sexp-enumerate-all' for a list of sexp
-entries which by default are NOT enumerated."
-  :version "25.1"
-  :type 'integer
+(defcustom ical:vcalendar-prodid
+  (format "-//gnu.org//GNU Emacs %s//EN" emacs-version)
+  "The value of the `icalendar-prodid' property for VCALENDAR objects
+produced by this Emacs."
+  :type 'string
   :group 'icalendar)
=20
-(defcustom icalendar-export-sexp-enumerate-all
-  nil
-  "Determines whether ALL sexp diary entries are enumerated.
-If non-nil all sexp diary entries are enumerated for
-`icalendar-export-sexp-enumeration-days' days instead of
-translating into an icalendar equivalent.  This affects the
-following sexp diary entries: `diary-anniversary',
-`diary-cyclic', `diary-date', `diary-float', `diary-block'.  All
-other sexp entries are enumerated in any case."
-  :version "25.1"
-  :type 'boolean
-  :group 'icalendar)
+(defconst ical:vcalendar-version "2.0"
+  "The current version of the VCALENDAR object, used in the
+`icalendar-version' property. \"2.0\" is the version corresponding to
+RFC5545.")
+
+(define-obsolete-variable-alias
+  'icalendar-export-sexp-enumeration-days
+  'diary-icalendar-export-sexp-enumeration-days
+  "32.1")
=20
+(define-obsolete-variable-alias
+  'icalendar-export-sexp-enumerate-all
+  'diary-icalendar-export-sexp-enumerate-all
+  "32.1")
=20
 (defcustom icalendar-export-alarms
   nil
@@ -286,16 +307,38 @@ icalendar-export-alarms
                                           (string :tag "Email"))))))
   :group 'icalendar)
=20
+(make-obsolete-variable
+ 'icalendar-export-alarms
+ "please use the new format in `diary-icalendar-export-alarms' instead."
+ "32.1")
+
+(defcustom icalendar-debug-level 1
+  "Minimum severity for logging iCalendar error messages.
+A value of 2 only logs errors.
+A value of 1 also logs warnings.
+A value of 0 also logs debugging information."
+  :type 'integer
+  :group 'icalendar)
=20
 (defvar icalendar-debug nil
   "Enable icalendar debug messages.")
=20
+(make-obsolete-variable
+ 'icalendar-debug
+ 'icalendar-debug-level
+ "32.1")
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; NO USER SERVICEABLE PARTS BELOW THIS LINE
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
=20
 (defconst icalendar--weekday-array ["SU" "MO" "TU" "WE" "TH" "FR" "SA"])
=20
+(make-obsolete-variable
+ 'icalendar--weekday-array
+ 'icalendar-weekday-numbers
+ "32.1")
+
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; all the other libs we need
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
@@ -307,8 +350,222 @@ icalendar--weekday-array
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 (defun icalendar--dmsg (&rest args)
   "Print message ARGS if `icalendar-debug' is non-nil."
-  (if icalendar-debug
-      (apply 'message args)))
+  (declare (obsolete icalendar-warn "32.1"))
+  (if (or icalendar-debug (=3D 0 icalendar-debug-level))
+      (with-current-buffer (ical:error-buffer)
+        (goto-char (point-max))
+        (insert (apply #'format-message args))
+        (insert "\n"))))
+
+;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
+;; Error handling
+;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
+(define-error 'ical:error "iCalendar error")
+
+(defconst ical:error-buffer-name "*icalendar-errors*"
+  "Name of buffer in which errors are listed when processing iCalendar dat=
a.")
+
+(defun ical:error-buffer ()
+  "Return the iCalendar errors buffer, creating it if necessary.
+The buffer name is based on `icalendar-error-buffer-name'."
+  (get-buffer-create ical:error-buffer-name))
+
+(defvar ical:inhibit-error-erasure nil
+  "When non-nil, `icalendar-init-error-buffer' will not erase the errors
+buffer.")
+
+(defun ical:init-error-buffer (&optional err-buffer)
+  "Prepare ERR-BUFFER for iCalendar errors.
+ERR-BUFFER defaults to the buffer returned by `icalendar-error-buffer'.
+Erases ERR-BUFFER and places it in `icalendar-errors-mode'."
+  (with-current-buffer (or err-buffer (ical:error-buffer))
+    (unless ical:inhibit-error-erasure
+      (let ((inhibit-read-only t))
+        (erase-buffer)))
+    (if (not (eq major-mode 'icalendar-errors-mode))
+        (icalendar-errors-mode))))
+
+(defun ical:errors-p (&optional err-buffer)
+  "Return non-nil if iCalendar errors have been reported in ERR-BUFFER.
+ERR-BUFFER defaults to the buffer returned by `icalendar-error-buffer'."
+  (with-current-buffer (or err-buffer (ical:error-buffer))
+    (not (=3D (point-min) (point-max)))))
+
+(defun ical:warn (msg &rest err-plist)
+  "Write a warning to the `icalendar-error-buffer' without signaling an
+error."
+  (plist-put err-plist :message msg)
+  (unless (plist-get err-plist :severity)
+    (plist-put err-plist :severity 1))
+  (ical:handle-generic-error `(ical:warning . ,err-plist)))
+
+(defconst ical:error-regexp
+  (rx line-start
+      (zero-or-one
+       (group "("
+              (or (group-n 3 "ERROR") (group-n 4 "WARNING") (group-n 5 "IN=
FO"))
+              ")"))
+      (group-n 1 (zero-or-more (not ":"))) ":"
+      (zero-or-one (group-n 2 (one-or-more digit)))
+      ":")
+  "Regexp to match iCalendar errors.
+
+Group 1 contains the buffer name where the error originated.
+Group 2 contains the buffer position.
+Groups 3-5 match the severity:
+  3 matches ERROR
+  4 matches WARNING
+  5 matches INFO")
+
+(cl-defun ical:format-error (&rest error-plist
+                             &key (message "Unknown error")
+                                  severity
+                                  buffer
+                                  position
+                                  &allow-other-keys)
+  "Format iCalendar error data to a string.
+
+MESSAGE should be a string; it defaults to \"Unknown error\".
+BUFFER should be a buffer; POSITION should be a position in BUFFER.
+SEVERITY can be 0 for debug information, or 1 for a warning; otherwise
+a genuine error is reported.
+
+The returned error message looks like
+
+(LEVEL)BUFFER:POSITION: MESSAGE
+DEBUG-INFO...
+
+where LEVEL is derived from SEVERITY. DEBUG-INFO contains any additional
+data in ERROR-PLIST, if `icalendar-debug-level' is
+0. `icalendar-error-regexp' matches the fields in such messages."
+  (let ((name (copy-sequence (buffer-name buffer)))
+        (pos (if (integer-or-marker-p position)
+                 (format "%d" position)
+               ""))
+        (level (cond ((eq severity 0) "INFO")
+                     ((eq severity 1) "WARNING")
+                     (t "ERROR")))
+        (debug-info (if (not (=3D 0 icalendar-debug-level))
+                        ""
+                      (mapconcat ;; (:key val...) =3D> "Key: val\n..."
+                       (lambda (val)
+                         (if (keywordp val)
+                             (capitalize (substring (symbol-name val) 1))
+                           (format ": %s\n" val)))
+                       error-plist))))
+    ;; Make sure buffer name doesn't take too much space:
+    (when (< 8 (length name))
+      (put-text-property 9 (length name) 'display "..." name))
+    (format "(%s)%s:%s: %s\n%s" level name pos message debug-info)))
+
+(defun ical:handle-generic-error (err-data &optional err-buffer)
+  "Log error data to ERR-BUFFER (default: the iCalendar error buffer).
+ERR-DATA should be a list (ERROR-SYMBOL . SIGNAL-DATA) where
+SIGNAL-DATA is a plist of error data."
+  (let* ((signal-data (cdr err-data))
+         (err-plist (when (plistp signal-data) signal-data))
+         (err-symbol (car err-data))
+         (severity (or (plist-get err-plist :severity) 2))
+         (buf (current-buffer)))
+    (when (<=3D ical:debug-level severity)
+      (with-current-buffer (or err-buffer (ical:error-buffer))
+        (goto-char (point-max))
+        (let ((inhibit-read-only t))
+          (unless (bolp) (insert "\n"))
+          (insert (apply #'ical:format-error
+                         (or err-plist
+                             (list :buffer buf
+                                   :message
+                                   (format "Unhandled %s error: %s"
+                                           err-symbol signal-data))))))))))
+
+(defmacro ical:condition-case (var bodyform &rest handlers)
+  "Like `condition-case', but with default handler for unhandled iCalendar=
 errors.
+If none of HANDLERS handles an error, it will be handled by
+`icalendar-handle-generic-error'."
+  `(condition-case ,var
+       ,bodyform
+     ,@handlers
+     (ical:error (ical:handle-generic-error ,var))))
+
+;;; Mode based on compilation-mode for navigating error buffer:
+(defun ical:-buffer-from-error ()
+  (when-let* ((name (match-string 1)))
+    (or (get-buffer name)
+        (find-buffer-visiting name))))
+
+(defun ical:-filename-from-error ()
+  (when-let* ((buf (ical:-buffer-from-error)))
+    (buffer-file-name buf)))
+
+(defun ical:-lineno-from-error ()
+  (when-let* ((buf (ical:-buffer-from-error))
+              (posstr (match-string 2))
+              (pos (string-to-number posstr)))
+    (with-current-buffer buf
+      (line-number-at-pos pos))))
+
+(defconst ical:error-regexp-alist
+  (list (list icalendar-error-regexp
+              #'ical:-filename-from-error
+              #'ical:-lineno-from-error
+              nil
+              nil
+              nil
+              '(2 compilation-line-face)
+              '(3 compilation-error-face)
+              '(4 compilation-warning-face)
+              '(5 compilation-info-face)))
+  "Specifies how errors are parsed in `icalendar-errors-mode';
+see `compilation-error-regexp-alist'.")
+
+(define-compilation-mode ical:errors-mode "iCalendar Errors"
+  "Mode for listing and visiting errors when processing iCalendar data"
+  :group 'icalendar
+  (setq-local compilation-error-regexp-alist ical:error-regexp-alist))
+
+(defvar ical:-uid-count 0
+  "Internal counter for creating unique ids.")
+
+(defun ical:make-uid (&optional contents _)
+  "Construct a unique ID from `icalendar-uid-format'.
+
+CONTENTS can be any object which represents the contents of the
+iCalendar component for which the UID is generated.  If CONTENTS is a
+string with the text property \\=3D'uid, that property's value will be
+used as the returned UID.
+
+Otherwise, CONTENTS will be used to create the hash substituted for
+\\=3D'%h' in `icalendar-uid-format'. If CONTENTS is not given, the hash
+will be based on an internal counter, the system name, and the current
+time in nanoseconds.
+
+The second optional argument is for backward compatibility and is ignored."
+  (cl-incf icalendar--uid-count)
+  (let* ((uid icalendar-uid-format)
+         (timestamp (format-time-string "%s%N"))
+         (tohash (or contents
+                     (format "%d%s%s" ical:-uid-count (system-name) timest=
amp))))
+    (if (and (stringp contents) (get-text-property 0 'uid contents))
+        ;; "Allow other apps (such as org-mode) to create its own uid"
+        ;; FIXME: is this necessary? If caller already has a UID, why
+        ;; call this function at all?
+	(setq uid (get-text-property 0 'uid contents))
+      (progn
+        (setq uid (replace-regexp-in-string
+                   "%c" (format "%d" icalendar--uid-count) uid t t))
+        (setq uid (replace-regexp-in-string
+                   "%t" timestamp uid t t))
+        (setq uid (replace-regexp-in-string
+                   "%h" (format "%d" (abs (sxhash tohash))) uid t t))
+        (setq uid (replace-regexp-in-string
+                   "%u" (or user-login-name "UNKNOWN_USER") uid t t))
+        ;; `%s' no longer used, but allowed for backward compatibility:
+        (setq uid (replace-regexp-in-string "%s" "" uid t t))))
+    uid))
+
+;; Essentially everything beyond this point is obsoleted by the new
+;; implementation in diary-icalendar.el.
=20
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 ;; Core functionality
@@ -321,6 +578,7 @@ icalendar--get-unfolded-buffer
 created buffer all occurrences of CR LF BLANK are replaced by the
 empty string.  Argument FOLDED-ICAL-BUFFER is the folded input
 buffer."
+  (declare (obsolete icalendar-unfolded-buffer-from-buffer "32.1"))
   (let ((unfolded-buffer (get-buffer-create " *icalendar-work*")))
     (save-current-buffer
       (set-buffer unfolded-buffer)
@@ -337,6 +595,7 @@ icalendar--clean-up-line-endings
 All occurrences of (CR LF) and (LF CF) are replaced with LF in
 the current buffer.  This is necessary in buffers which contain a
 mix of different line endings."
+  (declare (obsolete nil "32.1"))
   (save-excursion
     (goto-char (point-min))
     (while (re-search-forward "\r\n\\|\n\r" nil t)
@@ -352,6 +611,8 @@ icalendar--read-element
 This function calls itself recursively for each nested calendar element
 it finds.  The current buffer should be an unfolded buffer as returned
 from `icalendar--get-unfolded-buffer'."
+  (declare (obsolete "use `icalendar-parse' or one of `icalendar-parse-com=
ponent',
+`icalendar-parse-property', `icalendar-parse-params' instead." "32.1"))
   (let (element children line name params param param-name param-value
                 value
                 (continue t))
@@ -408,6 +669,7 @@ icalendar--read-element
=20
 (defun icalendar--get-event-property (event prop)
   "For the given EVENT return the value of the first occurrence of PROP."
+  (declare (obsolete icalendar-with-component "32.1"))
   (catch 'found
     (let ((props (car (cddr event))) pp)
       (while props
@@ -419,6 +681,7 @@ icalendar--get-event-property
=20
 (defun icalendar--get-event-property-attributes (event prop)
   "For the given EVENT return attributes of the first occurrence of PROP."
+  (declare (obsolete icalendar-with-component "32.1"))
   (catch 'found
     (let ((props (car (cddr event))) pp)
       (while props
@@ -430,6 +693,7 @@ icalendar--get-event-property-attributes
=20
 (defun icalendar--get-event-properties (event prop)
   "For the given EVENT return a list of all values of the property PROP."
+  (declare (obsolete icalendar-with-component "32.1"))
   (let ((props (car (cddr event))) pp result)
     (while props
       (setq pp (car props))
@@ -456,6 +720,7 @@ icalendar--get-children
   "Return all children of the given NODE which have a name NAME.
 For instance the VCALENDAR node can have VEVENT children as well as VTODO
 children."
+  (declare (obsolete icalendar-ast-node-children "32.1"))
   (let ((result nil)
         (children (cadr (cddr node))))
     (when (eq (car node) name)
@@ -476,6 +741,7 @@ icalendar--get-children
 ;; private
 (defun icalendar--all-events (icalendar)
   "Return the list of all existing events in the given ICALENDAR."
+  (declare (obsolete icalendar-with-component "32.1"))
   (let ((result '()))
     (mapc (lambda (elt)
 	    (setq result (append (icalendar--get-children elt 'VEVENT)
@@ -485,6 +751,7 @@ icalendar--all-events
=20
 (defun icalendar--split-value (value-string)
   "Split VALUE-STRING at `;=3D'."
+  (declare (obsolete nil "32.1"))
   (let ((result '())
         param-name param-value)
     (when value-string
@@ -509,6 +776,7 @@ icalendar--convert-tz-offset
 ALIST is an alist entry from a VTIMEZONE, like STANDARD.
 DST-P is non-nil if this is for daylight savings time.
 The strings are suitable for assembling into a TZ variable."
+  (declare (obsolete nil "32.1"))
   (let* ((offsetto (car (cddr (assq 'TZOFFSETTO alist))))
 	 (offsetfrom (car (cddr (assq 'TZOFFSETFROM alist))))
 	 (rrule-value (car (cddr (assq 'RRULE alist))))
@@ -561,6 +829,7 @@ icalendar--parse-vtimezone
   "Turn a VTIMEZONE ALIST into a cons (ID . TZ-STRING).
 Consider only the most recent date specification.
 Return nil if timezone cannot be parsed."
+  (declare (obsolete nil "32.1"))
   (let* ((tz-id (icalendar--convert-string-for-import
                  (icalendar--get-event-property alist 'TZID)))
 	 (daylight (cadr (cdar (icalendar--get-most-recent-observance alist 'DAYL=
IGHT))))
@@ -578,6 +847,7 @@ icalendar--get-most-recent-observance
   "Return the latest observance for SUB-COMP DAYLIGHT or STANDARD.
 ALIST is a VTIMEZONE potentially containing historical records."
 ;FIXME?: "most recent" should be relative to a given date
+  (declare (obsolete icalendar-recur-tz-observance-on "32.1"))
   (let ((components (icalendar--get-children alist sub-comp)))
     (list
      (car
@@ -600,6 +870,7 @@ icalendar--convert-all-timezones
   "Convert all timezones in the ICALENDAR into an alist.
 Each element of the alist is a cons (ID . TZ-STRING),
 like `icalendar--parse-vtimezone'."
+  (declare (obsolete nil "32.1"))
   (let (result)
     (dolist (zone (icalendar--get-children (car icalendar) 'VTIMEZONE))
       (setq zone (icalendar--parse-vtimezone zone))
@@ -610,6 +881,7 @@ icalendar--convert-all-timezones
 (defun icalendar--find-time-zone (prop-list zone-map)
   "Return a timezone string for the time zone in PROP-LIST, or nil if none.
 ZONE-MAP is a timezone alist as returned by `icalendar--convert-all-timezo=
nes'."
+  (declare (obsolete nil "32.1"))
   (let ((id (plist-get prop-list 'TZID)))
     (if id
 	(cdr (assoc id zone-map)))))
@@ -628,6 +900,7 @@ icalendar--decode-isodatetime
 RESULT-ZONE, if provided, is the timezone for encoding the result
 in any format understood by `decode-time'.
 FIXME: multiple comma-separated values should be allowed!"
+  (declare (obsolete icalendar-read-date-time "32.1"))
   (icalendar--dmsg isodatetimestring)
   (if isodatetimestring
       ;; day/month/year must be present
@@ -685,6 +958,7 @@ icalendar--decode-isoduration
=20
 FIXME: TZID-attributes are ignored....!
 FIXME: multiple comma-separated values should be allowed!"
+  (declare (obsolete icalendar-read-dur-value "32.1"))
   (if isodurationstring
       (save-match-data
         (string-match
@@ -740,6 +1014,7 @@ icalendar--add-decoded-times
   "Add TIME1 to TIME2.
 Both times must be given in decoded form.  One of these times must be
 valid (year > 1900 or something)."
+  (declare (obsolete icalendar-date-time-add "32.1"))
   ;; FIXME: does this function exist already?  Can we use decoded-time-add?
   (decode-time (encode-time
 		;; FIXME: Support subseconds.
@@ -761,6 +1036,8 @@ icalendar--datetime-to-american-date
 Optional argument SEPARATOR gives the separator between month,
 day, and year.  If nil a blank character is used as separator.
 American format: \"month day year\"."
+  (declare (obsolete "use `icalendar-date/time-to-date' and
+`diary-icalendar-format-date' instead." "32.1"))
   (if datetime
       (format "%d%s%d%s%d" (nth 4 datetime) ;month
               (or separator " ")
@@ -776,6 +1053,7 @@ icalendar--datetime-to-european-date
 day, and year.  If nil a blank character is used as separator.
 European format: (day month year).
 FIXME"
+  (declare (obsolete "use `icalendar-date/time-to-date' and `diary-icalend=
ar-format-date' instead." "32.1"))
   (if datetime
       (format "%d%s%d%s%d" (nth 3 datetime) ;day
               (or separator " ")
@@ -790,6 +1068,7 @@ icalendar--datetime-to-iso-date
 Optional argument SEPARATOR gives the separator between month,
 day, and year.  If nil a blank character is used as separator.
 ISO format: (year month day)."
+  (declare (obsolete "use `icalendar-date/time-to-date' and `diary-icalend=
ar-format-date' instead." "32.1"))
   (if datetime
       (format "%d%s%d%s%d" (nth 5 datetime) ;year
               (or separator " ")
@@ -805,6 +1084,7 @@ icalendar--datetime-to-diary-date
 day, and year.  If nil a blank character is used as separator.
 Call icalendar--datetime-to-*-date according to the current
 calendar date style."
+  (declare (obsolete "use `icalendar-date/time-to-date' and `diary-icalend=
ar-format-date' instead." "32.1"))
   (funcall (intern-soft (format "icalendar--datetime-to-%s-date"
                                 calendar-date-style))
            datetime separator))
@@ -812,10 +1092,12 @@ icalendar--datetime-to-diary-date
 (defun icalendar--datetime-to-colontime (datetime)
   "Extract the time part of a decoded DATETIME into 24-hour format.
 Note that this silently ignores seconds."
+  (declare (obsolete diary-icalendar-format-time "32.1"))
   (format "%02d:%02d" (nth 2 datetime) (nth 1 datetime)))
=20
 (defun icalendar--get-month-number (monthname)
   "Return the month number for the given MONTHNAME."
+  (declare (obsolete nil "32.1"))
   (catch 'found
     (let ((num 1)
           (m (downcase monthname)))
@@ -831,6 +1113,7 @@ icalendar--get-month-number
=20
 (defun icalendar--get-weekday-number (abbrevweekday)
   "Return the number for the ABBREVWEEKDAY."
+  (declare (obsolete "see `icalendar-weekday-numbers'" "32.1"))
   (if abbrevweekday
       (catch 'found
         (let ((num 0)
@@ -846,6 +1129,7 @@ icalendar--get-weekday-number
=20
 (defun icalendar--get-weekday-numbers (abbrevweekdays)
   "Return the list of numbers for the comma-separated ABBREVWEEKDAYS."
+  (declare (obsolete "see `icalendar-weekday-numbers'" "32.1"))
   (when abbrevweekdays
     (let* ((num -1)
            (weekday-alist (mapcar (lambda (day)
@@ -860,6 +1144,7 @@ icalendar--get-weekday-numbers
=20
 (defun icalendar--get-weekday-abbrev (weekday)
   "Return the abbreviated WEEKDAY."
+  (declare (obsolete "see `icalendar-weekday-numbers'" "32.1"))
   (catch 'found
     (let ((num 0)
           (w (downcase weekday)))
@@ -877,6 +1162,7 @@ icalendar--date-to-isodate
   "Convert DATE to iso-style date.
 DATE must be a list of the form (month day year).
 If DAY-SHIFT is non-nil, the result is shifted by DAY-SHIFT days."
+  (declare (obsolete icalendar-print-date "32.1"))
   (let ((mdy (calendar-gregorian-from-absolute
               (+ (calendar-absolute-from-gregorian date)
                  (or day-shift 0)))))
@@ -891,6 +1177,7 @@ icalendar--datestring-to-isodate
 must be either nil or an integer.  This function tries to figure
 the date style from DATESTRING itself.  If that is not possible
 it uses the current calendar date style."
+  (declare (obsolete "use `diary-icalendar-parse-date-form' and `icalendar=
-print-date' instead." "32.1"))
   (let ((day -1) month year)
     (save-match-data
       (cond ( ;; iso-style numeric date
@@ -981,6 +1268,7 @@ icalendar--diarytime-to-isotime
 AMPMSTRING would be \"pm\".  The minutes may be missing as long
 as the colon is missing as well, i.e. \"9\" is allowed as
 TIMESTRING and has the same result as \"9:00\"."
+  (declare (obsolete "use `diary-icalendar-parse-time' and `icalendar-prin=
t-date-time' instead." "32.1"))
   (if timestring
       (let* ((parts (save-match-data (split-string timestring ":")))
              (h (car parts))
@@ -1018,20 +1306,136 @@ icalendar-export-file
   "Export diary file to iCalendar format.
 All diary entries in the file DIARY-FILENAME are converted to iCalendar
 format.  The result is appended to the file ICAL-FILENAME."
+  (declare (obsolete diary-icalendar-export-file "32.1"))
   (interactive "FExport diary data from file: \n\
 Finto iCalendar file: ")
   (save-current-buffer
     (set-buffer (find-file diary-filename))
     (icalendar-export-region (point-min) (point-max) ical-filename)))
=20
-(defvar icalendar--uid-count 0
-  "Auxiliary counter for creating unique ids.")
+;;;###autoload
+(defun icalendar-export-region (min max ical-filename)
+  "Export region in diary file to iCalendar format.
+All diary entries in the region from MIN to MAX in the current buffer are
+converted to iCalendar format.  The result is appended to the file
+ICAL-FILENAME.
+This function attempts to return t if something goes wrong.  In this
+case an error string which describes all the errors and problems is
+written into the buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-export-region "32.1"))
+  (interactive "r
+FExport diary data into iCalendar file: ")
+  (let ((result "")
+        (entry-main "")
+        (entry-rest "")
+	(entry-full "")
+        (header "")
+        (contents)
+        (alarm)
+        (found-error nil)
+        (nonmarker (concat "^" (regexp-quote diary-nonmarking-symbol)
+                           "?"))
+        (other-elements nil)
+        (cns-cons-or-list nil))
+    ;; prepare buffer with error messages
+    (save-current-buffer
+      (set-buffer (get-buffer-create "*icalendar-errors*"))
+      (erase-buffer))
+
+    ;; here we go
+    (save-excursion
+      (goto-char min)
+      (while (re-search-forward
+              ;; possibly ignore hidden entries beginning with "&"
+              (if icalendar-export-hidden-diary-entries
+                  "^\\([^ \t\n#].+\\)\\(\\(\n[ \t].*\\)*\\)"
+                "^\\([^ \t\n&#].+\\)\\(\\(\n[ \t].*\\)*\\)")
+              max t)
+        (setq entry-main (match-string 1))
+        (if (match-beginning 2)
+            (setq entry-rest (match-string 2))
+          (setq entry-rest ""))
+	(setq entry-full (concat entry-main entry-rest))
+
+        (condition-case error-val
+            (progn
+              (setq cns-cons-or-list
+                    (icalendar--convert-to-ical nonmarker entry-main))
+              (setq other-elements (icalendar--parse-summary-and-rest
+				    entry-full))
+              (mapc (lambda (contents-n-summary)
+                      (setq contents (concat (car contents-n-summary)
+                                             "\nSUMMARY:"
+                                             (cdr contents-n-summary)))
+                      (let ((cla (cdr (assoc 'cla other-elements)))
+                            (des (cdr (assoc 'des other-elements)))
+                            (loc (cdr (assoc 'loc other-elements)))
+                            (org (cdr (assoc 'org other-elements)))
+                            (sta (cdr (assoc 'sta other-elements)))
+                            ;; (sum (cdr (assoc 'sum other-elements)))
+                            (url (cdr (assoc 'url other-elements)))
+                            (uid (cdr (assoc 'uid other-elements))))
+                        (if cla
+                            (setq contents (concat contents "\nCLASS:" cla=
)))
+                        (if des
+                            (setq contents (concat contents "\nDESCRIPTION=
:"
+                                                   des)))
+                        (if loc
+                            (setq contents (concat contents "\nLOCATION:" =
loc)))
+                        (if org
+                            (setq contents (concat contents "\nORGANIZER:"
+                                                   org)))
+                        (if sta
+                            (setq contents (concat contents "\nSTATUS:" st=
a)))
+                        ;;(if sum
+                        ;;    (setq contents (concat contents "\nSUMMARY:"=
 sum)))
+                        (if url
+                            (setq contents (concat contents "\nURL:" url)))
+
+                        (setq header (concat "\nBEGIN:VEVENT\nUID:"
+                                             (or uid
+                                                 (icalendar--create-uid
+                                                  entry-full contents))))
+                        (setq alarm (icalendar--create-ical-alarm
+                                     (cdr contents-n-summary))))
+                      (setq result (concat result header contents alarm
+                                           "\nEND:VEVENT")))
+                    (if (and (consp cns-cons-or-list)
+                             (not (listp (cdr cns-cons-or-list))))
+                        (list cns-cons-or-list)
+                      cns-cons-or-list)))
+          ;; handle errors
+          (error
+           (setq found-error t)
+           (save-current-buffer
+             (set-buffer (get-buffer-create "*icalendar-errors*"))
+             (insert (format-message "Error in line %d -- %s: `%s'\n"
+                                     (count-lines (point-min) (point))
+                                     error-val
+                                     entry-main))))))
+
+      ;; we're done, insert everything into the file
+      (save-current-buffer
+        (let ((coding-system-for-write 'utf-8))
+          (set-buffer (find-file ical-filename))
+          (goto-char (point-max))
+          (insert "BEGIN:VCALENDAR")
+          (insert "\nPRODID:-//Emacs//NONSGML icalendar.el//EN")
+          (insert "\nVERSION:2.0")
+          (insert result)
+          (insert "\nEND:VCALENDAR\n")
+          ;; save the diary file
+          (save-buffer)
+          (unless found-error
+            (bury-buffer)))))
+    found-error))
=20
 (defun icalendar--create-uid (entry-full contents)
   "Construct a unique iCalendar UID for a diary entry.
 ENTRY-FULL is the full diary entry string.  CONTENTS is the
 current iCalendar object, as a string.  Increase
 `icalendar--uid-count'.  Returns the UID string."
+  (declare (obsolete icalendar-make-uid "32.1"))
   (let ((uid icalendar-uid-format))
     (if
 	;; Allow other apps (such as org-mode) to create its own uid
@@ -1055,7 +1459,6 @@ icalendar--create-uid
                          (substring contents (match-beginning 1) (match-en=
d 1))
                        "DTSTART")))
         (setq uid (replace-regexp-in-string "%s" dtstart uid t t))))
-
     ;; Return the UID string
     uid))
=20
@@ -1068,6 +1471,7 @@ icalendar-export-region
 This function attempts to return t if something goes wrong.  In this
 case an error string which describes all the errors and problems is
 written into the buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-export-region "32.1"))
   (interactive "r
 FExport diary data into iCalendar file: ")
   (let ((result "")
@@ -1179,6 +1583,7 @@ icalendar--convert-to-ical
   "Convert a diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (or
    (unless icalendar-export-sexp-enumerate-all
      (or
@@ -1208,6 +1613,7 @@ icalendar--convert-to-ical
 (defun icalendar--parse-summary-and-rest (summary-and-rest)
   "Parse SUMMARY-AND-REST from a diary to fill iCalendar properties.
 Returns an alist."
+  (declare (obsolete diary-icalendar-parse-entry "32.1"))
   (save-match-data
     (if (functionp icalendar-import-format)
         ;; can't do anything
@@ -1322,6 +1728,7 @@ icalendar--parse-summary-and-rest
=20
 (defun icalendar--create-ical-alarm (summary)
   "Return VALARM blocks for the given SUMMARY."
+  (declare (obsolete diary-icalendar-add-valarms "32.1"))
   (when icalendar-export-alarms
     (let* ((advance-time (car icalendar-export-alarms))
            (alarm-specs (cadr icalendar-export-alarms))
@@ -1337,6 +1744,7 @@ icalendar--do-create-ical-alarm
 \(email (ADDRESS1 ...)), see `icalendar-export-alarms'.  Argument
 SUMMARY is a string which contains a short description for the
 alarm."
+  (declare (obsolete diary-icalendar-add-valarms "32.1"))
   (let* ((action (car alarm-spec))
          (act (format "\nACTION:%s"
                       (cdr (assoc action '((audio . "AUDIO")
@@ -1362,6 +1770,7 @@ icalendar--convert-ordinary-to-ical
   "Convert \"ordinary\" diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match
        (concat nonmarker
                "\\([^ /]+[ /]+[^ /]+[ /]+[^ ]+\\)\\s-*" ; date
@@ -1445,6 +1854,7 @@ icalendar--convert-ordinary-to-ical
 (defun icalendar-first-weekday-of-year (abbrevweekday year)
   "Find the first ABBREVWEEKDAY in a given YEAR.
 Returns day number."
+  (declare (obsolete icalendar-nth-weekday-in "32.1"))
   (let* ((day-of-week-jan01 (calendar-day-of-week (list 1 1 year)))
          (result (+ 1
                     (- (icalendar--get-weekday-number abbrevweekday)
@@ -1459,6 +1869,7 @@ icalendar--convert-weekly-to-ical
   "Convert weekly diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (and (string-match (concat nonmarker
                                  "\\([a-z]+\\)\\s-+"
                                  "\\(\\([0-9][0-9]?:[0-9][0-9]\\)"
@@ -1541,6 +1952,7 @@ icalendar--convert-yearly-to-ical
   "Convert yearly diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             (if (eq calendar-date-style 'european)
                                 "\\([0-9]+[0-9]?\\)\\s-+\\([a-z]+\\)\\s-+"
@@ -1626,6 +2038,7 @@ icalendar--convert-sexp-to-ical
=20
 Optional argument START determines the first day of the
 enumeration, given as a Lisp time value -- used for test purposes."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (cond ((string-match (concat nonmarker
                                "%%(and \\(([^)]+)\\))\\(\\s-*.*?\\) ?$")
                        entry-main)
@@ -1678,6 +2091,7 @@ icalendar--convert-block-to-ical
   "Convert block diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-block \\([^ /]+[ /]+[^ /]+[ /]+[^ ]+=
\\)"
                             " +\\([^ /]+[ /]+[^ /]+[ /]+[^ ]+\\))\\s-*"
@@ -1751,6 +2165,7 @@ icalendar--convert-block-to-ical
     nil))
=20
 (defun icalendar--convert-float-to-ical (nonmarker entry-main)
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   "Convert float diary entry to iCalendar format -- partially unsupported!
=20
   FIXME! DAY from `diary-float' yet unimplemented.
@@ -1817,6 +2232,7 @@ icalendar--convert-date-to-ical
=20
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-date \\([^)]+\\))\\s-*\\(.*?\\) ?$")
                     entry-main)
@@ -1830,6 +2246,7 @@ icalendar--convert-cyclic-to-ical
   "Convert `diary-cyclic' diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-cyclic \\([^ ]+\\) +"
                             "\\([^ /]+[ /]+[^ /]+[ /]+[^ ]+\\))\\s-*"
@@ -1904,6 +2321,7 @@ icalendar--convert-anniversary-to-ical
   "Convert `diary-anniversary' diary entry to iCalendar format.
 NONMARKER is a regular expression matching the start of non-marking
 entries.  ENTRY-MAIN is the first line of the diary entry."
+  (declare (obsolete "use `diary-icalendar-parse-entry' and `icalendar-pri=
nt-component-node' instead." "32.1"))
   (if (string-match (concat nonmarker
                             "%%(diary-anniversary \\([^)]+\\))\\s-*"
                             "\\(\\([0-9][0-9]?:[0-9][0-9]\\)\\([ap]m\\)?"
@@ -1986,6 +2404,7 @@ icalendar-import-file
 Argument DIARY-FILENAME input `diary-file'.
 Optional argument NON-MARKING determines whether events are created as
 non-marking or not."
+  (declare (obsolete diary-icalendar-import-file "32.1"))
   (interactive "fImport iCalendar data from file: \nFInto diary file: \nP")
   ;; clean up the diary file
   (save-current-buffer
@@ -2012,6 +2431,7 @@ icalendar-import-buffer
 Return code t means that importing worked well, return code nil
 means that an error has occurred.  Error messages will be in the
 buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-import-buffer "32.1"))
   (interactive)
   (save-current-buffer
     ;; prepare ical
@@ -2048,6 +2468,7 @@ icalendar-import-buffer
=20
 (defun icalendar--format-ical-event (event)
   "Create a string representation of an iCalendar EVENT."
+  (declare (obsolete diary-icalendar-format-entry "32.1"))
   (if (functionp icalendar-import-format)
       (funcall icalendar-import-format event)
     (let ((string icalendar-import-format)
@@ -2093,6 +2514,7 @@ icalendar--convert-ical-to-diary
 This function attempts to return t if something goes wrong.  In this
 case an error string which describes all the errors and problems is
 written into the buffer `*icalendar-errors*'."
+  (declare (obsolete diary-icalendar-import-buffer "32.1"))
   (let* ((ev (icalendar--all-events ical-list))
          (error-string "")
          (event-ok t)
@@ -2255,6 +2677,7 @@ icalendar--convert-recurring-to-diary
 DTSTART-DEC is the DTSTART property of E.
 START-T is the event's start time in diary format.
 END-T is the event's end time in diary format."
+  (declare (obsolete diary-icalendar-format-entry "32.1"))
   (icalendar--dmsg "recurring event")
   (let* ((rrule        (icalendar--get-event-property e 'RRULE))
          (rrule-props  (icalendar--split-value rrule))
@@ -2492,6 +2915,7 @@ icalendar--convert-non-recurring-all-day-to-diary
 DTSTART is the decoded DTSTART property of E.
 Argument START-D gives the first day.
 Argument END-D gives the last day."
+  (declare (obsolete diary-icalendar-format-time-range "32.1"))
   (icalendar--dmsg "non-recurring all-day event")
   (format "%%%%(and (diary-block %s %s))" start-d end-d))
=20
@@ -2503,6 +2927,7 @@ icalendar--convert-non-recurring-not-all-day-to-diary
 DTSTART-DEC is the decoded DTSTART property of E.
 START-T is the event's start time in diary format.
 END-T is the event's end time in diary format."
+  (declare (obsolete diary-icalendar-format-time-range "32.1"))
   (icalendar--dmsg "not all day event")
   (cond (end-t
          (format "%s %s-%s"
@@ -2523,6 +2948,8 @@ icalendar--add-diary-entry
 SUMMARY is not nil it must be a string that gives the summary of the
 entry.  In this case the user will be asked whether he wants to insert
 the entry."
+  (declare (obsolete "see `diary-icalendar-post-entry-format-hook' and
+`diary-icalendar--entry-import'" "32.1"))
   (when (or (not summary)
             (y-or-n-p (format-message "Add appointment for `%s' to diary? "
                                       summary)))
@@ -2541,6 +2968,7 @@ icalendar--add-diary-entry
 ;; =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
 (defun icalendar-import-format-sample (event)
   "Example function for formatting an iCalendar EVENT."
+  (declare (obsolete "see `diary-icalendar-vevent-skeleton'" "32.1"))
   (format (concat "SUMMARY=3D'%s' DESCRIPTION=3D'%s' LOCATION=3D'%s' ORGAN=
IZER=3D'%s' "
                   "STATUS=3D'%s' URL=3D'%s' CLASS=3D'%s'")
           (or (icalendar--get-event-property event 'SUMMARY) "")
@@ -2558,4 +2986,7 @@ icalendar-version
=20
 (provide 'icalendar)
=20
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
 ;;; icalendar.el ends here
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
diary-american b/test/lisp/calendar/diary-icalendar-resources/import-bug-11=
473.diary-american
new file mode 100644
index 00000000000..c65def0c8d9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.diary-a=
merican
@@ -0,0 +1,9 @@
+&5/15/2012 15:00-15:30 Query
+ Location: phone
+ Status: confirmed
+ Organizer: A. Luser <MAILTO:a.luser@HIDDEN>=20
+ Attendee: Luser, Other <MAILTO:other.luser@HIDDEN> (needs-action)
+ Access: public
+ UID: 040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000=
000010000000575268034ECDB649A15349B1BF240F15
+ Description: Whassup?
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
diary-european b/test/lisp/calendar/diary-icalendar-resources/import-bug-11=
473.diary-european
new file mode 100644
index 00000000000..b68cff22084
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.diary-e=
uropean
@@ -0,0 +1,9 @@
+&15/5/2012 15:00-15:30 Query
+ Location: phone
+ Status: confirmed
+ Organizer: A. Luser <MAILTO:a.luser@HIDDEN>=20
+ Attendee: Luser, Other <MAILTO:other.luser@HIDDEN> (needs-action)
+ Access: public
+ UID: 040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000=
000010000000575268034ECDB649A15349B1BF240F15
+ Description: Whassup?
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.d=
iary-iso
new file mode 100644
index 00000000000..f891fe78189
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.diary-i=
so
@@ -0,0 +1,9 @@
+&2012/5/15 15:00-15:30 Query
+ Location: phone
+ Status: confirmed
+ Organizer: A. Luser <MAILTO:a.luser@HIDDEN>=20
+ Attendee: Luser, Other <MAILTO:other.luser@HIDDEN> (needs-action)
+ Access: public
+ UID: 040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000=
000010000000575268034ECDB649A15349B1BF240F15
+ Description: Whassup?
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.ics
new file mode 100644
index 00000000000..bc3a6c69fb7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-11473.ics
@@ -0,0 +1,54 @@
+BEGIN:VCALENDAR
+METHOD:REQUEST
+PRODID:Microsoft Exchange Server 2007
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna
+BEGIN:STANDARD
+DTSTART:16010101T030000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D10
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:16010101T020000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D3
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+ORGANIZER;CN=3D"A. Luser":MAILTO:a.luser@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Luser, Oth
+ er":MAILTO:other.luser@HIDDEN
+DESCRIPTION;LANGUAGE=3Den-US:\nWhassup?\n\n
+SUMMARY;LANGUAGE=3Den-US:Query
+DTSTART;TZID=3D"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vien=
na"
+ :20120515T150000
+DTEND;TZID=3D"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna=
":2
+ 0120515T153000
+UID:040000008200E00074C5B7101A82E0080000000020FFAED0CFEFCC01000000000000000
+ 010000000575268034ECDB649A15349B1BF240F15
+RECURRENCE-ID;TZID=3D"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm=
, V
+ ienna":20120515T170000
+CLASS:PUBLIC
+PRIORITY:5
+DTSTAMP:20120514T153645Z
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+SEQUENCE:15
+LOCATION;LANGUAGE=3Den-US:phone
+X-MICROSOFT-CDO-APPT-SEQUENCE:15
+X-MICROSOFT-CDO-OWNERAPPTID:1907632092
+X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
+X-MICROSOFT-CDO-IMPORTANCE:1
+X-MICROSOFT-CDO-INSTTYPE:3
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:REMINDER
+TRIGGER;RELATED=3DSTART:-PT15M
+END:VALARM
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
diary-american b/test/lisp/calendar/diary-icalendar-resources/import-bug-22=
092.diary-american
new file mode 100644
index 00000000000..392345fe0a2
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.diary-a=
merican
@@ -0,0 +1,6 @@
+&12/8/2014 18:30-22:55 Norwegian til Tromsoe-Langnes -=20
+ Desc: Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; 8. de=
s 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til Troms=
&#248; 8. des 2014 21:00, DY390
+ Location: Stavanger-Sola
+ Organizer: noreply@HIDDEN
+ Class: PUBLIC
+ UID: RFCALITEM1
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
diary-european b/test/lisp/calendar/diary-icalendar-resources/import-bug-22=
092.diary-european
new file mode 100644
index 00000000000..6a64cf6a8e9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.diary-e=
uropean
@@ -0,0 +1,6 @@
+&8/12/2014 18:30-22:55 Norwegian til Tromsoe-Langnes -=20
+ Desc: Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; 8. de=
s 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til Troms=
&#248; 8. des 2014 21:00, DY390
+ Location: Stavanger-Sola
+ Organizer: noreply@HIDDEN
+ Class: PUBLIC
+ UID: RFCALITEM1
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.d=
iary-iso
new file mode 100644
index 00000000000..e0fadbf94dc
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.diary-i=
so
@@ -0,0 +1,6 @@
+&2014/12/8 18:30-22:55 Norwegian til Tromsoe-Langnes -=20
+ Desc: Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; 8. de=
s 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til Troms=
&#248; 8. des 2014 21:00, DY390
+ Location: Stavanger-Sola
+ Organizer: noreply@HIDDEN
+ Class: PUBLIC
+ UID: RFCALITEM1
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.ics
new file mode 100644
index 00000000000..4a4c679da9c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-22092.ics
@@ -0,0 +1,30 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//www.norwegian.no//iCalendar MIMEDIR//EN=0D
+VERSION:2.0=0D
+METHOD:REQUEST=0D
+BEGIN:VEVENT=0D
+UID:RFCALITEM1=0D
+SEQUENCE:1512040950=0D
+DTSTAMP:20141204T095043Z=0D
+ORGANIZER:noreply@HIDDEN=0D
+DTSTART:20141208T173000Z=0D
+
+DTEND:20141208T215500Z=0D
+
+LOCATION:Stavanger-Sola=0D
+
+DESCRIPTION:Fly med Norwegian, reservasjon. Fra Stavanger til Troms&#248; =
8. des 2014 18:30, DY545Fly med Norwegian, reservasjon . Fra Stavanger til =
Troms&#248; 8. des 2014 21:00, DY390=0D
+
+X-ALT-DESC;FMTTYPE=3Dtext/html:<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2=
//EN"><html><head><META NAME=3D"Generator" CONTENT=3D"MS Exchange Server ve=
rsion 08.00.0681.000"><title></title></head><body><b><font face=3D"Calibri"=
 size=3D"3">Reisereferanse</p></body></html>
+SUMMARY:Norwegian til Tromsoe-Langnes - =0D
+
+CATEGORIES:Appointment=0D
+
+
+PRIORITY:5=0D
+
+CLASS:PUBLIC=0D
+
+TRANSP:OPAQUE=0D
+END:VEVENT=0D
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.=
diary-all b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.d=
iary-all
new file mode 100644
index 00000000000..3579ce3a417
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-a=
ll
@@ -0,0 +1,12 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (BYDAY ((3 . 1))) (INTERVAL 1))
+             :exclude
+             '((0 46 11 6 1 2016 3 -1 0) (0 46 11 3 2 2016 3 -1 0)
+               (0 46 11 2 3 2016 3 -1 0) (0 46 10 4 5 2016 3 -1 0)
+               (0 46 10 1 6 2016 3 -1 0))
+             :start '(0 46 12 2 12 2015 3 -1 nil) :duration
+             '(0 14 3 0 nil nil nil -1 nil)) Summary
+ Location: Loc
+ Access: private
+ UID: 9188710a-08a7-4061-bae3-d4cf4972599a
+ Description: Desc
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.ics
new file mode 100644
index 00000000000..8851de7b80c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-24199.ics
@@ -0,0 +1,25 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:Summary=0D
+DESCRIPTION:Desc=0D
+LOCATION:Loc=0D
+DTSTART:20151202T124600=0D
+DTEND:20151202T160000=0D
+RRULE:FREQ=3DMONTHLY;BYDAY=3D1WE;INTERVAL=3D1=0D
+EXDATE:20160106T114600Z=0D
+EXDATE:20160203T114600Z=0D
+EXDATE:20160302T114600Z=0D
+EXDATE:20160504T104600Z=0D
+EXDATE:20160601T104600Z=0D
+CLASS:DEFAULT=0D
+TRANSP:OPAQUE=0D
+BEGIN:VALARM=0D
+ACTION:DISPLAY=0D
+TRIGGER;VALUE=3DDURATION:-PT3H=0D
+END:VALARM=0D
+LAST-MODIFIED:20160805T191040Z=0D
+UID:9188710a-08a7-4061-bae3-d4cf4972599a=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
diary-american b/test/lisp/calendar/diary-icalendar-resources/import-bug-33=
277.diary-american
new file mode 100644
index 00000000000..5d1f4286d5f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.diary-a=
merican
@@ -0,0 +1,2 @@
+&11/5/2018 21:00 event with same start/end time
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
diary-european b/test/lisp/calendar/diary-icalendar-resources/import-bug-33=
277.diary-european
new file mode 100644
index 00000000000..02ec8ee17b9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.diary-e=
uropean
@@ -0,0 +1,2 @@
+&5/11/2018 21:00 event with same start/end time
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.d=
iary-iso
new file mode 100644
index 00000000000..d2256e859a3
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.diary-i=
so
@@ -0,0 +1,2 @@
+&2018/11/5 21:00 event with same start/end time
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.=
ics b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.ics
new file mode 100644
index 00000000000..f3cb3a0c3e5
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-33277.ics
@@ -0,0 +1,15 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART:20181105T200000Z=0D
+DTSTAMP:20181105T181652Z=0D
+DESCRIPTION:=0D
+LAST-MODIFIED:20181105T181646Z=0D
+LOCATION:=0D
+SEQUENCE:0=0D
+SUMMARY:event with same start/end time=0D
+TRANSP:OPAQUE=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.d=
iary-all b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.dia=
ry-all
new file mode 100644
index 00000000000..11a958513d1
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.diary-all
@@ -0,0 +1,13 @@
+&%%(diary-rrule :rule '((FREQ WEEKLY) (INTERVAL 1) (BYDAY (1 3 4 5)))
+             :start '(0 30 11 21 4 2010 3 -1 nil) :duration
+             '(0 30 0 0 nil nil nil -1 nil)) Scrum
+ Status: confirmed
+ Access: public
+ UID: 8814e3f9-7482-408f-996c-3bfe486a1262
+
+&%%(diary-rrule :rule '((FREQ WEEKLY) (INTERVAL 1) (BYDAY (2 4))) :start
+             '(4 22 2010) :duration
+             '(nil nil nil 1 nil nil nil -1 nil)) Tues + Thurs thinking
+ Access: public
+ UID: 8814e3f9-7482-408f-996c-3bfe486a1263
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.i=
cs b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.ics
new file mode 100644
index 00000000000..72502ff05c4
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-bug-6766.ics
@@ -0,0 +1,28 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+CLASS:PUBLIC=0D
+DTEND;TZID=3DAmerica/New_York:20100421T120000=0D
+DTSTAMP:20100525T141214Z=0D
+DTSTART;TZID=3DAmerica/New_York:20100421T113000=0D
+RRULE:FREQ=3DWEEKLY;INTERVAL=3D1;BYDAY=3DMO,WE,TH,FR=0D
+SEQUENCE:1=0D
+STATUS:CONFIRMED=0D
+SUMMARY:Scrum=0D
+TRANSP:OPAQUE=0D
+UID:8814e3f9-7482-408f-996c-3bfe486a1262=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+CLASS:PUBLIC=0D
+DTSTAMP:20100525T141214Z=0D
+DTSTART;VALUE=3DDATE:20100422=0D
+DTEND;VALUE=3DDATE:20100423=0D
+RRULE:FREQ=3DWEEKLY;INTERVAL=3D1;BYDAY=3DTU,TH=0D
+SEQUENCE:1=0D
+SUMMARY:Tues + Thurs thinking=0D
+TRANSP:OPAQUE=0D
+UID:8814e3f9-7482-408f-996c-3bfe486a1263=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration-2=
.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-duration-2=
.diary-all
new file mode 100644
index 00000000000..bf6a90a3bd9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration-2.diary-=
all
@@ -0,0 +1,6 @@
+&%%(diary-rrule :rule
+             '((FREQ DAILY) (UNTIL (12 29 2001)) (INTERVAL 1) (WKST 0))
+             :start '(12 21 2001)) Urlaub
+ Access: public
+ UID: 20041127T183329Z-18215-1001-4536-49109@andromeda
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration-2=
.ics b/test/lisp/calendar/diary-icalendar-resources/import-duration-2.ics
new file mode 100644
index 00000000000..1d59da60599
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration-2.ics
@@ -0,0 +1,17 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:20041127T183329Z-18215-1001-4536-49109@andromeda=0D
+DTSTAMP:20041127T183315Z=0D
+LAST-MODIFIED:20041127T183329=0D
+SUMMARY:Urlaub=0D
+DTSTART;VALUE=3DDATE:20011221=0D
+DTEND;VALUE=3DDATE:20011221=0D
+RRULE:FREQ=3DDAILY;UNTIL=3D20011229;INTERVAL=3D1;WKST=3DSU=0D
+CLASS:PUBLIC=0D
+SEQUENCE:1=0D
+CREATED:20041127T183329=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.d=
iary-american b/test/lisp/calendar/diary-icalendar-resources/import-duratio=
n.diary-american
new file mode 100644
index 00000000000..81b81c30237
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.diary-am=
erican
@@ -0,0 +1,2 @@
+&%%(diary-block 2 17 2005 2 23 2005) duration
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.d=
iary-european b/test/lisp/calendar/diary-icalendar-resources/import-duratio=
n.diary-european
new file mode 100644
index 00000000000..ce906fcda13
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.diary-eu=
ropean
@@ -0,0 +1,2 @@
+&%%(diary-block 17 2 2005 23 2 2005) duration
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.d=
iary-iso b/test/lisp/calendar/diary-icalendar-resources/import-duration.dia=
ry-iso
new file mode 100644
index 00000000000..feec177ff9e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.diary-iso
@@ -0,0 +1,2 @@
+&%%(diary-block 2005 2 17 2005 2 23) duration
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-duration.i=
cs b/test/lisp/calendar/diary-icalendar-resources/import-duration.ics
new file mode 100644
index 00000000000..0fa41b709a0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-duration.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20050217=0D
+SUMMARY:duration=0D
+DURATION:P7D=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-fun=
ction.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-legac=
y-function.diary-all
new file mode 100644
index 00000000000..e0d27f4d1b0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-function.d=
iary-all
@@ -0,0 +1,10 @@
+ SUMMARY: Testing legacy `icalendar-import-format' function
+ DESCRIPTION: described
+ CLASS: private
+ LOCATION: somewhere
+ ORGANIZER: mailto:baz@HIDDEN
+ STATUS: CONFIRMED
+ URL: http://example.com/foo/baz
+ UID: some-unique-id-here
+ DTSTART: 20250919T090000
+ DTEND: 20250919T113000
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-fun=
ction.ics b/test/lisp/calendar/diary-icalendar-resources/import-legacy-func=
tion.ics
new file mode 100644
index 00000000000..760131b8192
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-function.i=
cs
@@ -0,0 +1,16 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:Testing legacy `icalendar-import-format' function=0D
+DESCRIPTION:described=0D
+CLASS:private=0D
+LOCATION:somewhere=0D
+ORGANIZER;CN=3D"Baz Foo":mailto:baz@HIDDEN=0D
+STATUS:CONFIRMED=0D
+URL:http://example.com/foo/baz=0D
+UID:some-unique-id-here=0D
+DTSTART;VALUE=3DDATE-TIME:20250919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20250919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.diary-american b/test/lisp/calendar/diary-icalendar-resources/import-lega=
cy-vars.diary-american
new file mode 100644
index 00000000000..42076a32138
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.diary=
-american
@@ -0,0 +1,8 @@
+9/19/2025 09:00-11:30 Testing legacy `icalendar-import-format*' vars
+ CLASS=3Dprivate
+ DESCRIPTION=3Ddescribed
+ LOCATION=3Dsomewhere
+ ORGANIZER=3Dmailto:baz@HIDDEN
+ STATUS=3Dconfirmed
+ URL=3Dhttp://example.com/foo/baz
+ UID=3Dsome-unique-id-here
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.diary-european b/test/lisp/calendar/diary-icalendar-resources/import-lega=
cy-vars.diary-european
new file mode 100644
index 00000000000..699c627e2f9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.diary=
-european
@@ -0,0 +1,8 @@
+19/9/2025 09:00-11:30 Testing legacy `icalendar-import-format*' vars
+ CLASS=3Dprivate
+ DESCRIPTION=3Ddescribed
+ LOCATION=3Dsomewhere
+ ORGANIZER=3Dmailto:baz@HIDDEN
+ STATUS=3Dconfirmed
+ URL=3Dhttp://example.com/foo/baz
+ UID=3Dsome-unique-id-here
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-legacy-va=
rs.diary-iso
new file mode 100644
index 00000000000..f6d69805c19
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.diary=
-iso
@@ -0,0 +1,8 @@
+2025/9/19 09:00-11:30 Testing legacy `icalendar-import-format*' vars
+ CLASS=3Dprivate
+ DESCRIPTION=3Ddescribed
+ LOCATION=3Dsomewhere
+ ORGANIZER=3Dmailto:baz@HIDDEN
+ STATUS=3Dconfirmed
+ URL=3Dhttp://example.com/foo/baz
+ UID=3Dsome-unique-id-here
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-legacy-var=
s.ics b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.ics
new file mode 100644
index 00000000000..cc69f1a3b97
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-legacy-vars.ics
@@ -0,0 +1,17 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:Testing legacy `icalendar-import-format*' vars=0D
+DESCRIPTION:described=0D
+CLASS:private=0D
+LOCATION:somewhere=0D
+ORGANIZER;CN=3D"Baz Foo":mailto:baz@HIDDEN=0D
+STATUS:CONFIRMED=0D
+URL:http://example.com/foo/baz=0D
+UID:some-unique-id-here=0D
+DTSTART;VALUE=3DDATE-TIME:20250919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20250919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.diary-american b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-multiple-vcalendars.diary-american
new file mode 100644
index 00000000000..5961a36fed8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.diary-american
@@ -0,0 +1,8 @@
+&7/23/2011 event-1
+
+&7/24/2011 event-2
+
+&7/25/2011 event-3a
+
+&7/25/2011 event-3b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.diary-european b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-multiple-vcalendars.diary-european
new file mode 100644
index 00000000000..169833eb55e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.diary-european
@@ -0,0 +1,8 @@
+&23/7/2011 event-1
+
+&24/7/2011 event-2
+
+&25/7/2011 event-3a
+
+&25/7/2011 event-3b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-m=
ultiple-vcalendars.diary-iso
new file mode 100644
index 00000000000..0dda3401bf8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.diary-iso
@@ -0,0 +1,8 @@
+&2011/7/23 event-1
+
+&2011/7/24 event-2
+
+&2011/7/25 event-3a
+
+&2011/7/25 event-3b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-multiple-v=
calendars.ics b/test/lisp/calendar/diary-icalendar-resources/import-multipl=
e-vcalendars.ics
new file mode 100644
index 00000000000..f6e2febec39
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-multiple-vcalenda=
rs.ics
@@ -0,0 +1,21 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110723=0D
+SUMMARY:event-1=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110724=0D
+SUMMARY:event-2=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110725=0D
+SUMMARY:event-3a=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20110725=0D
+SUMMARY:event-3b=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.diary-american b/test/lisp/calendar/diary-icalendar-resources/import-=
non-recurring-1.diary-american
new file mode 100644
index 00000000000..ece825f089f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.d=
iary-american
@@ -0,0 +1,2 @@
+&9/19/2003 09:00-11:30 non-recurring
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.diary-european b/test/lisp/calendar/diary-icalendar-resources/import-=
non-recurring-1.diary-european
new file mode 100644
index 00000000000..9b4ce920bc7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.d=
iary-european
@@ -0,0 +1,2 @@
+&19/9/2003 09:00-11:30 non-recurring
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-non-r=
ecurring-1.diary-iso
new file mode 100644
index 00000000000..278d72cb1a8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.d=
iary-iso
@@ -0,0 +1,2 @@
+&2003/9/19 09:00-11:30 non-recurring
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-1.ics b/test/lisp/calendar/diary-icalendar-resources/import-non-recurri=
ng-1.ics
new file mode 100644
index 00000000000..37855fbce6e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-1.i=
cs
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:non-recurring=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-non-recurring-all-day.diary-american
new file mode 100644
index 00000000000..7d8c6d82455
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.diary-american
@@ -0,0 +1,2 @@
+&9/19/2003 non-recurring allday
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-non-recurring-all-day.diary-european
new file mode 100644
index 00000000000..4ac8d9aab05
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.diary-european
@@ -0,0 +1,2 @@
+&19/9/2003 non-recurring allday
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import=
-non-recurring-all-day.diary-iso
new file mode 100644
index 00000000000..98f42084be9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.diary-iso
@@ -0,0 +1,2 @@
+&2003/9/19 non-recurring allday
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-all-day.ics b/test/lisp/calendar/diary-icalendar-resources/import-non-r=
ecurring-all-day.ics
new file mode 100644
index 00000000000..5992fe678f9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-all=
-day.ics
@@ -0,0 +1,9 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:non-recurring allday=0D
+DTSTART;VALUE=3DDATE:20030919=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.diary-american b/test/lisp/calendar/diary-icalendar-res=
ources/import-non-recurring-another-example.diary-american
new file mode 100644
index 00000000000..a0e91b9ddaa
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.diary-american
@@ -0,0 +1,5 @@
+&11/23/2004 14:45-15:45 another example
+ Status: tentative
+ Access: private
+ UID: 6161a312-3902-11d9-b512-f764153bb28b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.diary-european b/test/lisp/calendar/diary-icalendar-res=
ources/import-non-recurring-another-example.diary-european
new file mode 100644
index 00000000000..e6d0f81f5da
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.diary-european
@@ -0,0 +1,5 @@
+&23/11/2004 14:45-15:45 another example
+ Status: tentative
+ Access: private
+ UID: 6161a312-3902-11d9-b512-f764153bb28b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.diary-iso b/test/lisp/calendar/diary-icalendar-resource=
s/import-non-recurring-another-example.diary-iso
new file mode 100644
index 00000000000..6aa2014fa99
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.diary-iso
@@ -0,0 +1,5 @@
+&2004/11/23 14:45-15:45 another example
+ Status: tentative
+ Access: private
+ UID: 6161a312-3902-11d9-b512-f764153bb28b
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-another-example.ics b/test/lisp/calendar/diary-icalendar-resources/impo=
rt-non-recurring-another-example.ics
new file mode 100644
index 00000000000..5e3e09e8689
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-ano=
ther-example.ics
@@ -0,0 +1,23 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID=0D
+ :6161a312-3902-11d9-b512-f764153bb28b=0D
+SUMMARY=0D
+ :another example=0D
+STATUS=0D
+ :TENTATIVE=0D
+CLASS=0D
+ :PRIVATE=0D
+X-MOZILLA-ALARM-DEFAULT-LENGTH=0D
+ :0=0D
+DTSTART=0D
+ :20041123T144500=0D
+DTEND=0D
+ :20041123T154500=0D
+DTSTAMP=0D
+ :20041118T013641Z=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.diary-american b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-non-recurring-block.diary-american
new file mode 100644
index 00000000000..c665ab22f84
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.diary-american
@@ -0,0 +1,5 @@
+&%%(diary-block 7 19 2004 8 27 2004) Sommerferien
+ Status: tentative
+ Access: private
+ UID: 748f2da0-0d9b-11d8-97af-b4ec8686ea61
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.diary-european b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-non-recurring-block.diary-european
new file mode 100644
index 00000000000..96373d6ebb1
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.diary-european
@@ -0,0 +1,5 @@
+&%%(diary-block 19 7 2004 27 8 2004) Sommerferien
+ Status: tentative
+ Access: private
+ UID: 748f2da0-0d9b-11d8-97af-b4ec8686ea61
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-n=
on-recurring-block.diary-iso
new file mode 100644
index 00000000000..3d07106344e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.diary-iso
@@ -0,0 +1,5 @@
+&%%(diary-block 2004 7 19 2004 8 27) Sommerferien
+ Status: tentative
+ Access: private
+ UID: 748f2da0-0d9b-11d8-97af-b4ec8686ea61
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-block.ics b/test/lisp/calendar/diary-icalendar-resources/import-non-rec=
urring-block.ics
new file mode 100644
index 00000000000..c5c777844cf
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-blo=
ck.ics
@@ -0,0 +1,16 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:748f2da0-0d9b-11d8-97af-b4ec8686ea61=0D
+SUMMARY:Sommerferien=0D
+STATUS:TENTATIVE=0D
+CLASS:PRIVATE=0D
+X-MOZILLA-ALARM-DEFAULT-UNITS:Minuten=0D
+X-MOZILLA-RECUR-DEFAULT-INTERVAL:0=0D
+DTSTART;VALUE=3DDATE:20040719=0D
+DTEND;VALUE=3DDATE:20040828=0D
+DTSTAMP:20031103T011641Z=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.diary-american b/test/lisp/calendar/diary-icalendar-reso=
urces/import-non-recurring-folded-summary.diary-american
new file mode 100644
index 00000000000..ad40baa4de9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.diary-american
@@ -0,0 +1,5 @@
+&11/23/2004 14:00-14:30 folded summary
+ Status: tentative
+ Access: private
+ UID: 04979712-3902-11d9-93dd-8f9f4afe08da
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.diary-european b/test/lisp/calendar/diary-icalendar-reso=
urces/import-non-recurring-folded-summary.diary-european
new file mode 100644
index 00000000000..38f937e8151
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.diary-european
@@ -0,0 +1,5 @@
+&23/11/2004 14:00-14:30 folded summary
+ Status: tentative
+ Access: private
+ UID: 04979712-3902-11d9-93dd-8f9f4afe08da
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.diary-iso b/test/lisp/calendar/diary-icalendar-resources=
/import-non-recurring-folded-summary.diary-iso
new file mode 100644
index 00000000000..39b7ca3ac93
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.diary-iso
@@ -0,0 +1,5 @@
+&2004/11/23 14:00-14:30 folded summary
+ Status: tentative
+ Access: private
+ UID: 04979712-3902-11d9-93dd-8f9f4afe08da
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-folded-summary.ics b/test/lisp/calendar/diary-icalendar-resources/impor=
t-non-recurring-folded-summary.ics
new file mode 100644
index 00000000000..26348a8909b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-fol=
ded-summary.ics
@@ -0,0 +1,25 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID=0D
+ :04979712-3902-11d9-93dd-8f9f4afe08da=0D
+SUMMARY=0D
+ :folded summary=0D
+STATUS=0D
+ :TENTATIVE=0D
+CLASS=0D
+ :PRIVATE=0D
+X-MOZILLA-ALARM-DEFAULT-LENGTH=0D
+ :0=0D
+DTSTART=0D
+ :20041123T140000=0D
+DTEND=0D
+ :20041123T143000=0D
+DTSTAMP=0D
+ :20041118T013430Z=0D
+LAST-MODIFIED=0D
+ :20041118T013640Z=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.diary-american b/test/lisp/calendar/diary-icalendar-resour=
ces/import-non-recurring-long-summary.diary-american
new file mode 100644
index 00000000000..8adae619438
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.diary-american
@@ -0,0 +1,2 @@
+&9/19/2003 long summary
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.diary-european b/test/lisp/calendar/diary-icalendar-resour=
ces/import-non-recurring-long-summary.diary-european
new file mode 100644
index 00000000000..2e764fb1b2f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.diary-european
@@ -0,0 +1,2 @@
+&19/9/2003 long summary
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.diary-iso b/test/lisp/calendar/diary-icalendar-resources/i=
mport-non-recurring-long-summary.diary-iso
new file mode 100644
index 00000000000..e1eb4074ca0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.diary-iso
@@ -0,0 +1,2 @@
+&2003/9/19 long summary
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-non-recurr=
ing-long-summary.ics b/test/lisp/calendar/diary-icalendar-resources/import-=
non-recurring-long-summary.ics
new file mode 100644
index 00000000000..c024498fb68
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-non-recurring-lon=
g-summary.ics
@@ -0,0 +1,9 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:long summary=0D
+DTSTART;VALUE=3DDATE:20030919=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-05-29.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2003-05-29.diary-american
new file mode 100644
index 00000000000..e6c8712d254
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
5-29.diary-american
@@ -0,0 +1,6 @@
+&5/9/2003 07:00-12:00 On-Site Interview
+ Desc: 10:30am - Blah
+ Location: Cccc
+ Organizer: MAILTO:aaaaaaa@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E0080000000080B6DE661216C301000000000000=
000010000000DB823520692542408ED02D7023F9DFF9
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-05-29.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2003-05-29.diary-european
new file mode 100644
index 00000000000..cecca070a51
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
5-29.diary-european
@@ -0,0 +1,6 @@
+&9/5/2003 07:00-12:00 On-Site Interview
+ Desc: 10:30am - Blah
+ Location: Cccc
+ Organizer: MAILTO:aaaaaaa@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E0080000000080B6DE661216C301000000000000=
000010000000DB823520692542408ED02D7023F9DFF9
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-05-29.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2003-05-29.ics
new file mode 100644
index 00000000000..a0a5583dd15
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
5-29.ics
@@ -0,0 +1,54 @@
+BEGIN:VCALENDAR=0D
+METHOD:REQUEST=0D
+PRODID:Microsoft CDO for Microsoft Exchange=0D
+VERSION:2.0=0D
+BEGIN:VTIMEZONE=0D
+TZID:Kolkata, Chennai, Mumbai, New Delhi=0D
+X-MICROSOFT-CDO-TZID:23=0D
+BEGIN:STANDARD=0D
+DTSTART:16010101T000000=0D
+TZOFFSETFROM:+0530=0D
+TZOFFSETTO:+0530=0D
+END:STANDARD=0D
+BEGIN:DAYLIGHT=0D
+DTSTART:16010101T000000=0D
+TZOFFSETFROM:+0530=0D
+TZOFFSETTO:+0530=0D
+END:DAYLIGHT=0D
+END:VTIMEZONE=0D
+BEGIN:VEVENT=0D
+DTSTAMP:20030509T043439Z=0D
+DTSTART;TZID=3D"Kolkata, Chennai, Mumbai, New Delhi":20030509T103000=0D
+SUMMARY:On-Site Interview=0D
+UID:040000008200E00074C5B7101A82E0080000000080B6DE661216C30100000000000000=
0=0D
+ 010000000DB823520692542408ED02D7023F9DFF9=0D
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Xxxxx=0D
+ xxx Xxxxxxxxxxxx":MAILTO:xxxxxxxx@HIDDEN=0D
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Yyyyyyy Y=0D
+ yyyy":MAILTO:yyyyyyy@HIDDEN=0D
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
Zzzz Zzzz=0D
+ zz":MAILTO:zzzzzz@HIDDEN=0D
+ORGANIZER;CN=3D"Aaaaaa Aaaaa":MAILTO:aaaaaaa@HIDDEN=0D
+LOCATION:Cccc=0D
+DTEND;TZID=3D"Kolkata, Chennai, Mumbai, New Delhi":20030509T153000=0D
+DESCRIPTION:10:30am - Blah=0D
+SEQUENCE:0=0D
+PRIORITY:5=0D
+CLASS:=0D
+CREATED:20030509T043439Z=0D
+LAST-MODIFIED:20030509T043459Z=0D
+STATUS:CONFIRMED=0D
+TRANSP:OPAQUE=0D
+X-MICROSOFT-CDO-BUSYSTATUS:BUSY=0D
+X-MICROSOFT-CDO-INSTTYPE:0=0D
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY=0D
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE=0D
+X-MICROSOFT-CDO-IMPORTANCE:1=0D
+X-MICROSOFT-CDO-OWNERAPPTID:126441427=0D
+BEGIN:VALARM=0D
+ACTION:DISPLAY=0D
+DESCRIPTION:REMINDER=0D
+TRIGGER;RELATED=3DSTART:-PT00H15M00S=0D
+END:VALARM=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18a.diary-american b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18a.diary-american
new file mode 100644
index 00000000000..f2c914184e7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18a.diary-american
@@ -0,0 +1,6 @@
+&6/23/2003 11:00-12:00 Dress Rehearsal for XXXX-XXXX
+ Desc: 753 Zeichen hier radiert
+ Location: 555 or TN 555-5555 ID 5555 & NochWas (see below)
+ Organizer: MAILTO:xxx@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18a.diary-european b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18a.diary-european
new file mode 100644
index 00000000000..89cff58af42
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18a.diary-european
@@ -0,0 +1,6 @@
+&23/6/2003 11:00-12:00 Dress Rehearsal for XXXX-XXXX
+ Desc: 753 Zeichen hier radiert
+ Location: 555 or TN 555-5555 ID 5555 & NochWas (see below)
+ Organizer: MAILTO:xxx@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18a.ics b/test/lisp/calendar/diary-icalendar-resources/import-real=
-world-2003-06-18a.ics
new file mode 100644
index 00000000000..6bb5b05af17
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18a.ics
@@ -0,0 +1,36 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20030618T195512Z
+DTSTART;TZID=3D"Mountain Time (US & Canada)":20030623T110000
+SUMMARY:Dress Rehearsal for XXXX-XXXX
+UID:040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000000
+ 0100000007C3A6D65EE726E40B7F3D69A23BD567E
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE;CN=3D"=
AAAAA,AAA
+ AA (A-AAAAAAA,ex1)":MAILTO:aaaaa_aaaaa@HIDDEN
+ORGANIZER;CN=3D"ABCD,TECHTRAINING
+ (A-Americas,exgen1)":MAILTO:xxx@HIDDEN
+LOCATION:555 or TN 555-5555 ID 5555 & NochWas (see below)
+DTEND;TZID=3D"Mountain Time (US & Canada)":20030623T120000
+DESCRIPTION:753 Zeichen hier radiert
+SEQUENCE:0
+PRIORITY:5
+CLASS:
+CREATED:20030618T195518Z
+LAST-MODIFIED:20030618T195527Z
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+X-MICROSOFT-CDO-BUSYSTATUS:BUSY
+X-MICROSOFT-CDO-INSTTYPE:0
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
+X-MICROSOFT-CDO-IMPORTANCE:1
+X-MICROSOFT-CDO-OWNERAPPTID:1022519251
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:REMINDER
+TRIGGER;RELATED=3DSTART:-PT00H15M00S
+END:VALARM
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18b.diary-american b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18b.diary-american
new file mode 100644
index 00000000000..2c0774cdd83
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18b.diary-american
@@ -0,0 +1,6 @@
+&6/23/2003 17:00-18:00 Updated: Dress Rehearsal for ABC01-15
+ Desc: Viele Zeichen standen hier fr=C3=BCher
+ Location: 123 or TN 123-1234 ID abcd & SonstWo (see below)
+ Organizer: MAILTO:bbb@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18b.diary-european b/test/lisp/calendar/diary-icalendar-resources/=
import-real-world-2003-06-18b.diary-european
new file mode 100644
index 00000000000..95aac168699
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18b.diary-european
@@ -0,0 +1,6 @@
+&23/6/2003 17:00-18:00 Updated: Dress Rehearsal for ABC01-15
+ Desc: Viele Zeichen standen hier fr=C3=BCher
+ Location: 123 or TN 123-1234 ID abcd & SonstWo (see below)
+ Organizer: MAILTO:bbb@HIDDEN
+ Status: CONFIRMED
+ UID: 040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000=
0000100000007C3A6D65EE726E40B7F3D69A23BD567E
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2003-06-18b.ics b/test/lisp/calendar/diary-icalendar-resources/import-real=
-world-2003-06-18b.ics
new file mode 100644
index 00000000000..1523135adf3
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2003-0=
6-18b.ics
@@ -0,0 +1,55 @@
+BEGIN:VCALENDAR
+METHOD:REQUEST
+PRODID:Microsoft CDO for Microsoft Exchange
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:Mountain Time (US & Canada)
+X-MICROSOFT-CDO-TZID:12
+BEGIN:STANDARD
+DTSTART:16010101T020000
+TZOFFSETFROM:-0600
+TZOFFSETTO:-0700
+RRULE:FREQ=3DYEARLY;WKST=3DMO;INTERVAL=3D1;BYMONTH=3D10;BYDAY=3D-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:16010101T020000
+TZOFFSETFROM:-0700
+TZOFFSETTO:-0600
+RRULE:FREQ=3DYEARLY;WKST=3DMO;INTERVAL=3D1;BYMONTH=3D4;BYDAY=3D1SU
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:20030618T230323Z
+DTSTART;TZID=3D"Mountain Time (US & Canada)":20030623T090000
+SUMMARY:Updated: Dress Rehearsal for ABC01-15
+UID:040000008200E00074C5B7101A82E00800000000608AA7DA9835C301000000000000000
+ 0100000007C3A6D65EE726E40B7F3D69A23BD567E
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;X-REPLYTIME=3D2003=
0618T20
+ 0700Z;RSVP=3DTRUE;CN=3D"AAAAA,AAAAAA
+\(A-AAAAAAA,ex1)":MAILTO:aaaaaa_aaaaa@aaaaa
+ .com
+ORGANIZER;CN=3D"ABCD,TECHTRAINING
+\(A-Americas,exgen1)":MAILTO:bbb@HIDDEN
+LOCATION:123 or TN 123-1234 ID abcd & SonstWo (see below)
+DTEND;TZID=3D"Mountain Time (US & Canada)":20030623T100000
+DESCRIPTION:Viele Zeichen standen hier fr=C3=BCher
+SEQUENCE:0
+PRIORITY:5
+CLASS:
+CREATED:20030618T230326Z
+LAST-MODIFIED:20030618T230335Z
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+X-MICROSOFT-CDO-BUSYSTATUS:BUSY
+X-MICROSOFT-CDO-INSTTYPE:0
+X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
+X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
+X-MICROSOFT-CDO-IMPORTANCE:1
+X-MICROSOFT-CDO-OWNERAPPTID:1022519251
+BEGIN:VALARM
+ACTION:DISPLAY
+DESCRIPTION:REMINDER
+TRIGGER;RELATED=3DSTART:-PT00H15M00S
+END:VALARM
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2004-11-19.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2004-11-19.diary-american
new file mode 100644
index 00000000000..a986f700ba2
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2004-1=
1-19.diary-american
@@ -0,0 +1,19 @@
+&11/23/2004 14:00-14:30 Jjjjj & Wwwww
+ Status: TENTATIVE
+ Class: PRIVATE
+&11/23/2004 14:45-15:45 BB Aaaaaaaa Bbbbb
+ Status: TENTATIVE
+ Class: PRIVATE
+&11/23/2004 11:00-12:00 Hhhhhhhh
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 14 11 12 2004)) 14:00-18:30 MMM Aaaaaaaaa
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-block 11 19 2004 11 19 2004)) Rrrr/Cccccc ii Aaaaaaaa
+ Desc: Vvvvv Rrrr aaa Cccccc
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 7 11 1 2004)) Wwww aa hhhh
+ Status: TENTATIVE
+ Class: PRIVATE
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2004-11-19.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2004-11-19.diary-european
new file mode 100644
index 00000000000..cbfe99eb8e3
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2004-1=
1-19.diary-european
@@ -0,0 +1,19 @@
+&23/11/2004 14:00-14:30 Jjjjj & Wwwww
+ Status: TENTATIVE
+ Class: PRIVATE
+&23/11/2004 14:45-15:45 BB Aaaaaaaa Bbbbb
+ Status: TENTATIVE
+ Class: PRIVATE
+&23/11/2004 11:00-12:00 Hhhhhhhh
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 14 12 11 2004)) 14:00-18:30 MMM Aaaaaaaaa
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-block 19 11 2004 19 11 2004)) Rrrr/Cccccc ii Aaaaaaaa
+ Desc: Vvvvv Rrrr aaa Cccccc
+ Status: TENTATIVE
+ Class: PRIVATE
+&%%(and (diary-cyclic 7 1 11 2004)) Wwww aa hhhh
+ Status: TENTATIVE
+ Class: PRIVATE
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2004-11-19.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2004-11-19.ics
new file mode 100644
index 00000000000..9edb682fcad
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2004-1=
1-19.ics
@@ -0,0 +1,120 @@
+BEGIN:VCALENDAR
+VERSION
+ :2.0
+PRODID
+ :-//Mozilla.org/NONSGML Mozilla Calendar V1.0//EN
+BEGIN:VEVENT
+SUMMARY
+ :Jjjjj & Wwwww
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ :20041123T140000
+DTEND
+ :20041123T143000
+DTSTAMP
+ :20041118T013430Z
+LAST-MODIFIED
+ :20041118T013640Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :BB Aaaaaaaa Bbbbb
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ :20041123T144500
+DTEND
+ :20041123T154500
+DTSTAMP
+ :20041118T013641Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :Hhhhhhhh
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ :20041123T110000
+DTEND
+ :20041123T120000
+DTSTAMP
+ :20041118T013831Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :MMM Aaaaaaaaa
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+X-MOZILLA-RECUR-DEFAULT-INTERVAL
+ :2
+RRULE
+ :FREQ=3DWEEKLY;INTERVAL=3D2;BYDAY=3DFR
+DTSTART
+ :20041112T140000
+DTEND
+ :20041112T183000
+DTSTAMP
+ :20041118T014117Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :Rrrr/Cccccc ii Aaaaaaaa
+DESCRIPTION
+ :Vvvvv Rrrr aaa Cccccc
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+DTSTART
+ ;VALUE=3DDATE
+ :20041119
+DTEND
+ ;VALUE=3DDATE
+ :20041120
+DTSTAMP
+ :20041118T013107Z
+LAST-MODIFIED
+ :20041118T014203Z
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY
+ :Wwww aa hhhh
+STATUS
+ :TENTATIVE
+CLASS
+ :PRIVATE
+X-MOZILLA-ALARM-DEFAULT-LENGTH
+ :0
+RRULE
+ :FREQ=3DWEEKLY;INTERVAL=3D1;BYDAY=3DMO
+DTSTART
+ ;VALUE=3DDATE
+ :20041101
+DTEND
+ ;VALUE=3DDATE
+ :20041102
+DTSTAMP
+ :20041118T014045Z
+LAST-MODIFIED
+ :20041118T023846Z
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-02-07.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-02-07.diary-american
new file mode 100644
index 00000000000..ce7d835d96b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
2-07.diary-american
@@ -0,0 +1,5 @@
+&%%(and (diary-block 2 6 2005 2 6 2005)) Waitangi Day
+ Desc: abcdef
+ Status: CONFIRMED
+ Class: PRIVATE
+ UID: b60d398e-1dd1-11b2-a159-cf8cb05139f4
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-02-07.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-02-07.diary-european
new file mode 100644
index 00000000000..3a52b0ab271
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
2-07.diary-european
@@ -0,0 +1,5 @@
+&%%(and (diary-block 6 2 2005 6 2 2005)) Waitangi Day
+ Desc: abcdef
+ Status: CONFIRMED
+ Class: PRIVATE
+ UID: b60d398e-1dd1-11b2-a159-cf8cb05139f4
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-02-07.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2005-02-07.ics
new file mode 100644
index 00000000000..9eec71fe751
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
2-07.ics
@@ -0,0 +1,26 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+UID
+ :b60d398e-1dd1-11b2-a159-cf8cb05139f4
+SUMMARY
+ :Waitangi Day
+DESCRIPTION
+ :abcdef
+CATEGORIES
+ :Public Holiday
+STATUS
+ :CONFIRMED
+CLASS
+ :PRIVATE
+DTSTART
+ ;VALUE=3DDATE
+ :20050206
+DTEND
+ ;VALUE=3DDATE
+ :20050207
+DTSTAMP
+ :20050128T011209Z
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-03-01.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-03-01.diary-american
new file mode 100644
index 00000000000..23c93d45d9a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
3-01.diary-american
@@ -0,0 +1,2 @@
+&%%(and (diary-block 2 17 2005 2 23 2005)) Hhhhhh Aaaaa ii Aaaaaaaa
+ UID: 6AFA7558-6994-11D9-8A3A-000A95A0E830-RID
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-03-01.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-real-world-2005-03-01.diary-european
new file mode 100644
index 00000000000..106e9f3cdd0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
3-01.diary-european
@@ -0,0 +1,2 @@
+&%%(and (diary-block 17 2 2005 23 2 2005)) Hhhhhh Aaaaa ii Aaaaaaaa
+ UID: 6AFA7558-6994-11D9-8A3A-000A95A0E830-RID
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-2005-03-01.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-=
world-2005-03-01.ics
new file mode 100644
index 00000000000..ed9faa9b0bd
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-2005-0=
3-01.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTART;VALUE=3DDATE:20050217
+SUMMARY:Hhhhhh Aaaaa ii Aaaaaaaa
+UID:6AFA7558-6994-11D9-8A3A-000A95A0E830-RID
+DTSTAMP:20050118T210335Z
+DURATION:P7D
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-no-dst.diary-american b/test/lisp/calendar/diary-icalendar-resources/impor=
t-real-world-no-dst.diary-american
new file mode 100644
index 00000000000..290edb88760
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-no-dst=
.diary-american
@@ -0,0 +1,4 @@
+&11/16/2014 04:30-05:30 NoDST
+ Desc: Test event from timezone without DST
+ Location: Everywhere
+ UID: 20141116T171439Z-678877132@HIDDEN
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-no-dst.diary-european b/test/lisp/calendar/diary-icalendar-resources/impor=
t-real-world-no-dst.diary-european
new file mode 100644
index 00000000000..c56b7a6547a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-no-dst=
.diary-european
@@ -0,0 +1,4 @@
+&16/11/2014 04:30-05:30 NoDST
+ Desc: Test event from timezone without DST
+ Location: Everywhere
+ UID: 20141116T171439Z-678877132@HIDDEN
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-real-world=
-no-dst.ics b/test/lisp/calendar/diary-icalendar-resources/import-real-worl=
d-no-dst.ics
new file mode 100644
index 00000000000..5f147af4f37
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-real-world-no-dst=
.ics
@@ -0,0 +1,26 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//www.marudot.com//iCal Event Maker
+X-WR-CALNAME:Test
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Asia/Tehran
+TZURL:http://tzurl.org/zoneinfo-outlook/Asia/Tehran
+X-LIC-LOCATION:Asia/Tehran
+BEGIN:STANDARD
+TZOFFSETFROM:+0330
+TZOFFSETTO:+0330
+TZNAME:IRST
+DTSTART:19700101T000000
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:20141116T171439Z
+UID:20141116T171439Z-678877132@HIDDEN
+DTSTART;TZID=3D"Asia/Tehran":20141116T070000
+DTEND;TZID=3D"Asia/Tehran":20141116T080000
+SUMMARY:NoDST
+DESCRIPTION:Test event from timezone without DST
+LOCATION:Everywhere
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-anni=
versary.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rru=
le-anniversary.diary-all
new file mode 100644
index 00000000000..ee270024dfd
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-anniversary=
.diary-all
@@ -0,0 +1,2 @@
+&%%(diary-rrule :rule '((FREQ YEARLY)) :start '(8 15 2004)) Maria Himmelfa=
hrt
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-anni=
versary.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-ann=
iversary.ics
new file mode 100644
index 00000000000..de402a29d26
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-anniversary=
.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+DTSTART;VALUE=3DDATE:20040815=0D
+SUMMARY:Maria Himmelfahrt=0D
+RRULE:FREQ=3DYEARLY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.diary-american b/test/lisp/calendar/diary-icalendar-resources/i=
mport-rrule-count-bi-weekly.diary-american
new file mode 100644
index 00000000000..84b6d109953
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.diary-american
@@ -0,0 +1 @@
+&%%(and (diary-cyclic 14 9 19 2003) (diary-block 9 19 2003 10 31 2003)) 09=
:00-11:30 rrule count bi-weekly 3 times
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.diary-european b/test/lisp/calendar/diary-icalendar-resources/i=
mport-rrule-count-bi-weekly.diary-european
new file mode 100644
index 00000000000..0bebdf8872f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.diary-european
@@ -0,0 +1 @@
+&%%(and (diary-cyclic 14 19 9 2003) (diary-block 19 9 2003 31 10 2003)) 09=
:00-11:30 rrule count bi-weekly 3 times
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import=
-rrule-count-bi-weekly.diary-iso
new file mode 100644
index 00000000000..11429081abe
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.diary-iso
@@ -0,0 +1 @@
+&%%(and (diary-cyclic 14 2003 9 19) (diary-block 2003 9 19 2003 10 31)) 09=
:00-11:30 rrule count bi-weekly 3 times
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-bi-weekly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule=
-count-bi-weekly.ics
new file mode 100644
index 00000000000..888b85bb331
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-bi-we=
ekly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count bi-weekly 3 times
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DWEEKLY;COUNT=3D3;INTERVAL=3D2
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-long.diary-all b/test/lisp/calendar/diary-icalendar-resources/impor=
t-rrule-count-daily-long.diary-all
new file mode 100644
index 00000000000..ace6532fbeb
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-long.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (COUNT 14) (INTERVAL 1)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule count daily long
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-long.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrul=
e-count-daily-long.ics
new file mode 100644
index 00000000000..73df19a8196
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-long.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count daily long
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DDAILY;COUNT=3D14;INTERVAL=3D1
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-short.diary-all b/test/lisp/calendar/diary-icalendar-resources/impo=
rt-rrule-count-daily-short.diary-all
new file mode 100644
index 00000000000..79e8ca165d9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-short.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (COUNT 1) (INTERVAL 1)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule count daily short
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-daily-short.ics b/test/lisp/calendar/diary-icalendar-resources/import-rru=
le-count-daily-short.ics
new file mode 100644
index 00000000000..92ffe8be654
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-daily=
-short.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count daily short
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DDAILY;COUNT=3D1;INTERVAL=3D1
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-month.diary-all b/test/lisp/calendar/diary-icalendar-resourc=
es/import-rrule-count-every-second-month.diary-all
new file mode 100644
index 00000000000..757b93054c9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-month.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (INTERVAL 2) (COUNT 5)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule count every second month
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-month.ics b/test/lisp/calendar/diary-icalendar-resources/imp=
ort-rrule-count-every-second-month.ics
new file mode 100644
index 00000000000..3b27b665498
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-month.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count every second month
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-year.diary-all b/test/lisp/calendar/diary-icalendar-resource=
s/import-rrule-count-every-second-year.diary-all
new file mode 100644
index 00000000000..2bf683137a1
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-year.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ YEARLY) (INTERVAL 2) (COUNT 5)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule count every second year
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-every-second-year.ics b/test/lisp/calendar/diary-icalendar-resources/impo=
rt-rrule-count-every-second-year.ics
new file mode 100644
index 00000000000..ce21c34d09a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-every=
-second-year.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count every second year
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D2;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-monthly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-r=
rule-count-monthly.diary-all
new file mode 100644
index 00000000000..5293771ba1b
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-month=
ly.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (INTERVAL 1) (COUNT 5)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule count monthly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-monthly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-c=
ount-monthly.ics
new file mode 100644
index 00000000000..3391ca24252
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-month=
ly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count monthly
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DMONTHLY;INTERVAL=3D1;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-yearly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rr=
ule-count-yearly.diary-all
new file mode 100644
index 00000000000..e297cca55c6
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-yearl=
y.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ YEARLY) (INTERVAL 1) (COUNT 5)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule count yearly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-coun=
t-yearly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-co=
unt-yearly.ics
new file mode 100644
index 00000000000..d8569933e0c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-count-yearl=
y.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule count yearly
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;COUNT=3D5
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-two-day.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-r=
rule-daily-two-day.diary-all
new file mode 100644
index 00000000000..b5fc89ecc6c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-two-d=
ay.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (INTERVAL 2)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule daily
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-two-day.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-d=
aily-two-day.ics
new file mode 100644
index 00000000000..8c9cb3b2845
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-two-d=
ay.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule daily
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DDAILY;INTERVAL=3D2
+END:VEVENT
+END:VCALENDAR
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-with-exceptions.diary-all b/test/lisp/calendar/diary-icalendar-resources/=
import-rrule-daily-with-exceptions.diary-all
new file mode 100644
index 00000000000..015685a0708
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-with-=
exceptions.diary-all
@@ -0,0 +1,5 @@
+&%%(diary-rrule :rule '((FREQ DAILY) (INTERVAL 2)) :exclude
+             '((9 21 2003) (9 25 2003)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule daily with exceptions
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y-with-exceptions.ics b/test/lisp/calendar/diary-icalendar-resources/import=
-rrule-daily-with-exceptions.ics
new file mode 100644
index 00000000000..a07c99ce6a7
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily-with-=
exceptions.ics
@@ -0,0 +1,12 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule daily with exceptions=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DDAILY;INTERVAL=3D2=0D
+EXDATE;VALUE=3DDATE:20030921,20030925=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rrule-dai=
ly.diary-all
new file mode 100644
index 00000000000..6586c77210f
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily.diary=
-all
@@ -0,0 +1,3 @@
+&%%(diary-rrule :rule '((FREQ DAILY)) :start '(0 0 9 19 9 2003 5 -1 nil)
+             :duration '(0 30 2 0 nil nil nil -1 nil)) rrule daily
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-dail=
y.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily.ics
new file mode 100644
index 00000000000..93ed08065bc
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-daily.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule daily=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DDAILY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-no-end.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-=
rrule-monthly-no-end.diary-all
new file mode 100644
index 00000000000..3f66080a505
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-no-=
end.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule monthly no end
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-no-end.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-=
monthly-no-end.ics
new file mode 100644
index 00000000000..9448ca058f8
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-no-=
end.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule monthly no end=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DMONTHLY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-with-end.diary-all b/test/lisp/calendar/diary-icalendar-resources/impor=
t-rrule-monthly-with-end.diary-all
new file mode 100644
index 00000000000..f59fcc620c9
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-wit=
h-end.diary-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ MONTHLY) (UNTIL (8 19 2005))) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule monthly with end
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-mont=
hly-with-end.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrul=
e-monthly-with-end.ics
new file mode 100644
index 00000000000..8f611f4c6a4
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-monthly-wit=
h-end.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule monthly with end=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DMONTHLY;UNTIL=3D20050819=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-week=
ly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rrule-we=
ekly.diary-all
new file mode 100644
index 00000000000..e2a847cf02a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-weekly.diar=
y-all
@@ -0,0 +1,3 @@
+&%%(diary-rrule :rule '((FREQ WEEKLY)) :start '(0 0 9 19 9 2003 5 -1 nil)
+             :duration '(0 30 2 0 nil nil nil -1 nil)) rrule weekly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-week=
ly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-weekly.i=
cs
new file mode 100644
index 00000000000..44b6f44e2e0
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-weekly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:rrule weekly=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+RRULE:FREQ=3DWEEKLY=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
+=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-year=
ly.diary-all b/test/lisp/calendar/diary-icalendar-resources/import-rrule-ye=
arly.diary-all
new file mode 100644
index 00000000000..50015259f61
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-yearly.diar=
y-all
@@ -0,0 +1,4 @@
+&%%(diary-rrule :rule '((FREQ YEARLY) (INTERVAL 2)) :start
+             '(0 0 9 19 9 2003 5 -1 nil) :duration
+             '(0 30 2 0 nil nil nil -1 nil)) rrule yearly
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-rrule-year=
ly.ics b/test/lisp/calendar/diary-icalendar-resources/import-rrule-yearly.i=
cs
new file mode 100644
index 00000000000..21cca097f7e
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-rrule-yearly.ics
@@ -0,0 +1,11 @@
+BEGIN:VCALENDAR
+PRODID:-//Emacs//NONSGML icalendar.el//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:rrule yearly
+DTSTART;VALUE=3DDATE-TIME:20030919T090000
+DTEND;VALUE=3DDATE-TIME:20030919T113000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D2
+END:VEVENT
+END:VCALENDAR
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-time-forma=
t-12hr-blank.diary-iso b/test/lisp/calendar/diary-icalendar-resources/impor=
t-time-format-12hr-blank.diary-iso
new file mode 100644
index 00000000000..cf9a1bee9ce
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-time-format-12hr-=
blank.diary-iso
@@ -0,0 +1,2 @@
+&2003/9/19  9.00h-11.30h 12hr blank-padded
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-time-forma=
t-12hr-blank.ics b/test/lisp/calendar/diary-icalendar-resources/import-time=
-format-12hr-blank.ics
new file mode 100644
index 00000000000..7f436df5391
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-time-format-12hr-=
blank.ics
@@ -0,0 +1,9 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+SUMMARY:12hr blank-padded=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-attac=
hment.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-with-=
attachment.diary-iso
new file mode 100644
index 00000000000..9584bcb5453
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-attachment.d=
iary-iso
@@ -0,0 +1,4 @@
+&2003/9/19 09:00 Has an attachment
+ Attachment: R3Jl.plain
+ UID: f9fee9a0-1231-4984-9078-f1357db352db
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-attac=
hment.ics b/test/lisp/calendar/diary-icalendar-resources/import-with-attach=
ment.ics
new file mode 100644
index 00000000000..338e291d407
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-attachment.i=
cs
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:f9fee9a0-1231-4984-9078-f1357db352db=0D
+SUMMARY:Has an attachment=0D
+ATTACH;VALUE=3DBINARY;FMTTYPE=3Dtext/plain;ENCODING=3DBASE64:R3JlZXRpbmdzI=
SBJIGFtIGEgYmFzZTY0LWVuY29kZWQgZmlsZQ=3D=3D=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-timez=
one.diary-iso b/test/lisp/calendar/diary-icalendar-resources/import-with-ti=
mezone.diary-iso
new file mode 100644
index 00000000000..1739bb8180c
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-timezone.dia=
ry-iso
@@ -0,0 +1,4 @@
+&2012/1/15 15:00-15:30 standardtime
+
+&2012/12/15 11:00-11:30 daylightsavingtime
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-timez=
one.ics b/test/lisp/calendar/diary-icalendar-resources/import-with-timezone=
.ics
new file mode 100644
index 00000000000..0db619e4f0a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-timezone.ics
@@ -0,0 +1,27 @@
+BEGIN:VCALENDAR=0D
+BEGIN:VTIMEZONE=0D
+TZID:fictional=0D
+BEGIN:STANDARD=0D
+DTSTART:20100101T000000=0D
+TZOFFSETFROM:+0200=0D
+TZOFFSETTO:-0200=0D
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D1SU;BYMONTH=3D01=0D
+END:STANDARD=0D
+BEGIN:DAYLIGHT=0D
+DTSTART:20101201T000000=0D
+TZOFFSETFROM:-0200=0D
+TZOFFSETTO:+0200=0D
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D1SU;BYMONTH=3D11=0D
+END:DAYLIGHT=0D
+END:VTIMEZONE=0D
+BEGIN:VEVENT=0D
+SUMMARY:standardtime=0D
+DTSTART;TZID=3Dfictional:20120115T120000=0D
+DTEND;TZID=3Dfictional:20120115T123000=0D
+END:VEVENT=0D
+BEGIN:VEVENT=0D
+SUMMARY:daylightsavingtime=0D
+DTSTART;TZID=3Dfictional:20121215T120000=0D
+DTEND;TZID=3Dfictional:20121215T123000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.d=
iary-american b/test/lisp/calendar/diary-icalendar-resources/import-with-ui=
d.diary-american
new file mode 100644
index 00000000000..99ca57d83a1
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.diary-am=
erican
@@ -0,0 +1,3 @@
+&9/19/2003 09:00-11:30 non-recurring
+ UID: 1234567890uid
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.d=
iary-european b/test/lisp/calendar/diary-icalendar-resources/import-with-ui=
d.diary-european
new file mode 100644
index 00000000000..cfb507eb1ea
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.diary-eu=
ropean
@@ -0,0 +1,3 @@
+&19/9/2003 09:00-11:30 non-recurring
+ UID: 1234567890uid
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.d=
iary-iso b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.dia=
ry-iso
new file mode 100644
index 00000000000..0e782405520
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.diary-iso
@@ -0,0 +1,3 @@
+&2003/9/19 09:00-11:30 non-recurring
+ UID: 1234567890uid
+
diff --git a/test/lisp/calendar/diary-icalendar-resources/import-with-uid.i=
cs b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.ics
new file mode 100644
index 00000000000..53a34325c83
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-resources/import-with-uid.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR=0D
+PRODID:-//Emacs//NONSGML icalendar.el//EN=0D
+VERSION:2.0=0D
+BEGIN:VEVENT=0D
+UID:1234567890uid=0D
+SUMMARY:non-recurring=0D
+DTSTART;VALUE=3DDATE-TIME:20030919T090000=0D
+DTEND;VALUE=3DDATE-TIME:20030919T113000=0D
+END:VEVENT=0D
+END:VCALENDAR=0D
diff --git a/test/lisp/calendar/diary-icalendar-tests.el b/test/lisp/calend=
ar/diary-icalendar-tests.el
new file mode 100644
index 00000000000..a1f2c2ea83a
--- /dev/null
+++ b/test/lisp/calendar/diary-icalendar-tests.el
@@ -0,0 +1,1206 @@
+;;; diary-icalendar-tests.el --- Tests for diary-icalendar -*- lexical-bin=
ding: t; -*-
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(eval-when-compile (require 'cl-lib))
+(require 'diary-icalendar)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'icalendar)
+(require 'ert)
+(require 'ert-x)
+(require 'seq)
+
+
+;; Tests for diary import functions
+(defun dit:file-contents (filename)
+  "Return contents of file in test data directory named FILENAME."
+  (with-temp-buffer
+    (let ((coding-system-for-read 'raw-text)
+          (inhibit-eol-conversion t))
+      (insert-file-contents-literally
+       (ert-resource-file filename))
+      (buffer-string))))
+
+(defun dit:import-file (filename)
+  "Test diary import of FILENAME.
+
+FILENAME names a .ics file in diary-icalendar-resources directory.
+The calendar in FILENAME is parsed and imported in ISO, European, and
+American date styles. The output of each import is compared against the
+contents of the diary files with the same base name as FILENAME and
+extensions \".diary-iso\", \".diary-european\", and \".diary-american\"."
+  (let* ((basename (file-name-base filename))
+         (ics-file (ert-resource-file filename))
+         (import-buffer (icalendar-unfolded-buffer-from-file ics-file))
+         (all-file (ert-resource-file (concat basename ".diary-all")))
+         (iso-file (ert-resource-file (concat basename ".diary-iso")))
+         (european-file (ert-resource-file (concat basename ".diary-europe=
an")))
+         (american-file (ert-resource-file (concat basename ".diary-americ=
an"))))
+    (with-current-buffer import-buffer
+      (when (file-exists-p all-file)
+        (calendar-set-date-style 'american) ; because it's the default
+        (dit:-do-test-import all-file))
+      (when (file-exists-p iso-file)
+        (calendar-set-date-style 'iso)
+        (dit:-do-test-import iso-file))
+      (when (file-exists-p european-file)
+        (calendar-set-date-style 'european)
+        (dit:-do-test-import european-file))
+      (when (file-exists-p american-file)
+        (calendar-set-date-style 'american)
+        (dit:-do-test-import american-file))
+      (set-buffer-modified-p nil)) ; so we can kill it without being asked
+    (kill-buffer import-buffer)))
+
+(defun dit:-do-test-import (diary-filename)
+  "Perform import of current iCalendar buffer and compare the result with
+the contents of DIARY-FILENAME."
+  (ert-with-temp-file temp-file
+    :suffix "icalendar-test-diary"
+    (di:import-buffer temp-file t t)
+    (save-excursion
+      (find-file temp-file)
+      (let ((result (buffer-substring-no-properties (point-min) (point-max=
)))
+            (expected (dit:file-contents diary-filename)))
+        (should (equal result expected))
+        ;; This is useful for debugging differences when tests are failing:
+        ;; (let ((result-buf (current-buffer))
+        ;;       (diary-buf (find-file diary-filename)))
+        ;;   (ediff-buffers result-buf ; actual output
+        ;;                  diary-buf)
+        ;;   (switch-to-buffer-other-frame "*Ediff Control Panel*"))
+        ;; (error "Unexpected result; see ediff"))
+          ))
+    (kill-buffer (find-buffer-visiting temp-file))))
+
+(ert-deftest dit:import-non-recurring ()
+  "Import tests for standard, non-recurring events."
+  (dit:import-file "import-non-recurring-1.ics")
+  (dit:import-file "import-non-recurring-all-day.ics")
+  (dit:import-file "import-non-recurring-long-summary.ics")
+  (dit:import-file "import-non-recurring-block.ics")
+  (dit:import-file "import-non-recurring-folded-summary.ics")
+  (dit:import-file "import-non-recurring-another-example.ics"))
+
+(ert-deftest dit:import-w/legacy-vars ()
+  "Import tests using legacy import variables"
+  (let ((icalendar-import-format "%s%c%d%l%o%t%u%U")
+        (icalendar-import-format-summary "%s")
+        (icalendar-import-format-class "\n CLASS=3D%s")
+        (icalendar-import-format-description "\n DESCRIPTION=3D%s")
+        (icalendar-import-format-location "\n LOCATION=3D%s")
+        (icalendar-import-format-organizer "\n ORGANIZER=3D%s")
+        (icalendar-import-format-status "\n STATUS=3D%s")
+        (icalendar-import-format-url "\n URL=3D%s")
+        (icalendar-import-format-uid "\n UID=3D%s"))
+    (dit:import-file "import-legacy-vars.ics")))
+
+(defun dit:legacy-import-function (vevent)
+  "Example function value for `icalendar-import-format'"
+  (let ((props (nth 2 (car vevent))))
+    (mapconcat
+     (lambda (prop)
+       (format " %s: %s\n"
+               (symbol-name (nth 0 prop))
+               (nth 2 prop)))
+     props)))
+
+(ert-deftest dit:import-w/legacy-function ()
+  "Import tests using legacy import variables"
+  (let ((icalendar-import-format 'dit:legacy-import-function))
+    (dit:import-file "import-legacy-function.ics")))
+
+(ert-deftest dit:import-w/time-format ()
+  "Import tests for customized `diary-icalendar-time-format'"
+  (let ((diary-icalendar-time-format "%l.%Mh"))
+    (dit:import-file "import-time-format-12hr-blank.ics")))
+
+(ert-deftest dit:import-rrule ()
+  "Import tests for recurring events."
+  (dit:import-file "import-rrule-daily.ics")
+  (dit:import-file "import-rrule-daily-two-day.ics")
+  (dit:import-file "import-rrule-daily-with-exceptions.ics")
+  (dit:import-file "import-rrule-weekly.ics")
+  (dit:import-file "import-rrule-monthly-no-end.ics")
+  (dit:import-file "import-rrule-monthly-with-end.ics")
+  (dit:import-file "import-rrule-anniversary.ics")
+  (dit:import-file "import-rrule-yearly.ics")
+  (dit:import-file "import-rrule-count-daily-short.ics")
+  (dit:import-file "import-rrule-count-daily-long.ics")
+  (dit:import-file "import-rrule-count-monthly.ics")
+  (dit:import-file "import-rrule-count-every-second-month.ics")
+  (dit:import-file "import-rrule-count-yearly.ics")
+  (dit:import-file "import-rrule-count-every-second-year.ics"))
+
+(ert-deftest dit:import-duration ()
+  (dit:import-file "import-duration.ics")
+  ;; duration-2: this is actually an rrule test
+  (dit:import-file "import-duration-2.ics"))
+
+(ert-deftest dit:import-multiple-vcalendars ()
+  (dit:import-file "import-multiple-vcalendars.ics"))
+
+(ert-deftest dit:import-with-uid ()
+  "Perform import test with uid."
+  (dit:import-file "import-with-uid.ics"))
+
+(ert-deftest dit:import-with-attachment ()
+  "Test importing an attached file to `icalendar-attachment-directory'"
+  (ert-with-temp-directory temp-dir
+    (let ((di:attachment-directory temp-dir)
+          (uid-dir (file-name-concat temp-dir
+                                     ;; Event's UID:
+                                     "f9fee9a0-1231-4984-9078-f1357db352db=
")))
+      (dit:import-file "import-with-attachment.ics")
+      (should (file-directory-p uid-dir))
+      (let ((files (directory-files uid-dir t
+                                    ;; First 4 chars of base64-string:
+                                    "R3Jl")))
+        (should (length=3D files 1))
+        (with-temp-buffer
+          (insert-file-contents (car files))
+          (should (equal "Greetings! I am a base64-encoded file"
+                         (buffer-string))))))))
+
+(ert-deftest dit:import-with-timezone ()
+  ;; "standardtime" begins first sunday in january and is 4 hours behind C=
ET
+  ;; "daylightsavingtime" begins first sunday in november and is 1 hour be=
fore CET
+  (dit:import-file "import-with-timezone.ics"))
+
+(ert-deftest dit:import-bug-6766 ()
+  ;;bug#6766 -- multiple byday values in a weekly rrule
+  (dit:import-file "import-bug-6766.ics"))
+
+(ert-deftest dit:import-bug-24199 ()
+  ;;bug#24199 -- monthly rule with byday-clause
+  (dit:import-file "import-bug-24199.ics"))
+
+(ert-deftest dit:import-bug-33277 ()
+  ;;bug#33277 -- start time equals end time
+  (dit:import-file "import-bug-33277.ics"))
+
+(ert-deftest dit:import-bug-11473 ()
+  ;; bug#11473 -- illegal tzid
+  (dit:import-file "import-bug-11473.ics"))
+
+
+;; Tests for diary export functions
+(cl-defmacro dit:parse-test (entry &key parser type number
+                                   bindings tests
+                                   source)
+  "Create a test which parses data from ENTRY.
+
+PARSER should be a zero-argument function which parses data of TYPE in a
+buffer containing ENTRY.  The defined test passes if PARSER returns a
+list of NUMBER objects which satisfy TYPE. If NUMBER is nil, the return
+value of parser must be a single value satisfying TYPE.
+
+BINDINGS, if given, will be evaluated and made available in the lexical
+environment where PARSER is called; this can be used to temporarily set
+variables that affect parsing.
+
+TESTS, if given, is an additional test form that will be evaluated after
+the main tests. The variable `parsed' will be bound to the return value
+of PARSER when TESTS are evaluated.
+
+SOURCE, if given, should be a symbol; it is used to name the test."
+  (let ((parser-form `(funcall (function ,parser))))
+    `(ert-deftest
+         ,(intern (concat "diary-icalendar-test-"
+                          (string-replace "diary-icalendar-" ""
+                                          (symbol-name parser))
+                          (if source (concat "/" (symbol-name source)) "")=
))
+         ()
+       ,(format "Does `%s' correctly parse `%s' in diary entries?" parser =
type)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (unparsed ,entry))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let* (,@bindings
+               (parsed ,parser-form))
+           (when ,number
+               (should (length=3D parsed ,number))
+               (should (seq-every-p (lambda (val) (cl-typep val ,type))
+                                    parsed)))
+           (unless ,number
+             (should (cl-typep parsed ,type)))
+           ,tests)))))
+
+(dit:parse-test
+ "2025-04-01 A basic entry
+    Other data"
+:parser di:parse-entry-type
+:type 'symbol
+:source vevent
+:tests (should (eq parsed 'ical:vevent)))
+
+(dit:parse-test
+ "&2025-04-01 A nonmarking journal entry
+     Other data"
+:parser di:parse-entry-type
+:bindings ((di:export-nonmarking-as-vjournal t))
+:type 'symbol
+:source vjournal
+:tests (should (eq parsed 'ical:vjournal)))
+
+(dit:parse-test
+ "2025-04-01 Due: some task
+     Other data"
+:parser di:parse-entry-type
+:bindings ((di:todo-regexp "Due: "))
+:type 'symbol
+:source vtodo
+:tests (should (eq parsed 'ical:vtodo)))
+
+(defun dit:parse-vevent-transparency ()
+  "Call `di:parse-transparency' with \\=3D'icalendar-vevent"
+  (di:parse-transparency 'ical:vevent))
+
+(dit:parse-test
+ "&%%(diary-anniversary 7 28 1985) A transparent anniversary"
+ :parser dit:parse-vevent-transparency
+ :type 'ical:transp
+ :number 1
+ :source nonmarking
+ :tests
+ (ical:with-property (car parsed) nil
+   (should (equal value "TRANSPARENT"))))
+
+(dit:parse-test
+ "2025-04-01 Team Meeting
+   Some data
+   Organizer: Mr. Foo <foo@HIDDEN>
+   Attendees: Baz Bar <baz@HIDDEN>
+              Alice Unternehmer <alice@HIDDEN> (some other data)
+   Other data"
+:parser di:parse-attendees-and-organizer
+:number 3
+:type '(or ical:attendee ical:organizer)
+:tests
+(dolist (p parsed)
+  (ical:with-property p
+    ((ical:cnparam :value name))
+    (cond ((equal value "mailto:foo@HIDDEN")
+           (should (equal name "Mr. Foo"))
+           (should (ical:organizer-property-p p)))
+          ((equal value "mailto:baz@HIDDEN")
+           (should (equal name "Baz Bar"))
+           (should (ical:attendee-property-p p)))
+          ((equal value "mailto:alice@HIDDEN")
+           (should (equal name "Alice Unternehmer"))
+           (should (ical:attendee-property-p p)))
+          (t (error "Incorrectly parsed attendee address: %s" value))))))
+
+(dit:parse-test
+ "2025-04-01 An event with a UID
+    Some data
+    UID: emacs174560213714413195191
+    Other data"
+:parser di:parse-uid
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:uid
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "emacs174560213714413195191" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with a different style of UID
+    Some data
+    UID: 197846d7-51be-4d8e-837f-7e132286e7cf
+    Other data"
+:parser di:parse-uid
+:source with-org-id-uuid
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:uid
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "197846d7-51be-4d8e-837f-7e132286e7cf" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with a status
+    Some data
+    Status: confirmed
+    Other data"
+:parser di:parse-status
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:status
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "CONFIRMED" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with an access classification
+    Some data
+    Class: private
+    Other data"
+:parser di:parse-class
+:source private
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:class
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "PRIVATE" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with an access classification
+    Some data
+    Access: public
+    Other data"
+:parser di:parse-class
+:source public
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:class
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "PUBLIC" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with a location
+    Some data
+    Location: Sesamstra=C3=9Fe 13
+    Other data"
+:parser di:parse-location
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:location
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "Sesamstra=C3=9Fe 13" value))))
+
+(dit:parse-test
+ "2025-04-01 An event with an URL
+    Some data
+    URL: http://example.com/foo/bar?q=3Dbaz
+    Other data"
+:parser di:parse-url
+:bindings ((diary-date-forms diary-iso-date-forms))
+:type 'ical:url
+:tests
+(ical:with-property (car parsed) nil
+  (should (equal "http://example.com/foo/bar?q=3Dbaz" value))))
+
+
+;; N.B. There is no date at the start of the entry in the following two
+;; tests because di:parse-summary-and-description assumes that the date
+;; parsing functions have already moved the start of the restriction
+;; beyond it
+(dit:parse-test
+ "Event summary
+    Some data
+    Other data"
+:parser di:parse-summary-and-description
+:number 2
+:type '(or ical:summary ical:description)
+:bindings ((diary-date-forms diary-iso-date-forms))
+:tests
+(ical:with-property (car parsed) nil (should (equal "Event summary" value)=
)))
+
+(dit:parse-test
+ "Some data
+    Summary: Event summary
+    Other data"
+:parser di:parse-summary-and-description
+:number 2
+:bindings ((di:summary-regexp "^[[:space:]]+Summary: \\(.*\\)$"))
+:type '(or ical:summary ical:description)
+:bindings ((diary-date-forms diary-iso-date-forms))
+:source with-summary-regexp
+:tests
+(ical:with-property (car parsed) nil (should (equal "Event summary" value)=
)))
+
+(dit:parse-test
+ "2025/04/01 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-iso-date-forms))
+ :source iso-date
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "2025-04-01 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-iso-date-forms))
+ :source iso-date-dashes
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "1/4/2025 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source european-date
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "4/1/2025 Some entry"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source american-date
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "4/1 April Fool's"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source generic-year-american
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "1/5 Tag der Arbeit"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source generic-year-european
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 5 (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "1/*/2025 Rent due"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source generic-month
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 1 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "*/2/2025 Every day in February: go running"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-european-date-forms))
+ :source generic-day
+ :tests
+ (progn
+   (should (=3D 2025 (calendar-extract-year parsed)))
+   (should (=3D 2 (calendar-extract-month parsed)))
+   (should (eq t (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "Friday
+    Lab meeting
+    Backup data"
+ :parser di:parse-weekday-name
+ :type 'integer
+ :tests
+ (should (=3D 5 parsed)))
+
+;;; Examples from the Emacs manual:
+(dit:parse-test
+ "12/22/2015  Twentieth wedding anniversary!"
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/1
+ :tests
+ (progn
+   (should (=3D 2015 (calendar-extract-year parsed)))
+   (should (=3D 12 (calendar-extract-month parsed)))
+   (should (=3D 22 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date via unspecified year:
+ "10/22       Ruth's birthday."
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/2
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 10 (calendar-extract-month parsed)))
+   (should (=3D 22 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date via unspecified year:
+ "4/30  Results for April are due"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/3
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 30 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date with asterisks:
+ "* 21, *:    Payday"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/3
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 21 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic date with asterisks:
+ "*/25  Monthly cycle finishes"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/4
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 25 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Weekday name:
+ "Tuesday--weekly meeting with grad students at 10am
+           Supowit, Shen, Bitner, and Kapoor to attend."
+ :parser di:parse-weekday-name
+ :type 'integer
+ :source emacs-manual-sec33.10.1/4
+ :tests
+ (should (=3D 2 parsed)))
+
+(dit:parse-test
+ ;; Weekday name:
+ "Friday  Don't leave without backing up files"
+ :parser di:parse-weekday-name
+ :type 'integer
+ :source emacs-manual-sec33.10.3/5
+ :tests
+ (should (=3D 5 parsed)))
+
+(dit:parse-test
+ ;; Date with two-digit year:
+ "1/13/89     Friday the thirteenth!!"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/5
+ :tests
+ (progn
+   (should (=3D 1989 (calendar-extract-year parsed)))
+   (should (=3D 1 (calendar-extract-month parsed)))
+   (should (=3D 13 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Date with two-digit year:
+ "4/20/12  Switch-over to new tabulation system"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/1
+ :tests
+ (progn
+   (should (=3D 2012 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 20 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Abbreviated weekday name:
+ "thu 4pm     squash game with Lloyd."
+ :parser di:parse-weekday-name
+ :type 'integer
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/6
+ :tests
+ (should (=3D 4 parsed)))
+
+(dit:parse-test
+ ;; Abbreviated month name:
+ "mar 16      Dad's birthday"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/7
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 3 (calendar-extract-month parsed)))
+   (should (=3D 16 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Abbreviated month name with following period:
+ "apr. 25  Start tabulating annual results"
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.3/2
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 25 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Long form date:
+ "April 15, 2016 Income tax due."
+ :parser di:parse-date-form
+ :type 'ical:date
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/8
+ :tests
+ (progn
+   (should (=3D 2016 (calendar-extract-year parsed)))
+   (should (=3D 4 (calendar-extract-month parsed)))
+   (should (=3D 15 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ ;; Generic monthly date:
+ "* 15        time cards due."
+ :parser di:parse-date-form
+ :type 'list
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/9
+ :tests
+ (progn
+   (should (eq t (calendar-extract-year parsed)))
+   (should (eq t (calendar-extract-month parsed)))
+   (should (=3D 15 (calendar-extract-day parsed)))))
+
+(dit:parse-test
+ "%%(diary-anniversary 5 28 1995) A birthday"
+ :parser di:parse-sexp
+ :type 'list
+ :tests (should (eq 'diary-anniversary (car parsed))))
+
+(dit:parse-test
+ "%%(diary-time-block :start (0 0 13 2 4 2025 6 t 7200)
+                      :end (0 0 11 4 4 2025 6 t 7200))
+    A multiday event with different start and end times"
+ :parser di:parse-sexp
+ :type 'list
+ :source multiline-sexp
+ :tests (should (eq 'diary-time-block (car parsed))))
+
+(defun dit:entry-parser ()
+  "Call `di:parse-entry' on the full test buffer"
+  (let ((tz
+         (cond
+          ((eq 'local di:time-zone-export-strategy)
+           (di:current-tz-to-vtimezone))
+          ((listp di:time-zone-export-strategy)
+           (di:current-tz-to-vtimezone di:time-zone-export-strategy)))))
+
+    (di:parse-entry (point-min) (point-max) tz)))
+
+(dit:parse-test
+ ;; Weekly event, abbreviated weekday name:
+ "thu 4pm     squash game with Lloyd."
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source emacs-manual-sec33.10.1/6
+ :tests
+ (ical:with-component (car parsed)
+   ((ical:dtstart :value dtstart)
+    (ical:rrule :value rrule)
+    (ical:summary :value summary))
+   (should (equal summary "squash game with Lloyd."))
+   (should (equal (ical:date-time-to-date dtstart)
+                  (calendar-nth-named-day 1 4 1 di:recurring-start-year)))
+   (should (=3D 16 (decoded-time-hour dtstart)))
+   (should (eq (ical:recur-freq rrule) 'WEEKLY))
+   (should (equal (ical:recur-by* 'BYDAY rrule) (list 4)))))
+
+(dit:parse-test
+ ;; Multiline entry, parsed as one event:
+ "2025-05-03
+    9AM Lab meeting
+      Gunther to present on new assay
+    12:30-1:30PM Lunch with Phil
+    16:00 Experiment A finishes; move to freezer"
+ :parser dit:entry-parser
+ :source multiline-single
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-iso-date-forms)))
+
+(dit:parse-test
+ ;; Multiline entry, parsed linewise as three events:
+ "2025-05-03
+    9AM Lab meeting
+      Gunther to present on new assay
+    12:30-1:30PM Lunch with Phil
+    16:00 Experiment A finishes; move to freezer"
+ :parser dit:entry-parser
+ :source multiline-linewise
+ :type 'ical:vevent
+ :number 3
+ :bindings ((diary-date-forms diary-iso-date-forms)
+            (diary-icalendar-export-linewise t))
+ :tests
+ (progn
+   (dolist (event parsed)
+     (ical:with-component event
+       ((ical:dtstart :value-type start-type :value dtstart)
+        (ical:dtend :value-type end-type :value dtend)
+        (ical:summary :value summary))
+       (should (eq start-type 'ical:date-time))
+       (should (=3D 2025 (decoded-time-year dtstart)))
+       (should (=3D 5 (decoded-time-month dtstart)))
+       (should (=3D 3 (decoded-time-day dtstart)))
+       (when dtend
+         (should (eq end-type 'ical:date-time))
+         (should (=3D 2025 (decoded-time-year dtend)))
+         (should (=3D 5 (decoded-time-month dtend)))
+         (should (=3D 3 (decoded-time-day dtend))))
+       (cond ((equal summary "Lab meeting")
+              (should (=3D 9 (decoded-time-hour dtstart))))
+             ((equal summary "Lunch with Phil")
+              (should (=3D 12 (decoded-time-hour dtstart)))
+              (should (=3D 30 (decoded-time-minute dtstart)))
+              (should (=3D 13 (decoded-time-hour dtend)))
+              (should (=3D 30 (decoded-time-minute dtend))))
+             ((equal summary "Experiment A finishes; move to freezer")
+              (should (=3D 16 (decoded-time-hour dtstart))))
+             (t (error "Unknown event: %s" summary)))))))
+
+(dit:parse-test
+ ;; Multiline entry from the manual, parsed linewise:
+ ;; TODO: I've left the times verbatim in the example
+ ;; and in the tests, even though "2:30", "5:30" and "8:00"
+ ;; would most naturally be understood as PM times.
+ ;; Should probably fix the manual, then revise here.
+ "02/11/2012
+           Bill B. visits Princeton today
+           2pm Cognitive Studies Committee meeting
+           2:30-5:30 Liz at Lawrenceville
+           4:00pm Dentist appt
+           7:30pm Dinner at George's
+           8:00-10:00pm concert"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 6
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (diary-icalendar-export-linewise t))
+ :source emacs-manual-sec33.10.1/10
+ :tests
+ (progn
+   (dolist (event parsed)
+     (ical:with-component event
+       ((ical:dtstart :value-type start-type :value dtstart)
+        (ical:dtend :value-type end-type :value dtend)
+        (ical:summary :value summary))
+       (when (eq start-type 'ical:date)
+         (should (=3D 2012 (calendar-extract-year dtstart)))
+         (should (=3D 2 (calendar-extract-month dtstart)))
+         (should (=3D 11 (calendar-extract-day dtstart))))
+       (when (eq start-type 'ical:date-time)
+         (should (=3D 2012 (decoded-time-year dtstart)))
+         (should (=3D 2 (decoded-time-month dtstart)))
+         (should (=3D 11 (decoded-time-day dtstart))))
+       (when dtend
+         (should (eq end-type 'ical:date-time))
+         (should (=3D 2012 (decoded-time-year dtend)))
+         (should (=3D 2 (decoded-time-month dtend)))
+         (should (=3D 11 (decoded-time-day dtend))))
+       (cond ((equal summary "Bill B. visits Princeton today")
+              (should (eq start-type 'ical:date)))
+             ((equal summary "Cognitive Studies Committee meeting")
+              (should (=3D 14 (decoded-time-hour dtstart)))
+              (should (=3D 0 (decoded-time-minute dtstart))))
+             ((equal summary "Liz at Lawrenceville")
+              (should (=3D 2 (decoded-time-hour dtstart)))
+              (should (=3D 30 (decoded-time-minute dtstart)))
+              (should (=3D 5 (decoded-time-hour dtend)))
+              (should (=3D 30 (decoded-time-minute dtend))))
+             ((equal summary "Dentist appt")
+              (should (=3D 16 (decoded-time-hour dtstart)))
+              (should (=3D 0 (decoded-time-minute dtstart))))
+             ((equal summary "Dinner at George's")
+              (should (=3D 19 (decoded-time-hour dtstart)))
+              (should (=3D 30 (decoded-time-minute dtstart))))
+             ((equal summary "concert")
+              (should (=3D 8 (decoded-time-hour dtstart)))
+              (should (=3D 0 (decoded-time-minute dtstart)))
+              (should (=3D 22 (decoded-time-hour dtend)))
+              (should (=3D 0 (decoded-time-minute dtend))))
+             (t (error "Unknown event: %s" summary)))))))
+
+(dit:parse-test
+ ;; Same as the last, but with ignored data on the same line as the date
+ "02/11/2012 Ignored
+             2pm Cognitive Studies Committee meeting
+             2:30-5:30 Liz at Lawrenceville
+             4:00pm Dentist appt
+             7:30pm Dinner at George's
+             8:00-10:00pm concert"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 5
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (diary-icalendar-export-linewise t))
+ :source emacs-manual-sec33.10.1/10-first-line)
+
+(dit:parse-test
+ "%%(diary-anniversary 5 28 1995) H's birthday"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (calendar-date-style 'american))
+ :source diary-anniversary-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart '(5 28 1995)))
+   (should (eq (ical:recur-freq recur-value) 'YEARLY))
+   (should (equal summary "H's birthday"))))
+
+(dit:parse-test
+ "%%(diary-block 6 24 2012 7 10 2012) Vacation"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-block-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart '(6 24 2012)))
+   (should (equal (ical:recur-freq recur-value) 'DAILY))
+   (should (equal (ical:recur-until recur-value) '(7 10 2012)))
+   (should (equal summary "Vacation"))))
+
+(dit:parse-test
+ "%%(diary-cyclic 50 3 1 2012) Renew medication"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-cyclic-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart '(3 1 2012)))
+   (should (eq (ical:recur-freq recur-value) 'DAILY))
+   (should (eq (ical:recur-interval-size recur-value) 50))
+   (should (equal summary "Renew medication"))))
+
+(dit:parse-test
+ "%%(diary-float 11 4 4) American Thanksgiving"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-float-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart
+                  (calendar-nth-named-day 4 4 11 di:recurring-start-year)))
+   (should (eq (ical:recur-freq recur-value) 'MONTHLY))
+   (should (equal (ical:recur-by* 'BYMONTH recur-value) (list 11)))
+   (should (equal (ical:recur-by* 'BYDAY recur-value) (list '(4 . 4))))
+   (should (equal summary "American Thanksgiving"))))
+
+(dit:parse-test
+ "%%(diary-offset '(diary-float t 3 4) 2) Monthly committee meeting"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-offset-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart
+                  (calendar-nth-named-day 4 5 1 di:recurring-start-year)))
+   (should (eq (ical:recur-freq recur-value) 'MONTHLY))
+   ;; day 3 is Wednesday, so offset of 2 means Friday (=3D5):
+   (should (equal (ical:recur-by* 'BYDAY recur-value) (list '(5 . 4))))
+   (should (equal summary "Monthly committee meeting"))))
+
+(dit:parse-test
+ "%%(diary-rrule :start '(11 11 2024)
+                 :rule '((FREQ WEEKLY))
+                 :exclude '((12 23 2024) (12 30 2024))
+     ) Reading group"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-rrule-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:exdate :values exdates)
+      (ical:summary :value summary))
+   (should (equal dtstart '(11 11 2024)))
+   (should (eq (ical:recur-freq recur-value) 'WEEKLY))
+   (should (equal exdates '((12 23 2024) (12 30 2024))))
+   (should (equal summary "Reading group"))))
+
+(dit:parse-test
+ "%%(diary-date '(10 11 12) 22 t) Rake leaves"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms))
+ :source diary-date-recurrence
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rrule :value recur-value)
+      (ical:summary :value summary))
+   (should (equal dtstart (list 10 22 di:recurring-start-year)))
+   (should (eq (ical:recur-freq recur-value) 'YEARLY))
+   (should (equal (ical:recur-by* 'BYMONTH recur-value) (list 10 11 12)))
+   (should (equal (ical:recur-by* 'BYMONTHDAY recur-value) (list 22)))
+   (should (equal summary "Rake leaves"))))
+
+(dit:parse-test
+ ;; From the manual: "Suppose you get paid on the 21st of the month if
+ ;; it is a weekday, and on the Friday before if the 21st is on a
+ ;; weekend..."
+ "%%(let ((dayname (calendar-day-of-week date))
+          (day (cadr date)))
+          (or (and (=3D day 21) (memq dayname '(1 2 3 4 5)))
+              (and (memq day '(19 20)) (=3D dayname 5)))
+              ) Pay check deposited"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-date-forms diary-american-date-forms)
+            (di:export-sexp-enumeration-days 366))
+ :source emacs-manual-33.13.10.7
+ :tests
+ (ical:with-component (car parsed)
+     ((ical:dtstart :value dtstart)
+      (ical:rdate :values rdates)
+      (ical:summary :value summary))
+   (should (equal summary "Pay check deposited"))
+   (mapc
+    (lambda (date)
+      (should (or (and (=3D 21 (calendar-extract-day date))
+                       (memq (calendar-day-of-week date) (list 1 2 3 4 5)))
+                  (and (memq (calendar-extract-day date) (list 19 20))
+                       (=3D 5 (calendar-day-of-week date))))))
+    (cons dtstart rdates))))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with 'local strategy"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((tz (getenv "TZ"))
+            ;; Refresh output from `calendar-current-time-zone':
+            (calendar-current-time-zone-cache nil)
+	    ;; Assume Eastern European Time (UTC+2, UTC+3 daylight saving)
+            (_ (setenv "TZ" "EET-2EEST,M3.5.0/3,M10.5.0/4"))
+            ;; ...and use this TZ when exporting:
+            (diary-icalendar-time-zone-export-strategy 'local)
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-local
+ :tests
+ (unwind-protect
+     (let ((vtimezone (di:current-tz-to-vtimezone)))
+       (ical:with-component vtimezone
+         ((ical:standard :first std)
+          (ical:daylight :first dst))
+         (should (=3D (* 2 60 60) (ical:with-property-of std 'ical:tzoffse=
tto)))
+         (should (=3D (* 3 60 60) (ical:with-property-of dst 'ical:tzoffse=
tto))))
+       (ical:with-component (car parsed)
+         ((ical:dtstart :first start-node :value start))
+         (should (=3D (* 2 60 60) (decoded-time-zone start)))
+         (should (=3D 16 (decoded-time-hour start)))
+         (should (ical:with-param-of start-node 'ical:tzidparam))))
+   ;; restore time zone
+   (setenv "TZ" tz)))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with 'to-utc strategy"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((tz (getenv "TZ"))
+	    ;; Assume Eastern European Time (UTC+2, UTC+3 daylight saving)
+            (_ (setenv "TZ" "EET-2EEST,M3.5.0/3,M10.5.0/4"))
+            ;; ...and convert times to UTC on export:
+            (diary-icalendar-time-zone-export-strategy 'to-utc)
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-to-utc
+ :tests
+ (unwind-protect
+     (ical:with-component (car parsed)
+       ((ical:dtstart :first start-node :value start))
+       (should (=3D 0 (decoded-time-zone start)))
+       (should (=3D (- 16 2) (decoded-time-hour start)))
+       (should-not (ical:with-param-of start-node 'ical:tzidparam)))
+   ;; restore time zone
+   (setenv "TZ" tz)))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with 'floating strategy"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((tz (getenv "TZ"))
+	    ;; Assume Eastern European Time (UTC+2, UTC+3 daylight saving)
+            (_ (setenv "TZ" "EET-2EEST,M3.5.0/3,M10.5.0/4"))
+            ;; ...but use floating times:
+            (diary-icalendar-time-zone-export-strategy 'floating)
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-floating
+ :tests
+ (unwind-protect
+     (ical:with-component (car parsed)
+         ((ical:dtstart :first start-node :value start))
+       (should (null (decoded-time-zone start)))
+       (should (=3D 16 (decoded-time-hour start)))
+       (should-not (ical:with-param-of start-node 'ical:tzidparam)))
+
+     ;; restore time zone
+     (setenv "TZ" tz)))
+
+(dit:parse-test
+ "02/11/2012 4:00pm Exported with tz info list"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings (;; Encode Eastern European Time (UTC+2, UTC+3 daylight saving)
+            ;; directly in the variable:
+            (diary-icalendar-time-zone-export-strategy
+             '(120 60 "EET" "EEST"
+               (calendar-nth-named-day -1 0 3 year) ; last Sunday of March
+               (calendar-nth-named-day -1 0 10 year) ; last Sunday of Octo=
ber
+               240 180))
+            (diary-date-forms diary-european-date-forms))
+ :source tz-strategy-sexp
+ :tests
+ (let ((vtimezone (di:current-tz-to-vtimezone
+                   diary-icalendar-time-zone-export-strategy
+                   "EET")))
+   (ical:with-component vtimezone
+       ((ical:standard :first std)
+        (ical:daylight :first dst))
+     (should (=3D (* 2 60 60) (ical:with-property-of std 'ical:tzoffsetto)=
))
+     (should (=3D (* 3 60 60) (ical:with-property-of dst 'ical:tzoffsetto)=
)))
+   (ical:with-component (car parsed)
+       ((ical:dtstart :first start-node :value start))
+     (should (=3D 7200 (decoded-time-zone start)))
+     (should (=3D 16 (decoded-time-hour start)))
+     (should (ical:with-param-of start-node 'ical:tzidparam)))))
+
+(defun dit:parse-@-location ()
+  "Example user function for parsing additional properties.
+Parses anything following \"@\" to end of line as the entry's LOCATION."
+  (goto-char (point-min))
+  (when (re-search-forward "@\\([^\n]+\\)" nil t)
+    (list (ical:make-property ical:location
+                              (string-trim (match-string 1))))))
+
+(dit:parse-test
+ "2025/08/02 BBQ @ John's"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-icalendar-other-properties-parser #'dit:parse-@-locatio=
n)
+            (diary-date-forms diary-iso-date-forms))
+ :source other-properties-parser
+ :tests
+ (ical:with-component (car parsed)
+   ((ical:location :value location))
+   (should (equal location "John's"))))
+
+(dit:parse-test
+ "2025/05/15 11AM Department meeting
+  Attendee: <mydept@HIDDEN>"
+ :parser dit:entry-parser
+ :type 'ical:vevent
+ :number 1
+ :bindings ((diary-icalendar-export-alarms
+             '((audio 10)
+               (display 20 "In %t minutes: %s")
+               (email 60 "In %t minutes: %s" ("myemail@HIDDEN" from-e=
ntry))))
+            (diary-date-forms diary-iso-date-forms))
+ :source alarms-export
+ :tests
+ (ical:with-component (car parsed)
+   ((ical:valarm :all valarms))
+   (should (length=3D valarms 3))
+   (dolist (valarm valarms)
+     (ical:with-component valarm
+       ((ical:action :value action)
+        (ical:trigger :value trigger)
+        (ical:summary :value summary)
+        (ical:attendee :all attendee-nodes))
+       (cond ((equal action "AUDIO")
+              (should (eql -10 (decoded-time-minute trigger))))
+             ((equal action "DISPLAY")
+              (should (eql -20 (decoded-time-minute trigger)))
+              (should (equal summary "In 20 minutes: Department meeting")))
+             ((equal action "EMAIL")
+              (should (eql -60 (decoded-time-minute trigger)))
+              (should (equal summary "In 60 minutes: Department meeting"))
+              (should (length=3D attendee-nodes 2))
+              (let ((addrs (mapcar (lambda (n) (ical:with-node-value n))
+                                   attendee-nodes)))
+                (should (member "mailto:myemail@HIDDEN" addrs))
+                (should (member "mailto:mydept@HIDDEN" addrs))))
+             (t (error "Unknown alarm action %s" action)))))))
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("dit:" . "diary-icalendar-test-") ("di:" . "d=
iary-icalendar-") ("ical:" . "icalendar-"))
+;; End:
+;;; tests/icalendar-recur.el ends here
diff --git a/test/lisp/calendar/icalendar-parser-tests.el b/test/lisp/calen=
dar/icalendar-parser-tests.el
new file mode 100644
index 00000000000..09df808dc91
--- /dev/null
+++ b/test/lisp/calendar/icalendar-parser-tests.el
@@ -0,0 +1,2030 @@
+;;; tests/icalendar-parser.el --- Tests for icalendar-parser  -*- lexical-=
binding: t; -*-
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(eval-when-compile (require 'cl-lib))
+(require 'ert)
+(require 'ert-x)
+(require 'icalendar-parser)
+
+(cl-defmacro ipt:parse/print-test (string &key expected parser type printe=
r source)
+  "Create a test which parses STRING, prints the resulting parse
+tree, and compares the printed version with STRING (or with
+EXPECTED, if given). If they are the same, the test passes.
+PARSER and PRINTER should be the parser and printer functions
+appropriate to STRING. TYPE, if given, should be the type of
+object PARSER is expected to parse; it will be passed as PARSER's
+first argument. SOURCE should be a symbol; it is used to name the
+test."
+  (let ((parser-form
+         (if type
+             `(funcall (function ,parser) (quote ,type) (point-max))
+           `(funcall (function ,parser) (point-max)))))
+    `(ert-deftest ,(intern (concat "ipt:parse/print-" (symbol-name source)=
)) ()
+       ,(format "Parse and reprint example from `%s'; pass if they match" =
source)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (print-buf (get-buffer-create "*iCalendar Print Test*"))
+              (unparsed ,string)
+              (expected (or ,expected unparsed))
+              (printed nil))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let ((parsed ,parser-form))
+           (should (icalendar-ast-node-valid-p parsed))
+           (set-buffer print-buf)
+           (erase-buffer)
+           (insert (funcall (function ,printer) parsed))
+           ;; this may need adjusting if printers become coding-system awa=
re:
+           (decode-coding-region (point-min) (point-max) 'utf-8-dos)
+           (setq printed (buffer-substring-no-properties (point-min) (poin=
t-max)))
+           (should (equal expected printed)))))))
+
+(ipt:parse/print-test
+"ATTENDEE;RSVP=3DTRUE;ROLE=3DREQ-PARTICIPANT:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/1)
+
+(ipt:parse/print-test
+"RDATE;VALUE=3DDATE:19970304,19970504,19970704,19970904\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/2)
+
+(ipt:parse/print-test
+"ATTACH:http://example.com/public/quarterly-report.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/1)
+
+(ipt:parse/print-test
+;; Corrected. The original contains invalid base64 data; it was
+;; missing the final "=3D", as noted in errata ID 5602.
+;; The decoded string should read:
+;; The quick brown fox jumps over the lazy dog.
+"ATTACH;FMTTYPE=3Dtext/plain;ENCODING=3DBASE64;VALUE=3DBINARY:VGhlIHF1aWNr=
IGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4=3D\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/2)
+
+(ipt:parse/print-test
+"DESCRIPTION;ALTREP=3D\"cid:part1.0001@HIDDEN\":The Fall'98 Wild Wiza=
rds Conference - - Las Vegas\\, NV\\, USA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2/1)
+
+(ipt:parse/print-test
+"DESCRIPTION;ALTREP=3D\"CID:part3.msg.970415T083000@HIDDEN\": Project=
 XYZ Review Meeting will include the following agenda items: (a) Market Ove=
rview\\, (b) Finances\\, (c) Project Management\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.1/1)
+
+(ipt:parse/print-test
+"ORGANIZER;CN=3D\"John Smith\":mailto:jsmith@HIDDEN\n"
+;; CN param value does not require quotes, so they're missing when
+;; re-printed:
+:expected "ORGANIZER;CN=3DJohn Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.2/1)
+
+(ipt:parse/print-test
+"ATTENDEE;CUTYPE=3DGROUP:mailto:ietf-calsch@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.3/1)
+
+(ipt:parse/print-test
+"ATTENDEE;DELEGATED-FROM=3D\"mailto:jsmith@HIDDEN\":mailto:jdoe@examp=
le.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.4/1)
+
+(ipt:parse/print-test
+"ATTENDEE;DELEGATED-TO=3D\"mailto:jdoe@HIDDEN\",\"mailto:jqpublic@exa=
mple.com\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.5/1)
+
+(ipt:parse/print-test
+"ORGANIZER;DIR=3D\"ldap://example.com:6666/o=3DABC%20Industries,c=3DUS???(=
cn=3DJim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.6/1)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dtext/plain;ENCODING=3DBASE64;VALUE=3DBINARY:TG9yZW0gaXBz=
dW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2ljaW5nIGVsaXQsIHNlZCBkbyB=
laXVzbW9kIHRlbXBvciBpbmNpZGlkdW50IHV0IGxhYm9yZSBldCBkb2xvcmUgbWFnbmEgYWxpcX=
VhLiBVdCBlbmltIGFkIG1pbmltIHZlbmlhbSwgcXVpcyBub3N0cnVkIGV4ZXJjaXRhdGlvbiB1b=
GxhbWNvIGxhYm9yaXMgbmlzaSB1dCBhbGlxdWlwIGV4IGVhIGNvbW1vZG8gY29uc2VxdWF0LiBE=
dWlzIGF1dGUgaXJ1cmUgZG9sb3IgaW4gcmVwcmVoZW5kZXJpdCBpbiB2b2x1cHRhdGUgdmVsaXQ=
gZXNzZSBjaWxsdW0gZG9sb3JlIGV1IGZ1Z2lhdCBudWxsYSBwYXJpYXR1ci4gRXhjZXB0ZXVyIH=
NpbnQgb2NjYWVjYXQgY3VwaWRhdGF0IG5vbiBwcm9pZGVudCwgc3VudCBpbiBjdWxwYSBxdWkgb=
2ZmaWNpYSBkZXNlcnVudCBtb2xsaXQgYW5pbSBpZCBlc3QgbGFib3J1bS4=3D\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.7/1)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dapplication/msword:ftp://example.com/pub/docs/agenda.doc=
\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.8/1)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DBUSY:19980415T133000Z/19980415T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.9/1)
+
+(ipt:parse/print-test
+"SUMMARY;LANGUAGE=3Den-US:Company Holiday Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/1)
+
+(ipt:parse/print-test
+"LOCATION;LANGUAGE=3Den:Germany\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/2)
+
+(ipt:parse/print-test
+"LOCATION;LANGUAGE=3Dno:Tyskland\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/3)
+
+(ipt:parse/print-test
+"ATTENDEE;MEMBER=3D\"mailto:ietf-calsch@HIDDEN\":mailto:jsmith@exampl=
e.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/1)
+
+(ipt:parse/print-test
+"ATTENDEE;MEMBER=3D\"mailto:projectA@HIDDEN\",\"mailto:projectB@examp=
le.com\":mailto:janedoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/2)
+
+(ipt:parse/print-test
+"ATTENDEE;PARTSTAT=3DDECLINED:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.12/1)
+
+(ipt:parse/print-test
+"RECURRENCE-ID;RANGE=3DTHISANDFUTURE:19980401T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.13/1)
+
+(ipt:parse/print-test
+"TRIGGER;RELATED=3DEND:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.14/1)
+
+(ipt:parse/print-test
+"RELATED-TO;RELTYPE=3DSIBLING:19960401-080045-4000F192713@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.15/1)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DCHAIR:mailto:mrbig@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.16/1)
+
+(ipt:parse/print-test
+"ATTENDEE;RSVP=3DTRUE:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.17/1)
+
+(ipt:parse/print-test
+"ORGANIZER;SENT-BY=3D\"mailto:sray@HIDDEN\":mailto:jsmith@HIDDEN=
\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.18/1)
+
+(ipt:parse/print-test
+"DTSTART;TZID=3DAmerica/New_York:19980119T020000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/1)
+
+(ipt:parse/print-test
+"DTEND;TZID=3DAmerica/New_York:19980119T030000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/2)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dimage/vnd.microsoft.icon;ENCODING=3DBASE64;VALUE=3DBINAR=
Y:AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAACAAAAAgIAAAICAgADAwMAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAAAAABNEMQAAAAAAAkQgAAAAA=
AJEREQgAAACECQ0QgEgAAQxQzM0E0AABERCRCREQAADRDJEJEQwAAAhA0QwEQAAAAAEREAAAAAA=
AAREQAAAAAAAAkQgAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.1/1)
+
+(ipt:parse/print-test
+"TRUE"
+:type icalendar-boolean
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.2/1)
+
+(ipt:parse/print-test
+"mailto:jane_doe@HIDDEN"
+:type icalendar-cal-address
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.3/1)
+
+(ipt:parse/print-test
+"19970714"
+:type icalendar-date
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.4/1)
+
+(ipt:parse/print-test
+;; 'Floating' time:
+"19980118T230000"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/1)
+
+(ipt:parse/print-test
+;; UTC time:
+"19980119T070000Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/2)
+
+(ipt:parse/print-test
+;; Leap second (seconds =3D 60)
+"19970630T235960Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/3)
+
+(ipt:parse/print-test
+;; Local time:
+"DTSTART:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/4)
+
+(ipt:parse/print-test
+;; UTC time:
+"DTSTART:19970714T173000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/5)
+
+(ipt:parse/print-test
+;; Local time with TZ identifier:
+"DTSTART;TZID=3DAmerica/New_York:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/6)
+
+(ipt:parse/print-test
+"P15DT5H0M20S"
+:expected "P15DT5H20S"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/1)
+
+(ipt:parse/print-test
+"P7W"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/2)
+
+(ipt:parse/print-test
+"1000000.0000001"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/1)
+
+(ipt:parse/print-test
+"1.333"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/2)
+
+(ipt:parse/print-test
+"-3.14"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/3)
+
+(ipt:parse/print-test
+"1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/1)
+
+(ipt:parse/print-test
+"-1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/2)
+
+(ipt:parse/print-test
+"+1234567890"
+;; "+" sign isn't required, so it's not re-printed:
+:expected "1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/3)
+
+(ipt:parse/print-test
+"432109876"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/4)
+
+(ipt:parse/print-test
+"19970101T180000Z/19970102T070000Z"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/1)
+
+(ipt:parse/print-test
+"19970101T180000Z/PT5H30M"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/2)
+
+(ipt:parse/print-test
+"FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-1"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/1)
+
+(ipt:parse/print-test
+"FREQ=3DYEARLY;INTERVAL=3D2;BYMONTH=3D1;BYDAY=3DSU;BYHOUR=3D8,9;BYMINUTE=
=3D30"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/2)
+
+(ipt:parse/print-test
+"FREQ=3DDAILY;COUNT=3D10;INTERVAL=3D2"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/3)
+
+(ipt:parse/print-test
+"Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared."
+:type icalendar-text
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.11/1)
+
+(ipt:parse/print-test
+;; Local time:
+"230000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/1)
+
+(ipt:parse/print-test
+;; UTC time:
+"070000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/2)
+
+(ipt:parse/print-test
+;; Local time:
+"083000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/3)
+
+(ipt:parse/print-test
+;; UTC time:
+"133000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/4)
+
+(ipt:parse/print-test
+;; Local time with TZ identifier:
+"SOMETIMEPROP;TZID=3DAmerica/New_York;VALUE=3DTIME:083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.12/5)
+
+(ipt:parse/print-test
+"http://example.com/my-report.txt"
+:type icalendar-uri
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.13/1)
+
+(ipt:parse/print-test
+"-0500"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.14/1)
+
+(ipt:parse/print-test
+"+0100"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc55453.3.14/1)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@HIDDEN
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec3.4/1)
+
+(ipt:parse/print-test
+"DTSTART:19960415T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.5/1)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123401@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970903T163000Z
+DTEND:19970903T190000Z
+SUMMARY:Annual Employee Review
+CLASS:PRIVATE
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/1)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123402@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970401T163000Z
+DTEND:19970402T010000Z
+SUMMARY:Laurel is in sensitivity awareness class.
+CLASS:PUBLIC
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/2)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123403@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=3DDATE:19971102
+SUMMARY:Our Blissful Anniversary
+TRANSP:TRANSPARENT
+CLASS:CONFIDENTIAL
+CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION
+RRULE:FREQ=3DYEARLY
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/3)
+
+(ipt:parse/print-test
+"BEGIN:VEVENT
+UID:20070423T123432Z-541111@HIDDEN
+DTSTAMP:20070423T123432Z
+DTSTART;VALUE=3DDATE:20070628
+DTEND;VALUE=3DDATE:20070709
+SUMMARY:Festival International de Jazz de Montreal
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/4)
+
+(ipt:parse/print-test
+"BEGIN:VTODO
+UID:20070313T123432Z-456553@HIDDEN
+DTSTAMP:20070313T123432Z
+DUE;VALUE=3DDATE:20070501
+SUMMARY:Submit Quebec Income Tax Return for 2006
+CLASS:CONFIDENTIAL
+CATEGORIES:FAMILY,FINANCE
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/1)
+
+(ipt:parse/print-test
+"BEGIN:VTODO
+UID:20070514T103211Z-123404@HIDDEN
+DTSTAMP:20070514T103211Z
+DTSTART:20070514T110000Z
+DUE:20070709T130000Z
+COMPLETED:20070707T100000Z
+SUMMARY:Submit Revised Internet-Draft
+PRIORITY:1
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/2)
+
+(ipt:parse/print-test
+"BEGIN:VJOURNAL
+UID:19970901T130000Z-123405@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=3DDATE:19970317
+SUMMARY:Staff meeting minutes
+DESCRIPTION:1. Staff meeting: Participants include Joe\\,Lisa\\, and Bob. =
Aurora project plans were reviewed. There is currently no budget reserves f=
or this project. Lisa will escalate to management. Next meeting on Tuesday.=
\\n 2. Telephone Conference: ABC Corp. sales representative called to discu=
ss new printer. Promised to get us a demo by Friday.\\n3. Henry Miller (Han=
dsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 55=
5-2323 (tel).
+END:VJOURNAL
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.3/1)
+
+(ipt:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T082949Z-FA43EF@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTART:19971015T050000Z
+DTEND:19971016T050000Z
+DTSTAMP:19970901T083000Z
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/1)
+
+(ipt:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T095957Z-76A912@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTAMP:19970901T100000Z
+FREEBUSY:19971015T050000Z/PT8H30M,19971015T160000Z/PT5H30M,19971015T223000=
Z/PT6H30M
+URL:http://example.com/pub/busy/jpublic-01.ifb
+COMMENT:This iCalendar file contains busy time information for the next th=
ree months.
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/2)
+
+(ipt:parse/print-test
+;; Corrected. Original has invalid value in ORGANIZER
+"BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/3)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU;UNTIL=3D20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D1SU;UNTIL=3D20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/1)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:STANDARD
+DTSTART:20071104T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/2)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+TZURL:http://zones.example.com/tz/America-New_York.ics
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/3)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D-1SU;BYMONTH=3D10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D1SU;BYMONTH=3D4;UNTIL=3D19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/4)
+
+(ipt:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D-1SU;BYMONTH=3D10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D1SU;BYMONTH=3D4;UNTIL=3D19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19990424T020000
+RRULE:FREQ=3DYEARLY;BYDAY=3D-1SU;BYMONTH=3D4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/5)
+
+(ipt:parse/print-test
+"BEGIN:VALARM
+TRIGGER;VALUE=3DDATE-TIME:19970317T133000Z
+REPEAT:4
+DURATION:PT15M
+ACTION:AUDIO
+ATTACH;FMTTYPE=3Daudio/basic:ftp://example.com/pub/sounds/bell-01.aud
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/1)
+
+(ipt:parse/print-test
+"BEGIN:VALARM
+TRIGGER:-PT30M
+REPEAT:2
+DURATION:PT15M
+ACTION:DISPLAY
+DESCRIPTION:Breakfast meeting with executive\\nteam at 8:30 AM EST.
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/2)
+
+(ipt:parse/print-test
+"BEGIN:VALARM
+TRIGGER;RELATED=3DEND:-P2D
+ACTION:EMAIL
+ATTENDEE:mailto:john_doe@HIDDEN
+SUMMARY:*** REMINDER: SEND AGENDA FOR WEEKLY STAFF MEETING ***
+DESCRIPTION:A draft agenda needs to be sent out to the attendees to the we=
ekly managers meeting (MGR-LIST). Attached is a pointer the document templa=
te for the agenda file.
+ATTACH;FMTTYPE=3Dapplication/msword:http://example.com/templates/agenda.doc
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/3)
+
+(ipt:parse/print-test
+"CALSCALE:GREGORIAN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.1/1)
+
+(ipt:parse/print-test
+"METHOD:REQUEST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.2/1)
+
+(ipt:parse/print-test
+"PRODID:-//ABC Corporation//NONSGML My Product//EN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.3/1)
+
+(ipt:parse/print-test
+"VERSION:2.0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7./1)
+
+(ipt:parse/print-test
+"ATTACH:CID:jsmith.part3.960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/1)
+
+(ipt:parse/print-test
+"ATTACH;FMTTYPE=3Dapplication/postscript:ftp://example.com/pub/reports/r-9=
60812.ps\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/2)
+
+(ipt:parse/print-test
+"CATEGORIES:APPOINTMENT,EDUCATION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/1)
+
+(ipt:parse/print-test
+"CATEGORIES:MEETING\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/2)
+
+(ipt:parse/print-test
+"CLASS:PUBLIC\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.3/1)
+
+(ipt:parse/print-test
+"COMMENT:The meeting really needs to include both ourselves and the custom=
er. We can't hold this meeting without them. As a matter of fact\\, the ven=
ue for the meeting ought to be at their site. - - John\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.4/1)
+
+(ipt:parse/print-test
+"DESCRIPTION:Meeting to provide technical review for \"Phoenix\" design.\\=
nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\=
\nRSVP to team leader.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.5/1)
+
+(ipt:parse/print-test
+"GEO:37.386013;-122.082932\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.6/1)
+
+(ipt:parse/print-test
+"LOCATION:Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/1)
+
+(ipt:parse/print-test
+"LOCATION;ALTREP=3D\"http://xyzcorp.com/conf-rooms/f123.vcf\":Conference R=
oom - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/2)
+
+(ipt:parse/print-test
+"PERCENT-COMPLETE:39\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.8/1)
+
+(ipt:parse/print-test
+"PRIORITY:1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/1)
+
+(ipt:parse/print-test
+"PRIORITY:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/2)
+
+(ipt:parse/print-test
+"PRIORITY:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/3)
+
+(ipt:parse/print-test
+"RESOURCES:EASEL,PROJECTOR,VCR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/1)
+
+(ipt:parse/print-test
+"RESOURCES;LANGUAGE=3Dfr:Nettoyeur haute pression\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/2)
+
+(ipt:parse/print-test
+"STATUS:TENTATIVE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/1)
+
+(ipt:parse/print-test
+"STATUS:NEEDS-ACTION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/2)
+
+(ipt:parse/print-test
+"STATUS:DRAFT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/3)
+
+(ipt:parse/print-test
+"SUMMARY:Department Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.12/1)
+
+(ipt:parse/print-test
+"COMPLETED:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.1/1)
+
+(ipt:parse/print-test
+"DTEND:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/1)
+
+(ipt:parse/print-test
+"DTEND;VALUE=3DDATE:19980704\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/2)
+
+(ipt:parse/print-test
+"DUE:19980430T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.3/1)
+
+(ipt:parse/print-test
+"DTSTART:19980118T073000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.4/1)
+
+(ipt:parse/print-test
+"DURATION:PT1H0M0S\n"
+;; 0M and 0S are not re-printed because they don't contribute to the durat=
ion:
+:expected "DURATION:PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/1)
+
+(ipt:parse/print-test
+"DURATION:PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/2)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DBUSY-UNAVAILABLE:19970308T160000Z/PT8H30M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/1)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DFREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/2)
+
+(ipt:parse/print-test
+"FREEBUSY;FBTYPE=3DFREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,199703=
08T230000Z/19970309T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/3)
+
+(ipt:parse/print-test
+"TRANSP:TRANSPARENT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/1)
+
+(ipt:parse/print-test
+"TRANSP:OPAQUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/2)
+
+(ipt:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/1)
+
+(ipt:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/2)
+
+(ipt:parse/print-test
+"TZID:/example.org/America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/3)
+
+(ipt:parse/print-test
+"TZNAME:EST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/1)
+
+(ipt:parse/print-test
+"TZNAME;LANGUAGE=3Dfr-CA:HNE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/2)
+
+(ipt:parse/print-test
+"TZOFFSETFROM:-0500\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/1)
+
+(ipt:parse/print-test
+"TZOFFSETFROM:+1345\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/2)
+
+(ipt:parse/print-test
+"TZOFFSETTO:-0400\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/1)
+
+(ipt:parse/print-test
+"TZOFFSETTO:+1245\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/2)
+
+(ipt:parse/print-test
+"TZURL:http://timezones.example.org/tz/America-Los_Angeles.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.5/1)
+
+(ipt:parse/print-test
+"ATTENDEE;MEMBER=3D\"mailto:DEV-GROUP@HIDDEN\":mailto:joecool@example=
.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/1)
+
+(ipt:parse/print-test
+"ATTENDEE;DELEGATED-FROM=3D\"mailto:immud@HIDDEN\":mailto:ildoit@exam=
ple.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/2)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DTENTATIVE;CN=3DHenry Cabot:mai=
lto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/3)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;DELEGATED-FROM=3D\"mailto:bob@HIDDEN=
\";PARTSTAT=3DACCEPTED;CN=3DJane Doe:mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/4)
+
+(ipt:parse/print-test
+"ATTENDEE;CN=3DJohn Smith;DIR=3D\"ldap://example.com:6666/o=3DABC%20Indust=
ries,c=3DUS???(cn=3DJim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/5)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DTENTATIVE;DELEGATED-FROM=3D\"m=
ailto:iamboss@HIDDEN\";CN=3DHenry Cabot:mailto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/6)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DNON-PARTICIPANT;PARTSTAT=3DDELEGATED;DELEGATED-TO=3D\"mai=
lto:hcabot@HIDDEN\";CN=3DThe Big Cheese:mailto:iamboss@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/7)
+
+(ipt:parse/print-test
+"ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;CN=3DJane Doe:mailto:=
jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/8)
+
+(ipt:parse/print-test
+;; Corrected. Original lacks quotes around SENT-BY address.
+"ATTENDEE;SENT-BY=3D\"mailto:jan_doe@HIDDEN\";CN=3DJohn Smith:mailto:=
jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/9)
+
+(ipt:parse/print-test
+"CONTACT:Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/1)
+
+(ipt:parse/print-test
+;; Corrected. Original contained unallowed backslash in ldap: URI
+"CONTACT;ALTREP=3D\"ldap://example.com:6666/o=3DABC%20Industries,c=3DUS???=
(cn=3DJim%20Dolittle)\":Jim Dolittle\\, ABC Industries\\,+1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/2)
+
+(ipt:parse/print-test
+"CONTACT;ALTREP=3D\"CID:part3.msg970930T083000SILVER@HIDDEN\":Jim Dol=
ittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/3)
+
+(ipt:parse/print-test
+"CONTACT;ALTREP=3D\"http://example.com/pdi/jdoe.vcf\":Jim Dolittle\\, ABC =
Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/4)
+
+(ipt:parse/print-test
+"ORGANIZER;CN=3DJohn Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/1)
+
+(ipt:parse/print-test
+"ORGANIZER;CN=3DJohnSmith;DIR=3D\"ldap://example.com:6666/o=3DDC%20Associa=
tes,c=3DUS???(cn=3DJohn%20Smith)\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/2)
+
+(ipt:parse/print-test
+"ORGANIZER;SENT-BY=3D\"mailto:jane_doe@HIDDEN\":mailto:jsmith@example=
.com\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/3)
+
+(ipt:parse/print-test
+"RECURRENCE-ID;VALUE=3DDATE:19960401\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/1)
+
+(ipt:parse/print-test
+"RECURRENCE-ID;RANGE=3DTHISANDFUTURE:19960120T120000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/2)
+
+(ipt:parse/print-test
+"RELATED-TO:jsmith.part7.19960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/1)
+
+(ipt:parse/print-test
+"RELATED-TO:19960401-080045-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/2)
+
+(ipt:parse/print-test
+"URL:http://example.com/pub/calendars/jsmith/mytime.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.6/1)
+
+(ipt:parse/print-test
+"UID:19960401T080045Z-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.7/1)
+
+(ipt:parse/print-test
+"EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.1/1)
+
+(ipt:parse/print-test
+"RDATE:19970714T123000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/1)
+
+(ipt:parse/print-test
+"RDATE;TZID=3DAmerica/New_York:19970714T083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/2)
+
+(ipt:parse/print-test
+"RDATE;VALUE=3DPERIOD:19960403T020000Z/19960403T040000Z,19960404T010000Z/P=
T3H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/3)
+
+(ipt:parse/print-test
+"RDATE;VALUE=3DDATE:19970101,19970120,19970217,19970421,19970526,19970704,=
19970901,19971014,19971128,19971129,19971225\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/4)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;COUNT=3D10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/1)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;UNTIL=3D19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/2)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;INTERVAL=3D2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/3)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;INTERVAL=3D10;COUNT=3D5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/4)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;UNTIL=3D20000131T140000Z;BYMONTH=3D1;BYDAY=3DSU,MO,TU=
,WE,TH,FR,SA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/5)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;UNTIL=3D20000131T140000Z;BYMONTH=3D1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/6)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;COUNT=3D10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/7)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;UNTIL=3D19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/8)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;WKST=3DSU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/9)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;UNTIL=3D19971007T000000Z;WKST=3DSU;BYDAY=3DTU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/10)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;COUNT=3D10;WKST=3DSU;BYDAY=3DTU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/11)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;UNTIL=3D19971224T000000Z;WKST=3DSU;BYDAY=
=3DMO,WE,FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/12)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D8;WKST=3DSU;BYDAY=3DTU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/13)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYDAY=3D1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/14)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;UNTIL=3D19971224T000000Z;BYDAY=3D1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/15)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;COUNT=3D10;BYDAY=3D1SU,-1SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/16)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D6;BYDAY=3D-2MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/17)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D-3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/18)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D2,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/19)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D1,-1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/20)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;INTERVAL=3D18;COUNT=3D10;BYMONTHDAY=3D10,11,12,13,14=
,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/21)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;BYDAY=3DTU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/22)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;COUNT=3D10;BYMONTH=3D6,7\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/23)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D2;COUNT=3D10;BYMONTH=3D1,2,3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/24)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D3;COUNT=3D10;BYYEARDAY=3D1,100,200\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/25)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYDAY=3D20MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/26)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYWEEKNO=3D20;BYDAY=3DMO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/27)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3DTH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/28)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;BYDAY=3DTH;BYMONTH=3D6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/29)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYDAY=3DFR;BYMONTHDAY=3D13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/30)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYDAY=3DSA;BYMONTHDAY=3D7,8,9,10,11,12,13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/31)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D4;BYMONTH=3D11;BYDAY=3DTU;BYMONTHDAY=3D2,3=
,4,5,6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/32)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;COUNT=3D3;BYDAY=3DTU,WE,TH;BYSETPOS=3D3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/33)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/34)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DHOURLY;INTERVAL=3D3;UNTIL=3D19970902T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/35)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMINUTELY;INTERVAL=3D15;COUNT=3D6\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/36)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMINUTELY;INTERVAL=3D90;COUNT=3D4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/37)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DDAILY;BYHOUR=3D9,10,11,12,13,14,15,16;BYMINUTE=3D0,20,40\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/38)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMINUTELY;INTERVAL=3D20;BYHOUR=3D9,10,11,12,13,14,15,16\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/39)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DMO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/40)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DSU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/41)
+
+(ipt:parse/print-test
+"RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D15,30;COUNT=3D5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/42)
+
+(ipt:parse/print-test
+"ACTION:AUDIO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/1)
+
+(ipt:parse/print-test
+"ACTION:DISPLAY\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/2)
+
+(ipt:parse/print-test
+"REPEAT:4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.2/1)
+
+(ipt:parse/print-test
+"TRIGGER:-PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/1)
+
+(ipt:parse/print-test
+"TRIGGER;RELATED=3DEND:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/2)
+
+(ipt:parse/print-test
+"TRIGGER;VALUE=3DDATE-TIME:19980101T050000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/3)
+
+(ipt:parse/print-test
+"CREATED:19960329T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.1/1)
+
+(ipt:parse/print-test
+"DTSTAMP:19971210T080000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.2/1)
+
+(ipt:parse/print-test
+"LAST-MODIFIED:19960817T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.3/1)
+
+(ipt:parse/print-test
+"SEQUENCE:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/1)
+
+(ipt:parse/print-test
+"SEQUENCE:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/2)
+
+(ipt:parse/print-test
+"DRESSCODE:CASUAL\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/1)
+
+(ipt:parse/print-test
+"NON-SMOKING;VALUE=3DBOOLEAN:TRUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/2)
+
+(ipt:parse/print-test
+"X-ABC-MMSUBJ;VALUE=3DURI;FMTTYPE=3Daudio/basic:http://www.example.org/mys=
ubj.au\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.2/1)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:2.0;Success\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/1)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/2)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:2.8; Success\\, repeating event ignored. Scheduled as a si=
ngle event.;RRULE:FREQ=3DWEEKLY\\;INTERVAL=3D2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/3)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:4.1;Event conflict.  Date-time is busy.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/4)
+
+(ipt:parse/print-test
+"REQUEST-STATUS:3.7;Invalid calendar user;ATTENDEE:mailto:jsmith@HIDDEN=
om\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/5)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congre=
ss Center\\nAtlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/1)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//RDU Software//NONSGML HandCal//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:19981025T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19990404T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:19980309T231000Z
+UID:guid-1.example.com
+ORGANIZER:mailto:mrbig@HIDDEN
+ATTENDEE;RSVP=3DTRUE;ROLE=3DREQ-PARTICIPANT;CUTYPE=3DGROUP:mailto:employee=
-A@HIDDEN
+DESCRIPTION:Project XYZ Review Meeting
+CATEGORIES:MEETING
+CLASS:PUBLIC
+CREATED:19980309T130000Z
+SUMMARY:XYZ Project Review
+DTSTART;TZID=3DAmerica/New_York:19980312T083000
+DTEND;TZID=3DAmerica/New_York:19980312T093000
+LOCATION:1CP Conference Room 4350
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/2)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@HIDDEN
+ORGANIZER:mailto:jdoe@HIDDEN
+ATTENDEE;RSVP=3DTRUE:mailto:jsmith@HIDDEN
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar=
 and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=3Dapplication/postscript:ftp://example.com/pub/conf/bkgrnd.=
ps
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/3)
+
+(ipt:parse/print-test
+;; Corrected. The TRIGGER property originally did not specify
+;; VALUE=3DDATE-TIME, which is required since it is not the default type.
+;; See https://www.rfc-editor.org/errata/eid2039
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VTODO
+DTSTAMP:19980130T134500Z
+SEQUENCE:2
+UID:uid4@HIDDEN
+ORGANIZER:mailto:unclesam@HIDDEN
+ATTENDEE;PARTSTAT=3DACCEPTED:mailto:jqpublic@HIDDEN
+DUE:19980415T000000
+STATUS:NEEDS-ACTION
+SUMMARY:Submit Income Taxes
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER;VALUE=3DDATE-TIME:19980403T120000Z
+ATTACH;FMTTYPE=3Daudio/basic:http://example.com/pub/audio-files/ssbanner.a=
ud
+REPEAT:4
+DURATION:PT1H
+END:VALARM
+END:VTODO
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/4)
+
+(ipt:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VJOURNAL
+DTSTAMP:19970324T120000Z
+UID:uid5@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+STATUS:DRAFT
+CLASS:PUBLIC
+CATEGORIES:Project Report,XYZ,Weekly Meeting
+DESCRIPTION:Project xyz Review Meeting Minutes\\nAgenda\\n1. Review of pro=
ject version 1.0 requirements.\\n2.Definitionof project processes.\\n3. Rev=
iew of project schedule.\\nParticipants: John Smith\\, Jane Doe\\, Jim Dand=
y\\n-It was decided that the requirements need to be signed off byproduct m=
arketing.\\n-P roject processes were accepted.\\n-Project schedule needs to=
 account for scheduled holidaysand employee vacation time. Check with HR fo=
r specificdates.\\n-New schedule will be distributed by Friday.\\n-Next wee=
ks meeting is cancelled. No meeting until 3/23.
+END:VJOURNAL
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/5)
+
+(ipt:parse/print-test
+;; Corrected. Original text in the standard is missing UID and DTSTAMP.
+;; See https://www.rfc-editor.org/errata/eid4149
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//RDU Software//NONSGML HandCal//EN
+BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/6)
+
+
+;; Tests from real world data:
+(ert-deftest ipt:bad-organizer-params ()
+  "Real example: bad ORGANIZER property with params introduced by colon"
+  (let ((bad "ORGANIZER:CN=3DORGANIZER:mailto:anonymized@HIDDEN\n")
+        (ok  "ORGANIZER;CN=3DORGANIZER:mailto:anonymized@HIDDEN\n"=
))
+    (should-error (ical:parse-from-string 'ical:organizer bad))
+    (should (ical:ast-node-p (ical:parse-from-string 'ical:organizer ok)))=
))
+
+(ert-deftest ipt:bad-attendee ()
+  "Real example: bad ATTENDEE property missing mailto: prefix"
+  (let ((bad "ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELLER:anonymized@do=
main.example\n")
+        (ok  "ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELLER:mailto:anonym=
ized@HIDDEN\n"))
+    (should-error (ical:parse-from-string 'ical:attendee bad))
+    (should (ical:ast-node-p (ical:parse-from-string 'ical:attendee ok)))))
+
+(ert-deftest ipt:bad-attach ()
+  "Real example: bad ATTACH property containing broken URI"
+  (let ((bad "ATTACH;VALUE=3DURI:Glass\n")
+        (ok  "ATTACH;VALUE=3DURI:https://example.com\n"))
+    (should-error (ical:parse-from-string 'ical:attach bad))
+    (should (ical:ast-node-p (ical:parse-from-string 'ical:attach ok)))))
+
+(ert-deftest ipt:bad-cnparam ()
+  "Real example: bad unquoted CN parameter containing a comma"
+  (let ((bad "ORGANIZER;CN=3DHartlauer Gesch=C3=A4ft Wien, Taborstr. 18:ma=
ilto:anonymized@HIDDEN\n")
+        (ok  "ORGANIZER;CN=3D\"Hartlauer Gesch=C3=A4ft Wien, Taborstr. 18\=
":mailto:anonymized@HIDDEN\n"))
+    ;; strict parser should reject bad but accept ok:
+    (let ((ical:parse-strictly t))
+      (should (ical:ast-node-p (ical:parse-from-string 'ical:organizer ok)=
))
+      (should-error (ical:parse-from-string 'ical:organizer bad)))
+    ;; relaxed parser should accept bad:
+    (let ((ical:parse-strictly nil))
+      (should (ical:ast-node-p (ical:parse-from-string 'ical:organizer bad=
))))))
+
+(ert-deftest ipt:fix-bad-description ()
+  "Real example: bad DESCRIPTION property containing blank lines,
+fixed by `icalendar-fix-blank-lines'."
+  (let ((bad "BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:45dd7698-5c53-47e3-9280-19c5dff62571
+PRIORITY:1
+DTSTART:20210721T175200
+DTEND:20210721T192400
+LOCATION:Verona Porta Nuova
+DESCRIPTION:Verona Porta Nuova-Firenze S. M. Novella;Train: Frecciarossa 8=
527, departing from Verona Porta Nuova Hours: 17:52; arriving at Firenze S.=
 M. Novella Hours: 19:24 Coach 8, Position 7A; pnr code CLS345
+
+
+SUMMARY:Trip Verona Porta Nuova-Firenze S. M. Novella, Train Frecciarossa =
8527, Coach 8, Position 7A, PNR CLS345,
+ORGANIZER;CN=3DORGANIZER:mailto:anonymized@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DBUYER:mailto:anonymized@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELLER:mailto:anonymized@HIDDEN=
ample
+END:VEVENT
+END:VCALENDAR
+"))
+    ;; The default parser should produce an error on the blank lines in
+    ;; DESCRIPTION:
+    (let ((ical:pre-parsing-hook nil))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (ical:parse)
+        ;; Parsing should produce error at the bad description property:
+        (should (ical:errors-p))))
+    ;; cleaning up the blank lines before parsing should correct this:
+    (let ((ical:pre-parsing-hook '(ical:fix-blank-lines)))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse)))
+          (should (not (ical:errors-p)))
+          (ical:with-component vcal
+              ((ical:vevent vevent))
+            (ical:with-component vevent
+                ((ical:description :value description))
+              (let* ((expected "CLS345")
+                     (end (length description))
+                     (start (- end (length expected))))
+              (should (equal expected
+                             (substring description start end)))))))))))
+
+(ert-deftest ipt:bad-hyphenated-dates ()
+  "Real example: bad date values containing hyphens, fixed by
+`icalendar-fix-hyphenated-dates'."
+  (let ((bad "BEGIN:VCALENDAR
+X-LOTUS-CHARSET:UTF-8
+VERSION:2.0
+PRODID:http://www.bahn.de
+METHOD:PUBLISH
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D3
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=3DYEARLY;INTERVAL=3D1;BYDAY=3D-1SU;BYMONTH=3D10
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:bahn2023-08-29141400
+CLASS:PUBLIC
+SUMMARY:Frankfurt(Main)Hbf -> Hamburg Hbf
+DTSTART;TZID=3DEurope/Berlin:2023-08-29T141400
+DTEND;TZID=3DEurope/Berlin:2023-08-29T183600
+DTSTAMP:2023-07-30T194700Z
+END:VEVENT
+END:VCALENDAR
+"))
+    ;; default parser should skip the invalid DTSTART, DTEND, and DTSTAMP =
values:
+    (let ((ical:pre-parsing-hook nil))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse)))
+          ;; Parsing should produce errors as the bad properties are
+          ;; skipped:
+          (should (ical:errors-p))
+          ;; The resulting calendar is invalid because the VEVENT
+          ;; contains no DTSTAMP:
+          (should-error (ical:ast-node-valid-p vcal t)))))
+    ;; cleaning up the hyphenated dates before parsing should correct
+    ;; these problems:
+    (let ((ical:pre-parsing-hook '(ical:fix-hyphenated-dates)))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse))
+              (expected-dtstamp
+               (ical:make-date-time :year 2023 :month 7 :day 30
+                                    :hour 19 :minute 47 :second 0
+                                    :zone 0)))
+          (should (not (ical:errors-p)))
+          (should (ical:ast-node-valid-p vcal t))
+          (ical:with-component vcal
+              ((ical:vevent vevent))
+            (ical:with-component vevent
+                ((ical:dtstamp :value dtstamp))
+              (should (equal dtstamp expected-dtstamp)))))))))
+
+(ert-deftest ipt:bad-user-addresses ()
+  "Real example: bad calendar user addresses missing \"mailto:\", fixed by
+`icalendar-fix-missing-mailtos'."
+  (let ((bad "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:missing
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:45dd7698-5c53-47e3-9280-19c5dff62571
+PRIORITY:1
+DTSTART:20210721T175200
+DTEND:20210721T192400
+LOCATION:Verona Porta Nuova
+SUMMARY:Trip Verona Porta Nuova-Firenze S. M. Novella
+ORGANIZER;SENT-BY=3D\"other@HIDDEN\":anonymized@HIDDEN
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;CN=3DTRAVELER:traveler@HIDDEN
+END:VEVENT
+END:VCALENDAR
+"))
+    (let ((ical:pre-parsing-hook nil))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (ical:parse)
+        ;; Parsing should produce errors as the bad properties are
+        ;; skipped:
+        (should (ical:errors-p))))
+    ;; cleaning up the addresses before parsing should correct
+    ;; these problems:
+    (let ((ical:pre-parsing-hook '(ical:fix-missing-mailtos)))
+      (with-temp-buffer
+        (ical:init-error-buffer)
+        (insert bad)
+        (goto-char (point-min))
+        (let ((vcal (ical:parse))
+              (expected-attendee "mailto:traveler@HIDDEN")
+              (expected-organizer "mailto:anonymized@HIDDEN")
+              (expected-sender "mailto:other@HIDDEN"))
+          (should (not (ical:errors-p)))
+          (ical:with-component vcal
+              ((ical:vevent vevent))
+            (ical:with-component vevent
+                ((ical:attendee :value attendee)
+                 (ical:organizer :value organizer))
+              (should (equal attendee expected-attendee))
+              (should (equal organizer expected-organizer))
+              (ical:with-property organizer
+                  ((ical:sentbyparam :value sent-by))
+                  (should (equal sent-by expected-sender))))))))))
+
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ipt:" . "icalendar-parser-test-") ("ical:" .=
 "icalendar-"))
+;; End:
+;;; tests/icalendar-parser.el ends here
diff --git a/test/lisp/calendar/icalendar-recur-tests.el b/test/lisp/calend=
ar/icalendar-recur-tests.el
new file mode 100644
index 00000000000..90c257faefd
--- /dev/null
+++ b/test/lisp/calendar/icalendar-recur-tests.el
@@ -0,0 +1,2867 @@
+;;; icalendar-recur-tests.el --- Tests for icalendar-recur  -*- lexical-bi=
nding: t; -*-
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ert)
+(require 'icalendar-recur)
+(require 'icalendar-utils)
+(require 'icalendar-parser)
+(require 'icalendar-ast)
+
+;; Some constants for tests that use time zones:
+(defconst ict:tz-eastern
+  (ical:parse-from-string 'ical:vtimezone
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU;UNTIL=3D20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D1SU;UNTIL=3D20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+")
+"`icalendar-vtimezone' representing America/New_York (Eastern) time.")
+
+(defconst ict:est-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:standard :all stds))
+    (seq-find (lambda (obs)
+                (ical:date-time=3D
+                 (ical:make-date-time :year 2007 :month 11 :day 4
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              stds))
+  "The observance of Eastern Standard Time which began 2007-11-04")
+
+(defconst ict:edt-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:daylight :all dls))
+    (seq-find (lambda (obs)
+                (ical:date-time=3D
+                 (ical:make-date-time :year 2007 :month 3 :day 11
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              dls))
+  "The observance of Eastern Daylight Time which began 2007-03-11")
+
+(defconst ict:est -18000  ;; =3D -0500
+  "UTC offset for Eastern Standard Time")
+
+(defconst ict:edt -14400 ;; =3D -0400
+  "UTC offset for Eastern Daylight Time")
+
+
+;; Tests for basic functions:
+
+(ert-deftest ict:recur-bysetpos-filter ()
+  "Test that `icr:make-bysetpos-filter' filters correctly by position"
+  (let* ((t1 (list 1 1 2024))
+         (t2 (list 2 1 2024))
+         (t3 (list 12 30 2024))
+         (dts (list t1 t2 t3))
+         (filter (icr:make-bysetpos-filter (list 1 -1)))
+         (filtered (funcall filter dts)))
+    (should (member t1 filtered))
+    (should (member t3 filtered))
+    (should-not (member t2 filtered))))
+
+(ert-deftest ict:recur-yearday-number ()
+  "Test that `icr:calendar-date-from-yearday-number' finds correct dates"
+  (let* ((year 2025)
+         (daynos (list '(1 . (1 1 2025))
+                       '(8 . (1 8 2025))
+                       '(-1 . (12 31 2025))
+                       '(363 . (12 29 2025)))))
+    (dolist (d daynos)
+      (let ((dayno (car d))
+            (date (cdr d)))
+        (should
+         (equal date (ical:calendar-date-from-yearday-number year dayno)))=
))))
+
+(ert-deftest ict:date-time-add ()
+  "Does `ical:date-time-add' correctly handle time zone transitions?"
+  ;; A sum that does not use a time zone at all:
+  (let* ((dt (ical:make-date-time :year 2007 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3)))
+    (should (equal expected (ical:date-time-add dt delta))))
+
+  ;; A sum that does not cross an observance boundary:
+  (let* ((dt (ical:make-date-time :year 2007 :month 2 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3 :tz 'preserve)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should preserve clock ti=
me:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 12 :zone ict:edt :dst t=
)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should be exactly 48 hou=
rs later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 12 :hour 13
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should preserve clock ti=
me:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 5 :zone ict:est :dst ni=
l)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should be exactly 48 hou=
rs later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 5 :hour 11
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the Std->DST boundary and should result
+  ;; in a clock time one hour later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 11 :hour 3
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the DST->Std boundary and should result
+  ;; in a clock time one hour earlier:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 4 :hour 1
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern)))=
))
+
+(ert-deftest ict:recur-nonexistent-date-time-p ()
+  "Does `icr:nonexistent-date-time-p' correctly identify nonexistent times=
?"
+  (let*  ((dst-onset (ical:make-date-time :year 2025 :month 3 :day 9
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:est :dst nil))
+          ;; 2:30 AM falls into the gap when shifting from 2AM EST to 3AM =
EDT:
+          (nonexistent1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                             :hour 2 :minute 30 :second 0
+                                             :zone ict:est :dst nil))
+          (nonexistent2 (ical:date-time-variant nonexistent1
+                                                :zone ict:edt :dst t))
+          (std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:30AM around the shift back to EST exists twice (once in
+          ;; EDT, once in EST) and should not be nonexistent:
+          (existent1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:edt :dst t))
+          (existent2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:est :dst nil)))
+    (should (icr:nonexistent-date-time-p nonexistent1 dst-onset ict:edt-la=
test))
+    (should (icr:nonexistent-date-time-p nonexistent2 dst-onset ict:edt-la=
test))
+    (should-not
+     (icr:nonexistent-date-time-p existent1 std-onset ict:est-latest))
+    (should-not
+     (icr:nonexistent-date-time-p existent2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-date-time-occurs-twice-p ()
+  "Does `icr:date-time-occurs-twice-p' correctly identify times that occur=
 twice?"
+  (let*  ((std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:00, 1:30 AM occur twice when shifting from 2AM EDT to 1AM E=
ST:
+          (twice1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 0 :second 0))
+          (twice2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 30 :second 0))
+          ;; 12:59 AM, 2AM should not occur twice:
+          (once1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 0 :minute 59 :second 0
+                                      :zone ict:edt :dst t))
+          (once2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 2 :minute 0 :second 0
+                                      :zone ict:est :dst nil)))
+    (should (icr:date-time-occurs-twice-p twice1 std-onset ict:est-latest))
+    (should (icr:date-time-occurs-twice-p twice2 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once1 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-find-secondly-interval ()
+  "Does `icr:find-secondly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0 :second 0
+                                       ;; Use UTC for the tests with no
+                                       ;; time zone, so that the results
+                                       ;; don't depend on system's local t=
ime
+                                       :zone 0))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :z=
one 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 0 :tz 'preserve)
+             (ical:date-time-variant target :second 1 :tz 'preserve)
+             (ical:date-time-variant target :second 10 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :=
zone 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 10 :tz 'preserve)
+             (ical:date-time-variant target :second 11 :tz 'preserve)
+             (ical:date-time-variant target :second 20 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :second 20
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 20 :tz 'preserve)
+             (ical:date-time-variant target :second 21 :tz 'preserve)
+             (ical:date-time-variant target :second 30 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :second 30
+                                           :zone ict:edt :dst t))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 30 :tz 'preserve)
+             (ical:date-time-variant target :second 31 :tz 'preserve)
+             (ical:date-time-variant target :second 40 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-east=
ern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observan=
ce]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :second 1
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :second 10
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-east=
ern))))
+
+    ;; With timezone, with a "pathological" interval size of 59 seconds.
+    ;; There should be no problem with this case, because the interval
+    ;; bounds calculation is done in absolute time, but it's annoying to
+    ;; calculate the expected interval by hand:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2031 :month 4 :day 15
+                                           :hour 12 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+           (intsize 59)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 59 :second 16
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 11 :minute 59 :second 17
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 15
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-minutely-interval ()
+  "Does `icr:find-minutely-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0
+                                       ;; make sure intervals are
+                                       ;; bounded on whole minutes:
+                                       :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just a fixed offset, around a target that doesn't fall=
 on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :minute 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 0 :second 0)
+             (ical:date-time-variant target :minute 1 :second 0)
+             (ical:date-time-variant target :minute 10 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :minute 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 10 :second 0)
+             (ical:date-time-variant target :minute 11 :second 0)
+             (ical:date-time-variant target :minute 20 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :minute 20
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 20 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 21 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :minute 30
+                                           :zone ict:edt :dst t))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observan=
ce]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-hourly-interval ()
+  "Does `icr:find-hourly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole hours:
+                                       :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :hour 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 1 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0))=
))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :hour 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 20 :minute 0 :second 0))=
))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :hour 10
+                                           :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift. Note that
+    ;; because of our decision to calculate with absolute times in
+    ;; SECONDLY/MINUTELY/HOURLY rules (see `icr:find-secondly-recurrence-r=
ule')
+    ;; the interval clock times shift an hour here:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :hour 12
+                                           :zone ict:edt :dst t))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 13 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observan=
ce]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:make-date-time :year 2030 :month 3 :day 10
+                                        :hour 2 :minute 30 :second 30
+                                        :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 4 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 5 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date ()
+  "Does `icr:find-daily-interval' find correct date intervals?"
+  (let* ((dtstart (list 1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target (list 1 9 2026))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 7
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 8
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 14
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-daily-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date-time ()
+  "Does `icr:find-daily-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wedn=
esday
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst ni=
l)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart
+                                              :year 2026 :month 1 :day 9))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 8 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 14
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :da=
y 6))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 6 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 13 :hour 0 :minute 0 :sec=
ond 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz :year 2028 :month 2 =
:day 2
+                                           :zone ict:est :dst nil))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :seco=
nd 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 3 :hour 0 :minute 0 :seco=
nd 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :seco=
nd 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize ict:tz-ea=
stern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :day 28
+                                           :zone ict:edt :dst t))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :sec=
ond 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 24 :hour 0 :minute 0 :sec=
ond 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 30 :hour 0 :minute 0 :sec=
ond 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize
+                                       ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date ()
+  "Does `icr:find-weekly-interval' find correct date intervals?"
+  (let* ((dtstart '(1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target '(1 9 2026))
+           (intsize 2)
+           (expected-int-mon
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 5
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 12
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 19
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int-mon
+                     (icr:find-weekly-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date-time ()
+  "Does `icr:find-weekly-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wedn=
esday
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23)))
+
+    ;; Year numbers are monotonically increasing in the following test cas=
es,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :month 1 :da=
y 9))
+           (intsize 2)
+           (weds 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :day 5 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 12 :hour 0 :minute 0 :sec=
ond 0)
+             (ical:date-time-variant target :day 19 :hour 0 :minute 0 :sec=
ond 0)))
+           ;; expected interval for Wednesday week start:
+           (expected-int-wed
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 14 :hour 0 :minute 0 :sec=
ond 0)
+             (ical:date-time-variant target :day 21 :hour 0 :minute 0 :sec=
ond 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize)))
+      (should
+       (equal expected-int-wed
+              (icr:find-weekly-interval target dtstart intsize weds))))
+
+    ;; Around a target that does fall on an interval boundary, Monday week=
 start:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :da=
y 4))
+           (intsize 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :year 2026 :month 12 :day 21
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :year 2026 :month 12 :day 28
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 11
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize))))
+
+    ;; Around a target that does fall on an interval boundary, Sunday week=
 start:
+    (let* ((target (ical:date-time-variant dtstart :year 2028 :month 1 :da=
y 2))
+           (intsize 3)
+           (sun 0)
+           ;; expected interval for Sunday week start:
+           (expected-int-sun
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :seco=
nd 0)
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :sec=
ond 0))))
+      (should
+       (equal expected-int-sun
+              (icr:find-weekly-interval target dtstart intsize sun))))))
+
+(ert-deftest ict:recur-find-monthly-interval ()
+  "Does `icr:find-monthly-interval' find correct intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+           (intsize 5)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2025 :month 6 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 7 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 11 :day 1
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that doesn't fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2026 :month 3 :day =
9))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :day 1 :hour 0 :minute 0 :second=
 0)
+           (ical:date-time-variant target :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded =
on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2027 :month 5 :day =
1))
+         (intsize 7)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2027 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does not fall on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2028 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11))
+         (target (ical:date-time-variant dtstart
+                                         :year 2029 :month 4 :day 15))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2029 :month 3 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart
+                                         :year 2030 :month 5 :day 1))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2030 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 7 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month =3D target month
+  (let* ((dtstart (ical:make-date-time :year 2031 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart :year 2032 :month 11 :day=
 11))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2032 :month 11 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2032 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2033 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize)))))
+
+(ert-deftest ict:recur-find-yearly-interval ()
+  "Does `icr:find-yearly-interval' find correct date intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2025 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target not on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2026 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2026 :month 7 :day 28
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 3)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2027 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2028 :month 1 :day 1
+                                      :hour 0 :minute 0 :second 0))
+         (intsize 4)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2028 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2031 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target earlier than dtstart but in the same year;
+  ;; it's important that this works when looking up recurrences of
+  ;; time zone observance onsets
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 5 :day 28
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2029 :month 2 :day 14
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 1)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize)))))
+
+;; Subintervals:
+
+(ert-deftest ict:recur-refine-byyearday ()
+  "Does `icr:refine-byyearday' correctly refine by yeardays?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (yeardays (list 2 -7))
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 25)
+                     (ical:date-time-variant low :month 12 :day 26)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byyearday interval yeardays)))))
+
+(ert-deftest ict:recur-refine-bymonth ()
+  "Does `icr:refine-bymonth' correctly refine by months?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (months (list 9 2))
+         (sub1 (list (ical:date-time-variant low :month 2 :day 1)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (sub2 (list (ical:date-time-variant low :month 9 :day 1)
+                     (ical:date-time-variant low :month 10 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonth interval months)))))
+
+(ert-deftest ict:recur-refine-bymonthday ()
+  "Does `icr:refine-bymonthday' correctly refine by days of the month?"
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (monthdays (list -1 2 29))
+         ;; N.B. we should get no subinterval for Feb. 29, 2025
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :day 28)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonthday interval monthdays)))))
+
+(ert-deftest ict:recur-refine-byday ()
+  "Does `icr:refine-byday' correctly refine by days of the week?"
+  ;; The simple case: just day names
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 3 ; a Monday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 7))
+         (interval (list low high high))
+         (days (list 0 6)) ; just the weekend, please!
+         (sub1 (list (ical:date-time-variant low :day 8)
+                     (ical:date-time-variant low :day 9)))
+         (sub2 (list (ical:date-time-variant low :day 9)
+                     (ical:date-time-variant low :day 10)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days))))
+
+  ;; Day names with offsets within the month
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 1 ; a Saturday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (days (list '(1 . 2) '(1 . -1)))  ; second and last Monday
+         (sub1 (list (ical:date-time-variant low :day 10)
+                     (ical:date-time-variant low :day 11)))
+         (sub2 (list (ical:date-time-variant low :day 31)
+                     (ical:date-time-variant low :month 4 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days t))))
+
+  ;; Day names with offsets within the year
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (days (list '(5 . 1) '(5 . -1)))  ; first and last Friday
+         (sub1 (list (ical:date-time-variant low :day 3)
+                     (ical:date-time-variant low :day 4)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 26)
+                     (ical:date-time-variant low :month 12 :day 27)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days nil)))))
+
+(ert-deftest ict:recur-refine-byhour ()
+  "Does `icr:refine-byhour' correctly refine by hours?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 1))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2)
+                     (ical:date-time-variant low :hour 3)))
+         (sub2 (list (ical:date-time-variant low :hour 19)
+                     (ical:date-time-variant low :hour 20)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :day 1 ict:tz-eastern))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2 :tz 'preserve)
+                     (ical:date-time-variant low :hour 3 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :hour 19 :tz 'preserve)
+                     (ical:date-time-variant low :hour 20 :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours ict:tz-eastern)))))
+
+(ert-deftest ict:recur-refine-byminute ()
+  "Does `icr:refine-byminute' correctly refine by minutes?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 0 :second 0))
+         (high (ical:date/time-add low :hour 1))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7)
+                     (ical:date-time-variant low :minute 8)))
+         (sub2 (list (ical:date-time-variant low :minute 59)
+                     (ical:date-time-variant low :hour 14 :minute 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :hour 1 ict:tz-eastern))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7 :tz 'preserve)
+                     (ical:date-time-variant low :minute 8 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :minute 59 :tz 'preserve)
+                     (ical:date-time-variant low :hour 14 :minute 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes ict:tz-eastern)))=
))
+
+(ert-deftest ict:recur-refine-bysecond ()
+  "Does `icr:refine-bysecond' correctly refine by seconds?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 59 :second 0))
+         (high (ical:date/time-add low :minute 1))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24)
+                     (ical:date-time-variant low :second 25)))
+         (sub2 (list (ical:date-time-variant low :second 59)
+                     (ical:date-time-variant low :hour 14 :minute 0 :secon=
d 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 19 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :minute 1 ict:tz-eastern))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24 :tz 'preserve)
+                     (ical:date-time-variant low :second 25 :tz 'preserve)=
))
+         (sub2 (list (ical:date-time-variant low :second 59 :tz 'preserve)
+                     (ical:date-time-variant low :minute 20 :second 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds ict:tz-eastern)))=
))
+
+(ert-deftest ict:recur-subintervals-to-dates ()
+  "Does `icr:subintervals-to-dates' correctly generate recurrences?"
+  ;; Two subintervals, the first three days long, the second less than a s=
ingle day
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :day 3))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 31
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :hour 3)) ; later but on the same=
 day
+         (sub2 (list low2 high2))
+         (low-date1 (ical:date-time-to-date low1))
+         (low-date2 (ical:date-time-to-date low2))
+         (expected-recs (list low-date1
+                              (ical:date/time-add low-date1 :day 1)
+                              (ical:date/time-add low-date1 :day 2)
+                              (ical:date/time-add low-date1 :day 3)
+                              low-date2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-dates (list sub1 sub2))))))
+
+(ert-deftest ict:recur-subintervals-to-date-times ()
+  "Does `icr:subintervals-to-date-times' correctly generate recurrences?"
+  ;; Two subintervals, each one second long, no time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :second 1))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 2
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :second 1))
+         (sub2 (list low2 high2))
+         (expected-recs (list low1 low2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1 sub2)))))
+
+  ;; A subinterval five seconds long, with time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 6 :day 1
+                                    :hour 13 :minute 59 :second 0
+                                    :zone ict:edt :dst t))
+         (high1 (ical:date/time-add low1 :second 5 ict:tz-eastern))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date/time-add low1 :second 1 ict:tz-eastern)
+                (ical:date/time-add low1 :second 2 ict:tz-eastern)
+                (ical:date/time-add low1 :second 3 ict:tz-eastern)
+                (ical:date/time-add low1 :second 4 ict:tz-eastern))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-east=
ern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set forward an hour; these seconds should therefore be in =
EDT:
+  (let* ((low1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:est :dst nil))
+         (high1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 3 :minute 0 :second 3
+                                     :zone ict:edt :dst t))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve)
+                (ical:date-time-variant high1 :second 2 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-east=
ern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set back an hour; these seconds should therefore be in
+  ;; EST:
+  (let* ((low1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:edt :dst t))
+         (high1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                     :hour 1 :minute 0 :second 2
+                                     :zone ict:est :dst nil))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-east=
ern)))))
+
+;; Tests for time zone functions:
+
+(ert-deftest ict:recur-tz-observance-on/nonexistent ()
+  "Does `icr:tz-observance-on' correctly interpret nonexistent times?"
+  (let* ((onset-start (ical:make-date-time :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:est :dst nil))
+         (start-shifted (ical:date-time-variant onset-start :hour 3
+                                                :zone ict:edt :dst t))
+         ;; 2:30AM falls into the gap when the clock jumps from 2AM to 3AM:
+         (nonexistent (ical:date-time-variant onset-start :minute 30
+                                              :zone ict:est :dst nil))
+         (nonexistent-shifted (ical:date-time-variant nonexistent :hour 3
+                                                      :zone ict:edt :dst t=
)))
+    (icr:tz-observance-on onset-start ict:tz-eastern t) ;; updates the tim=
e to EDT
+    (icr:tz-observance-on nonexistent ict:tz-eastern t) ;; updates the tim=
e to EDT
+    (should (equal onset-start start-shifted))
+    (should (equal nonexistent nonexistent-shifted))))
+
+(ert-deftest ict:recur-tz-observance-on/occurs-twice ()
+  "Does `icr:tz-observance-on' correctly interpret times that occur twice?"
+  (let* ((onset-start (ical:make-date-time :year 2025 :month 11 :day 2
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+         ;; 1:30AM occurs twice when the clock is set back from 2AM to 1AM:
+         (no-zone (ical:date-time-variant onset-start :hour 1 :minute 30))
+         (first (ical:date-time-variant onset-start :hour 1 :minute 30
+                                        :zone ict:edt :dst t))
+         (second (ical:date-time-variant first :zone ict:est :dst nil))
+         (first+1h (ical:date/time-add first :hour 1 ict:tz-eastern)))
+    (icr:tz-observance-on no-zone ict:tz-eastern t) ;; sets zone
+    (should (equal first no-zone))
+    (should (equal second first+1h))))
+
+(ert-deftest ict:recur-tz-observance-on ()
+  "Does `icr:tz-observance-on' correctly find observances?"
+
+  ;; A date before the start of all observances in the timezone.
+  ;; In this case, there is no matching observance, so we should get nil.
+  (let* ((dt (ical:make-date-time :year 1900 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (ts (encode-time dt)))
+    (should (null (icr:tz-observance-on dt ict:tz-eastern)))
+    (should (null (icr:tz-observance-on ts ict:tz-eastern))))
+
+  ;; A date matching the start of one of the STANDARD observances:
+  (let* ((dt (ical:make-date-time :year 1967 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the start of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1967 :month 4 :day 30
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an RDATE of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1975 :month 2 :day 23
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a STANDARD observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 6 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a DAYLIGHT observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 7 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset in the middle of a DAYLIGHT observance
+  ;; which has ended:
+  (let* ((dt (ical:make-date-time :year 1980 :month 4 :day 27
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (end (ical:make-date-time :year 1986 :month 4 :day 27
+                                   :hour 7 :minute 0 :second 0
+                                   :zone 0)) ; UNTIL is in UTC
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal end (ical:recur-until
+                        (ical:with-property-of obs 'ical:rrule nil value))=
))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset of the DAYLIGHT observance which is
+  ;; ongoing:
+  (let* ((dt (ical:make-date-time :year 2025 :month 3 :day 9
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the DAYLIGHT observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:est :dst nil))
+         (dt (ical:make-date-time :year 2025 :month 5 :day 28
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the STANDARD observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; The following two tests were useful in detecting a broken optimizatio=
n:
+  (let* ((start (ical:make-date-time :year 2006 :month 10 :day 29
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2006 :month 11 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  (let* ((start (ical:make-date-time :year 2007 :month 11 :day 4
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2008 :month 2 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+
+  ;; A date in the middle of the STANDARD observance which is ongoing;
+  ;; test that the update flag correctly sets the zone information:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  ;; no zone information
+                                  ))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern t))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))))
+
+
+;; Tests for recurrence rule interpretation:
+(cl-defmacro ict:rrule-test (recur-string doc
+                             &key dtstart
+                             (low dtstart)
+                             high
+                             tz
+                             rdates
+                             exdates
+                             members
+                             nonmembers
+                             size
+                             source)
+
+  "Create a test which parses RECUR-STRING to an `icalendar-recur',
+creates an event with a recurrence set from this value, and checks
+various properties of the recurrence set.
+
+DTSTART should be an `icalendar-date' or `icalendar-date-time'
+  value appropriate to the RECUR-STRING. The value will be
+  bound to the symbol `dtstart'; this symbol can thus be used inside
+  the expressions for MEMBERS and NONMEMBERS.
+LOW and HIGH should be the bounds of the window in which to compute
+  recurrences. LOW defaults to DTSTART.
+TZ, if present, should be an `icalendar-vtimezone'.
+  Date-times in the recurrence set will be calculated relative to this
+  time zone.
+RDATES, if present, should be a list of additional
+  `icalendar-date' or `icalendar-date-time' values to be added to
+  the recurrence set *in addition to* those generated by the
+  recurrence rule (see `icalendar-rdate').
+EXDATES, if present, should be a list of `icalendar-date' or
+  `icalendar-date-time' values to be excluded from the recurrence
+  set, *even if* they are in RDATES or generated by the
+  recurrence rule (see `icalendar-exdate').
+MEMBERS, if present, should be a list of values that are expected
+  to be present in the recurrence set.
+NONMEMBERS, if present, should be a list of values that are expected
+  to be excluded from the recurrence set.
+SIZE, if present, should be a positive integer representing the
+  expected size of the recurrence set. Defaults to the value of the
+  COUNT clause in the recurrence rule, if any.
+SOURCE should be a symbol; it is used to name the test."
+  `(ert-deftest ,(intern (concat "ict:rrule-test-" (symbol-name source))) =
()
+     ,(format "Parse and evaluate recur-value example from `%s':\n%s"
+              source doc)
+     (let* ((parsed (ical:parse-from-string 'ical:recur ,recur-string))
+            (recvalue (ical:ast-node-value parsed))
+            (until (ical:recur-until recvalue))
+            (count (ical:recur-count recvalue))
+            (dtstart ,dtstart)
+            (recset-size (or ,size count))
+            (vevent
+             (ical:make-vevent
+              (ical:uid (concat "uid-test-" ,(symbol-name source)))
+              (ical:dtstart ,dtstart (ical:tzidparam "America/New_York"))
+              (ical:rrule parsed)
+              (ical:rdate ,rdates)
+              (ical:exdate ,exdates)))
+            ;; default for HIGH: UNTIL or DTSTART+3*INTERVAL
+            (win-high
+             (or ,high
+                 until
+                 (cadr
+                  (icr:nth-interval 2 ,dtstart recvalue))))
+            (recs
+             (if count
+                 (icr:recurrences-to-count vevent ,tz)
+               (icr:recurrences-in-window ,low win-high vevent ,tz))))
+       (should (ical:ast-node-valid-p parsed))
+       (when ,members
+         (dolist (dt ,members)
+           (should (member dt recs))))
+       (when ,nonmembers
+         (dolist (dt ,nonmembers)
+           (should-not (member dt recs))))
+       (when recset-size
+         (should (length=3D recs recset-size))))))
+
+(ict:rrule-test
+ "FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-1"
+ "Last non-weekend day of the month"
+ :dtstart '(3 31 2025)
+ :high '(6 1 2025)
+ :members '((3 31 2025) (4 30 2025) (5 30 2025))
+ :nonmembers '((5 31 2025)) ;; 5/31/2025 is a Saturday
+ :source rfc5545-sec3.3.10/1)
+
+(ict:rrule-test
+ "FREQ=3DYEARLY;INTERVAL=3D2;BYMONTH=3D1;BYDAY=3DSU;BYHOUR=3D8,9;BYMINUTE=
=3D30"
+ "Every Sunday in January at 8:30AM and 9:30AM, every other year"
+ :dtstart (ical:read-date-time "20250105T083000")
+ :high (ical:read-date-time "20271231T000000")
+ :members
+ (let ((jan3-27 (ical:make-date-time :year 2027 :month 1 :day 3
+                                     :hour 8 :minute 30 :second 0)))
+   (list dtstart
+         ;; 2025: Jan 5, 12, 19, 26
+         (ical:date-time-variant dtstart :hour 9)
+         (ical:date-time-variant dtstart :day 12)
+         (ical:date-time-variant dtstart :day 12 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 26)
+         (ical:date-time-variant dtstart :day 26 :hour 9)
+         ;; 2027: Jan 3, 10, 17, 24, 31
+         (ical:date-time-variant jan3-27 :hour 9)
+         (ical:date-time-variant jan3-27 :day 10)
+         (ical:date-time-variant jan3-27 :day 10 :hour 9)
+         (ical:date-time-variant jan3-27 :day 17)
+         (ical:date-time-variant jan3-27 :day 17 :hour 9)
+         (ical:date-time-variant jan3-27 :day 24)
+         (ical:date-time-variant jan3-27 :day 24 :hour 9)
+         (ical:date-time-variant jan3-27 :day 31)
+         (ical:date-time-variant jan3-27 :day 31 :hour 9)))
+ :nonmembers
+ (list
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 8 :minute 30 :second 0)
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 9 :minute 30 :second 0))
+ :source rfc5545-sec3.3.10/2)
+
+(ict:rrule-test
+ "FREQ=3DYEARLY;BYMONTH=3D2;BYMONTHDAY=3D-1"
+ "Every year on the last day in February"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 28 2025) (2 28 2026) (2 28 2027) (2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/1)
+
+(ict:rrule-test
+ "FREQ=3DYEARLY;INTERVAL=3D4;BYMONTH=3D2;BYMONTHDAY=3D29"
+ "Every four years on February 29"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/2)
+
+(ict:rrule-test
+"FREQ=3DDAILY;COUNT=3D10"
+"Daily for 10 occurrences"
+:dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                              :hour 9 :minute 0 :second 0)
+:members
+;; (1997 9:00 AM EDT) September 2-11
+(mapcar
+ (lambda (day) (ical:date-time-variant dtstart :day day))
+ (number-sequence 2 11))
+:source rfc5545-sec3.3.10/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY"
+ "Every year on a specific date, e.g. an anniversary"
+ :dtstart '(11 11 2024)
+ :high '(10 1 2030)
+ :members '((11 11 2024)
+            (11 11 2025)
+            (11 11 2026)
+            (11 11 2027)
+            (11 11 2028)
+            (11 11 2029))
+ :nonmembers '((11 11 2030))
+ :source rfc5545-sec3.6.1/3)
+
+;; Time zone tests
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYMONTH=3D4;BYDAY=3D-1SU;UNTIL=3D19730429T070000Z"
+ "Every year on the last Sunday of April (through 1973-04-29) at 2AM.
+(Onset of US Eastern Daylight Time.)"
+ :tz ict:tz-eastern
+ ;; DTSTART and all the times below are at *3*AM EDT, because 2AM EST
+ ;; (the onset of the observance) does not exist as a local time:
+ :dtstart (ical:make-date-time :year 1967 :month 4 :day 30
+                               :hour 3 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 1973 :month 4 :day 30
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1968 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1969 :day 27 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1970 :day 26 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1971 :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1972 :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1973 :day 29 :tz 'preserve))
+ :source rfc5545-sec3.6.5/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYMONTH=3D11;BYDAY=3D1SU"
+ "Every year on the first Sunday of November at 2AM.
+(Onset of Eastern Standard Time)."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 11 :day 4
+                               :hour 2 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 2010 :month 11 :day 8
+                               :zone ict:est :dst nil)
+ :members
+ ;; all the times below are at *1*AM EST, because 2AM EDT (the onset of
+ ;; the observance) is when clocks get set back:
+ (list (ical:date-time-variant dtstart
+                               :year 2008 :month 11 :day 2
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2009 :month 11 :day 1
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2010 :month 11 :day 7
+                               :zone ict:est :dst nil))
+ :source rfc5545-sec3.6.5/3.1)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D3;BYDAY=3D1SU"
+ "Every three months on the first Sunday of the month."
+ :dtstart '(1 5 2025)
+ :high '(1 1 2026)
+ :members (list '(4 6 2025)
+                '(7 6 2025)
+                '(10 5 2025))
+ :nonmembers (list '(1 12 2025) ;; second Sun.
+                   '(2 2 2025) ;; first Sun. in Feb.
+                   '(4 5 2025)) ;; Sat.
+ :source monthly/interval)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;COUNT=3D10\n"
+ "Daily for 10 occurrences"
+ :dtstart (ical:read-date-time "19970902T090000")
+ :members
+ (mapcar
+  (lambda (day) (ical:date-time-variant dtstart :day day))
+  (number-sequence 2 11))
+ :nonmembers (list (ical:date-time-variant dtstart :day 12))
+ :high (ical:read-date-time "19970912T090000")
+ :source rfc5545-sec3.8.5.3/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;UNTIL=3D19971224T000000Z\n"
+ "Daily at 9AM until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+   ;; EDT:
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 2 30)) ;; Sept. 2--30
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :month 10 :day day
+                                         :tz 'preserve))
+   (number-sequence 1 25)) ;; Oct. 1--25
+  ;; EST:
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :zone ict:est :dst=
 nil))
+   (number-sequence 26 31))) ;; Oct. 26--31
+ :source rfc5545-sec3.8.5.3/2)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;INTERVAL=3D2\n"
+ "Every other day - forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 12 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,4,6,8...24,26,28,30;
+  ;;                    October 2,4,6...20,22,24
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 12))
+  ;; (1997 9:00 AM EST) October 26,28,30;
+  ;;                    November 1,3,5,7...25,27,29;
+  ;;                    December 1,3,...
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n)
+                             :zone ict:est :dst nil))
+   (number-sequence 13 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 11 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 12 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 2)))
+
+ :nonmembers
+ (list
+  ;; e.g.
+  (ical:make-date-time :year 1997 :month 10 :day 27
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;INTERVAL=3D10;COUNT=3D5\n"
+ "Every ten days for five recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members ;; (1997 9:00 AM EDT) September 2,12,22; October 2,12
+ (list
+  dtstart
+  (ical:make-date-time :year 1997 :month 9 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 9 :day 22
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 2
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t))
+ :source rfc5545-sec3.8.5.3/4)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;UNTIL=3D20000131T140000Z;BYMONTH=3D1;BYDAY=3DSU,MO,T=
U,WE,TH,FR,SA\n"
+ "Every day in January, for three years (weekdays explicit)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/5)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;UNTIL=3D20000131T140000Z;BYMONTH=3D1\n"
+ "Every day in January, for three years (weekdays implicit)"
+ ;; TODO: as things are currently implemented, this way of expressing
+ ;; the rule is quite expensive, since we end up computing intervals and
+ ;; recurrences for every day of the year, even though the only relevant
+ ;; days are in January and there are no recurrences on the other days.
+ ;; We could try to optimize e.g. icr:refine-from-clauses to deal with such
+ ;; cases.
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/6)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;COUNT=3D10\n"
+ "Weekly for ten occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :tz 'preserve))
+   (list 7 14 21))
+  ;; (1997 9:00 AM EST) October 28;November 4
+  (list
+   (ical:make-date-time :year 1997 :month 10 :day 28
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)
+   (ical:make-date-time :year 1997 :month 11 :day 4
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)))
+ :source rfc5545-sec3.8.5.3/7)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;UNTIL=3D19971224T000000Z\n"
+ "Every week until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (let ((oct97 (ical:date-time-variant dtstart :month 10
+                                      :zone ict:edt :dst t))
+       (nov97 (ical:date-time-variant dtstart :month 11
+                                      :zone ict:est :dst nil))
+       (dec97 (ical:date-time-variant dtstart :month 12
+                                      :zone ict:est :dst nil)))
+   (append
+    ;; (1997 9:00 AM EDT) September 2,9,16,23,30;
+    ;;                    October 7,14,21
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dtstart :day day :tz 'preserve))
+     (list 2 9 16 23 30))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant oct97 :day day :tz 'preserve))
+     (list 7 14 21))
+    ;; (1997 9:00 AM EST) October 28;
+    ;;                    November 4,11,18,25;
+    ;;                    December 2,9,16,23
+    (list (ical:date-time-variant oct97 :day 28 :zone ict:est :dst nil))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant nov97 :day day :tz 'preserve))
+     (list 4 11 18 25))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dec97 :day day :tz 'preserve))
+     (list 2 9 16 23))))
+ :source rfc5545-sec3.8.5.3/8)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;WKST=3DSU\n"
+ "Every other week - forever; Weekstart on Sunday"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,16,30;
+  ;;                        October 14
+  dtstart
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+  ;;    (1997 9:00 AM EST) October 28;
+  ;;                       November 11,25;
+  ;;                       December 9,23
+  (ical:date-time-variant dtstart :month 10 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  ;;    (1998 9:00 AM EST) January 6,20;
+  ;;                       February 3, 17
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 17
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/9)
+
+(ict:rrule-test
+"RRULE:FREQ=3DWEEKLY;UNTIL=3D19971007T000000Z;WKST=3DSU;BYDAY=3DTU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using UNTIL"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/10)
+
+(ict:rrule-test
+"RRULE:FREQ=3DWEEKLY;COUNT=3D10;WKST=3DSU;BYDAY=3DTU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using COUNT"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/11)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;UNTIL=3D19971224T000000Z;WKST=3DSU;BYDA=
Y=3DMO,WE,FR\n"
+ "Every other week on Monday, Wednesday, and Friday until December 24,
+1997, starting on Monday, September 1, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  dtstart
+  ;; =3D=3D> (1997 9:00 AM EDT) September 1,3,5,15,17,19,29;
+  ;;                        October 1,3,13,15,17
+  (ical:date-time-variant dtstart :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 5 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 29 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 13 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 17 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 27,29,31;
+  ;;                        November 10,12,14,24,26,28;
+  ;;                        December 8,10,12,22
+  (ical:date-time-variant dtstart :month 10 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 29 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 31 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 14 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 26 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 8  :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22 :zone ict:est :dst nil=
))
+ :nonmembers
+ (list
+  ;; These match the rule, but are just past the UNTIL date:
+  (ical:date-time-variant dtstart :month 12 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 26 :zone ict:est :dst nil=
))
+ :source rfc5545-sec3.8.5.3/12)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D8;WKST=3DSU;BYDAY=3DTU,TH\n"
+ "Every other week on Tuesday and Thursday, for 8 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ ;; =3D=3D> (1997 9:00 AM EDT) September 2,4,16,18,30;
+ ;;                        October 2,14,16
+ (list
+   dtstart
+   (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 16 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/13)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYDAY=3D1FR\n"
+ "Monthly on the first Friday for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 5;October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2;February 6;March 6;April 3
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 3
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 1;June 5
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 5 :tz 'preserve=
))
+  :source rfc5545-sec3.8.5.3/14)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;UNTIL=3D19971224T000000Z;BYDAY=3D1FR\n"
+ "Monthly on the first Friday until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 5; October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/15)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;COUNT=3D10;BYDAY=3D1SU,-1SU\n"
+ "Every other month on the first and last Sunday of the month for 10 occur=
rences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 7
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 7,28
+  dtstart
+  (ical:date-time-variant dtstart :day 28 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,30
+  (ical:date-time-variant dtstart :month 11 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 4,25;March 1,29
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 25
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 3,31
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 31 :tz 'preserv=
e))
+ :source rfc5545-sec3.8.5.3/16)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D6;BYDAY=3D-2MO\n"
+ "Monthly on the second-to-last Monday of the month for 6 months"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 22
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 22;October 20
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 20 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 17;December 22
+  (ical:date-time-variant dtstart :month 11 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 19;February 16
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 19
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 16
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/17)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D-3\n"
+ "Monthly on the third-to-the-last day of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 28
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 28
+  dtstart
+  ;;     (1997 9:00 AM EST) October 29;November 28;December 29
+  (ical:date-time-variant dtstart :month 10 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/18)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D2,15\n"
+ "Monthly on the 2nd and 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,15;October 2,15
+  dtstart
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,15;December 2,15
+  (ical:date-time-variant dtstart :month 11 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 15
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 15
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2,15
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 15
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/19)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D10;BYMONTHDAY=3D1,-1\n"
+ "Monthly on the first and last day of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 30
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 30;October 1
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 31;November 1,30;December 1,31
+  (ical:date-time-variant dtstart :month 10 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 31
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 1,31;February 1
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 1
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/20)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D18;COUNT=3D10;BYMONTHDAY=3D10,11,12,13,1=
4,15\n"
+ "Every 18 months on the 10th thru 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  (list
+   ;; =3D=3D> (1997 9:00 AM EDT) September 10,11,12,13,14,15
+   dtstart
+   (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 12 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 13 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 15 :tz 'preserve))
+
+  ;;     (1999 9:00 AM EST) March 10,11,12,13
+  (let ((mar99 (ical:make-date-time :year 1999 :month 3 :day 10
+                                    :hour 9 :minute 0 :second 0
+                                    :zone ict:est :dst nil)))
+    (list
+     mar99
+     (ical:date-time-variant mar99 :day 11 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 12 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 13 :tz 'preserve))))
+ :nonmembers
+ (list
+  ;; These match the rule but are excluded by the COUNT clause:
+  (ical:make-date-time :year 1999 :month 3 :day 14
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil)
+  (ical:make-date-time :year 1999 :month 3 :day 15
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/21)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;INTERVAL=3D2;BYDAY=3DTU\n"
+ "Every Tuesday, every other month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 2,9,16,23,30
+  dtstart
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 4,11,18,25
+  (ical:date-time-variant dtstart :month 11 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 6,13,20,27;March 3,10,17,24,31
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 27
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 24
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 31
+                          :zone ict:est :dst nil))
+ :nonmembers
+ ;; e.g. Tuesdays in December 1997:
+ (list
+  (ical:date-time-variant dtstart :month 12 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 9 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 16 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil=
))
+ :source rfc5545-sec3.8.5.3/22)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;COUNT=3D10;BYMONTH=3D6,7\n"
+ "Yearly in June and July for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ ;; Note: Since none of the BYDAY, BYMONTHDAY, or BYYEARDAY
+ ;; clauses are specified, the month day is gotten from "DTSTART"
+ :members
+ ;; =3D=3D> (1997 9:00 AM EDT) June 10;July 10
+ ;;     (1998 9:00 AM EDT) June 10;July 10
+ ;;     (1999 9:00 AM EDT) June 10;July 10
+ ;;     (2000 9:00 AM EDT) June 10;July 10
+ ;;     (2001 9:00 AM EDT) June 10;July 10
+ (mapcan
+  (lambda (y)
+    (list
+     (ical:date-time-variant dtstart :year y :month 6 :tz 'preserve)
+     (ical:date-time-variant dtstart :year y :month 7 :tz 'preserve)))
+  (number-sequence 1997 2001))
+ :source rfc5545-sec3.8.5.3/23)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;INTERVAL=3D2;COUNT=3D10;BYMONTH=3D1,2,3\n"
+ "Every other year on January, February, and March for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ ;; =3D=3D> (1997 9:00 AM EST) March 10
+ ;;     (1999 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2001 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2003 9:00 AM EST) January 10;February 10;March 10
+ (cons
+  dtstart
+  (mapcan
+   (lambda (y)
+     (list
+      (ical:date-time-variant dtstart :year y :month 1 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 2 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 3 :tz 'preserve)))
+   (list 1999 2001 2003)))
+ :source rfc5545-sec3.8.5.3/24)
+
+(ict:rrule-test
+"RRULE:FREQ=3DYEARLY;INTERVAL=3D3;COUNT=3D10;BYYEARDAY=3D1,100,200\n"
+"Every third year on the 1st, 100th, and 200th day for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EST) January 1
+  dtstart
+  ;;     (1997 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :month 4 :day 10 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 7 :day 19 :zone ict:edt :dst t)
+  ;;     (2000 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2000 :tz 'preserve)
+  ;;     (2000 9:00 AM EDT) April 9;July 18
+  (ical:date-time-variant dtstart :year 2000 :month 4 :day 9 :zone ict:edt=
 :dst t)
+  (ical:date-time-variant dtstart :year 2000 :month 7 :day 18 :zone ict:ed=
t :dst t)
+  ;;     (2003 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2003 :tz 'preserve)
+  ;;     (2003 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :year 2003 :month 4 :day 10 :zone ict:ed=
t :dst t)
+  (ical:date-time-variant dtstart :year 2003 :month 7 :day 19 :zone ict:ed=
t :dst t)
+  ;;     (2006 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2006 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/25)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYDAY=3D20MO\n"
+ "Every 20th Monday of the year, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 5 :day 19
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) May 19
+  ;;     (1998 9:00 AM EDT) May 18
+  ;;     (1999 9:00 AM EDT) May 17
+  ;;     ...
+  dtstart
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/26)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYWEEKNO=3D20;BYDAY=3DMO\n"
+ "Every year on Monday in Week 20 (where the week starts Monday), forever"
+ :tz ict:tz-eastern
+ :dtstart
+ (ical:make-date-time :year 1997 :month 5 :day 12
+                      :hour 9 :minute 0 :second 0
+                      :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 12 :tz 'preserve) ; a Tu=
esday
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)) ; wro=
ng weekno
+ :source rfc5545-sec3.8.5.3/27)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3DTH\n"
+ "Every Thursday in March, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ (append
+  ;; =3D=3D> (1997 9:00 AM EST) March 13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 13 20 27))
+  ;;     (1998 9:00 AM EST) March 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 5 12 19 26))
+  ;;     (1999 9:00 AM EST) March 4,11,18,25
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 4 11 18 25)))
+ :source rfc5545-sec3.8.5.3/28)
+
+(ict:rrule-test
+"RRULE:FREQ=3DYEARLY;BYDAY=3DTH;BYMONTH=3D6,7,8\n"
+"Every Thursday, but only during June, July, and August, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; =3D=3D> (1997 9:00 AM EDT) June 5,12,19,26;July 3,10,17,24,31;
+  ;;                        August 7,14,21,28
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 5 12 19 26))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 7 :day d :tz 'preserve))
+   (list 3 10 17 24 31))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 8 :day d :tz 'preserve))
+   (list 7 14 21 28))
+  ;;     (1998 9:00 AM EDT) June 4,11,18,25;July 2,9,16,23,30;
+  ;;                        August 6,13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 4 11 18 25))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 7 :day d :tz 'prese=
rve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 8 :day d :tz 'prese=
rve))
+   (list 6 13 20 27))
+  ;;     (1999 9:00 AM EDT) June 3,10,17,24;July 1,8,15,22,29;
+  ;;                        August 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 3 10 17 24))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 7 :day d :tz 'prese=
rve))
+   (list 1 8 15 22 29))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 8 :day d :tz 'prese=
rve))
+   (list 5 12 19 26)))
+ :source rfc5545-sec3.8.5.3/29)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYDAY=3DFR;BYMONTHDAY=3D13\n"
+ "Every Friday the 13th, forever, *excluding* DTSTART "
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 2000 :month 10 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :exdates (list dtstart)
+ :members
+ (list
+  ;; =3D=3D> (1998 9:00 AM EST) February 13;March 13;November 13
+  ;;     (1999 9:00 AM EDT) August 13
+  ;;     (2000 9:00 AM EDT) October 13
+  ;;     ...
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 11 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1999 :month 8 :day 13 :tz 'preserv=
e)
+  (ical:date-time-variant dtstart :year 2000 :month 10 :day 13 :tz 'preser=
ve))
+ :source rfc5545-sec3.8.5.3/30)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYDAY=3DSA;BYMONTHDAY=3D7,8,9,10,11,12,13\n"
+ "The first Saturday that follows the first Sunday of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 6 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 13;October 11
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 11 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 8;December 13
+  (ical:date-time-variant dtstart :month 11 :day 8 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 13 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 10;February 7;March 7
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 7
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 7
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) April 11;May 9;June 13...
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 11 :tz 'preserv=
e)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 13 :tz 'preserv=
e))
+ :source rfc5545-sec3.8.5.3/31)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DYEARLY;INTERVAL=3D4;BYMONTH=3D11;BYDAY=3DTU;BYMONTHDAY=3D2,=
3,4,5,6,7,8\n"
+ "Every 4 years, the first Tuesday after a Monday in November, forever
+(U.S. Presidential Election day)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1996 :month 11 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; =3D=3D> (1996 9:00 AM EST) November 5
+  dtstart
+  ;;     (2000 9:00 AM EST) November 7
+  (ical:date-time-variant dtstart :year 2000 :day 7 :tz 'preserve)
+  ;;     (2004 9:00 AM EST) November 2
+  (ical:date-time-variant dtstart :year 2004 :day 2 :tz 'preserve))
+  :source rfc5545-sec3.8.5.3/32)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;COUNT=3D3;BYDAY=3DTU,WE,TH;BYSETPOS=3D3\n"
+ "The third instance into the month of one of Tuesday, Wednesday, or
+Thursday, for the next 3 months"
+ ;; TODO: Yikes, why is this so slow??
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 4
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 4;October 7
+  ;;     (1997 9:00 AM EST) November 6
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 7 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 11 :day 6 :zone ict:est :dst nil))
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYDAY=3DMO,TU,WE,TH,FR;BYSETPOS=3D-2\n"
+ "The second-to-last weekday of the month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 29
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; =3D=3D> (1997 9:00 AM EDT) September 29
+  dtstart
+  ;;     (1997 9:00 AM EST) October 30;November 27;December 30
+  (ical:date-time-variant dtstart :month 10 :day 30 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26;March 30
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 30
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/34)
+
+(ict:rrule-test
+ ;; corrected, see Errata ID 3883: https://www.rfc-editor.org/errata/eid38=
83
+ "RRULE:FREQ=3DHOURLY;INTERVAL=3D3;UNTIL=3D19970902T210000Z\n"
+ "Every 3 hours from 9:00 AM to 5:00 PM on a specific day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; =3D=3D> (September 2, 1997 EDT) 09:00,12:00,15:00
+  dtstart
+  (ical:date-time-variant dtstart :hour 12 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/35)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMINUTELY;INTERVAL=3D15;COUNT=3D6\n"
+ "Every 15 minutes for 6 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; =3D=3D> (September 2, 1997 EDT) 09:00,09:15,09:30,09:45,10:00,10:15
+  dtstart
+  (ical:date-time-variant dtstart :minute 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 45 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/36)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMINUTELY;INTERVAL=3D90;COUNT=3D4\n"
+ "Every hour and a half for 4 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (September 2, 1997 EDT) 09:00,10:30;12:00;13:30
+  dtstart
+  (ical:date-time-variant dtstart :hour 10 :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 12 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 13 :minute 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/37)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DDAILY;BYHOUR=3D9,10,11,12,13,14,15,16;BYMINUTE=3D0,20,40\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; =3D=3D> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preser=
ve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'prese=
rve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'prese=
rve)))
+   (number-sequence 9 16)))
+ :source rfc5545-sec3.8.5.3/38)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMINUTELY;INTERVAL=3D20;BYHOUR=3D9,10,11,12,13,14,15,16\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day
+(Alternative rule for the previous example)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; =3D=3D> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preser=
ve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'prese=
rve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'prese=
rve)))
+   (number-sequence 9 16)))
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DMO\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Monday, for four recurr=
ences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 EDT) August 5,10,19,24
+  dtstart
+  (ical:date-time-variant dtstart :day 10 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 24 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/40)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DWEEKLY;INTERVAL=3D2;COUNT=3D4;BYDAY=3DTU,SU;WKST=3DSU\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Sunday, for four recurr=
ences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (1997 EDT) August 5,17,19,31
+  dtstart
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 31 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/41)
+
+(ict:rrule-test
+ "RRULE:FREQ=3DMONTHLY;BYMONTHDAY=3D15,30;COUNT=3D5\n"
+ "An example where an invalid date (i.e., February 30) is ignored."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 1 :day 15
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2007 :month 4 :day 1
+                               :hour 0 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; =3D=3D> (2007 EST) January 15,30
+  ;;     (2007 EST) February 15
+  ;;     (2007 EDT) March 15,30
+  dtstart
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 3 :day 15 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 3 :day 30 :zone ict:edt :dst t))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :month 2 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/42)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-") ("icr:" . "icalen=
dar-recur-") ("ical:" . "icalendar-"))
+;; End:
+;;; tests/icalendar-recur.el ends here
--=20
2.39.5


--=-=-=--




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at submit <at> debbugs.gnu.org:


Received: (at submit) by debbugs.gnu.org; 12 Mar 2025 10:02:58 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Wed Mar 12 06:02:58 2025
Received: from localhost ([127.0.0.1]:47455 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tsIvS-00033R-MK
	for submit <at> debbugs.gnu.org; Wed, 12 Mar 2025 06:02:58 -0400
Received: from lists.gnu.org ([2001:470:142::17]:59600)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1tsIvQ-00033B-MD
 for submit <at> debbugs.gnu.org; Wed, 12 Mar 2025 06:02:56 -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 <rwl@HIDDEN>)
 id 1tsIvI-0001SD-8x
 for bug-gnu-emacs@HIDDEN; Wed, 12 Mar 2025 06:02:48 -0400
Received: from fhigh-b1-smtp.messagingengine.com ([202.12.124.152])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <rwl@HIDDEN>)
 id 1tsIvB-0004qC-3g
 for bug-gnu-emacs@HIDDEN; Wed, 12 Mar 2025 06:02:47 -0400
Received: from phl-compute-13.internal (phl-compute-13.phl.internal
 [10.202.2.53])
 by mailfhigh.stl.internal (Postfix) with ESMTP id 6C4892540263
 for <bug-gnu-emacs@HIDDEN>; Wed, 12 Mar 2025 06:02:37 -0400 (EDT)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-13.internal (MEProxy); Wed, 12 Mar 2025 06:02:37 -0400
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:content-type:content-type:date:date
 :from:from:in-reply-to:message-id:mime-version:reply-to:subject
 :subject:to:to; s=fm3; t=1741773757; x=1741860157; bh=mFPnwfK1JW
 mePmz7BUQv1TNeDeqQhyXo9K/KY++czZQ=; b=qzeGhwNpJH9vyBTwjo9QN1MT3c
 iHndWkP3ob1R4pv0q7SAgKNK9vTsldOnDIxnVexJr6HHOkB2p/dLyScVU1lvalGU
 t0El7MrCltAY9S1mBk889APA768tWa5vS2DTWNVHe8lCM1ZZUHg6GS3gokiyE8FL
 Zb2KXAd0pZYxb9fkINLm2E4brsdaspFvew+UiljcyBycebbN0WiAPPighVE8fDKW
 G20dEgjpUYXgriZ9pE9Q1j4EF9M8I/8VF/GaWNIQAHU4gkOubyytIP1vN0IdxnRC
 Los/Q0Pr80G9ceLlsmQbKx3DBszjHMRCmb61ppsmQdogMtakq62thT/I5V9A==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:message-id
 :mime-version:reply-to:subject:subject:to:to:x-me-proxy
 :x-me-sender:x-me-sender:x-sasl-enc; s=fm1; t=1741773757; x=
 1741860157; bh=mFPnwfK1JWmePmz7BUQv1TNeDeqQhyXo9K/KY++czZQ=; b=e
 /6x2cOziYgE4tkKUYzVEqKI+8yjHgsxTl3O+rp7/ciIlKaNlAEUjnXdbMieNBzyM
 EyVWCDfVU/cyJKdJhiy50StqOqLJfbaiYRnuYFxxKP2TcBep+SEdDC2l9OfrTO/c
 9c6PruNCa5f12jOA6Al6q/1FBNo9PeORjY9KVsd8An5aBxmT3afNeBAiCErE7Uou
 N3/pE0FpCPn4s+1TFFGayqK3SALXPwh7nxwdzvvkBm9150BlPREY6+eihFoj4fIY
 7XvN2+3E/5TvT6mxOS+ySbBiOU42IfL7wg+0eyRTjX4vLrPE2eiyoODlP7swoSNN
 3nnY0ZGh4d9H6IykW0CYA==
X-ME-Sender: <xms:vFvRZ8vMyZLwM_GQgLnGbh6usuRgpw57oETv-Fjh1jwat8VHw1DBEg>
 <xme:vFvRZ5ewBOtDTbo027IDYugpoa-6HvCvSSpKmeQKembMPAujwmRlEWjpFyOgh4jPN
 qQkEeusHfsg3qtNXQ>
X-ME-Received: <xmr:vFvRZ3wyO_yA_mlK4ED0gQ2rIn2zsQirNnaJeD0O0la3rk0g6NUy5tnD-Q>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefvddrtddtgdduvdegjeekucetufdoteggodetrf
 dotffvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdggtfgfnhhsuhgsshgtrhhisggv
 pdfurfetoffkrfgpnffqhgenuceurghilhhouhhtmecufedttdenucenucfjughrpefhvf
 fufffkgggtsehmtderredttddtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgvnhgt
 vgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrghtth
 gvrhhnpeeuuedtiefhleduudetjeduieethefftdduheevleeuleeiuedtvdduudegvddv
 vdenucffohhmrghinhepvghlrdhithenucevlhhushhtvghrufhiiigvpedtnecurfgrrh
 grmhepmhgrihhlfhhrohhmpehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvght
 pdhnsggprhgtphhtthhopedupdhmohguvgepshhmthhpohhuthdprhgtphhtthhopegsuh
 hgqdhgnhhuqdgvmhgrtghssehgnhhurdhorhhg
X-ME-Proxy: <xmx:vVvRZ_NSw-vcB5ADwAxTyi8qsGSXctD7i-8vGOH-8vLdtRjt_-lTlA>
 <xmx:vVvRZ89n-Exs3LUp9aD_JLUxs_UWud3mh6PwtjkuYZ6El7M2p9Mg_w>
 <xmx:vVvRZ3XJwqjHFgqFWNfX4vcLUTQLV_rhp2siKsEydBGT7GTP_9GZTw>
 <xmx:vVvRZ1d2aHwew-HYKTu4A7tPK0lJk71rTJt-84dRdEsoxoIxXoSQNQ>
 <xmx:vVvRZ6mpKLpOv1TJf3hcY_EmGQyWQ7BtZURTyIlDlNeaw4b7Mtk5AxXB>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA for
 <bug-gnu-emacs@HIDDEN>; Wed, 12 Mar 2025 06:02:35 -0400 (EDT)
From: Richard Lawrence <rwl@HIDDEN>
To: bug-gnu-emacs@HIDDEN
Subject: [PATCH] Updated patch for Bug#74994: add support for recurrence rules
X-Debbugs-Cc: Richard Lawrence <rwl@HIDDEN>
Date: Wed, 12 Mar 2025 11:02:28 +0100
Message-ID: <87seniftxn.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="=-=-="
Received-SPF: pass client-ip=202.12.124.152;
 envelope-from=rwl@HIDDEN; helo=fhigh-b1-smtp.messagingengine.com
X-Spam_score_int: -27
X-Spam_score: -2.8
X-Spam_bar: --
X-Spam_report: (-2.8 / 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,
 RCVD_IN_DNSWL_LOW=-0.7, RCVD_IN_VALIDITY_CERTIFIED_BLOCKED=0.001,
 RCVD_IN_VALIDITY_RPBL_BLOCKED=0.001, SPF_HELO_PASS=-0.001,
 SPF_PASS=-0.001 autolearn=ham autolearn_force=no
X-Spam_action: no action
X-Debbugs-Envelope-To: submit
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>

--=-=-=
Content-Type: text/plain

Tags: patch

I've been steadily working on this and I've reached another milestone
this week: I now have a working implementation of recurrence rules,
including time zone support. This is the big, complicated part of
iCalendar semantics that none of the existing code completely supports;
but it's essential, because most date-times in iCalendar are defined as
local times with a reference to a time zone, and calculating an actual
UTC offset requires applying the recurrence rules for that time zone.
Updated patch attached; the main addition here is icalendar-recur.el,
plus supporting functions in icalendar-macs.el and icalendar-utils.el.

It was a *lot* more work to get this working than I expected (mostly
because time zones are complicated...who knew? ;), but I can at least
say now that all the examples in RFC5545 work; see the tests in
icalendar-recur-tests.el.



--=-=-=
Content-Type: text/patch
Content-Disposition: attachment;
 filename=0001-Updated-patch-for-Bug-74994-add-support-for-recurren.patch

From 671f5e462808d725ac41544ffeba1f0d01dacfb2 Mon Sep 17 00:00:00 2001
From: Richard Lawrence <rwl@HIDDEN>
Date: Thu, 19 Dec 2024 14:30:57 +0100
Subject: [PATCH] Updated patch for Bug#74994: add support for recurrence rules

---
 lisp/calendar/icalendar-ast.el               |  400 ++
 lisp/calendar/icalendar-macs.el              | 1093 +++++
 lisp/calendar/icalendar-mode.el              |  610 +++
 lisp/calendar/icalendar-parser.el            | 4107 ++++++++++++++++++
 lisp/calendar/icalendar-recur.el             | 1968 +++++++++
 lisp/calendar/icalendar-uri-schemes.el       |  444 ++
 lisp/calendar/icalendar-utils.el             |  603 +++
 test/lisp/calendar/icalendar-parser-tests.el | 1803 ++++++++
 test/lisp/calendar/icalendar-recur-tests.el  | 2871 ++++++++++++
 9 files changed, 13899 insertions(+)
 create mode 100644 lisp/calendar/icalendar-ast.el
 create mode 100644 lisp/calendar/icalendar-macs.el
 create mode 100644 lisp/calendar/icalendar-mode.el
 create mode 100644 lisp/calendar/icalendar-parser.el
 create mode 100644 lisp/calendar/icalendar-recur.el
 create mode 100644 lisp/calendar/icalendar-uri-schemes.el
 create mode 100644 lisp/calendar/icalendar-utils.el
 create mode 100644 test/lisp/calendar/icalendar-parser-tests.el
 create mode 100644 test/lisp/calendar/icalendar-recur-tests.el

diff --git a/lisp/calendar/icalendar-ast.el b/lisp/calendar/icalendar-ast.el
new file mode 100644
index 00000000000..56b319a9eea
--- /dev/null
+++ b/lisp/calendar/icalendar-ast.el
@@ -0,0 +1,400 @@
+;;; icalendar-ast.el --- Syntax trees for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the abstract syntax tree representation for
+;; iCalendar data.
+
+
+;;; Code:
+(eval-when-compile (require 'cl-lib))
+(require 'org-element-ast)
+
+;;; Type symbols and metadata
+
+;; All nodes in the syntax treee have a type symbol as their first element.
+;; We use the following symbol properties (all prefixed with 'icalendar-')
+;; to associate type symbols with various important data about the type:
+;;
+;; is-type - t (marks this symbol as an icalendar type)
+;; is-value, is-param, is-property, or is-component - t
+;;   (specifies what sort of value this type represents)
+;; list-sep - for property and parameters types, a string (typically
+;;   "," or ";") which separates individual printed values, if the
+;;   type allows lists of values. If this is non-nil, syntax nodes of
+;;   this type should always have a list of values in their VALUE
+;;   field (even if there is only one value)
+;; matcher - a function to match this type. This function matches the
+;;   regular expression defined under the type's name; it is used to provide
+;;   syntax highlighting in `icalendar-mode'
+;; begin-rx, end-rx - for component-types, an `rx' regular expression which
+;;   matches the BEGIN and END lines that form its boundaries
+;; value-rx - an `rx' regular expression which matches individual values
+;;   of this type, with no consideration for quoting or lists of values.
+;;   (For value types, this is just a synonym for the rx definition
+;;   under the type's symbol)
+;; values-rx - for types that accept lists of values, an `rx' regular
+;;   expression which matches the whole list (including quotes, if required)
+;; full-value-rx - for property and parameter types, an `rx' regular
+;;   expression which matches a valid value expression in group 2, or
+;;   an invalid value in group 3
+;; value-reader - for value types, a function which creates syntax
+;;   nodes of this type given a string representing their value
+;; value-printer - for value types, a function to print individual
+;;   values of this type. It accepts a value and returns its string
+;;   representation.
+;; default-value - for property and parameter types, a string
+;;   representing a default value for nodes of this type. This is the
+;;   value assumed when no node of this type is present in the
+;;   relevant part of the syntax tree.
+;; substitute-value - for parameter types, a string representing a value
+;;   which will be substituted at parse times for unrecognized values.
+;;   (This is normally the same as default-value, but differs from it
+;;   in at least one case in RFC5545, thus it is stored separately.)
+;; default-type - for property types which can have values of multiple
+;;   types, this is the default type when no type for the value is
+;;   specified in the parameters. Any type of value other than this
+;;   one requires a VALUE=... parameter when the property is read or printed.
+;; other-types - for property types which can have values of multiple types,
+;;   this is a list of other types that the property can accept.
+;; child-spec - for property and component types, a plist describing the
+;;   required and optional child nodes. See `icalendar-define-property' and
+;;   `icalendar-define-component' for details.
+;; other-validator - a function to perform type-specific validation
+;;   for nodes of this type. If present, this function will be called
+;;   by `icalendar-ast-node-valid-p' during validation.
+;; type-documentation - a string documenting the type. This documentation is
+;;   printed in the help buffer when `describe-symbol' is called on TYPE.
+;; link - a hyperlink to the documentation of the type in the relevant standard
+
+(defun ical:type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is an iCalendar type symbol.
+
+This function only checks that SYMBOL has been marked as a type;
+it returns t for value types defined by `icalendar-define-type',
+but also e.g. for types defined by `icalendar-define-param' and
+`icalendar-define-property'. To check that SYMBOL names a value
+type for property or parameter values, see
+`icalendar-value-type-symbol-p' and
+`icalendar-printable-value-type-symbol-p'."
+  (and (symbolp symbol)
+       (get symbol 'ical:is-type)))
+
+(defun ical:value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a value
+type, i.e., a type for an iCalendar property or parameter value
+defined by `icalendar-define-type'.
+
+This means that SYMBOL must both satisfy
+`icalendar-type-symbol-p' and have the property
+`icalendar-is-value'. It does not require the type to be
+associated with a print name in `icalendar-value-types';
+for that see `icalendar-printable-value-type-symbol-p'."
+  (and (ical:type-symbol-p symbol)
+       (get symbol 'ical:is-value)))
+
+(defun ical:expects-list-of-values-p (type)
+  "Return non-nil if the syntax node type named by TYPE accepts a
+list of values. This is never t for value types or component
+types. For property and parameter types defined with
+`ical:define-param' and `ical:define-property', it is true if the
+:list-sep argument was specified in the definition."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:list-sep)))
+
+(defun ical:param-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+parameter."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-param)))
+
+(defun ical:property-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+property."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-property)))
+
+(defun ical:component-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+component."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-component)))
+
+;; TODO: we could define other accessors here for the other metadata
+;; properties, but at the moment I see no advantage to this; they would
+;; all just be long-winded wrappers around `get'.
+
+
+;; We define general accessors and a constructor `ical:make-ast-node'
+;; for this representation here:
+(defalias 'ical:ast-node-type #'org-element-type)
+
+(defsubst ical:ast-node-value (node)
+  "Return the value of iCalendar syntax node NODE.
+In component nodes, this is nil. Otherwise, it is a syntax node
+representing an iCalendar (property or parameter) value."
+  (org-element-property :value node))
+
+(defalias 'ical:ast-node-children #'org-element-contents)
+
+(defalias 'ical:ast-node-set-children #'org-element-set-contents)
+
+(defun ical:ast-node-p (val)
+  "Return non-nil if VAL is an iCalendar syntax node"
+  (and (listp val)
+       (ical:type-symbol-p (ical:ast-node-type val))))
+
+(defalias 'ical:ast-node-meta-get #'org-element-property)
+
+(defalias 'ical:ast-node-meta-set #'org-element-put-property)
+
+(defun ical:ast-node-first-child-of (type node)
+  "Return the first child of NODE of type TYPE, or nil if there is
+no such child."
+  (assq type (ical:ast-node-children node)))
+
+(defun ical:ast-node-children-of (type node)
+  "Return a list of all the children of NODE of type TYPE, or nil if
+there are none."
+  (seq-filter (lambda (c) (eq type (ical:ast-node-type c)))
+              (ical:ast-node-children node)))
+
+(defun ical:make-ast-node (type props &optional children)
+  "Construct an iCalendar syntax node of type TYPE with the properties
+PROPS and descendants CHILDREN.
+
+CHILDREN, if given, should be a list of syntax nodes. In
+property nodes, these should be the parameters of the property.
+In component nodes, these should be the properties or
+subcomponents of the component. It should otherwise be nil.
+
+PROPS should be a plist with any of the following keywords:
+
+:value - if given, should be a single syntax node. In value nodes, this
+  should be the Elisp value parsed from a property or parameter's value
+  string. In parameter and property nodes, this should be a value node. In
+  component nodes, it should not be present.
+:buffer - buffer from which VALUE was parsed
+:begin - position at which this node begins in BUFFER
+:end - position at which this node ends in BUFFER
+:value-begin - position at which VALUE begins in BUFFER
+:value-end - position at which VALUE ends in BUFFER
+:original-value - a string containing the original, uninterpreted value
+  of the node. This can differ from (a string represented by) VALUE
+  if e.g. a default VALUE was substituted for an unrecognized but
+  syntactically correct value.
+:original-name - a string containing the original, uninterpreted name
+  of the parameter, property or component this node represents.
+  This can differ from (a string representing) TYPE
+  if e.g. a default TYPE was substituted for an unrecognized but
+  syntactically correct one."
+  ;; automatically mark :value as a "secondary property" for org-element-ast
+  (let ((full-props (if (plist-member props :value)
+                        (plist-put props :secondary (list :value))
+                      props)))
+    (apply #'org-element-create type full-props children)))
+
+
+;;; Validation:
+
+;; Errors at the validation stage:
+;; e.g. property/param values did not match, or are of the wrong type,
+;; or required properties not present in a component
+(define-error 'ical:validation-error "Invalid iCalendar data")
+
+(defun ical:param-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a parameter type."
+  (and (ical:ast-node-p node)
+       (ical:param-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:property-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a property type."
+  (and (ical:ast-node-p node)
+       (ical:property-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:component-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a component type."
+  (and (ical:ast-node-p node)
+       (ical:component-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:ast-node-valid-value-p (node)
+  "Validate that NODE's value satisfies the requirements of its type.
+Signals an `icalendar-validation-error' if NODE's value is
+invalid, or returns NODE."
+  (let* ((type (ical:ast-node-type node))
+         (value (ical:ast-node-value node)))
+    (cond ((ical:value-type-symbol-p type)
+           (unless (cl-typep value type) ; see `ical:define-type'
+             (signal 'ical:validation-error
+                     (list (format "Invalid value for `%s' node: %s"
+                                   type value)
+                           node)))
+           node)
+          ((ical:component-node-p node)
+           ;; component types have no value, so no need to check anything
+           node)
+          ((and (or (ical:param-type-symbol-p type)
+                    (ical:property-type-symbol-p type))
+                (null (get type 'ical:value-type))
+                (stringp value))
+           ;; property and param nodes with no value type are assumed to contain
+           ;; strings which match a value regex:
+           (unless (string-match (rx-to-string (get type 'ical:value-rx)) value)
+             (signal 'ical:validation-error
+                     (list (format "Invalid string value for `%s' node: %s"
+                                   type value)
+                           node)))
+           node)
+          ;; otherwise this is a param or property node which itself
+          ;; should have one or more syntax nodes as a value, so
+          ;; recurse on value(s):
+          ((ical:expects-list-of-values-p type)
+           (unless (listp value) ;; TODO: check elements' types...?
+             (signal 'ical:validation-error
+                     (list (format "Expected list of values for `%s' node"
+                                   type)
+                           node)))
+           (mapc #'ical:ast-node-valid-value-p value)
+           node)
+          (t
+           (unless (ical:ast-node-p value)
+             (signal 'ical:validation-error
+                     (list (format "Invalid value for `%s' node: %s"
+                                   type value)
+                           node)))
+           (ical:ast-node-valid-value-p value)))))
+
+(defun ical:count-children-by-type (node)
+  "Return an alist mapping type symbols to the number of child nodes
+of that type in NODE."
+  (let ((children (ical:ast-node-children node))
+        (map nil))
+    (dolist (child children map)
+      (let* ((type (ical:ast-node-type child))
+             (n (alist-get type map)))
+        (setf (alist-get type map) (1+ (or n 0)))))))
+
+(defun ical:ast-node-valid-children-p (node)
+  "Validate that NODE's children satisfy the :child-spec associated
+with its type by `icalendar-define-component',
+`icalendar-define-property', `icalendar-define-param', or
+`icalendar-define-type'. Signals an `icalendar-validation-error'
+if NODE is invalid, or returns NODE.
+
+Note that this function does not check that the children of NODE
+are themselves valid; for that, see `ical:ast-node-valid-p'."
+  (let* ((type (ical:ast-node-type node))
+         (child-spec (get type 'ical:child-spec))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when child-spec
+
+      (dolist (child-type (plist-get child-spec :one))
+        (unless (= 1 (alist-get child-type child-counts 0))
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node must contain exactly one `%s'"
+                                type child-type)
+                        node))))
+
+      (dolist (child-type (plist-get child-spec :one-or-more))
+        (unless (<= 1 (alist-get child-type child-counts 0))
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node must contain one or more `%s'"
+                                type child-type)
+                        node))))
+
+      (dolist (child-type (plist-get child-spec :zero-or-one))
+        (unless (<= (alist-get child-type child-counts 0)
+                    1)
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node may contain at most one `%s'"
+                                type child-type)
+                        node))))
+
+      ;; check that all child nodes are allowed:
+      (unless (plist-get child-spec :allow-others)
+        (let ((allowed-types (append (plist-get child-spec :one)
+                                     (plist-get child-spec :one-or-more)
+                                     (plist-get child-spec :zero-or-one)
+                                     (plist-get child-spec :zero-or-more)))
+              (appearing-types (mapcar #'car child-counts)))
+
+          (dolist (child-type appearing-types)
+            (unless (member child-type allowed-types)
+              (signal 'ical:validation-error
+                      (list (format "`%s' may not contain `%s'"
+                                    type child-type)
+                            node)))))))
+    ;; success:
+    node))
+
+(defun ical:ast-node-valid-p (node &optional recursively)
+  "Check that NODE is a valid iCalendar syntax node.
+By default, the check will only validate NODE itself, but if
+RECURSIVELY is non-nil, it will recursively check all its
+descendants as well. Signals an `icalendar-validation-error' if
+NODE is invalid, or returns NODE."
+  (unless (ical:ast-node-p node)
+    (signal 'ical:validation-error
+            (list "Not an iCalendar syntax node"
+                  node)))
+
+  ;; (ical:ast-node-valid-meta-p node)
+  (ical:ast-node-valid-value-p node)
+  (ical:ast-node-valid-children-p node)
+
+  (let* ((type (ical:ast-node-type node))
+         (other-validator (get type 'ical:other-validator)))
+
+    (unless (ical:type-symbol-p type)
+      (signal 'ical:validation-error
+              (list (format "Node's type `%s' is not an iCalendar type symbol"
+                            type)
+                    node)))
+
+    (when (and other-validator (not (functionp other-validator)))
+      (signal 'ical:validation-error
+              (list (format "Bad validator function `%s' for type `%s'"
+                            other-validator type))))
+
+    (when other-validator
+      (funcall other-validator node)))
+
+  (let ((children (ical:ast-node-children node)))
+    (when (and recursively (not (null children)))
+      (dolist (c children)
+        (ical:ast-node-valid-p c recursively))))
+
+  ;; success:
+  node)
+
+(provide 'icalendar-ast)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-ast.el ends here
diff --git a/lisp/calendar/icalendar-macs.el b/lisp/calendar/icalendar-macs.el
new file mode 100644
index 00000000000..15b905e430f
--- /dev/null
+++ b/lisp/calendar/icalendar-macs.el
@@ -0,0 +1,1093 @@
+;;; icalendar-macs.el --- Macros for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', used in
+;; icalendar-parser.el to define the particular value types, parameters,
+;; properties and components in the standard as type symbols.
+
+;; TODOs:
+;;   - in the define* macros, :default needs rethinking.
+;;     I had made this a string because otherwise you can't distinguish
+;;     an unspecified default from an explicit "FALSE" for icalendar-boolean
+;;     But this might not be true/might not matter anyway, and it's a pain
+;;     to have to read the default value where you need it. Probably
+;;     should just change these to be the value as read.
+
+
+(eval-when-compile (require 'cl-lib))
+
+(declare-function ical:ast-node-p "icalendar-ast")
+(declare-function ical:ast-node-type "icalendar-ast")
+(declare-function ical:ast-node-value "icalendar-ast")
+(declare-function ical:type-symbol-p "icalendar-ast")
+(declare-function ical:value-type-symbol-p "icalendar-ast")
+(declare-function ical:expects-list-of-values-p "icalendar-ast")
+
+;; Some utilities:
+(defun ical:protected-intern (sym-name)
+  "Call `intern' on SYM-NAME and return the result, but warn if the
+resulting symbol already has icalendar-relevant properties."
+  (let ((sym (intern sym-name)))
+    (when (or (fboundp sym)
+              (get sym 'rx-definition)
+              (get sym 'ical:is-type))
+      (warn "Symbol `%s' already has iCalendar properties" sym))
+    sym))
+
+(defun ical:format-child-spec (child-spec)
+  "Format CHILD-SPEC as a table for use in symbol documentation."
+  (concat
+   (format "%-30s%6s\n" "Type" "Number")
+   (make-string 36 ?-) "\n"
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1"))
+    (plist-get child-spec :one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1+"))
+    (plist-get child-spec :one-or-more))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0-1"))
+    (plist-get child-spec :zero-or-one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0+"))
+    (plist-get child-spec :zero-or-more))))
+
+
+;; Define value types:
+(cl-defmacro ical:define-type (symbolic-name print-name doc specifier matcher
+                               &key link
+                                    (reader #'identity)
+                                    (printer #'identity))
+  (declare (doc-string 2))
+  "Define an iCalendar value type named SYMBOLIC-NAME.
+
+PRINT-NAME should be the string used to represent this type in
+the value of an `icalendar-valuetypeparam' property parameter, or
+nil if this is not a type that should be specified there. DOC
+should be a documentation string for the type. SPECIFIER should
+be a type specifier in the sense of `cl-deftype'. MATCHER should
+be an RX definition body (see `rx-define'; argument lists are not
+supported).
+
+Before the type is defined with `cl-deftype', a function will be
+defined named `icalendar-match-PRINT-NAME-value'
+(or `icalendar-match-OTHER-value', if PRINT-NAME is nil, where
+OTHER is derived from SYMBOLIC-NAME by removing any prefix
+\"icalendar-\" and suffix \"value\"). This function takes a
+string argument and matches it against MATCHER. This function may
+thus occur in SPECIFIER (e.g. in a (satisfies ...) clause).
+
+See the functions `icalendar-read-value-node',
+`icalendar-parse-value-node', and `icalendar-print-value-node' to
+convert values defined with this macro to and from their text
+representation in iCalendar format.
+
+The following keyword arguments are accepted:
+
+:reader - a function to read data of this type. It will be passed
+  a string matching MATCHER and should return an Elisp data structure.
+  Its name does not need to be quoted. (default: identity)
+
+:printer - a function to convert an Elisp data structure of this
+  type to a string. Its name does not need to be quoted.
+  (default: identity)
+
+:link - a string containing an URL for further documentation of this type"
+  (let* (;; Related functions:
+         (type-dname (if print-name
+                         (downcase print-name)
+                       (string-trim
+                        (symbol-name symbolic-name)
+                        "icalendar-" "value")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" type-dname "-value")))
+
+         ;; Documentation:
+         (header "It names a value type defined by `icalendar-define-type'.")
+         (matcher-doc (format
+"Strings representing values of this type can be matched with
+`%s'.\n" matcher-name))
+         (reader-doc (format "They can be read with `%s'\n" reader))
+         (printer-doc (format "and printed with `%s'." printer))
+         (full-doc (concat header "\n\n" doc "\n\n"
+                           matcher-doc reader-doc printer-doc "\n\n"
+"A syntax node of this type can be read with
+`icalendar-read-value-node' or parsed with `icalendar-parse-value-node',
+and printed with `icalendar-print-value-node'.")))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-value t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-rx (quote ,symbolic-name)
+                    'ical:value-reader (function ,reader)
+                    'ical:value-printer (function ,printer)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       (rx-define ,symbolic-name
+         ,matcher)
+
+       (defun ,matcher-name (s)
+         ,(format "Match string S against rx `%s'." symbolic-name)
+         (string-match (rx ,symbolic-name) s))
+
+       (cl-deftype ,symbolic-name () ,specifier)
+
+       ;; Store the association between the print name and the type
+       ;; symbol in ical:value-types. The check against print name
+       ;; here allows us to also define value types that aren't
+       ;; "really" types according to the standard, like
+       ;; `ical:geo-coordinates'. Only types that have a
+       ;; print-name can be specified in a VALUE parameter.
+       (when ,print-name
+         (push (cons ,print-name (quote ,symbolic-name)) ical:value-types)))))
+
+;; TODO: not sure this is needed. I've only used it once in the parser.
+(cl-defmacro ical:define-keyword-type (symbolic-name print-name doc matcher
+                                       &key link
+                                            (reader 'intern)
+                                            (printer 'symbol-name))
+  "Like `icalendar-define-type', except that string values matching MATCHER
+are assumed to be type-specific keywords that should be interned
+as symbols when read. (Thus no type specifier is necessary: it is
+always just \\='symbol.) Their printed representation is their
+symbol name."
+  `(ical:define-type ,symbolic-name ,print-name ,doc
+                     'symbol
+                     ,matcher
+                     :link ,link
+                     :reader ,reader
+                     :printer ,printer))
+
+
+;; Define parameters:
+(cl-defmacro ical:define-param (symbolic-name param-name doc value
+                                &key quoted
+                                     list-sep
+                                     default
+                                     (unrecognized default)
+                                     ((:name-face name-face)
+                                      'ical:parameter-name nondefault-name-face)
+                                     ((:value-face value-face)
+                                      'ical:parameter-value nondefault-value-face)
+                                     ((:warn-face warn-face)
+                                      'ical:warning nondefault-warn-face)
+                                     extra-faces
+                                     link)
+  (declare (doc-string 2))
+  "Define iCalendar parameter PARAM-NAME under the symbol SYMBOLIC-NAME.
+PARAM-NAME should be the parameter name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing values.
+If it is a regular expression, it is assumed that the values of
+this parameter are strings which match that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME which matches the
+parameter is defined:
+  Group 1 of this regex matches PARAM-NAME
+    (or any valid parameter name, if PARAM-NAME is nil).
+  Group 2 matches VALUE, which specifies a correct value
+    for this parameter according to RFC5545.
+  Group 3, if matched, contains any parameter value which does
+    *not* match VALUE, and is incorrect according to the standard.
+
+This regex matches the entire string representing this parameter,
+from \";\" to the end of its value. Another regular expression
+named `SYMBOLIC-NAME-value' is also defined to match just the
+value part, after \";PARAM-NAME=\", with groups 2 and 3 as above.
+
+A function to match the complete parameter expression called
+`icalendar-match-PARAM-NAME-param' is defined
+(or `icalendar-match-OTHER-param-value' if PARAM-NAME is nil,
+where OTHER is derived from SYMBOLIC-NAME by removing any prefix
+`icalendar-' and suffix `param'). This function is used
+to provide syntax highlighting in `icalendar-mode'.
+
+See the functions `icalendar-read-param-value',
+`icalendar-parse-param-value', `icalendar-parse-params' and
+`icalendar-print-param-node' to convert parameters defined with
+this macro to and from their text representation in iCalendar
+format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if the
+  parameter is not specified on a given property.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but syntactically
+  correct according to RFC5545. Unrecognized values must be in match
+  group 5 of the regex determined by VALUE. An unrecognized value will
+  be preserved in the syntax tree metadata and printed instead of this
+  value when the node is printed. Defaults to any value specified for
+  :default.
+
+:quoted - non-nil if values of this parameter must always be surrounded
+  by (double-)quotation marks when printed, according to RFC5545.
+
+:list-sep - if the parameter accepts a list of values, this should be a
+  string which separates the values (typically \",\"). If :list-sep is
+  non-nil, the value string will first be split on the separator, then
+  if :quoted is non-nil, the individual values will be unquoted, then
+  each value will be read according to VALUE and collected into a list
+  when parsing.  When printing, the inverse happens: values are quoted
+  if :quoted is non-nil, then joined with :list-sep. Passing this
+  argument marks SYMBOLIC-NAME as a type that accepts a list of values
+  for `icalendar-expects-list-of-values-p'.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-parameter-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-parameter-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form accepted for HIGHLIGHT in
+  `font-lock-keywords'.  In particular,
+    ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]]) ...)
+  can be used to apply different faces to different
+  match subgroups.
+
+:link - a string containing a URL for documentation of this parameter.
+  The URL will be provided in the documentation shown by
+  `describe-symbol' for SYMBOLIC-NAME."
+  (let* (;; Related function names:
+         (param-dname (if param-name
+                          (downcase param-name)
+                        (string-trim (symbol-name symbolic-name)
+                                     "icalendar-" "param")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" param-dname "-param")))
+
+         (type-predicate-name
+          (ical:protected-intern (concat "icalendar-" param-dname "-param-p")))
+         ;; Value regexes:
+         (qvalue-rx (if quoted `(seq ?\" ,value ?\") value))
+         (values-rx (when list-sep
+                     `(seq ,qvalue-rx (zero-or-more ,list-sep ,qvalue-rx))))
+         (full-value-rx-name (ical:protected-intern
+                               (concat (symbol-name symbolic-name) "-value")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-value-face
+                        nondefault-warn-face extra-faces))
+         ;; Documentation:
+         (header "It names a parameter type defined by `icalendar-define-param'.")
+         (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "...")
+                     "VAL"))
+         (s (if list-sep "s" "")) ; to make plurals
+         (val-doc (concat "VAL" s " "
+                          "must be " (unless list-sep "a ") (when quoted "quoted ")
+                          (if (ical:value-type-symbol-p value)
+                              (format "`%s' value%s" (symbol-name value) s)
+                            (format "string%s matching rx `%s'" s value))))
+         (syntax-doc (format "Syntax: %s=%s\n%s"
+                             (or param-name "(NAME)") val-list val-doc))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-param t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:is-quoted ,quoted
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regex which matches just the value of the parameter:
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx qvalue-rx))
+             (group-n 3 ical:param-value)))
+
+       ;; Regex which matches the full parameter:
+       ;; Group 1: the parameter name,
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,symbolic-name
+         (seq ";"
+              ;; if the parameter name has no printed form, the best we
+              ;; can do is match ical:param-name:
+              (group-n 1 ,(or param-name 'ical:param-name))
+              "="
+              ,full-value-rx-name))
+
+       ;; CL-type to represent syntax nodes for this parameter:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s parameter" param-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Matcher for the full param string, for syntax highlighting:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s parameter (defined by define-param)" param-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; Entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `ical:parse-params' and `ical:print-param':
+       (when ,param-name
+         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types)))))
+
+
+;; Define properties:
+(cl-defmacro ical:define-property (symbolic-name property-name doc value
+                                   &key default
+                                        (unrecognized default)
+                                        (default-type
+                                         (if (ical:value-type-symbol-p value)
+                                             value
+                                           'ical:text))
+                                        other-types
+                                        list-sep
+                                        child-spec
+                                        other-validator
+                                        ((:name-face name-face)
+                                         'ical:property-name nondefault-name-face)
+                                        ((:value-face value-face)
+                                         'ical:property-value nondefault-value-face)
+                                        ((:warn-face warn-face)
+                                         'ical:warning nondefault-warn-face)
+                                        extra-faces
+                                        link)
+  (declare (doc-string 2))
+  "Define iCalendar property PROPERTY-NAME under SYMBOLIC-NAME.
+PROPERTY-NAME should be the property name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing the
+property's value. If it is a regular expression, it is assumed
+that the values are strings of type `icalendar-text' which match
+that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME is defined to
+match the property:
+  Group 1 of this regex matches PROPERTY-NAME.
+  Group 2 matches VALUE.
+  Group 3, if matched, contains any property value which does
+   *not* match VALUE, and is incorrect according to the standard.
+  Group 4, if matched, contains the (unparsed) property parameters;
+   its boundaries can be used for parsing these.
+
+This regex matches the entire string representing this property,
+from the beginning of the content line to the end of its value.
+Another regular expression named `SYMBOLIC-NAME-value' is also
+defined to match just the value part, after the separating colon,
+with groups 2 and 3 as above.
+
+A function to match the complete property expression called
+`icalendar-match-PROPERTY-NAME-property' is defined. This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+See the functions `icalendar-read-property-value',
+`icalendar-parse-property-value', `icalendar-parse-property', and
+`icalendar-print-property-node' to convert properties defined
+with this macro to and from their text representation in
+iCalendar format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if
+  the property is not specified in a given component.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but
+  syntactically correct according to RFC5545. Unrecognized values
+  must be in match group 5 of the regex determined by VALUE. An
+  unrecognized value will be preserved in the syntax tree
+  metadata and printed instead of this value when the node is
+  printed. Defaults to any value specified for :default.
+
+:default-type - a type symbol naming the default type of the
+  property's value. If the property's value differs from this
+  type, an `icalendar-valuetypeparam' parameter will be added to
+  the property's syntax node and printed when the node is
+  printed. Default is VALUE if VALUE is a value type symbol,
+  otherwise the type `icalendar-text'.
+
+:other-types - a list of type symbols naming value types other
+  than :default-type. These represent alternative types for the
+  property's value. If parsing the property's value under its
+  default type fails, these types will be tried in turn, and only
+  if the property's value matches none of them will an error be
+  signaled.
+
+:list-sep - if the property accepts a list of values, this should
+  be a string which separates the values (typically \",\"). If
+  :list-sep is non-nil, the value string will first be split on
+  the separator, then each value will be read according to VALUE
+  and collected into a list when parsing. When printing, the
+  inverse happens: values are printed individually and then
+  joined with :list-sep. Passing this argument marks
+  SYMBOLIC-NAME as a type that accepts a list of values for
+  `icalendar-expects-list-of-values-p'.
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - parameters that must appear exactly once
+  :one-or-more   - parameters that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - parameters that must appear at most once
+  :zero-or-more  - parameters that may appear more than once
+  :allow-others  - if non-nil, other parameters besides those listed in
+                   the above are allowed to appear. (In this case, a
+                   :zero-or-more clause is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `icalendar-property-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `icalendar-property-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `icalendar-warning')
+
+:extra-faces - a list of the form for HIGHLIGHT in `font-lock-keywords'.
+  In particular, ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]])...)
+  can be used to apply different faces to different match subgroups.
+
+:link - a string containing a URL for documentation of this property"
+  (let* (;; Value RX:
+        (full-value-rx-name
+         (ical:protected-intern
+          (concat (symbol-name symbolic-name) "-property-value")))
+        (values-rx (when list-sep
+                    `(seq ,value (zero-or-more ,list-sep ,value))))
+        ;; Related functions:
+        (property-dname (if property-name
+                            (downcase property-name)
+                          (string-trim (symbol-name symbolic-name)
+                                       "icalendar-" "-property")))
+        (matcher-name (ical:protected-intern
+                       (concat "icalendar-match-"
+                               property-dname
+                               "-property")))
+        (type-predicate-name
+         (ical:protected-intern (concat "icalendar-"
+                                        property-dname
+                                        "-property-p")))
+        ;; Faces:
+        (has-faces (or nondefault-name-face nondefault-value-face
+                       nondefault-warn-face extra-faces))
+        ;; Documentation:
+        (header "It names a property type defined by `icalendar-define-property'.")
+        (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "...")
+                    "VAL"))
+        (default-doc (if default (format "The default value is: \"%s\"\n" default)
+                       ""))
+        (s (if list-sep "s" "")) ; to make plurals
+        (val-doc (concat "VAL" s " "
+                         "must be " (unless list-sep "a ")
+                         (format "value%s of one of the following types:\n" s)
+                         (string-join
+                          (cons
+                           (format "`%s' (default)" default-type)
+                           (mapcar (lambda (type) (format "`%s'" type))
+                                   other-types))
+                          "\n")
+                         default-doc))
+        (name-doc (if property-name "" "NAME must match rx `icalendar-name'"))
+        (syntax-doc (format "Syntax: %s[;PARAM...]:%s\n%s\n%s\n"
+                            (or property-name "NAME") val-list name-doc val-doc))
+        (child-doc
+         (concat
+          "The following parameters are required or allowed\n"
+          "as children in syntax nodes of this type:\n\n"
+          (ical:format-child-spec child-spec)
+          (when (plist-get child-spec :allow-others)
+            "\nOther parameters of any type are also allowed.\n")))
+        (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-property t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:default-type (quote ,default-type)
+                    'ical:other-types (quote ,other-types)
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Value regex which matches:
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx value))
+             (group-n 3 (zero-or-more any))))
+
+       ;; Full property regex which matches:
+       ;; Group 1: the property name,
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,symbolic-name
+         (seq line-start
+              (group-n 1 ,(or property-name 'ical:name))
+              (group-n 4 (zero-or-more ical:other-param-safe))
+              ":"
+              ,full-value-rx-name
+              line-end))
+
+       ;; Matcher:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for `%s' property (defined by define-property)"
+                  symbolic-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; CL-type to represent syntax nodes for this property:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s property" property-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-property', `icalendar-print-property-node', etc.:
+       (when ,property-name
+         (push (cons ,property-name (quote ,symbolic-name)) ical:property-types))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords)))))
+
+
+;; Define components:
+(cl-defmacro ical:define-component (symbolic-name component-name doc
+                                    &key
+                                    ((:keyword-face keyword-face)
+                                     'ical:keyword nondefault-keyword-face)
+                                    ((:name-face name-face)
+                                     'ical:component-name nondefault-name-face)
+                                    child-spec
+                                    other-validator
+                                    link)
+  (declare (doc-string 2))
+  "Define iCalendar component COMPONENT-NAME under SYMBOLIC-NAME.
+COMPONENT-NAME should be the name of the component as it should
+appear in iCalendar data.
+
+Regular expressions to match the component boundaries are defined
+named `COMPONENT-NAME-begin' and `COMPONENT-NAME-end' (or
+`OTHER-begin' and `OTHER-end', where `OTHER' is derived from
+SYMBOLIC-NAME by removing any prefix `icalendar-' and suffix
+`-component' if COMPONENT-NAME is nil).
+  Group 1 of these regexes matches the \"BEGIN\" or \"END\"
+    keyword that marks a component boundary.
+  Group 2 matches the component name.
+
+A function to match the component boundaries is defined called
+`icalendar-match-COMPONENT-NAME-component' (or
+`icalendar-match-OTHER-component', with OTHER as above). This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+The following keyword arguments are accepted:
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - properties or components that must appear exactly once
+  :one-or-more   - properties or components that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - properties or components that must appear at most once
+  :zero-or-more  - properties or components that may appear more than once
+  :allow-others  - if non-nil, other children besides those listed in the above
+                   are allowed to appear. (In this case, a :zero-or-more clause
+                   is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:keyword-face - a face symbol for highlighting the BEGIN/END keyword
+  (default: `icalendar-keyword')
+
+:name-face - a face symbol for highlighting the component name
+  (default: `icalendar-component-name')
+
+:link - a string containing a URL for documentation of this component"
+  (let* (;; Regexes:
+         (name-rx (or component-name 'ical:name))
+         (component-dname (if component-name
+                              (downcase component-name)
+                            (string-trim (symbol-name symbolic-name)
+                                         "icalendar-" "-component")))
+         (begin-rx-name (ical:protected-intern
+                         (concat "icalendar-" component-dname "-begin")))
+         (end-rx-name (ical:protected-intern
+                       (concat "icalendar-" component-dname "-end")))
+         ;; Related functions:
+         (matcher-name
+          (ical:protected-intern
+           (concat "icalendar-match-" component-dname "-component")))
+         (type-predicate-name
+          (ical:protected-intern
+           (concat "icalendar-" component-dname "-component-p")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-keyword-face))
+         ;; Documentation:
+         (header "It names a component type defined by
+`icalendar-define-component'.")
+         (name-doc (if (not component-name)
+                       "\nNAME must match rx `icalendar-name'"
+                     ""))
+         (syntax-doc (format "Syntax:\nBEGIN:%s\n[contentline ...]\nEND:%1$s%s"
+                             (or component-name "NAME")
+                             name-doc))
+         (child-doc
+          (concat
+           "The following properties and components are required or "
+           "allowed\nas children in syntax nodes of this type:\n\n"
+           (ical:format-child-spec child-spec)
+           (when (plist-get child-spec :allow-others)
+             "\nOther properties and components of any type are also allowed.\n")))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-component t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:begin-rx (quote ,begin-rx-name)
+                    'ical:end-rx (quote ,end-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regexes which match:
+       ;; Group 1: BEGIN or END, and
+       ;; Group 2: the component name
+       (rx-define ,begin-rx-name
+         (seq line-start
+              (group-n 1 "BEGIN")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (rx-define ,end-rx-name
+         (seq line-start
+              (group-n 1  "END")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s component boundaries"
+                  (or component-name "unrecognized"))
+           (re-search-forward (rx (or ,begin-rx-name ,end-rx-name)) limit t))
+
+       ;; CL-type to represent syntax nodes for this component:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s component"
+                  (or component-name "unrecognized"))
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,keyword-face) t t)
+                       (2 (quote ,name-face) t t)))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-component', `icalendar-print-component' etc.:
+       (when ,component-name
+         (push (cons ,component-name (quote ,symbolic-name))
+               ical:component-types)))))
+
+
+;; Macros for destructuring and binding AST nodes
+
+(defmacro ical:with-node-children (node bindings &rest body)
+  "Bind the variables in BINDINGS to the corresponding
+child nodes in NODE, and execute BODY with these bindings.  NODE should
+be an iCalendar syntax node representing a component or property.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :first - the first child node of TYPE
+   :default - the default value, if any, for TYPE
+   :value-node - the value (which is itself a node) of the node in :first
+   :value-type - the type of the node in :value-node.
+   :value - the value of the node in :value-node.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values (which are themselves nodes) of the node in :first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the node in :value-node.
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not."
+  (declare (indent 2))
+  ;; Static checks on the bindings prevent various annoying bugs:
+  (dolist (b bindings)
+    (let ((type (car b))
+          (kwargs (cdr b)))
+      (unless (ical:type-symbol-p type)
+        (error "Not an iCalendar type symbol: %s" type))
+      (when (and (plist-member kwargs :all)
+                 (> 2 (length kwargs)))
+        (error ":all may not be combined with other bindings"))
+      (if (ical:expects-list-of-values-p type)
+            (when (or (plist-member kwargs :value-node)
+                      (plist-member kwargs :value-type)
+                      (plist-member kwargs :value))
+              (error "Type `%s' expects a list of values" type))
+        (when (or (plist-member kwargs :value-nodes)
+                  (plist-member kwargs :value-types)
+                  (plist-member kwargs :values))
+              (error "Type `%s' does not expect a list of values" type)))))
+
+  (let ((nd (gensym "icalendar-node")))
+    `(let* ((,nd ,node)
+            ,@(mapcan
+               (lambda (tv)
+                 (let ((type (car tv))
+                       (vars (cdr tv)))
+                   (when (and (symbolp (car vars)) (null (cdr vars)))
+                     ;; the simple (TYPE VAR) case:
+                     (setq vars (list :first (car vars))))
+
+                   (let ((first-var (or (plist-get vars :first)
+                                        (gensym "first")))
+                         (default-var (or (plist-get vars :default)
+                                          (gensym "default")))
+                         (vnode-var (or (plist-get vars :value-node)
+                                        (gensym "value-node")))
+                         (vtype-var (or (plist-get vars :value-type)
+                                        (gensym "value-type")))
+                         (vval-var (or (plist-get vars :value)
+                                       (gensym "value")))
+
+                         (vnodes-var (or (plist-get vars :value-nodes)
+                                         (gensym "value-nodes")))
+                         (vtypes-var (or (plist-get vars :value-types)
+                                         (gensym "value-types")))
+                         (vvals-var (or (plist-get vars :values)
+                                        (gensym "values")))
+
+                         (all-var (or (plist-get vars :all)
+                                      (gensym "all")))
+                         ;; The corresponding vars for :all are too
+                         ;; complicated to be useful, I think, so
+                         ;; not implementing them for now
+                         )
+                     (delq nil
+                           (list
+                            (when (plist-member vars :all)
+                              `(,all-var (ical:ast-node-children-of
+                                          (quote ,type) ,nd)))
+                            (when (not (plist-member vars :all))
+                              `(,first-var (ical:ast-node-first-child-of
+                                            (quote ,type) ,nd)))
+                            (when (plist-member vars :default)
+                              `(,default-var (get (quote ,type)
+                                                  'ical:default-value)))
+                            ;; Single value:
+                            (when (or (plist-member vars :value-node)
+                                      (plist-member vars :value-type)
+                                      (plist-member vars :value))
+                              `(,vnode-var (when (ical:ast-node-p ,first-var)
+                                             (ical:ast-node-value ,first-var))))
+                            (when (plist-member vars :value-type)
+                              `(,vtype-var
+                                (when ,vnode-var
+                                  (ical:ast-node-type ,vnode-var))))
+                            (when (plist-member vars :value)
+                              `(,vval-var
+                                (when ,vnode-var
+                                  (ical:ast-node-value ,vnode-var))))
+
+                            ;; List of values:
+                            (when (or (plist-member vars :value-nodes)
+                                      (plist-member vars :value-types)
+                                      (plist-member vars :values))
+                              `(,vnodes-var
+                                (when (ical:ast-node-p ,first-var)
+                                  (ical:ast-node-value ,first-var))))
+                            (when (plist-member vars :value-types)
+                              `(,vtypes-var
+                                (when ,vnodes-var
+                                  (mapcar #'ical:ast-node-type ,vnodes-var))))
+                            (when (plist-member vars :values)
+                              `(,vvals-var
+                                (when ,vnodes-var
+                                  (mapcar #'ical:ast-node-value
+                                          ,vnodes-var)))))))))
+
+               bindings))
+       ,@body)))
+
+(defalias 'ical:with-component #'ical:with-node-children
+    "Bind the variables in BINDINGS to the corresponding
+properties in NODE, and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing an iCalendar
+component: `icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal',
+`icalendar-vtimezone', `icalendar-vfreebusy', `icalendar-standard',
+`icalendar-daylight'. It may also be an entire `icalendar-vcalendar'.
+
+Each binding in BINDINGS should be a list of one of the following forms:
+
+(TYPE VAR)
+  TYPE should be a type symbol for an iCalendar property or component
+  which can be a child of COMPONENT. The first child node of TYPE, if
+  any, will be bound to VAR in BODY.
+
+(TYPE KEY1 VAR1 ...)
+  For each KEY present, the corresponding VAR will be bound as follows:
+   :all - a list of all child nodes of TYPE. If this keyword is present,
+     none of the others are allowed.
+   :default - the default value, if any, for TYPE
+   :first - the first child node of TYPE
+   :value-node - the value (which is itself a node) of the node in :first
+   :value-type - the type of the node in :value-node.
+   :value - the value of the node in :value-node.
+  If TYPE expects a list of values, you should use the following keywords
+  instead of the previous three:
+   :value-nodes - the values (which are themselves nodes) of the node in :first
+   :value-types - a list of the types of the nodes in :value-nodes.
+   :values - a list of the values of the node in :value-node.
+  It is a compile-time error to use the singular keywords with a TYPE that
+  takes multiple values, or the plural keywords with a TYPE that does not.")
+
+(defmacro ical:with-node-value (node bindings &rest body)
+  "Bind the value in NODE and any of NODE's children in BINDINGS
+and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing a property or
+parameter. If NODE is not a syntax node, this form evalutes to nil
+without binding the variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is not a syntax node, then `value' is instead bound
+directly to NODE's value, and `value-type' and `value-node' are bound to
+nil.
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form."
+  (let ((vn (gensym "icalendar-node"))
+        (val (gensym "icalendar-value")))
+    `(let ((,vn ,node))
+       (when (ical:ast-node-p ,vn)
+         (let* ((,val (ical:ast-node-value ,vn))
+                (value-node (when (ical:ast-node-p ,val) ,val))
+                (value-type (when (ical:ast-node-p value-node)
+                              (ical:ast-node-type value-node)))
+                (value (if (ical:ast-node-p value-node)
+                           (ical:ast-node-value value-node)
+                         ,val)))
+           (ignore value-type) ; Silence the byte compiler when
+           (ignore value)      ; one of these goes unused
+           (ical:with-node-children ,vn ,bindings ,@body))))))
+
+(defalias 'ical:with-property #'ical:with-node-value
+    "Bind the property value in NODE and any of its parameters in BINDINGS
+and execute BODY with these bindings.
+
+NODE should be an iCalendar syntax node representing a property. If NODE
+is not a syntax node, this form evalutes to nil without binding the
+variables in BINDINGS and without executing BODY.
+
+Within BODY, if NODE's value is itself a syntax node, the symbol
+`value-node' will be bound to the syntax node for NODE's value,
+`value-type' will be bound to `value-node's type, and `value' will be
+bound to `value-node's value.
+
+If NODE's value is not a syntax node, then `value' is bound directly to
+NODE's value, and `value-type' and `value-node' are bound to nil.
+
+BINDINGS are passed on to `icalendar-with-node-children' and will be
+available in BODY; see its docstring for their form.")
+
+(defmacro ical:with-param (parameter &rest body)
+  "Bind the value in PARAMETER and execute BODY.
+
+PARAMETER should be an iCalendar syntax node representing a
+parameter. If PARAMETER is nil, this form evalutes to nil without
+executing BODY.
+
+Within BODY, if PARAMETER's value is a syntax node, the symbol
+`value-node' will be bound to that syntax node, `value-type' will be
+bound to the value node's type, and `value' will be bound to the value
+node's value.
+
+If PARAMETER's value is not a syntax node, then `value' is bound
+directly to PARAMETER's value, and `value-type' and `value-node' are
+bound to nil."
+  `(ical:with-node-value ,parameter nil ,@body))
+
+(defmacro ical:with-child-of (node type bindings &rest body)
+  "Like `icalendar-with-node-value', but applies to the parent of the node
+of interest.
+
+Find the first child node of type TYPE in NODE, bind that
+child node's value and any of its children in BINDINGS and execute BODY
+with these bindings.  If there is no such node, this form evalutes to
+nil without executing BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound as in `icalendar-with-node-value'. See
+`icalendar-with-node-children' for the form of BINDINGS."
+  (let ((child (gensym "icalendar-node")))
+    `(let ((,child (ical:ast-node-first-child-of ,type ,node)))
+       (ical:with-node-value ,child ,bindings ,@body))))
+
+(defalias 'ical:with-property-of #'ical:with-child-of
+  "Like `icalendar-with-property', but applies to the parent component NODE.
+
+Find the first property node of type TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the property's value node, type and value as in
+`icalendar-with-node-value'. BINDINGS can be used to bind its
+parameters; see `icalendar-with-node-children' for the form of BINDINGS.")
+
+(defalias 'ical:with-param-of #'ical:with-child-of
+  "Like `icalendar-with-param', but applies to the parent property NODE.
+
+Find the first parameter node of type TYPE in NODE and execute BODY.
+
+Within BODY, the symbols `value-node', `value-type', and `value' will be
+bound to the parameter's value node, type and value as in
+`icalendar-with-node-value'.")
+
+
+
+(provide 'icalendar-macs)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-macs.el ends here
diff --git a/lisp/calendar/icalendar-mode.el b/lisp/calendar/icalendar-mode.el
new file mode 100644
index 00000000000..49a7fb8afda
--- /dev/null
+++ b/lisp/calendar/icalendar-mode.el
@@ -0,0 +1,610 @@
+;;; icalendar-mode.el --- Major mode for iCalendar format  -*- lexical-binding: t; -*-
+;;;
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines icalendar-mode, a major mode for editing
+;; iCalendar data. It defines a syntax table, faces, hooks, and
+;; commands for the mode and sets up syntax highlighting via
+;; font-lock-mode. Syntax highlighting uses the entries for
+;; font-lock-keywords already gathered in icalendar-parser.el, which
+;; see.
+
+;; When activated, icalendar-mode offers to unfold content lines if
+;; necessary, and switch to a new buffer containing the unfolded data;
+;; see `ical:maybe-switch-to-unfolded-buffer'. This is because the
+;; parsing facilities, and thus syntax highlighting, assume that
+;; content lines have already been unfolded. When a buffer is saved,
+;; icalendar-mode also offers to fold long content if necessary, as
+;; required by RFC5545; see `ical:before-save-checks'.
+
+;;; Code:
+
+(require 'icalendar-parser)
+
+;; Faces and font lock:
+(defgroup ical:faces
+  '((ical:property-name custom-face)
+    (ical:property-value custom-face)
+    (ical:parameter-name custom-face)
+    (ical:parameter-value custom-face)
+    (ical:component-name custom-face)
+    (ical:keyword custom-face)
+    (ical:binary-data custom-face)
+    (ical:date-time-types custom-face)
+    (ical:numeric-types custom-face)
+    (ical:recurrence-rule custom-face)
+    (ical:warning custom-face)
+    (ical:ignored custom-face))
+  "Faces for icalendar-mode.") ; TODO: :group
+
+(defface ical:property-name
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for iCalendar property names")
+
+(defface ical:property-value
+  '((default . (:inherit default)))
+  "Face for iCalendar property values")
+
+(defface ical:parameter-name
+  '((default . (:inherit font-lock-property-name-face)))
+  "Face for iCalendar parameter names")
+
+(defface ical:parameter-value
+  '((default . (:inherit font-lock-property-use-face)))
+  "Face for iCalendar parameter values")
+
+(defface ical:component-name
+  '((default . (:inherit font-lock-constant-face)))
+  "Face for iCalendar component names")
+
+(defface ical:keyword
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for other iCalendar keywords")
+
+(defface ical:binary-data
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar values that represent binary data")
+
+(defface ical:date-time-types
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar values that represent dates, date-times,
+durations, periods, and UTC offsets")
+
+(defface ical:numeric-types
+  '((default . (:inherit ical:property-value-face)))
+  "Face for iCalendar values that represent integers, floats, and geolocations")
+
+(defface ical:recurrence-rule
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar recurrence rule values")
+
+(defface ical:uri
+  '((default . (:inherit ical:property-value-face :underline t)))
+  "Face for iCalendar values that are URIs (including URLs and mail addresses)")
+
+(defface ical:warning
+  '((default . (:inherit font-lock-warning-face)))
+  "Face for iCalendar syntax errors")
+
+(defface ical:ignored
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar syntax which is parsed but ignored")
+
+;;; Font lock:
+(defconst ical:params-font-lock-keywords
+  '((ical:match-other-param
+     (1 'font-lock-comment-face t t)
+     (2 'font-lock-comment-face t t)
+     (3 'ical:warning t t))
+    (ical:match-value-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sent-by-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-rsvp-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-role-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-reltype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-related-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-range-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-partstat-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-member-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-language-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-fbtype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-fmttype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-encoding-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-dir-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-to-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-from-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-cutype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-cn-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-altrep-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar property parameters in `font-lock-keywords'.")
+
+(defconst ical:properties-font-lock-keywords
+  '((ical:match-request-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-other-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sequence-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-last-modified-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstamp-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-created-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-trigger-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-repeat-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-action-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-rrule-property
+     (1 'ical:property-name t t)
+     (2 'ical:recurrence-rule t t)
+     (3 'ical:warning t t))
+    (ical:match-rdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-exdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-uid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-url-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-related-to-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-recurrence-id-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-organizer-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-contact-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attendee-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzurl-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetto-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetfrom-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzname-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-transp-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-freebusy-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-duration-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstart-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-due-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtend-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-completed-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-summary-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-resources-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-priority-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-percent-complete-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-location-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-geo-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-description-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-comment-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-class-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-categories-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attach-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t)
+     (13 'ical:uri t t)
+     (14 'ical:binary-data t t))
+    (ical:match-version-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-prodid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-method-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-calscale-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar properties in `font-lock-keywords'.")
+
+(defconst ical:ignored-properties-font-lock-keywords
+  `((,(rx ical:other-property) (1 'ical:ignored keep)
+                               (2 'ical:ignored keep)))
+  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
+
+(defconst ical:components-font-lock-keywords
+  '((ical:match-vcalendar-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-other-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-valarm-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-daylight-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-standard-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtimezone-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vfreebusy-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vjournal-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtodo-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vevent-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t)))
+  "Entries for iCalendar components in `font-lock-keywords'.")
+
+(defvar ical:font-lock-keywords
+  (append ical:params-font-lock-keywords
+          ical:properties-font-lock-keywords
+          ical:components-font-lock-keywords
+          ical:ignored-properties-font-lock-keywords)
+  "Value of `font-lock-keywords' for icalendar-mode.")
+
+
+;; The major mode:
+
+;;; Mode hook
+(defvar ical:mode-hook nil
+  "Hook run when activating `icalendar-mode'.")
+
+;;; Activating the mode for .ics files
+;;; TODO: what about e.g. buffers displaying text/calendar MIME parts?
+(add-to-list 'auto-mode-alist '("\\.ics\\'" . icalendar-mode))
+
+;;; Syntax table
+(defvar ical:mode-syntax-table
+    (let ((st (make-syntax-table)))
+      ;; Characters for which the standard syntax table suffices:
+      ;; ; (punctuation): separates some property values, and property parameters
+      ;; " (string): begins and ends string values
+      ;; : (punctuation): separates property name (and parameters) from property
+      ;;                  values
+      ;; , (punctuation): separates values in a list
+      ;; CR, LF (whitespace): content line endings
+      ;; space (whitespace): when at the beginning of a line, continues the
+      ;;                     previous line
+
+      ;; Characters which need to be adjusted from the standard syntax table:
+      ;; = is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?= ".   " st)
+      ;; / is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?/ ".   " st)
+      st)
+    "Syntax table used in `icalendar-mode'.")
+
+;;; Commands
+
+;; TODO: is there a corresponding list by mimetype for buffers
+;; displaying message parts? Thought I saw this somewhere...
+
+(defun ical:switch-to-unfolded-buffer ()
+  "Switch to viewing the contents of the current buffer in a new
+buffer where content lines have been unfolded.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets; `unfolding'
+means removing the extra whitespace inserted by folding. The
+iCalendar standard (RFC5545) requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it. In icalendar-mode, folded lines may not have proper
+syntax highlighting; this command allows you to view iCalendar
+data with proper syntax highlighting, as the parser sees it.
+
+If the current buffer is visiting a file, this function will
+offer to save the buffer first, and then reload the contents from
+the file, performing unfolding with `icalendar-unfold-undecoded-region'
+before decoding it. This is the most reliable way to unfold lines.
+
+If it is not visiting a file, it will unfold the new buffer
+with `icalendar-unfold-region'. This can in some cases have
+undesirable effects (see its docstring), so the original contents
+are preserved unchanged in the current buffer.
+
+In both cases, after switching to the new buffer, this command
+offers to kill the original buffer.
+
+It is recommended to turn off `auto-fill-mode' when viewing an
+unfolded buffer, so that filling does not interfere with syntax
+highlighting. This function offers to disable `auto-fill-mode' if
+it is enabled in the new buffer; consider using
+`visual-line-mode' instead."
+  (interactive)
+  (when (and buffer-file-name (buffer-modified-p))
+    (when (y-or-n-p (format "Save before reloading from %s?"
+                            (file-name-nondirectory buffer-file-name)))
+      (save-buffer)))
+  (let ((old-buffer (current-buffer))
+        (mmode major-mode)
+        (uf-buffer (if buffer-file-name
+                       (ical:unfolded-buffer-from-file buffer-file-name)
+                     (ical:unfolded-buffer-from-buffer (current-buffer)))))
+    (switch-to-buffer uf-buffer)
+    ;; restart original major mode, in case the new buffer is
+    ;; still in fundamental-mode: TODO: is this necessary?
+    (funcall mmode)
+    (when (y-or-n-p (format "Unfolded buffer is shown. Kill %s?"
+                            (buffer-name old-buffer)))
+      (kill-buffer old-buffer))
+    (when (and auto-fill-function
+               (y-or-n-p "Disable auto-fill-mode?"))
+      (auto-fill-mode -1))))
+
+(defun ical:maybe-switch-to-unfolded-buffer ()
+  "Check for folded lines and ask for confirmation before calling
+`icalendar-switch-to-unfolded-buffer', which see.
+
+This function is intended to be run via `icalendar-mode-hook'
+when `icalendar-mode' is activated."
+  (interactive)
+  (if (ical:contains-folded-lines-p)
+      (when (y-or-n-p "Buffer contains folded lines; unfold in new buffer?")
+        (ical:switch-to-unfolded-buffer))
+    ;; No need for unfolding, just inform the user:
+    (message "Buffer does not contain any lines to unfold")))
+
+(add-hook 'ical:mode-hook 'ical:maybe-switch-to-unfolded-buffer)
+
+(defun ical:before-save-checks ()
+  "Offer to change coding system and fold content lines in the
+current buffer when saving a buffer in `icalendar-mode'.
+
+The iCalendar standard requires CR-LF line endings, so if
+`buffer-file-coding-system' does not use a coding system which
+specifies them, this command offers to switch to a corresponding
+coding system which does.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets. The iCalendar
+standard requires folding lines when serializing data to
+iCalendar format, so if the buffer contains unfolded lines, this
+command asks you whether you want to fold them."
+  (interactive)
+  (when (eq major-mode 'icalendar-mode)
+    (let* ((cs buffer-file-coding-system)
+           (suggested-cs (if cs (coding-system-change-eol-conversion cs 'dos)
+                           'prefer-utf-8-dos)))
+      (when (and (not (coding-system-equal cs suggested-cs))
+                 (y-or-n-p
+                  (format "Current coding system %s does not use CR-LF line endings. Change to %s for save?" cs suggested-cs)))
+        (set-buffer-file-coding-system suggested-cs))
+      (when (and (ical:contains-unfolded-lines-p)
+                 (y-or-n-p "Fold content lines before saving?"))
+        (ical:fold-region (point-min) (point-max))))))
+
+(add-hook 'before-save-hook 'ical:before-save-checks)
+
+;;; Mode definition
+(define-derived-mode icalendar-mode text-mode "iCalendar"
+  "Major mode for viewing and editing iCalendar (RFC5545) data.
+
+This mode provides syntax highlighting for iCalendar components,
+properties, values, and property parameters, and commands to deal
+with folding and unfolding iCalendar content lines.
+
+`Folding' means inserting whitespace characters to continue long
+lines; `unfolding' means removing the extra whitespace inserted
+by folding. The iCalendar standard requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it.
+
+Thus icalendar-mode's syntax highlighting is designed to work with
+unfolded lines. When icalendar-mode is activated, it will offer to
+unfold lines; see `icalendar-switch-to-unfolded-buffer'. It will also
+offer to fold lines when saving a buffer to a file; see
+`icalendar-before-save-checks'. That function also offers to convert the
+line endings in the file to CR-LF, as the standard requires."
+  :group 'icalendar
+  :syntax-table ical:mode-syntax-table
+  ;; TODO: Keymap?
+  ;; TODO: buffer-local variables?
+  ;; TODO: indent-line-function and indentation variables
+  ;; TODO: mode-specific menu and context menus
+  ;; TODO: eldoc integration
+  ;; TODO: completion of keywords
+  ;; TODO: hook for folding in change-major-mode-hook?
+  (progn
+    (setq font-lock-defaults '(ical:font-lock-keywords nil t))))
+
+(provide 'icalendar-mode)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-mode.el ends here
diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-parser.el
new file mode 100644
index 00000000000..a47327319e1
--- /dev/null
+++ b/lisp/calendar/icalendar-parser.el
@@ -0,0 +1,4107 @@
+;;; icalendar-parser.el --- Parse iCalendar grammar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines regular expressions, constants and functions that
+;; implement the iCalendar grammar according to RFC5545.
+;;
+;; iCalendar data is grouped into *components*, such as events or
+;; to-do items. Each component contains one or more *content lines*,
+;; which each contain a *property* name and its *value*, and possibly
+;; also property *parameters* with additional data that affects the
+;; interpretation of the property.
+;;
+;; The macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', defined in
+;; icalendar-macs.el, each create rx-style regular expressions for one
+;; of these categories in the grammar and are used here to define the
+;; particular value types, parameters, properties and components in the
+;; standard as type symbols. These type symbols store all the metadata
+;; about the relevant types, and are used for type-based dispatch in the
+;; parser and printer functions. In the abstract syntax tree, each node
+;; contains a type symbol naming its type. A number of other regular
+;; expressions which encode basic categories of the grammar are also
+;; defined in this file.
+;;
+;; The following functions provide the high-level interface to the parser:
+;;
+;;   `icalendar-parse-component'
+;;   `icalendar-parse-property'
+;;   `icalendar-parse-params'
+;;
+;; The format of the abstract syntax tree which these functions create
+;; is documented in icalendar-ast.el. Nodes in this tree can be
+;; serialized to iCalendar format with the corresponding printer
+;; functions:
+;;
+;;   `icalendar-print-component-node'
+;;   `icalendar-print-property-node'
+;;   `icalendar-print-params'
+
+;;; Code:
+
+(eval-when-compile (require 'icalendar-macs))
+(require 'icalendar-ast)
+(eval-when-compile (require 'cl-lib))
+(require 'subr-x)
+(require 'seq)
+(require 'rx)
+(require 'calendar)
+(require 'time-date)
+(require 'simple)
+(require 'help-mode)
+
+;;; Functions for folding and unfolding
+;;
+;; According to RFC5545, iCalendar content lines longer than 75 octets
+;; should be *folded* by inserting extra line breaks and leading
+;; whitespace to continue the line. Such lines must be *unfolded*
+;; before they can be parsed.  Unfolding can only reliably happen
+;; before Emacs decodes a region of text, because decoding potentially
+;; replaces the CR-LF line endings which terminate content lines.
+;; Programs that can control when decoding happens should use the
+;; stricter `ical:unfold-undecoded-region' to unfold text; programs
+;; that must work with decoded data should use the looser
+;; `ical:unfold-region'. `ical:fold-region' will fold content lines
+;; using line breaks appropriate to the buffer's coding system.
+;;
+;; All the parsing-related code belows assumes that lines have
+;; already been unfolded if necessary.
+
+(defun ical:unfold-undecoded-region (start end &optional buffer)
+  "Unfold an undecoded region in BUFFER between START and END.
+If omitted, BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation). RFC5545 specifies these whitespace
+characters to be a CR-LF sequence followed by a single space or
+tab character. Unfolding can only be done reliably before a
+region is decoded, since decoding potentially replaces CR-LF line
+endings. This function searches strictly for CR-LF sequences, and
+will fail if they have already been replaced, so it should only
+be called with a region that has not yet been decoded."
+  (with-current-buffer (or buffer (current-buffer))
+    (with-restriction start end
+      (goto-char (point-min))
+      (while (re-search-forward (rx (seq "\r\n" (or " " "\t")))
+                                nil t)
+        (replace-match "" nil nil)))))
+
+(defun ical:unfold-region (start end &optional buffer)
+  "Unfold a region in BUFFER between START and END. If omitted,
+BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation).
+
+WARNING: Unfolding can only be done reliably before text is
+decoded, since decoding potentially replaces CR-LF line endings.
+Unfolding an already-decoded region could lead to unexpected
+results, such as displaying multibyte characters incorrectly,
+depending on the contents and the coding system used.
+
+This function attempts to do the right thing even if the region
+is already decoded. If it is still undecoded, it is better to
+call `icalendar-unfold-undecoded-region' directly instead, and
+decode it afterward."
+  ;; TODO: also make this a command so it can be run manually?
+  (with-current-buffer (or buffer (current-buffer))
+    (let ((was-multibyte enable-multibyte-characters)
+          (start-char (position-bytes start))
+          (end-char (position-bytes end)))
+      ;; we put the buffer in unibyte mode and later restore its
+      ;; previous state, so that if the buffer was already multibyte,
+      ;; any multibyte characters where line folds broke up their
+      ;; bytes can be reinterpreted:
+      (set-buffer-multibyte nil)
+      (with-restriction start-char end-char
+        (goto-char (point-min))
+        ;; since we can't be sure that line folds have a leading CR
+        ;; in already-decoded regions, do the best we can:
+        (while (re-search-forward (rx (seq (zero-or-one "\r") "\n"
+                                           (or " " "\t")))
+                                  nil t)
+          (replace-match "" nil nil)))
+      ;; restore previous state, possibly reinterpreting characters:
+      (set-buffer-multibyte was-multibyte))))
+
+(defun ical:unfolded-buffer-from-region (start end &optional buffer)
+  "Create a new buffer with the same contents as the region between
+START and END (in BUFFER, if provided) and perform line unfolding
+in the new buffer with `icalendar-unfold-region'. That function
+can in some cases have undesirable effects; see its docstring. If
+BUFFER is visiting a file, it may be better to reload its
+contents from that file and perform line unfolding before
+decoding; see `icalendar-unfolded-buffer-from-file'. Returns the
+new buffer."
+  (let* ((old-buffer (or buffer (current-buffer)))
+         (contents (with-current-buffer old-buffer
+                     (buffer-substring start end)))
+         (uf-buffer (generate-new-buffer
+                     (concat (buffer-name old-buffer)
+                             "~UNFOLDED")))) ;; TODO: again, move to modeline?
+    (with-current-buffer uf-buffer
+      (insert contents)
+      (ical:unfold-region (point-min) (point-max))
+      ;; ensure we'll use CR-LF line endings on write, even if they weren't
+      ;; in the source data. The standard also says UTF-8 is the default
+      ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+      ;; is nil.
+      (setq buffer-file-coding-system
+            (if last-coding-system-used
+                (coding-system-change-eol-conversion last-coding-system-used
+                                                     'dos)
+              'prefer-utf-8-dos)))
+    uf-buffer))
+
+(defun ical:unfolded-buffer-from-buffer (buffer)
+  "Create a new buffer with the same contents as BUFFER and perform
+line unfolding with `icalendar-unfold-region'. That function can in
+some cases have undesirable effects; see its docstring. If BUFFER
+is visiting a file, it may be better to reload its contents from
+that file and perform line unfolding before decoding; see
+`icalendar-unfolded-buffer-from-file'. Returns the new buffer."
+  (with-current-buffer buffer
+    (ical:unfolded-buffer-from-region (point-min) (point-max) buffer)))
+
+(defun ical:unfolded-buffer-from-file (filename &optional visit beg end)
+    "Create a new buffer with the contents of FILENAME and perform
+line unfolding with `icalendar-unfold-undecoded-region', then
+decode the buffer, setting an appropriate value for
+`buffer-file-coding-system'. Optional arguments VISIT, BEG, END
+are as in `insert-file-contents'. Returns the new buffer."
+    (unless (and (file-exists-p filename)
+                 (file-readable-p filename))
+      (error "File cannot be read: %s" filename))
+    ;; TODO: instead of messing with the buffer name, it might be more
+    ;; useful to keep track of the folding state in a variable and
+    ;; display it somewhere else in the mode line
+    (let ((uf-buffer (generate-new-buffer (concat (file-name-nondirectory filename)
+                                                  "~UNFOLDED"))))
+      (with-current-buffer uf-buffer
+        (set-buffer-multibyte nil)
+        (insert-file-contents-literally filename visit beg end t)
+        (ical:unfold-undecoded-region (point-min) (point-max))
+        (set-buffer-multibyte t)
+        (decode-coding-inserted-region (point-min) (point-max) filename)
+        ;; ensure we'll use CR-LF line endings on write, even if they weren't
+        ;; in the source data. The standard also says UTF-8 is the default
+        ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+        ;; is nil. FIXME: for some reason, this doesn't seem to run at all!
+        (setq buffer-file-coding-system
+              (if last-coding-system-used
+                  (coding-system-change-eol-conversion last-coding-system-used
+                                                       'dos)
+                'prefer-utf-8-dos))
+        ;; restore buffer name after renaming by set-visited-file-name:
+        (let ((bname (buffer-name)))
+          (set-visited-file-name filename t)
+          (rename-buffer bname)))
+      uf-buffer))
+
+(defun ical:fold-region (begin end &optional use-tabs)
+  "Fold all content lines in the region longer than 75 octets.
+
+\"Folding\" means inserting a line break and a single space
+character at the beginning of the new line. If USE-TABS is
+non-nil, insert a tab character instead of a single space.
+
+RFC5545 specifies that lines longer than 75 *octets* (excluding
+the line-ending CR-LF sequence) must be folded, and allows that
+some implementations might fold lines in the middle of a
+multibyte character. This function takes care not to do that in a
+buffer where `enable-multibyte-characters' is non-nil, and only
+folds between character boundaries. If the buffer is in unibyte
+mode, however, and contains undecoded multibyte data, it may fold
+lines in the middle of a multibyte character."
+  ;; TODO: also make this a command so it can be run manually?
+  (save-excursion
+    (goto-char begin)
+    (when (not (bolp))
+      (let ((inhibit-field-text-motion t))
+        (beginning-of-line)))
+    (let ((bol (point))
+          (eol (make-marker))
+          (reg-end (make-marker))
+          (line-fold
+           (concat
+            ;; if \n will be translated to \r\n on save (EOL type 1,
+            ;; "DOS"), just insert \n, otherwise the full fold sequence:
+            ;; FIXME: is buffer-file-coding-system the only relevant one here?
+            ;; What if the buffer is not visiting a file, but has come from a
+            ;; process, represents a mime part in an email, etc.?
+            (if (eq 1 (coding-system-eol-type buffer-file-coding-system))
+                "\n"
+              "\r\n")
+            ;; leading whitespace after line break:
+            (if use-tabs "\t" " "))))
+      (set-marker reg-end end)
+      (while (< bol reg-end)
+        (let ((inhibit-field-text-motion t))
+          (end-of-line))
+        (set-marker eol (point))
+        (when (< 75 (- (position-bytes (marker-position eol))
+                       (position-bytes bol)))
+          (goto-char
+           ;; the max of 75 excludes the two CR-LF
+           ;; characters we're about to add:
+           (byte-to-position (+ 75 (position-bytes bol))))
+          (insert line-fold)
+          (set-marker eol (point)))
+        (setq bol (goto-char (1+ eol)))))))
+
+(defun ical:contains-folded-lines-p ()
+  "Determine whether the current buffer contains folded content
+lines that should be unfolded for parsing and display purposes.
+If it does, return the position at the end of the first fold."
+  (save-excursion
+    (goto-char (point-min))
+    (re-search-forward (rx (seq line-start (or " " "\t")))
+                       nil t)))
+
+(defun ical:contains-unfolded-lines-p ()
+  "Determine whether the current buffer contains long content lines
+that should be folded before saving or transmitting. If it does,
+return the position at the beginning of the first line that
+requires folding."
+  (save-excursion
+    (goto-char (point-min))
+    (let ((bol (point))
+          (eol (make-marker)))
+      (catch 'unfolded-line
+        (while (< bol (point-max))
+          (let ((inhibit-field-text-motion t))
+            (end-of-line))
+          (set-marker eol (point))
+          ;; the max of 75 excludes the two CR-LF characters
+          ;; after position eol:
+          (when (< 75 (- (position-bytes (marker-position eol))
+                         (position-bytes bol)))
+            (throw 'unfolded-line bol))
+          (setq bol (goto-char (1+ eol))))
+        nil))))
+
+
+;; Parsing-related code starts here. All the parsing code assumes that
+;; content lines have already been unfolded.
+
+;;;; Error handling:
+
+;; Errors at the parsing stage:
+;; e.g. value does not match expected regex
+(define-error 'ical:parse-error "Could not parse iCalendar data")
+
+;; Errors at the printing stage:
+;; e.g. default print function doesn't know how to print value
+(define-error 'ical:print-error "Unable to print iCalendar data")
+
+;;;; Some utilities:
+(defun ical:parse-one-of (types limit)
+  "Parse a value of one of the TYPES, which should be a list of type
+symbols, from point up to LIMIT. For each type in TYPES, the
+parser function associated with that type will be called at
+point. The return value of the first successful parser function
+is returned. If none of the parser functions are able to parse a
+value, an `icalendar-parse-error' is signaled."
+  (let* ((value nil)
+         (start (point))
+         (type (car types))
+         (parser (get type 'ical:value-parser))
+         (rest (cdr types)))
+    (while (and parser (not value))
+      (condition-case nil
+          (setq value (funcall parser limit))
+        (ical:parse-error
+         ;; value of this type not found, so try again:
+         (goto-char start)
+         (setq type (car rest)
+               rest (cdr rest)
+               parser (get type 'ical:value-parser)))))
+    (unless value
+      (signal 'ical:parse-error
+              (list (format "Unable to parse any of %s between %d and %d"
+                            types start limit))))
+    value))
+
+(defun ical:read-list-with (reader string
+                            &optional value-regex separators omit-nulls trim)
+  "Read a list of values from STRING with READER.
+
+READER should be a reader function that accepts a single string argument.
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string'.
+SEPARATORS defaults to \"[^\\][,;]\". TRIM defaults to matching a
+double quote character.
+
+VALUE-REGEX should be a regular expression if READER assumes that
+individual substrings in STRING have previously been matched
+against this regex. In this case, each value in S is placed in a
+temporary buffer and the match against VALUE-REGEX is performed
+before READER is called."
+  (let* ((wrapped-reader
+           (if (not value-regex)
+               ;; no need for temp buffer:
+               reader
+             ;; match the regex in a temp buffer before calling reader:
+             (lambda (s)
+               (with-temp-buffer
+                 (insert s)
+                 (goto-char (point-min))
+                 (unless (looking-at value-regex)
+                   (signal 'ical:parse-error
+                           (list (format "Expected list of values matching '%s'"
+                                         value-regex)
+                                 s)))
+                 (funcall reader (match-string 0))))))
+         (seps (or separators "[^\\][,;]"))
+         (trm (or trim "\""))
+         (raw-values (split-string string seps omit-nulls trm)))
+
+    (unless (functionp reader)
+      (signal 'ical:parser-error
+              (list (format "`%s' is not a reader function" reader))))
+
+    (mapcar wrapped-reader raw-values)))
+
+(defun ical:read-list-of (type string
+                          &optional separators omit-nulls trim)
+  "Read a list of values of type TYPE from STRING.
+
+TYPE should be a value type symbol. The reader function
+associated with that type will be called to read the successive
+values in STRING, and the values will be returned as a list of
+syntax nodes.
+
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string' and
+will be passed on, if provided, to `icalendar-read-list-with'."
+  (let* ((reader (lambda (s) (ical:read-value-node type s)))
+         (val-regex (rx-to-string (get type 'ical:value-rx))))
+    (ical:read-list-with reader string val-regex
+                         separators omit-nulls trim)))
+
+(defun ical:list-of-p (list type)
+  "Returns non-nil if each value in LIST satisfies TYPE according to
+`cl-typep'"
+  (seq-every-p (lambda (val) (cl-typep val type)) list))
+
+(defun ical:default-value-printer (val)
+  "Default printer for a *single* property or parameter value.
+
+If VAL is a string, just return it unchanged.
+
+Otherwise, VAL should be a syntax node representing a value. In
+that case, return the original string value if another was
+substituted at parse time, or look up the printer function for
+the node's type and call it on the value inside the node.
+
+For properties and parameters that only allow a single value,
+this function should be a sufficient value printer. It is not
+sufficient for those that allow lists of values, or which have
+other special requirements like quoting or escaping."
+  (cond ((stringp val) val)
+        ((and (ical:ast-node-p val)
+              (get (ical:ast-node-type val) 'ical:value-printer))
+         (or (ical:ast-node-meta-get :original-value val)
+             (let* ((stored-value (ical:ast-node-value val))
+                    (type (ical:ast-node-type val))
+                    (printer (get type 'ical:value-printer)))
+               (funcall printer stored-value))))
+        ;; TODO: other cases to make things easy?
+        ;; e.g. symbols print as their names?
+        (t (signal 'ical:print-error
+                   (list (format "Don't know how to print value: %s" val)
+                         val)))))
+
+
+;;; Section 3.1: Content lines
+
+;; Regexp constants for parsing:
+
+;; In the following regexps and define-* declarations, because
+;; Emacs does not have named groups, we observe the following
+;; convention so that the regexps can be combined in sensible ways:
+;;
+;; - Groups 1 through 5 are reserved for the highest-level regexes
+;;   created by define-param, define-property and define-component and
+;;   used in the match-* functions. Group 1 always represents a 'key'
+;;   (e.g. param or property name), group 2 always represents a
+;;   correctly parsed value for that key, and group 3 (if matched) an
+;;   invalid or unknown value.
+;;
+;;   Groups 4 and 5 are reserved for other information in these
+;;   highest-level regexes, such as the parameter string between a
+;;   property name and its value, or unrecognized values allowed by
+;;   the standard and required to be treated like a default value.
+;;
+;; - Groups 6 through 10 are currently unused
+;; - Groups 11 through 20 are reserved for significant sub-expressions
+;;   of individual value expressions, e.g. the number of weeks in a
+;;   duration value. The various read-* functions rely on these groups
+;;   when converting iCalendar data to Elisp data structures.
+
+(rx-define ical:iana-token
+  (one-or-more (any alnum "-")))
+
+(rx-define ical:x-name
+  (seq "X-"
+      (zero-or-one (>= 3 (any alnum)) "-") ; Vendor ID
+      (one-or-more (any alnum "-")))) ; Name
+
+(rx-define ical:name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:crlf
+  (seq #x12 #xa))
+
+(rx-define ical:control
+  ;; All the controls except HTAB
+  (any (#x00 . #x08) (#x0A . #x1F) #x7F))
+
+;; TODO: double check that "nonascii" class actually corresponds to
+;; the range in the standard
+(rx-define ical:safe-char
+  ;; Any character except ical:control, ?\", ?\;, ?:, ?,
+  (any #x09 #x20 #x21  (#x23 . #x2B) (#x2D . #x39) (#x3C . #x7E) nonascii))
+
+(rx-define ical:qsafe-char
+  ;; Any character except ical:control and ?"
+  (any #x09 #x20 #x21 (#x23 . #x7E) nonascii))
+
+(rx-define ical:quoted-string
+  (seq ?\" (zero-or-more ical:qsafe-char) ?\"))
+
+(rx-define ical:paramtext
+  ;; RFC5545 allows *zero* characters here, but that would mean we could
+  ;; have parameters like ;FOO=;BAR="somethingelse", and what would then
+  ;; be the value of FOO? I see no reason to allow this and it breaks
+  ;; parameter parsing so I have required at least one char here
+  (one-or-more ical:safe-char))
+
+(rx-define ical:param-name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:param-value
+  (or ical:paramtext ical:quoted-string))
+
+(rx-define ical:value-char
+  (any #x09 #x20 (#x21 . #x7E) nonascii))
+
+(rx-define ical:value
+  (zero-or-more ical:value-char))
+
+;; some helpers for brevity, not defined in the standard:
+(rx-define ical:comma-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?, item-rx))))
+
+(rx-define ical:semicolon-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?\; item-rx))))
+
+
+;;; Section 3.3: Property Value Data Types
+
+;; Note: These definitions are here (out of order with respect to the
+;; standard) because a few of them are already required for property
+;; parameter definitions (section 3.2) below.
+
+(defconst ical:value-types nil ;; populated by define-type
+  "Alist mapping value type strings in `icalendar-valuetypeparam'
+parameters to type symbols defined with `icalendar-define-type'")
+
+(defun ical:read-value-node (type s)
+  "Read an iCalendar value of type TYPE from string S to a syntax node.
+Returns a syntax node containing the value."
+  (let ((reader (get type 'ical:value-reader)))
+    (ical:make-ast-node type (list :value (funcall reader s)))))
+
+(defun ical:parse-value-node (type limit)
+  "Parse an iCalendar value of type TYPE from point up to LIMIT.
+Returns a syntax node containing the value."
+  (let ((value-regex (rx-to-string (get type 'ical:value-rx))))
+
+    (unless (re-search-forward value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "No %s value between %d and %s"
+                            type (point) limit))))
+
+    (let ((begin (match-beginning 0))
+          (end (match-end 0))
+          (node (ical:read-value-node type (match-string 0))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      (ical:ast-node-meta-set node :begin begin)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-value-node (node)
+  "Serialize an iCalendar syntax node containing a value to a string."
+  (let* ((type (ical:ast-node-type node))
+         (value-printer (get type 'ical:value-printer)))
+    (funcall value-printer (ical:ast-node-value node))))
+
+(defun ical:printable-value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a printable
+iCalendar value type, i.e., a type for a property or parameter
+value defined by `icalendar-define-type' which has a print
+name (mainly for use in `icalendar-valuetypeparam' parameters).
+
+This means that SYMBOL must both satisfy
+`icalendar-value-type-symbol-p' and be associated with a print
+name in `icalendar-value-types'."
+  (and (ical:value-type-symbol-p symbol)
+       (rassq symbol ical:value-types)))
+
+(defun ical:value-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a value type."
+  (and (ical:ast-node-p node)
+       (ical:value-type-symbol-p (ical:ast-node-type node))))
+
+;;;; 3.3.1 Binary
+;; from https://www.rfc-editor.org/rfc/rfc4648#section-4:
+(rx-define ical:base64char
+  (any (?A . ?Z) (?a . ?z) (?0 . ?9) ?+ ?/))
+
+(ical:define-type ical:binary "BINARY"
+   "Type for Binary values.
+
+The parsed and printed representations are the same: a string of characters
+representing base64-encoded data."
+   '(and string (satisfies ical:match-binary-value))
+   (seq (zero-or-more (= 4 ical:base64char))
+        (zero-or-one (or (seq (= 2 ical:base64char) "==")
+                         (seq (= 3 ical:base64char) "="))))
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.1")
+
+;;;; 3.3.2 Boolean
+(defun ical:read-boolean (s)
+  "Read an `icalendar-boolean' value from a string S.
+S should be a match against rx `icalendar-boolean'."
+  (let ((upcased (upcase s)))
+    (cond ((equal upcased "TRUE") t)
+          ((equal upcased "FALSE") nil)
+          (t (signal 'ical:parse-error
+                     (list "Expected 'TRUE' or 'FALSE'" s))))))
+
+(defun ical:print-boolean (b)
+  "Serialize an `icalendar-boolean' value B to a string."
+    (if b "TRUE" "FALSE"))
+
+(ical:define-type ical:boolean "BOOLEAN"
+   "Type for Boolean values.
+
+When printed, either the string 'TRUE' or 'FALSE'.
+When read, either t or nil."
+   'boolean
+   (or "TRUE" "FALSE")
+   :reader ical:read-boolean
+   :printer ical:print-boolean
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.2")
+
+;;;; 3.3.3 Calendar User Address
+;; Defined with URI, below
+
+;; Dates and Times:
+
+;;;; 3.3.4 Date
+(cl-deftype ical:numeric-year () '(integer 0 9999))
+(cl-deftype ical:numeric-month () '(integer 1 12))
+(cl-deftype ical:numeric-monthday () '(integer 1 31))
+
+(rx-define ical:year
+  (= 4 digit))
+
+(rx-define ical:month
+  (= 2 digit))
+
+(rx-define ical:mday
+  (= 2 digit))
+
+(defun ical:read-date (s)
+  "Read an `icalendar-date' from a string S.
+S should be a match against rx `icalendar-date'."
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8))))
+    (list month day year)))
+
+(defun ical:print-date (d)
+  "Serialize an `icalendar-date' to a string."
+  (format "%04d%02d%02d"
+          (calendar-extract-year d)
+          (calendar-extract-month d)
+          (calendar-extract-day d)))
+
+(ical:define-type ical:date "DATE"
+   "Type for Date values.
+
+When printed, a date is a string of digits in YYYYMMDD format.
+
+When read, a date is a list (MONTH DAY YEAR), with the three
+values being integers in the appropriate ranges; see `calendar.el'
+for functions that work with this representation."
+   '(and (satisfies calendar-date-is-valid-p))
+   (seq ical:year ical:month ical:mday)
+   :reader ical:read-date
+   :printer ical:print-date
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.4")
+
+;;;; 3.3.12 Time
+;; (Defined here so that ical:time RX can be used in ical:date-time)
+(cl-deftype ical:numeric-hour () '(integer 0 23))
+(cl-deftype ical:numeric-minute () '(integer 0 59))
+(cl-deftype ical:numeric-second () '(integer 0 60)) ; 60 represents a leap second
+
+(declare-function ical:make-date-time "icalendar-utils")
+
+(defun ical:read-time (s)
+  "Read an `icalendar-time' from a string S.
+S should be a match against rx `icalendar-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular require
+  (let ((hour (string-to-number (substring s 0 2)))
+        (minute (string-to-number (substring s 2 4)))
+        (second (string-to-number (substring s 4 6)))
+        (utcoffset (if (and (length= s 7)
+                            (equal "Z" (substring s 6 7)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :zone utcoffset)))
+
+(defun ical:print-time (time)
+  "Serialize an `icalendar-time' to a string."
+  (format "%02d%02d%02d%s"
+          (decoded-time-hour time)
+          (decoded-time-minute time)
+          (decoded-time-second time)
+          (if (eql 0 (decoded-time-zone time))
+              "Z" "")))
+
+(defun ical:-decoded-time-p (val)
+  "Return non-nil if VAL is a valid decoded *time*.
+This predicate does not check date-related values in VAL;
+for that, see `icalendar--decoded-date-time-p'."
+  ;; FIXME: this should probably be defined alongside the
+  ;; other decoded-time-* functions!
+  (and (listp val)
+       (length= val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(ical:define-type ical:time "TIME"
+  "Type for Time values.
+
+When printed, a time is a string of six digits HHMMSS, followed
+by the letter 'Z' if it is in UTC.
+
+When read, a time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values. When
+read, the DAY, MONTH, YEAR, and DOW fields are nil, and these
+fields and DST are ignored when printed."
+  '(satisfies ical:-decoded-time-p)
+  (seq (= 6 digit) (zero-or-one ?Z))
+  :reader ical:read-time
+  :printer ical:print-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.12")
+
+;;;; 3.3.5 Date-Time
+(defun ical:-decoded-date-time-p (val)
+  (and (listp val)
+       (length= val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-day val) 'ical:numeric-monthday)
+       (cl-typep (decoded-time-month val) 'ical:numeric-month)
+       (cl-typep (decoded-time-year val) 'ical:numeric-year)
+       (calendar-date-is-valid-p (list (decoded-time-month val)
+                                       (decoded-time-day val)
+                                       (decoded-time-year val)))
+       ;; FIXME: the weekday slot value should be automatically
+       ;; calculated from month, day, and year, like:
+       ;;   (calendar-day-of-week (list month day year))
+       ;; Although `ical:read-date-time' does this correctly,
+       ;; `make-decoded-time' does not. Thus we can't use
+       ;; `make-decoded-time' to construct valid `ical:date-time'
+       ;; values unless this check is turned off,
+       ;; which means it's annoying to write tests and anything
+       ;; that uses cl-typecase to dispatch on values created by
+       ;; `make-decoded-time':
+       ;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(defun ical:read-date-time (s)
+  "Read an `icalendar-date-time' from a string S.
+S should be a match against rx `icalendar-date-time'."
+  (require 'icalendar-utils) ; for ical:make-date-time; avoids circular requires
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8)))
+        ;; "T" is index 8
+        (hour (string-to-number (substring s 9 11)))
+        (minute (string-to-number (substring s 11 13)))
+        (second (string-to-number (substring s 13 15)))
+        (utcoffset (if (and (length= s 16)
+                            (equal "Z" (substring s 15 16)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (ical:make-date-time :second second
+                         :minute minute
+                         :hour hour
+                         :day day
+                         :month month
+                         :year year
+                         :zone utcoffset)))
+
+(defun ical:print-date-time (datetime)
+  "Serialize an `icalendar-date-time' to a string."
+  (format "%04d%02d%02dT%02d%02d%02d%s"
+          (decoded-time-year datetime)
+          (decoded-time-month datetime)
+          (decoded-time-day datetime)
+          (decoded-time-hour datetime)
+          (decoded-time-minute datetime)
+          (decoded-time-second datetime)
+          (if (ical:date-time-is-utc-p datetime)
+              "Z" "")))
+
+(defun ical:date-time-is-utc-p (datetime)
+  "Return non-nil if DATETIME is in UTC time"
+  (let ((offset (decoded-time-zone datetime)))
+    (and offset (= 0 offset))))
+
+(ical:define-type ical:date-time "DATE-TIME"
+   "Type for Date-Time values.
+
+When printed, a date-time is a string of digits like:
+  YYYYMMDDTHHMMSS
+where the 'T' is literal, and separates the date string from the
+time string.
+
+When read, a date-time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values."
+   '(satisfies ical:-decoded-date-time-p)
+  (seq ical:date ?T ical:time)
+  :reader ical:read-date-time
+  :printer ical:print-date-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5")
+
+;;;; 3.3.6 Duration
+(rx-define ical:dur-second
+  (seq (group-n 19 (one-or-more digit)) ?S))
+
+(rx-define ical:dur-minute
+  (seq (group-n 18 (one-or-more digit)) ?M (zero-or-one ical:dur-second)))
+
+(rx-define ical:dur-hour
+  (seq (group-n 17 (one-or-more digit)) ?H (zero-or-one ical:dur-minute)))
+
+(rx-define ical:dur-day
+  (seq (group-n 16 (one-or-more digit)) ?D))
+
+(rx-define ical:dur-week
+  (seq (group-n 15 (one-or-more digit)) ?W))
+
+(rx-define ical:dur-time
+  (seq ?T (or ical:dur-hour ical:dur-minute ical:dur-second)))
+
+(rx-define ical:dur-date
+  (seq ical:dur-day (zero-or-one ical:dur-time)))
+
+;; TODO: This function already exists! Super: replace with iso8601-parse-duration
+(defun ical:read-dur-value (s)
+  "Read an `icalendar-dur-value' from a string S.
+S should be a match against rx `icalendar-dur-value'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((sign (if (equal (match-string 20) "-") -1 1)))
+    (if (match-string 15)
+        ;; dur-value specified in weeks, so just return an integer:
+        (* sign (string-to-number (match-string 15)))
+      ;; otherwise, make a time delta from the other units:
+      (let* ((days (match-string 16))
+             (ndays (* sign (if days (string-to-number days) 0)))
+             (hours (match-string 17))
+             (nhours (* sign (if hours (string-to-number hours) 0)))
+             (minutes (match-string 18))
+             (nminutes (* sign (if minutes (string-to-number minutes) 0)))
+             (seconds (match-string 19))
+             (nseconds (* sign (if seconds (string-to-number seconds) 0))))
+        (make-decoded-time :second nseconds :minute nminutes :hour nhours
+                           :day ndays)))))
+
+(defun ical:print-dur-value (dur)
+  "Serialize an `icalendar-dur-value' to a string"
+  (if (integerp dur)
+      ;; dur-value specified in weeks can only contain weeks:
+      (format "%sP%dW" (if (< dur 0) "-" "") (abs dur))
+    ;; otherwise, show all the time units present:
+    (let* ((days+- (or (decoded-time-day dur) 0))
+           (hours+- (or (decoded-time-hour dur) 0))
+           (minutes+- (or (decoded-time-minute dur) 0))
+           (seconds+- (or (decoded-time-second dur) 0))
+           ;; deal with the possibility of mixed positive and negative values
+           ;; in a time delta list:
+           (sum (+ seconds+-
+                   (* 60 minutes+-)
+                   (* 60 60 hours+-)
+                   (* 60 60 24 days+-)))
+           (abssum (abs sum))
+           (days (/ abssum (* 60 60 24)))
+           (sumnodays (mod abssum (* 60 60 24)))
+           (hours (/ sumnodays (* 60 60)))
+           (sumnohours (mod sumnodays (* 60 60)))
+           (minutes (/ sumnohours 60))
+           (seconds (mod sumnohours 60))
+           (sign (when (< sum 0) "-"))
+           (time-sep (unless (and (zerop hours) (zerop minutes) (zerop seconds))
+                       "T")))
+      (concat sign
+              "P"
+              (unless (zerop days) (format "%dD" days))
+              time-sep
+              (unless (zerop hours) (format "%dH" hours))
+              (unless (zerop minutes) (format "%dM" minutes))
+              (unless (zerop seconds) (format "%dS" seconds))))))
+
+(defun ical:-time-delta-p (val)
+  (and (listp val)
+       (length= val 9)
+       (let ((seconds (decoded-time-second val))
+             (minutes (decoded-time-minute val))
+             (hours (decoded-time-hour val))
+             (days (decoded-time-day val))) ; other values in list are ignored
+         (and
+          (cl-typep seconds 'integer)
+          (cl-typep minutes 'integer)
+          (cl-typep hours 'integer)
+          (cl-typep days 'integer)
+          (not (and (zerop seconds) (zerop minutes) (zerop hours)
+                    (zerop days)))))))
+
+(ical:define-type ical:dur-value "DURATION"
+  "Type for Duration values.
+
+When printed, a duration is a string containing:
+  - possibly a +/- sign
+  - the letter 'P'
+  - one or more sequences of digits followed by a letter representing a unit
+    of time: 'W' for weeks, 'D' for days, etc. Units smaller than a day are
+    separated from days by the letter 'T'. If a duration is specified in weeks,
+    other units of time are not allowed.
+
+For example, a duration of 15 days, 5 hours, and 20 seconds would be printed:
+   P15DT5H0M20S
+and a duration of 7 weeks would be printed:
+   P7W
+
+When read, a duration is either an integer, in which case it
+represents a number of weeks, or a decoded time, in which case it
+must represent a time delta in the sense of `decoded-time-add'.
+Note that, in the time delta representation, units of time longer
+than a day are not supported and will be ignored if present.
+
+This type is named `icalendar-dur-value' rather than
+`icalendar-duration' for consistency with the text of RFC5545 and
+so that its name does not collide with the symbol for the
+`DURATION' property."
+  '(or integer (satisfies ical:-time-delta-p))
+  ;; Group 15: weeks
+  ;; Group 16: days
+  ;; Group 17: hours
+  ;; Group 18: minutes
+  ;; Group 19: seconds
+  ;; Group 20: sign
+  (seq
+   (group-n 20 (zero-or-one (or ?+ ?-)))
+   ?P
+   (or ical:dur-date ical:dur-time ical:dur-week))
+  :reader ical:read-dur-value
+  :printer ical:print-dur-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.6")
+
+
+;;;; 3.3.7 Float
+(ical:define-type ical:float "FLOAT"
+   "Type for Float values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits,
+and possibly a decimal. When read, an Elisp float value."
+   '(float * *)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit)
+    (zero-or-one (seq ?. (one-or-more digit))))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.7")
+
+;;;; 3.3.8 Integer
+(ical:define-type ical:integer "INTEGER"
+   "Type for Integer values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits.
+When read, an Elisp integer value between -2147483648 and 2147483647."
+   '(integer -2147483648 2147483647)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8")
+
+;;;; 3.3.9 Period
+;; TODO: This function already exists! Super: replace with iso8601-parse-interval
+(defsubst ical:period-start (period)
+  "Return the `icalendar-date-time' which marks the start of PERIOD."
+  (car period))
+
+(defsubst ical:period-end (period)
+  "Return the `icalendar-date-time' which marks the end of PERIOD, or nil."
+  (cadr period))
+
+(defsubst ical:period-dur-value (period)
+  "Return the `icalendar-dur-value' which gives the length of PERIOD, or nil."
+  (caddr period))
+
+(defun ical:period-p (val)
+  (and (listp val)
+       (length= val 3)
+       (cl-typep (ical:period-start val) 'ical:date-time)
+       (cl-typep (ical:period-end val) '(or null ical:date-time))
+       (cl-typep (ical:period-dur-value val) '(or null ical:dur-value))))
+
+(defun ical:read-period (s)
+  "Read an `icalendar-period' from a string S.
+S should have been matched against rx `icalendar-period'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((start (ical:read-date-time (match-string 11)))
+        (end (when (match-string 12) (ical:read-date-time (match-string 12))))
+        (dur (when (match-string 13) (ical:read-dur-value (match-string 13)))))
+    (list start end dur)))
+
+(defun ical:print-period (per)
+  "Serialize an `icalendar-period' to a string"
+  (let ((start (ical:period-start per))
+        (end (ical:period-end per))
+        (dur (ical:period-dur-value per)))
+    (concat (ical:print-date-time start)
+            "/"
+            (if dur
+                (ical:print-dur-value dur)
+              (ical:print-date-time end)))))
+
+(ical:define-type ical:period "PERIOD"
+   "Type for Period values.
+
+A period of time is specified as a starting date-time together
+with either an explicit date-time as its end, or a duration which
+gives its length and implicitly marks its end.
+
+When printed, the starting date-time is separated from the end or
+duration by a / character.
+
+When read, a period is represented as a list (START END DUR),
+where START is an `icalendar-date-time', END is either an
+`icalendar-date-time' or nil, and DUR is either an
+`icalendar-dur-value' or nil. (This representation allows END to
+be computed from DUR and cached, and also distinguishes DUR and
+END, which might both be decoded times.)"
+  '(satisfies ical:period-p)
+  (seq (group-n 11 ical:date-time)
+       "/"
+       (or (group-n 12 ical:date-time)
+           (group-n 13 ical:dur-value)))
+  :reader ical:read-period
+  :printer ical:print-period
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.9")
+
+;;;; 3.3.10 Recurrence rules:
+(rx-define ical:freq
+   (or "SECONDLY" "MINUTELY" "HOURLY" "DAILY" "WEEKLY" "MONTHLY" "YEARLY"))
+
+(rx-define ical:weekday
+   (or "SU" "MO" "TU" "WE" "TH" "FR" "SA"))
+
+(rx-define ical:ordwk
+  (** 1 2 digit)) ; 1 to 53
+
+(rx-define ical:weekdaynum
+  ;; Group 19: Week num, if present
+  ;; Group 20: week day abbreviation
+   (seq (zero-or-one
+         (group-n 19 (seq (zero-or-one (or ?+ ?-))
+                          ical:ordwk)))
+        (group-n 20 ical:weekday)))
+
+(rx-define ical:weeknum
+  (seq (zero-or-one (or ?+ ?-))
+       ical:ordwk))
+
+(rx-define ical:monthdaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 31
+
+(rx-define ical:monthnum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 12
+
+(rx-define ical:yeardaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 3 digit))) ; 1 to 366
+
+(defconst ical:weekday-numbers
+  '(("SU" . 0)
+    ("MO" . 1)
+    ("TU" . 2)
+    ("WE" . 3)
+    ("TH" . 4)
+    ("FR" . 5)
+    ("SA" . 6))
+  "Alist mapping two-letter weekday abbreviations to numbers 0 to 6.
+Weekday abbreviations in recurrence rule parts are translated to
+and from numbers for compatibility with calendar-* and
+decoded-time-* functions.")
+
+(defun ical:read-weekdaynum (s)
+  "Read a weekday abbreviation to a number.
+If the abbreviation is preceded by an offset, read a dotted
+pair (WEEKDAY . OFFSET). Thus \"SU\" becomes 0, \"-1SU\"
+becomes (0 . -1), etc. S should have been matched against
+`icalendar-weekdaynum'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((dayno (cdr (assoc (match-string 20) ical:weekday-numbers)))
+        (weekno (match-string 19)))
+    (if weekno
+        (cons dayno (string-to-number weekno))
+      dayno)))
+
+(defun ical:print-weekdaynum (val)
+  "Serialize a number or dotted pair VAL to a string
+(as part of a BYDAY recur rule part). See `icalendar-read-weekdaynum'
+for the value format."
+  (if (consp val)
+      (let* ((dayno (car val))
+             (day (car (rassq dayno ical:weekday-numbers)))
+             (offset (cdr val)))
+        (concat (number-to-string offset) day))
+    ;; number alone just stands for a day:
+    (car (rassq val ical:weekday-numbers))))
+
+(defun ical:read-recur-rule-part (s)
+  "Read an `icalendar-recur-rule-part' from string S.
+S should have been matched against `icalendar-recur-rule-part'.
+The return value is a list (KEYWORD VALUE), where VALUE may
+itself be a list, depending on the values allowed by KEYWORD."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((keyword (intern (upcase (match-string 11))))
+        (values (match-string 12)))
+    (list keyword
+      (cl-case keyword
+        (FREQ (intern (upcase values)))
+        (UNTIL (if (length> values 8)
+                   (ical:read-date-time values)
+                 (ical:read-date values)))
+        ((COUNT INTERVAL)
+         (string-to-number values))
+        ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYSETPOS)
+         (ical:read-list-with #'string-to-number values nil ","))
+        (BYDAY
+         (ical:read-list-with #'ical:read-weekdaynum values
+                              (rx ical:weekdaynum) ","))
+        (WKST (cdr (assoc values ical:weekday-numbers)))))))
+
+(defun ical:print-recur-rule-part (part)
+  "Serialize recur rule part PART to a string."
+  (let ((keyword (car part))
+        (values (cadr part))
+        values-str)
+    (cl-case keyword
+      (FREQ (setq values-str (symbol-name values)))
+      (UNTIL (setq values-str (cl-typecase values
+                                (ical:date-time (ical:print-date-time values))
+                                (ical:date (ical:print-date values)))))
+      ((COUNT INTERVAL)
+       (setq values-str (number-to-string values)))
+      ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYSETPOS)
+       (setq values-str (string-join (mapcar #'number-to-string values)
+                                     ",")))
+      (BYDAY
+       (setq values-str (string-join (mapcar #'ical:print-weekdaynum values)
+                                     ",")))
+      (WKST (setq values-str (car (rassq values ical:weekday-numbers)))))
+
+    (concat (symbol-name keyword) "=" values-str)))
+
+(rx-define ical:recur-rule-part
+  ;; Group 11: keyword
+  ;; Group 12: value(s)
+  (or (seq (group-n 11 "FREQ") "=" (group-n 12 ical:freq))
+      (seq (group-n 11 "UNTIL") "=" (group-n 12 (or ical:date-time ical:date)))
+      (seq (group-n 11 "COUNT") "=" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "INTERVAL") "=" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "BYSECOND") "=" (group-n 12 ; 0 to 60
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYMINUTE") "=" (group-n 12 ; 0 to 59
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYHOUR") "=" (group-n 12 ; 0 to 23
+                                       (ical:comma-list (** 1 2 digit)))) ; 0 to 23
+      (seq (group-n 11 "BYDAY") "=" (group-n 12 ; weeknum? daynum, e.g. SU or 34SU
+                                      (ical:comma-list ical:weekdaynum)))
+      (seq (group-n 11 "BYMONTHDAY") "=" (group-n 12
+                                           (ical:comma-list ical:monthdaynum)))
+      (seq (group-n 11 "BYYEARDAY") "=" (group-n 12
+                                          (ical:comma-list ical:yeardaynum)))
+      (seq (group-n 11 "BYWEEKNO") "=" (group-n 12 (ical:comma-list ical:weeknum)))
+      (seq (group-n 11 "BYMONTH") "=" (group-n 12 (ical:comma-list ical:monthnum)))
+      (seq (group-n 11 "BYSETPOS") "=" (group-n 12
+                                         (ical:comma-list ical:yeardaynum)))
+      (seq (group-n 11 "WKST") "=" (group-n 12 ical:weekday))))
+
+(defun ical:read-recur (s)
+  "Read a recurrence rule value from string S.
+S should be a match against rx `icalendar-recur'."
+  ;; TODO: let's switch to keywords and a plist, so we can more easily
+  ;; write these clauses also in diary sexp entries without so many parens
+  (ical:read-list-with #'ical:read-recur-rule-part s (rx ical:recur-rule-part) ";"))
+
+(defun ical:print-recur (val)
+  "Serialize a recurrence rule value VAL to a string."
+  ;; RFC5545 sec. 3.3.10: "to ensure backward compatibility with
+  ;; applications that pre-date this revision of iCalendar the
+  ;; FREQ rule part MUST be the first rule part specified in a
+  ;; RECUR value."
+  (string-join
+   (cons
+    (ical:print-recur-rule-part (assq 'FREQ val))
+    (mapcar #'ical:print-recur-rule-part
+            (seq-filter (lambda (part) (not (eq 'FREQ (car part))))
+                        val)))
+   ";"))
+
+(defconst ical:-recur-value-types
+  ;; Note: "list-of" is not a cl-type specifier, just a symbol here; it is
+  ;; handled specially when checking types in ical:recur-value-p:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+    UNTIL (or ical:date-time ical:date)
+    COUNT (integer 1 *)
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) (satisfies ical:dayno-offset-p)))
+    BYMONTHDAY (list-of (or (integer -31 -1) (integer 1 31)))
+    BYYEARDAY (list-of (or (integer -366 -1) (integer 1 366)))
+    BYWEEKNO (list-of (or (integer -53 -1) (integer 1 53)))
+    BYMONTH (list-of (integer 1 12)) ; unlike the others, months cannot be negative
+    BYSETPOS (list-of (or (integer -366 -1) (integer 1 366)))
+    WKST (integer 0 6))
+  "Plist mapping `icalendar-recur' keywords to type specifiers")
+
+(defun ical:dayno-offset-p (val)
+  "Return non-nil if VAL is a pair (DAYNO . OFFSET), part of a
+recurrence rule BYDAY value"
+  (and (consp val)
+       (cl-typep (car val) '(integer 0 6))
+       (cl-typep (cdr val) '(or (integer -53 -1) (integer 1 53)))))
+
+(defun ical:recur-value-p (vals)
+  "Return non-nil if VALS is an iCalendar recurrence rule value."
+  (and (listp vals)
+       ;; FREQ is always required:
+       (assq 'FREQ vals)
+       ;; COUNT and UNTIL are mutually exclusive if present:
+       (not (and (assq 'COUNT vals) (assq 'UNTIL vals)))
+       ;; If BYSETPOS is present, another BYXXX clause must be too:
+       (or (not (assq 'BYSETPOS vals))
+           (assq 'BYMONTH vals)
+           (assq 'BYWEEKNO vals)
+           (assq 'BYYEARDAY vals)
+           (assq 'BYMONTHDAY vals)
+           (assq 'BYDAY vals)
+           (assq 'BYHOUR vals)
+           (assq 'BYMINUTE vals)
+           (assq 'BYSECOND vals))
+       (let ((freq (ical:recur-freq vals))
+             (byday (ical:recur-by* 'BYDAY vals))
+             (byweekno (ical:recur-by* 'BYWEEKNO vals))
+             (bymonthday (ical:recur-by* 'BYMONTHDAY vals))
+             (byyearday (ical:recur-by* 'BYYEARDAY vals)))
+         (and
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value when the FREQ rule part is not set to MONTHLY or
+          ;; YEARLY."
+          (or (not (consp (car byday)))
+              (memq freq '(MONTHLY YEARLY)))
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value with the FREQ rule part set to YEARLY when the
+          ;; BYWEEKNO rule part is specified." This also covers:
+          ;; "[The BYWEEKNO] rule part MUST NOT be used when the FREQ
+          ;; rule part is set to anything other than YEARLY."
+          (or (not byweekno)
+              (and (eq freq 'YEARLY)
+                   (not (consp (car byday)))))
+          ;; "The BYMONTHDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to WEEKLY."
+          (not (and bymonthday (eq freq 'WEEKLY)))
+          ;; "The BYYEARDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
+          (not (and byyearday (memq freq '(DAILY WEEKLY MONTHLY))))))
+       ;; check types of all rule parts:
+       (seq-every-p
+        (lambda (kv)
+          (when (consp kv)
+            (let* ((keyword (car kv))
+                   (val (cadr kv))
+                   (type (plist-get ical:-recur-value-types keyword)))
+              (and keyword val type
+                   (if (and (consp type)
+                            (eq (car type) 'list-of))
+                       (ical:list-of-p val (cadr type))
+                     (cl-typep val type))))))
+         vals)))
+
+(ical:define-type ical:recur "RECUR"
+  "Type for Recurrence Rule values.
+
+When printed, a recurrence rule value looks like
+  KEY1=VAL1;KEY2=VAL2;...
+where the VALs may themselves be lists or have other syntactic
+structure; see RFC5545 sec. 3.3.10 for all the gory details.
+
+The KEYs and their associated value types when read are as follows.
+The first is required:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+These two are mutually exclusive; at most one may appear:
+    UNTIL (or icalendar-date-time icalendar-date)
+    COUNT (integer 1 *)
+All others are optional:
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) ; day of week
+                       (pair (integer 0 6)  ; (day of week . offset)
+                             (integer -53 53))) ; except 0
+    BYMONTHDAY (list-of (integer -31 31))  ; except 0
+    BYYEARDAY (list-of (integer -366 366)) ; except 0
+    BYWEEKNO (list-of (integer -53 53))    ; except 0
+    BYMONTH (list-of (integer 1 12))       ; months cannot be negative
+    BYSETPOS (list-of (integer -366 366))  ; except 0
+    WKST (integer 0 6))
+
+When read, these KEYs and their associated VALs are gathered into
+an alist.
+
+In general, the VALs consist of integers or lists of integers.
+Abbreviations for weekday names are translated into integers
+0 (=Sunday) through 6 (=Saturday), for compatibility with
+calendar.el and decoded-time-* functions.
+
+Some examples:
+
+1) Printed: FREQ=DAILY;COUNT=10;INTERVAL=2
+   Meaning: 10 occurrences that occur every other day
+   Read: ((FREQ DAILY)
+          (COUNT 10)
+          (INTERVAL 2))
+
+2) Printed: FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA
+   Meaning: Every day in January of every year until 2000/01/31 at 14:00 UTC
+   Read: ((FREQ YEARLY)
+          (UNTIL (0 0 14 31 1 2000 1 -1 0))
+          (BYMONTH (1))
+          (BYDAY (0 1 2 3 4 5 6)))
+
+3) Printed: FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2
+   Meaning: Every month on the second-to-last weekday of the month
+   Read: ((FREQ MONTHLY)
+          (BYDAY (1 2 3 4 5))
+          (BYSETPOS (-2)))
+
+Notice that singleton values are still wrapped in a list when the
+KEY accepts a list of values, but not when the KEY always has a
+single (e.g. integer) value."
+  '(satisfies ical:recur-value-p)
+  (ical:semicolon-list ical:recur-rule-part)
+  :reader ical:read-recur
+  :printer ical:print-recur
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10")
+
+(defun ical:recur-freq (recur-value)
+  "Return the frequency in RECUR-VALUE"
+  (car (alist-get 'FREQ recur-value)))
+
+(defun ical:recur-interval-size (recur-value)
+  "Return the interval size specified in RECUR-VALUE, or the default
+of 1."
+  (or (car (alist-get 'INTERVAL recur-value)) 1))
+
+(defun ical:recur-until (recur-value)
+  "Return the UNTIL date(-time) in RECUR-VALUE"
+  (car (alist-get 'UNTIL recur-value)))
+
+(defun ical:recur-count (recur-value)
+  "Return the COUNT in RECUR-VALUE"
+  (car (alist-get 'COUNT recur-value)))
+
+(defun ical:recur-weekstart (recur-value)
+  "Return the weekday which starts the work week specified in
+RECUR-VALUE, or the default (1 = Monday)"
+  (or (car (alist-get 'WKST recur-value)) 1))
+
+(defun ical:recur-by* (byunit recur-value)
+  "Return the values in the BYUNIT clause in RECUR-VALUE.
+BYUNIT should be a symbol: \\='BYMONTH, \\='BYDAY, etc.
+See `icalendar-recur' for all the possible BYUNIT values."
+  (car (alist-get byunit recur-value)))
+
+;;;; 3.3.11 Text
+(rx-define ical:escaped-char
+   (seq ?\\ (or ?\\ ?\; ?, ?N ?n)))
+
+(rx-define ical:text-safe-char
+  (not (or ?\" ?\; ?: ?\\ ?, ical:control))) ;; TODO: is this correct?
+
+(defun ical:text-region-p (val)
+  "Return t if VAL represents a region of text."
+  (and (listp val)
+       (markerp (car val))
+       (not (null (marker-buffer (car val))))
+       (markerp (cdr val))))
+
+(defun ical:make-text-region (&optional buffer begin end)
+  "Return an object that represents the region of text in BUFFER
+between BEGIN and END. BUFFER defaults to the current buffer, and
+BEGIN and END default to point and mark in BUFFER."
+  (let ((buf (or buffer (current-buffer)))
+        (b (make-marker))
+        (e (make-marker)))
+    (with-current-buffer buf
+      (set-marker b (or begin (min (point) (mark))) buf)
+      (set-marker e (or end (max (point) (mark))))
+      (cons b e))))
+
+(defsubst ical:text-region-begin (r)
+  "Return the marker at the beginning of the text region R"
+  (car r))
+
+(defsubst ical:text-region-end (r)
+  "Return the marker at the end of the text region R"
+  (cdr r))
+
+(defun ical:unescape-text-in-region (begin end)
+ "Unescape the text between BEGIN and END, replacing
+literal '\\n' and '\\N' with newline, and removing backslashes that escape
+commas, semicolons, and backslashes."
+ (with-restriction begin end
+   (save-excursion
+    (replace-string-in-region "\\N" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\n" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\," "," (point-min) (point-max))
+    (replace-string-in-region "\\;" ";" (point-min) (point-max)))
+    (replace-string-in-region (concat "\\" "\\") "\\" (point-min) (point-max))))
+
+(defun ical:unescape-text-string (s)
+ "Unescape the text in string S, replacing literal '\\n' and '\\N'
+with newline, and removing backslashes that escape commas, semicolons
+and backslashes."
+  (with-temp-buffer
+    (insert s)
+    (ical:unescape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:escape-text-in-region (begin end)
+  "Escape the text between BEGIN and END, replacing newlines with
+literal '\\n', and escaping commas, semicolons and backslashes with a
+backslash."
+ (with-restriction begin end
+  (save-excursion
+    ;; replace backslashes first, so the ones introduced when
+    ;; escaping other characters don't end up double-escaped:
+    (replace-string-in-region "\\" (concat "\\" "\\") (point-min) (point-max))
+    (replace-string-in-region "\n" "\\n" (point-min) (point-max))
+    (replace-string-in-region "," "\\," (point-min) (point-max))
+    (replace-string-in-region ";" "\\;" (point-min) (point-max)))))
+
+(defun ical:escape-text-string (s)
+  "Escape the text in S, replacing newlines with '\\n', and escaping
+commas, semicolons, and backslashes with a backslash."
+  (with-temp-buffer
+    (insert s)
+    (ical:escape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:read-text (s)
+  "Read an `icalendar-text' value from a string S.
+S should be a match against rx `icalendar-text'."
+  (ical:unescape-text-string s))
+
+(defun ical:print-text (val)
+  "Serialize an iCalendar text value. VAL may be a string or a text
+region (see `icalendar-make-text-region'). The text will be escaped before
+printing. If VAL is a region, the text it contains will not be
+modified; it is copied before escaping."
+  (if (stringp val)
+      (ical:escape-text-string val)
+    ;; val is a region, so copy and escape its contents:
+    (let* ((beg (ical:text-region-begin val))
+           (buf (marker-buffer beg))
+           (end (ical:text-region-end val)))
+      (with-temp-buffer
+        (insert-buffer-substring buf (marker-position beg) (marker-position end))
+        (ical:escape-text-in-region (point-min) (point-max))
+        (buffer-string)))))
+
+(defun ical:text-to-string (node)
+  "Return the value of an `icalendar-text' NODE as a string.
+The returned string is *not* escaped. For that, see `icalendar-print-text'."
+  (ical:with-node-value node nil
+    (if (stringp value) value
+      ;; Otherwise the value is a text region:
+      (let* ((beg (ical:text-region-begin value))
+             (buf (marker-buffer beg))
+             (end (ical:text-region-end value)))
+        (with-current-buffer buf
+          (buffer-substring (marker-position beg) (marker-position end)))))))
+
+;; TODO: would it be useful to add a third representation, namely a
+;; function or thunk? So that e.g. Org can pre-process its own syntax
+;; and return a plain text string to use in the description?
+(ical:define-type ical:text "TEXT"
+   "Type for Text values.
+
+Text values can be represented in Elisp in two ways: as strings,
+or as buffer regions. For values which aren't expected to change,
+such as property values in a text/calendar email attachment, use
+strings. For values which are user-editable and might change
+between parsing and serializing to iCalendar format, use a
+region. In that case, a text value contains two markers BEGIN and
+END which mark the bounds of the region. See
+`icalendar-make-text-region' to create such values, and
+`icalendar-text-region-begin' and `icalendar-text-region-end' to
+access the markers.
+
+Certain characters in text values are required to be escaped by
+the iCalendar standard. These characters should NOT be
+pre-escaped when inserting them into the parse tree. Instead,
+`icalendar-print-text' takes care of escaping text values, and
+`icalendar-read-text' takes care of unescaping them, when parsing and
+printing iCalendar data."
+  '(or string (satisfies ical:text-region-p))
+  (zero-or-more (or ical:text-safe-char ?: ?\" ical:escaped-char))
+  :reader ical:read-text
+  :printer ical:print-text
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11")
+
+;; 3.3.12 Time - Defined above
+
+;;;; 3.3.13 URI
+;; see https://www.rfc-editor.org/rfc/rfc3986#section-3
+(require 'icalendar-uri-schemes)
+(rx-define ical:uri-with-scheme
+  ;; Group 11: URI scheme; see icalendar-uri-schemes.el
+  ;; Group 12: rest of URI after ":"
+  ;; This regex mostly just scans for all characters allowed by
+  ;; RFC3986. We make an effort to parse the scheme, even though this
+  ;; is an open-ended list, because otherwise the regex is either too
+  ;; permissive or too complicated to be useful. (ical:binary, in
+  ;; particular, matches a subset of the characters allowed in a URI).
+  ;; TODO: should we parse more structure here?
+  (seq (group-n 11 ical:uri-scheme)
+       ":"
+       (group-n 12
+         (one-or-more
+          (any alnum ?- ?. ?_ ?~                   ; unreserved chars
+               ?: ?/ ?? ?# ?\[ ?\] ?@              ; gen-delims
+               ?! ?$ ?& ?' ?\( ?\) ?* ?+ ?, ?\; ?= ; sub-delims
+               ?%)))))                             ; for %-encoding
+
+(ical:define-type ical:uri "URI"
+   "Type for URI values.
+
+The parsed and printed representations are the same: a URI string."
+   '(satisfies ical:match-uri-value)
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.13")
+
+;;;; 3.3.3 Calendar User Address
+(ical:define-type ical:cal-address "CAL-ADDRESS"
+   "Type for Calendar User Address values.
+
+The parsed and printed representations are the same: a URI string.
+Typically, this should be a mailto: URI.
+
+RFC5545 says: '*When used to address an Internet email transport
+  address* for a calendar user, the value MUST be a mailto URI,
+  as defined by [RFC2368]'
+
+Since it is unclear whether there are Calendar User Address values
+which are not used to address email, this type does not enforce the use
+of the mailto: scheme, but be prepared for problems if you create
+values of this type with any other scheme."
+   '(and string (satisfies ical:match-cal-address-value))
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.3")
+
+;;;; 3.3.14 UTC Offset
+(defun ical:read-utc-offset (s)
+  "Read a UTC offset from a string.
+S should be a match against rx `icalendar-utc-offset'"
+  (let ((sign (if (equal (substring s 0 1) "-") -1 1))
+        (nhours (string-to-number (substring s 1 3)))
+        (nminutes (string-to-number (substring s 3 5)))
+        (nseconds (if (length= s 7)
+                      (string-to-number (substring s 5 7))
+                    0)))
+    (* sign (+ nseconds (* 60 nminutes) (* 60 60 nhours)))))
+
+(defun ical:print-utc-offset (utcoff)
+  "Serialize a UTC offset to a string"
+  (let* ((sign (if (< utcoff 0) "-" "+"))
+         (absoff (abs utcoff))
+         (nhours (/ absoff (* 60 60)))
+         (no-hours (mod absoff (* 60 60)))
+         (nminutes (/ no-hours 60))
+         (nseconds (mod no-hours 60)))
+    (if (zerop nseconds)
+        (format "%s%02d%02d" sign nhours nminutes)
+      (format "%s%02d%02d%02d" sign nhours nminutes nseconds))))
+
+(ical:define-type ical:utc-offset "UTC-OFFSET"
+  "Type for UTC Offset values.
+
+When printed, a sign followed by a string of digits, like +HHMM
+or -HHMMSS. When read, an integer representing the number of
+seconds offset from UTC. This representation is for compatibility
+with `decode-time' and related functions."
+  '(integer -999999 999999)
+  (seq (or ?+ ?-) ; + is not optional for positive values!
+       (= 4 digit) ; HHMM
+       (zero-or-one (= 2 digit))) ; SS
+  :reader ical:read-utc-offset
+  :printer ical:print-utc-offset
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.14")
+
+
+;;; Section 3.2: Property Parameters
+
+(defconst ical:param-types nil ;; populated by ical:define-param
+  "Alist mapping printed parameter names to type symbols")
+
+(defun ical:maybe-quote-param-value (s &optional always)
+  "Add quotes around param value string S if required. If ALWAYS is non-nil,
+add quotes to S regardless of its contents"
+  (if (or always
+          (not (string-match (rx ical:paramtext) s))
+          (< (match-end 0) (length s)))
+      (concat "\"" s "\"")
+    s))
+
+(defun ical:read-param-value (type s)
+  "Read a value for a parameter of type TYPE from a string S.
+S should have already been matched against the regex for TYPE and
+the match data should be available to this function. Returns a
+syntax node of type TYPE containing the read value.
+
+If TYPE accepts a list of values, S will be split on the list
+separator for TYPE and read individually."
+  (let* ((value-type (get type 'ical:value-type)) ; if nil, value is just a string
+         (value-regex (when (get type 'ical:value-rx)
+                         (rx-to-string (get type 'ical:value-rx))))
+         (list-sep (get type 'ical:list-sep))
+         (substitute-val (get type 'ical:substitute-value))
+         (unrecognized-val (match-string 5)) ; see :unrecognized in define-param
+         (raw-val (if unrecognized-val substitute-val s))
+         (one-val-reader (if (ical:value-type-symbol-p value-type)
+                             (lambda (s) (ical:read-value-node value-type s))
+                           #'identity)) ; value is just a string
+         ;; values may be quoted even if :quoted does not require it,
+         ;; so they need to be stripped of quotes. read-list-of does
+         ;; this by default; in the single value case, use string-trim
+         (read-val (if list-sep
+                       (ical:read-list-with one-val-reader raw-val
+                                            value-regex list-sep)
+                     (funcall one-val-reader
+                              (string-trim raw-val "\"" "\"")))))
+    (ical:make-ast-node type
+                        (list :value read-val
+                              :original-value unrecognized-val))))
+
+(defun ical:parse-param-value (type limit)
+  "Parse the value for a parameter of type TYPE from point up to LIMIT.
+TYPE should be a type symbol for an iCalendar parameter type.
+This function expects point to be at the start of the value
+string, after the parameter name and the equals sign. Returns a
+syntax node representing the parameter."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+    (unless (re-search-forward full-value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Unable to parse `%s' value between %d and %d"
+                            type (point) limit))))
+    (when (match-string 3)
+      (signal 'ical:parse-error
+              (list (format "Invalid value for `%s' parameter" type)
+                    (match-string 3))))
+
+    (let ((value-begin (match-beginning 2))
+          (value-end (match-end 2))
+          (node (ical:read-param-value type (match-string 2))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; :begin must be set by parse-params
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end value-end)
+
+      node)))
+
+(defun ical:parse-params (limit)
+  "Parse the parameter string of the current property, up to LIMIT.
+Point should be at the \";\" at the start of the first parameter.
+Returns a list of parameters, which may be nil if none are present.
+After parsing, point is at the end of the parameter string and the
+start of the property value string."
+  (let ((params nil))
+    (rx-let ((ical:param-start (seq ";" (group-n 1 ical:param-name) "=")))
+      (while (re-search-forward (rx ical:param-start) limit t)
+        (when-let* ((begin (match-beginning 1))
+                    (param-name (match-string 1))
+                    (param-type (or (alist-get (upcase param-name)
+                                               ical:param-types
+                                               nil nil #'equal)
+                                    'ical:otherparam))
+                    (param-node (ical:parse-param-value param-type limit)))
+          (ical:ast-node-meta-set param-node :begin begin)
+          ;; store the original param name if we didn't recognize it:
+          (when (eq param-type 'ical:otherparam)
+            (ical:ast-node-meta-set param-node :original-name param-name))
+          (push param-node params))))
+    (nreverse params)))
+
+(defun ical:print-param-node (node)
+  "Serialize a parameter syntax node NODE to a string.
+NODE should be a syntax node whose type is an iCalendar
+parameter type."
+  (let* ((param-type (ical:ast-node-type node))
+         (list-sep (get param-type 'ical:list-sep))
+
+         (val/s (ical:ast-node-value node))
+         (printed (if (and list-sep (listp val/s))
+                      (mapcar #'ical:default-value-printer val/s)
+                    (ical:default-value-printer val/s)))
+         ;; add quotes to each value as needed, even if :quoted
+         ;; does not require it:
+         (must-quote (get param-type 'ical:is-quoted))
+         (quoted (if (listp printed)
+                     (mapcar
+                      (lambda (v) (ical:maybe-quote-param-value v must-quote))
+                      printed)
+                   (ical:maybe-quote-param-value printed must-quote)))
+         (val-str (or (ical:ast-node-meta-get :original-value node)
+                      (if (and list-sep (listp quoted))
+                          (string-join quoted list-sep)
+                        quoted)))
+         (param-name (car (rassq param-type ical:param-types)))
+         (name-str (or param-name
+                       ;; set by parse-params for unrecognized params:
+                       (ical:ast-node-meta-get :original-name node))))
+    (format ";%s=%s" name-str val-str)))
+
+(defun ical:print-params (param-nodes)
+  "Print the property parameter nodes in PARAM-NODES. Returns the
+printed parameter list as a string."
+  (apply #'concat
+    (mapcar #'ical:print-param-node
+            param-nodes)))
+
+;; Parameter definitions in RFC5545:
+
+(ical:define-param ical:altrepparam "ALTREP"
+  "Alternate text representation (URI)"
+  ical:uri
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.1")
+
+(ical:define-param ical:cnparam "CN"
+  "Common Name"
+  ical:param-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.2")
+
+(ical:define-param ical:cutypeparam "CUTYPE"
+  "Calendar User Type"
+  (or "INDIVIDUAL"
+      "GROUP"
+      "RESOURCE"
+      "ROOM"
+      "UNKNOWN"
+      (group-n 5
+        (or ical:x-name ical:iana-token)))
+  :default "INDIVIDUAL"
+  ;; "Applications MUST treat x-name and iana-token values they
+  ;; don't recognize the same way as they would the UNKNOWN
+  ;; value":
+  :unrecognized "UNKNOWN"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.3")
+
+(ical:define-param ical:delfromparam "DELEGATED-FROM"
+  "Delegators.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have delegated their participation to the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.4")
+
+(ical:define-param ical:deltoparam "DELEGATED-TO"
+  "Delegatees.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have been delegated to participate by the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.5")
+
+(ical:define-param ical:dirparam "DIR"
+  "Directory Entry Reference.
+
+This parameter may be specified on properties with a
+`icalendar-cal-address' value type. It is a quoted URI which specifies
+a reference to a directory entry associated with the calendar
+user which is the value of the property."
+   ical:uri
+   :quoted t
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.6")
+
+(ical:define-param ical:encodingparam "ENCODING"
+  "Inline Encoding, either \"8BIT\" (text, default) or \"BASE64\" (binary).
+
+If \"BASE64\", the property value is base64-encoded binary data.
+This parameter must be specified if the `icalendar-valuetypeparam'
+is \"BINARY\"."
+  (or "8BIT" "BASE64")
+  :default "8BIT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.7")
+
+(rx-define ical:mimetype
+  (seq ical:mimetype-regname "/" ical:mimetype-regname))
+
+;; from https://www.rfc-editor.org/rfc/rfc4288#section-4.2:
+(rx-define ical:mimetype-regname
+  (** 1 127 (any alnum ?! ?# ?$ ?& ?. ?+ ?- ?^ ?_)))
+
+(ical:define-param ical:fmttypeparam "FMTTYPE"
+  "Format Type (Mimetype per RFC4288)
+
+Specifies the media type of the object referenced in the property value,
+for example \"text/plain\" or \"text/html\".
+Valid media types are defined in RFC4288; see
+URL `https://www.rfc-editor.org/rfc/rfc4288#section-4.2'"
+  ical:mimetype
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.8")
+
+(ical:define-param ical:fbtypeparam "FBTYPE"
+  "Free/Busy Time Type. Default is \"BUSY\".
+
+RFC5545 gives the following meanings to the values:
+
+FREE: the time interval is free for scheduling.
+BUSY: the time interval is busy because one or more events have
+  been scheduled for that interval.
+BUSY-UNAVAILABLE: the time interval is busy and that the interval
+  can not be scheduled.
+BUSY-TENTATIVE: the time interval is busy because one or more
+  events have been tentatively scheduled for that interval.
+Other values are treated like BUSY."
+  (or "FREE"
+      "BUSY-UNAVAILABLE"
+      "BUSY-TENTATIVE"
+      "BUSY"
+      ical:x-name
+      ical:iana-token)
+  :default "BUSY"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.9")
+
+;; TODO: see https://www.rfc-editor.org/rfc/rfc5646#section-2.1
+(rx-define ical:rfc5646-lang
+  (one-or-more (any alnum ?-)))
+
+(ical:define-param ical:languageparam "LANGUAGE"
+  "Language tag (per RFC5646)
+
+This parameter specifies the language of the property value as a
+language tag, for example \"en-US\" for US English or \"no\" for
+Norwegian. Valid language tags are defined in RFC5646; see
+URL `https://www.rfc-editor.org/rfc/rfc5646'"
+  ical:rfc5646-lang
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.10")
+
+(ical:define-param ical:memberparam "MEMBER"
+  "Group or List Membership.
+
+This is a comma-separated list of quoted `icalendar-cal-address'
+values. These are addresses of groups or lists of which the user
+in the property value is a member."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.11")
+
+(ical:define-param ical:partstatparam "PARTSTAT"
+  "Participation status.
+
+The value specifies the participation status of the calendar user
+in the property value. They have different interpretations
+depending on whether they occur in a VEVENT, VTODO or VJOURNAL
+component. RFC5545 gives the values the following meanings:
+
+NEEDS-ACTION (all): needs action by the user
+ACCEPTED (all): accepted by the user
+DECLINED (all): declined by the user
+TENTATIVE (VEVENT, VTODO): tentatively accepted by the user
+DELEGATED (VEVENT, VTODO): delegated by the user
+COMPLETED (VTODO): completed at the `icalendar-date-time' in the
+  VTODO's `icalendar-completed' property
+IN-PROCESS (VTODO): in the process of being completed"
+  (or "NEEDS-ACTION"
+      "ACCEPTED"
+      "DECLINED"
+      "TENTATIVE"
+      "DELEGATED"
+      "COMPLETED"
+      "IN-PROCESS"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; NEEDS-ACTION value."
+  :default "NEEDS-ACTION"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.12")
+
+(ical:define-param ical:rangeparam "RANGE"
+  "Recurrence Identifier Range.
+
+Specifies the effective range of recurrence instances of the property's value.
+The value \"THISANDFUTURE\" is the only value compliant with RFC5545;
+legacy applications might also produce \"THISANDPRIOR\"."
+  "THISANDFUTURE"
+  :default "THISANDFUTURE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.13")
+
+(ical:define-param ical:trigrelparam "RELATED"
+  "Alarm Trigger Relationship.
+
+This parameter may be specified on properties whose values give
+an alarm trigger as an `icalendar-duration'. If the parameter
+value is \"START\" (the default), the alarm triggers relative to
+the start of the component; similarly for \"END\"."
+  (or "START" "END")
+  :default "START"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.14")
+
+(ical:define-param ical:reltypeparam "RELTYPE"
+  "Relationship type.
+
+This parameter specifies a hierarchical relationship between the
+calendar component referenced in a `icalendar-related-to'
+property and the calendar component in which it occurs.
+\"PARENT\" means the referenced component is superior to this
+one, \"CHILD\" that the referenced component is subordinate to
+this one, and \"SIBLING\" means they are peers."
+  (or "PARENT"
+      "CHILD"
+      "SIBLING"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values they don't
+  ;; recognize the same way as they would the PARENT value."
+  :default "PARENT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15")
+
+(ical:define-param ical:roleparam "ROLE"
+  "Participation role.
+
+This parameter specifies the participation role of the calendar
+user in the property value. RFC5545 gives the parameter values
+the following meanings:
+CHAIR: chair of the calendar entity
+REQ-PARTICIPANT (default): user's participation is required
+OPT-PARTICIPANT: user's participation is optional
+NON-PARTICIPANT: user is copied for information purposes only"
+  (or "CHAIR"
+      "REQ-PARTICIPANT"
+      "OPT-PARTICIPANT"
+      "NON-PARTICIPANT"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; REQ-PARTICIPANT value."
+  :default "REQ-PARTICIPANT"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.16")
+
+(ical:define-param ical:rsvpparam "RSVP"
+  "RSVP expectation.
+
+This parameter is an `icalendar-boolean' which specifies whether
+the calendar user in the property value is expected to reply to
+the Organizer of a VEVENT or VTODO."
+  ical:boolean
+  :default "FALSE"
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.17")
+
+(ical:define-param ical:sentbyparam "SENT-BY"
+  "Sent by.
+
+This parameter specifies a calendar user that is acting on behalf
+of the user in the property value."
+  ;; "The parameter value MUST be a mailto URI as defined in [RFC2368]"
+  ;; Weirdly, this is the only place in the standard I've seen "mailto:"
+  ;; be *required* for a cal-address. We ignore this requirement for
+  ;; now, because coding around the exception is not worth it: it
+  ;; requires some hackery to work around the fact that two different
+  ;; types, the looser and the more stringent cal-address, would need to
+  ;; have the same print name.
+  ical:cal-address
+  :quoted t
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.18")
+
+(ical:define-param ical:tzidparam "TZID"
+  "Time Zone identifier.
+
+This parameter identifies the VTIMEZONE component in the calendar
+which should be used to interpret the time value given in the
+property. The value of this parameter must be equal to the value
+of the TZID property in that VTIMEZONE component; there must be
+exactly one such component for every unique value of this
+parameter in the calendar."
+  ;; TODO: "This parameter MUST be specified on the "DTSTART","DTEND",
+  ;; "DUE", "EXDATE", and "RDATE" properties when either a DATE-TIME
+  ;; or TIME value type is specified and when the value is neither a
+  ;; UTC or a "floating" time."
+  ;; TODO: "The "TZID" property parameter MUST NOT be applied to DATE
+  ;; properties and DATE-TIME or TIME properties whose time values are
+  ;; specified in UTC."
+  (seq (zero-or-one "/") ical:paramtext)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.19")
+
+(defun ical:read-value-type (s)
+  "Read a value type from string S.
+S should contain the printed representation of a value type in a \"VALUE=...\"
+property parameter. If S represents a known type in `icalendar-value-types',
+it is read as the associated type symbol. Otherwise S is returned unchanged."
+  (let ((type-assoc (assoc s ical:value-types)))
+    (if type-assoc
+        (cdr type-assoc)
+      s)))
+
+(defun ical:print-value-type (type)
+  "Print a value type TYPE.
+TYPE should be an iCalendar type symbol naming a known value type
+defined with `icalendar-define-type', or a string naming an
+unknown type. If it is a symbol, return the associated printed
+representation for the type from `icalendar-value-types'.
+Otherwise return TYPE."
+  (if (symbolp type)
+      (car (rassq type ical:value-types))
+    type))
+
+(ical:define-type ical:printed-value-type nil
+  "Type to represent values of the `icalendar-valuetypeparam' parameter.
+
+When read, if the type named by the parameter is a known value
+type in `icalendar-value-types', it is represented as a type
+symbol for that value type. If it is an unknown value type, it is
+represented as a string. When printed, a string is returned
+unchanged; a type symbol is printed as the associated name in
+`icalendar-value-types'.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing of the `icalendar-valuetypeparam' parameter."
+  '(or string (satisfies ical:printable-value-type-symbol-p))
+  (or "BINARY"
+      "BOOLEAN"
+      "CAL-ADDRESS"
+      "DATE-TIME"
+      "DATE"
+      "DURATION"
+      "FLOAT"
+      "INTEGER"
+      "PERIOD"
+      "RECUR"
+      "TEXT"
+      "TIME"
+      "URI"
+      "UTC-OFFSET"
+      ;; Note: "Applications MUST preserve the value data for x-name
+      ;; and iana-token values that they don't recognize without
+      ;; attempting to interpret or parse the value data." So in this
+      ;; case we don't specify :default or :unrecognized in the
+      ;; parameter definition, and we don't put the value in group 5;
+      ;; the reader will just preserve whatever string matches here.
+      ical:x-name
+      ical:iana-token)
+  :reader ical:read-value-type
+  :printer ical:print-value-type)
+
+(ical:define-param ical:valuetypeparam "VALUE"
+  "Property value data type.
+
+This parameter is used to specify the value type of the
+containing property's value, if it is not of the default value
+type."
+  ical:printed-value-type
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.20")
+
+(ical:define-param ical:otherparam nil ; don't add to ical:param-types
+  "Parameter with an unknown name.
+
+This is not a parameter type defined by RFC5545; it represents
+parameters with an unknown name (matching rx `icalendar-param-name')
+whose values must be parsed and preserved but not further
+interpreted."
+  ical:param-value)
+
+(rx-define ical:other-param-safe
+  ;; we use this rx to skip params when matching properties and
+  ;; their values. Thus we *don't* capture the param names and param values
+  ;; in numbered groups here, which would clobber the groups of the enclosing
+  ;; expression.
+  (seq ";"
+       (or ical:iana-token ical:x-name)
+       "="
+       (ical:comma-list ical:param-value)))
+
+
+;;; Properties:
+
+(defconst ical:property-types nil ;; populated by ical:define-property
+  "Alist mapping printed property names to type symbols")
+
+(defun ical:read-property-value (type s &optional params)
+  "Read a value for the property type TYPE from a string S.
+
+TYPE should be a type symbol for an iCalendar property type
+defined with `icalendar-define-property'. The property value is
+assumed to be of TYPE's default value type, unless an
+`icalendar-valuetypeparam' parameter appears in PARAMS, in which
+case a value of that type will be read. S should have already
+been matched against TYPE's value regex and the match data should
+be available to this function. Returns a property syntax node of
+type TYPE containing the read value and the list of PARAMS.
+
+If TYPE accepts lists of values, they will be split from S on the
+list separator and read separately."
+  (let* ((value-type (or (ical:value-type-from-params params)
+                         (get type 'ical:default-type)))
+         (list-sep (get type 'ical:list-sep))
+         (unrecognized-val (match-string 5))
+         (raw-val (if unrecognized-val
+                      (get type 'ical:substitute-value)
+                    s))
+         (value (if list-sep
+                    (ical:read-list-of value-type raw-val list-sep)
+                  (ical:read-value-node value-type raw-val))))
+    (ical:make-ast-node type
+                        (list :value value
+                              :original-value unrecognized-val)
+                        params)))
+
+(defun ical:parse-property-value (type limit &optional params)
+  "Parse a value for the property type TYPE from point up to LIMIT.
+This function expects point to be at the start of the value
+expression, after \"PROPERTY-NAME[PARAM...]:\". Returns a syntax
+node of type TYPE containing the parsed value and the list of
+PARAMS."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Unable to parse `%s' property value between %d and %d"
+                            type (point) limit))))
+
+    (when (match-string 3)
+      (signal 'ical:parse-error
+              (list (format "Invalid value for `%s' property" type)
+                    (match-string 3))))
+
+    (let* ((value-begin (match-beginning 2))
+           (value-end (match-end 2))
+           (end value-end)
+           (node (ical:read-property-value type (match-string 2) params)))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; 'begin must be set by parse-property
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-property-node (node)
+  "Serialize a property syntax node NODE to a string."
+  (ical:maybe-add-value-param node)
+  (let* ((type (ical:ast-node-type node))
+         (list-sep (get type 'ical:list-sep))
+         (property-name (car (rassq type ical:property-types)))
+         (params (ical:ast-node-children node))
+         (value (ical:ast-node-value node))
+         (value-str
+          (or (ical:ast-node-meta-get :original-value node)
+              (if list-sep
+                  (string-join (mapcar #'ical:default-value-printer value)
+                               list-sep)
+                (ical:default-value-printer value))))
+         (name-str (or property-name
+                       (ical:ast-node-meta-get :original-name node))))
+
+    (unless (and (stringp name-str)
+                 (length> name-str 0))
+      (signal 'ical:print-error
+              (list (format "Unknown property name for type `%s'" type)
+                    type node)))
+
+    (concat name-str
+            (ical:print-params params)
+            ":"
+            value-str
+            ;; TODO: make line ending sensitive to coding system?
+            "\r\n")))
+
+(defun ical:maybe-add-value-param (property-node)
+  "If the type of PROPERTY-NODE's value is not the same as its
+default-type, check that its parameter list contains an
+`icalendar-valuetypeparam' specifying that type as the type for
+the value. If not, add such a parameter to PROPERTY-NODE's list
+of parameters. Returns the possibly-modified PROPERTY-NODE.
+
+If the parameter list already contains a value type parameter for
+a type other than the property value's type, an
+`icalendar-validation-error' is signaled.
+
+If PROPERTY's value is a list, the type of the first element will
+be assumed to be the type for all the values in the list. If the
+list is empty, no change will be made to PROPERTY's parameters."
+  (catch 'no-value-type
+    (let* ((property-type (ical:ast-node-type property-node))
+           (value/s (ical:ast-node-value property-node))
+           (value (if (and (ical:expects-list-of-values-p property-type)
+                           (listp value/s))
+                      (car value/s)
+                    value/s))
+           (value-type (cond ((stringp value) 'ical:text)
+                             ((ical:ast-node-p value)
+                              (ical:ast-node-type value))
+                             ;; if we can't determine a type from the value, bail:
+                             (t (throw 'no-value-type property-node))))
+           (params (ical:ast-node-children property-node))
+           (expected-type (ical:value-type-from-params params)))
+
+      (when (not (eq value-type (get property-type 'ical:default-type)))
+        (if expected-type
+            (when (not (eq value-type expected-type))
+              (signal 'ical:validation-error
+                      (list (format (concat "Mismatching VALUE parameter. "
+                                            "VALUE specifies %s but "
+                                            "property value has type %s")
+                                    expected-type value-type))))
+          ;; the value isn't of the default type, but we didn't find a
+          ;; VALUE parameter, so add one now:
+          (let* ((valuetype-param
+                  (ical:make-ast-node 'ical:valuetypeparam
+                                      (list :value (ical:make-ast-node
+                                                    'ical:printed-value-type
+                                                    (list :value value-type)))))
+                 (new-params (cons valuetype-param
+                                   (ical:ast-node-children property-node))))
+            (ical:ast-node-set-children property-node new-params))))
+
+      ;; Return the modified property node:
+      property-node)))
+
+(defun ical:value-type-from-params (params)
+  "If there is an `icalendar-valuetypeparam' in PARAMS, return the
+type symbol associated with the value type it specifies."
+  (catch 'found
+    (dolist (param params)
+      (when (ical:value-param-p param)
+        (let ((type (ical:ast-node-value
+                     (ical:ast-node-value param))))
+          (throw 'found type))))))
+
+(defun ical:parse-property (limit)
+  "Parse the current property, up to LIMIT. Point should be at the
+beginning of a property line; LIMIT should be the position at the
+end of the line.
+
+Returns a syntax node for the property. After parsing, point is
+at the beginning of the next content line."
+  (rx-let ((ical:property-start (seq line-start
+                                     (group-n 1 ical:name))))
+    (let ((line-begin nil)
+          (line-end nil)
+          (property-name nil)
+          (params nil))
+
+      ;; Property name
+      (unless (re-search-forward (rx ical:property-start) limit t)
+        (signal 'ical:parse-error
+                (list (format (concat "Malformed property at line %d, position %d:"
+                                      "could not match property name")
+                              (line-number-at-pos (point))
+                              (line-beginning-position)))))
+
+      (setq property-name (match-string 1))
+      (setq line-begin (line-beginning-position))
+      (setq line-end (line-end-position))
+
+      ;; Parameters
+      (when (looking-at ";")
+        (setq params (ical:parse-params line-end)))
+
+      (unless (looking-at ":")
+        (signal 'ical:parse-error
+                (list (format (concat "Malformed property at line %d, position %d:"
+                                      "missing colon before value")
+                              (line-number-at-pos (point))
+                              (point)))))
+      (forward-char)
+
+      ;; Value
+      (let* ((known-type (alist-get (upcase property-name)
+                                    ical:property-types
+                                    nil nil #'equal))
+             (property-type (or known-type 'ical:other-property))
+             (node (ical:parse-property-value property-type limit params)))
+
+        ;; sanity check, since e.g. invalid base64 data might not
+        ;; match all the way to the end of the line, as test
+        ;; rfc5545-sec3.1.3/2 initially revealed
+        (unless (eql (point) (line-end-position))
+          (signal 'ical:parse-error
+                  (list (format "Property value did not consume line %d: %s"
+                                (line-number-at-pos (point))
+                                (ical:default-value-printer
+                                 (ical:ast-node-value node))))))
+
+        ;; Set point up for the next property parser:
+        (while (not (bolp))
+          (forward-char))
+
+        ;; value, children are set in ical:read-property-value,
+        ;; value-begin, value-end, end in ical:parse-property-value.
+        ;; begin and original-name are only available here:
+        (ical:ast-node-meta-set node :begin line-begin)
+        (when (eq property-type 'ical:other-property)
+          (ical:ast-node-meta-set node :original-name property-name))
+
+        ;; Return the syntax node
+        node))))
+
+
+;;;; Section 3.7: Calendar Properties
+(ical:define-property ical:calscale "CALSCALE"
+  "Calendar scale.
+
+This property specifies the time scale of an
+`icalendar-vcalendar' object. The only scale defined by RFC5545
+is \"GREGORIAN\", which is the default."
+  ;; only allowed value:
+  "GREGORIAN"
+  :default "GREGORIAN"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.1")
+
+(ical:define-property ical:method "METHOD"
+  "Method for a scheduling request.
+
+When an `icalendar-vcalendar' is sent in a MIME message, this property
+specifies the semantics of the request in the message: e.g. it is
+a request to publish the calendar object, or a reply to an
+invitation. This property and the MIME message's \"method\"
+parameter value must be the same.
+
+RFC5545 does not define any methods, but RFC5546 does; see
+URL `https://www.rfc-editor.org/rfc/rfc5546.html#section-3.2'"
+  ;; TODO: implement methods in RFC5546?
+  ical:iana-token
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.2")
+
+(ical:define-property ical:prodid "PRODID"
+  "Product Identifier.
+
+This property identifies the program that created an
+`icalendar-vcalendar' object. It must be specified exactly once
+in a calendar object. Its value should be a globally unique
+identifier for the program, though RFC5545 does not specify any
+particular way of creating such an identifier."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.3")
+
+(ical:define-property ical:version "VERSION"
+  "Version (2.0 corresponds to RFC5545).
+
+This property specifies the version number of the iCalendar
+specification to which an `icalendar-vcalendar' object conforms,
+and must be specified exactly once in a calendar object. It is
+either the string \"2.0\" or a string like MIN;MAX specifying
+minimum and maximum versions of future revisions of the
+specification."
+  (or "2.0"
+      ;; minver ";" maxver
+      (seq ical:iana-token ?\; ical:iana-token))
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.4")
+
+
+;;;; Section 3.8:
+;;;;; Section 3.8.1: Descriptive Component Properties
+
+(ical:define-property ical:attach "ATTACH"
+  "Attachment.
+
+This property specifies a file attached to an iCalendar
+component, either via a URI, or as encoded binary data. In
+`icalendar-valarm' components, it is used to specify the
+notification sent by the alarm."
+  ;; Groups 11, 12 are used in ical:uri
+  (or (group-n 13 ical:uri)
+      (group-n 14 ical:binary))
+  :default-type ical:uri
+  :other-types (ical:binary)
+  :child-spec (:zero-or-one (ical:fmttypeparam
+                             ical:valuetypeparam
+                             ical:encodingparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:attach-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1")
+
+(defun ical:attach-validator (node)
+  "Additional validator for an `icalendar-attach' NODE.
+Checks that NODE has a correct `icalendar-encodingparam' and
+`icalendar-valuetypeparam' if its value is an `icalendar-binary'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+ATTACH nodes; it is not normally necessary to call it directly."
+  (let* ((value-node (ical:ast-node-value node))
+         (value-type (ical:ast-node-type value-node))
+         (valtypeparam (ical:ast-node-first-child-of 'ical:valuetypeparam node))
+         (encodingparam (ical:ast-node-first-child-of 'ical:encodingparam node)))
+
+    (when (eq value-type 'ical:binary)
+      (unless (and (ical:ast-node-p valtypeparam)
+                   (eq 'ical:binary
+                       (ical:ast-node-value ; unwrap inner printed-value-type
+                        (ical:ast-node-value valtypeparam))))
+        (signal 'ical:validation-error
+                (list (concat "`icalendar-binary' attachment requires "
+                              "'VALUE=BINARY' parameter")
+                      node)))
+      (unless (and (ical:ast-node-p encodingparam)
+                   (equal "BASE64" (ical:ast-node-value encodingparam)))
+        (signal 'ical:validation-error
+                (list (concat "`icalendar-binary' attachment requires "
+                              "'ENCODING=BASE64' parameter")
+                      node))))
+    ;; success:
+    node))
+
+(ical:define-property ical:categories "CATEGORIES"
+  "Categories.
+
+This property lists categories or subtypes of an iCalendar
+component for e.g. searching or filtering. The categories can be
+any `icalendar-text' value."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.2")
+
+(ical:define-property ical:class "CLASS"
+  "(Access) Classification.
+
+This property specifies the scope of access that the calendar
+owner intends for a given component, e.g. public or private."
+  (or "PUBLIC"
+      "PRIVATE"
+      "CONFIDENTIAL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "If not specified in a component that allows this property, the
+  ;; default value is PUBLIC. Applications MUST treat x-name and
+  ;; iana-token values they don't recognize the same way as they would
+  ;; the PRIVATE value."
+  :default "PUBLIC"
+  :unrecognized "PRIVATE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3")
+
+(ical:define-property ical:comment "COMMENT"
+  "Comment to calendar user.
+
+This property can be specified multiple times in calendar components,
+and can contain any `icalendar-text' value."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.4")
+
+(ical:define-property ical:description "DESCRIPTION"
+  "Description.
+
+This property should be a longer, more complete description of
+the calendar component than is contained in the
+`icalendar-summary' property. In a `icalendar-vjournal'
+component, it is used to capture a journal entry, and may be
+specified multiple times. Otherwise it may only be specified
+once. In an `icalendar-valarm' component, it contains the
+notification text for a DISPLAY or EMAIL alarm."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5")
+
+(defun ical:read-geo-coordinates (s)
+  "Read an `icalendar-geo-coordinates' value from string S"
+  (let ((vals (mapcar #'string-to-number (string-split s ";"))))
+    (cons (car vals) (cadr vals))))
+
+(defun ical:print-geo-coordinates (val)
+  "Serialize an `icalendar-geo-coordinates' value to a string"
+  (concat (number-to-string (car val)) ";" (number-to-string (cdr val))))
+
+(defun ical:geo-coordinates-p (val)
+  "Return non-nil if VAL is an `icalendar-geo-coordinates' value"
+  (and (floatp (car val)) (floatp (cdr val))))
+
+(ical:define-type ical:geo-coordinates nil ; don't add to ical:value-types
+  "Type for global positions.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-geo' property. When printed, it
+is represented as a pair of `icalendar-float' values separated by
+a semicolon, like LATITUDE;LONGITUDE. When read, it is a dotted
+pair of Elisp floats (LATITUDE . LONGITUDE)."
+  '(satisfies ical:geo-coordinates-p)
+  (seq ical:float ";" ical:float)
+  :reader ical:read-geo-coordinates
+  :printer ical:print-geo-coordinates)
+
+(ical:define-property ical:geo "GEO"
+  "Global position of a component as a pair LATITUDE;LONGITUDE.
+
+Both values are floats representing a number of degrees. The
+latitude value is north of the equator if positive, and south of
+the equator if negative. The longitude value is east of the prime
+meridian if positive, and west of it if negative."
+  ical:geo-coordinates
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.6")
+
+(ical:define-property ical:location "LOCATION"
+  "Location.
+
+This property describes the intended location or venue of a
+component, e.g. a particular room or building, with an
+`icalendar-text' value. RFC5545 suggests using the
+`icalendar-altrep' parameter on this property to provide more
+structured location information."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7")
+
+;; TODO: type for percentages?
+(ical:define-property ical:percent-complete "PERCENT-COMPLETE"
+  "Percent Complete.
+
+This property describes progress toward the completion of an
+`icalendar-vtodo' component. It can appear at most once in such a
+component. If this TODO is assigned to multiple people, the value
+represents the completion state for each person individually. The
+value should be between 0 and 100 (though this is not currently
+enforced here)."
+  ical:integer
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.8")
+
+;; TODO: type for priority values?
+(ical:define-property ical:priority "PRIORITY"
+  "Priority.
+
+This property describes the priority of a component. 0 means an
+undefined priority. Other values range from 1 (highest priority)
+to 9 (lowest priority). See RFC5545 for suggestions on how to
+represent other priority schemes with this property."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9")
+
+(ical:define-property ical:resources "RESOURCES"
+  "Resources for an activity.
+
+This property is a list of `icalendar-text' values that describe
+any resources required or foreseen for the activity represented
+by a component, e.g. a projector and screen for a meeting."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.10")
+
+(ical:define-type ical:status-keyword nil
+  "Keyword value of a STATUS property.
+
+This is not a real type defined by RFC5545; it is defined here to
+facilitate parsing that property."
+  '(and string (satisfies ical:match-status-keyword-value))
+  ;; Note that this type does NOT allow arbitrary text:
+  (or "TENTATIVE"
+      "CONFIRMED"
+      "CANCELLED"
+      "NEEDS-ACTION"
+      "COMPLETED"
+      "IN-PROCESS"
+      "DRAFT"
+      "FINAL"))
+
+(ical:define-property ical:status "STATUS"
+  "Overall status or confirmation.
+
+This property is a keyword used by an Organizer to inform
+Attendees about the status of a component, e.g. whether an
+`icalendar-vevent' has been cancelled, whether an
+`icalendar-vtodo' has been completed, or whether an
+`icalendar-vjournal' is still in draft form. It can be specified
+at most once on these components."
+  ical:status-keyword
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11")
+
+(ical:define-property ical:summary "SUMMARY"
+  "Short summary.
+
+This property provides a short, one-line description of a
+component for display purposes. In an EMAIL `icalendar-valarm',
+it is used as the subject of the email. A longer description of
+the component can be provided in the `icalendar-description'
+property."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12")
+
+;;;;; Section 3.8.2: Date and Time Component Properties
+
+(ical:define-property ical:completed "COMPLETED"
+  "Time completed.
+
+This property is a timestamp that records the date and time when
+an `icalendar-vtodo' was actually completed. The value must be an
+`icalendar-date-time' with a UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
+
+(ical:define-property ical:dtend "DTEND"
+  "End time of an event or free/busy block.
+
+This property's value specifies when an `icalendar-vevent' or
+`icalendar-freebusy' ends. Its value must be of the same type as
+the value of the component's corresponding `icalendar-dtstart'
+property. The value is a non-inclusive bound, i.e., the value of
+this property must be the first time or date *after* the end of
+the event or free/busy block."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2")
+
+(ical:define-property ical:due "DUE"
+  "Due date.
+
+This property specifies the date (and possibly time) by which an
+`icalendar-todo' item is expected to be completed, i.e., its
+deadline. If the component also has an `icalendar-dtstart'
+property, the two properties must have the same value type, and
+the value of the DTSTART property must be earlier than the value
+of this property."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.3")
+
+(ical:define-property ical:dtstart "DTSTART"
+  "Start time of a component.
+
+This property's value specifies when a component starts. In an
+`icalendar-vevent', it specifies the start of the event. In an
+`icalendar-vfreebusy', it specifies the start of the free/busy
+block. In `icalendar-standard' and `icalendar-daylight'
+sub-components, it defines the start time of a time zone
+specification.
+
+It is required in any component with an `icalendar-rrule'
+property, and in any `icalendar-vevent' component contained in a
+calendar that does not have a `icalendar-method' property.
+
+Its value must be of the same type as the value of the
+component's corresponding `icalendar-dtend' property. In an
+`icalendar-vtodo' component, it must also be of the same type as
+the value of an `icalendar-due' property (if present)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4")
+
+(ical:define-property ical:duration "DURATION"
+  "Duration.
+
+This property specifies a duration of time for a component.
+In an `icalendar-vevent', it can be used to implicitly specify
+the end of the event, instead of an explicit `icalendar-dtend'.
+In an `icalendar-vtodo', it can likewise be used to implicitly specify
+the due date, instead of an explicit `icalendar-due'.
+In an `icalendar-valarm', it used to specify the delay period
+before the alarm repeats.
+
+If a related `icalendar-dtstart' property has an `icalendar-date'
+value, then the duration must be given as a number of weeks or days."
+  ical:dur-value
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.5")
+
+(ical:define-property ical:freebusy "FREEBUSY"
+  "Free/Busy Times.
+
+This property specifies a list of periods of free or busy time in
+an `icalendar-vfreebusy' component. Whether it specifies free or
+busy times is determined by its `icalendar-fbtype' parameter. The
+times in each period must be in UTC format."
+  ical:period
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:fbtypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.6")
+
+(ical:define-property ical:transp "TRANSP"
+  "Time Transparency for free/busy searches.
+
+Note that this property only allows two values: \"TRANSPARENT\"
+or \"OPAQUE\". An OPAQUE value means that the component consumes
+time on a calendar. TRANSPARENT means it does not, and thus is
+invisible to free/busy time searches."
+  ;; Note that this does NOT allow arbitrary text:
+  (or "TRANSPARENT"
+      "OPAQUE")
+  :default "OPAQUE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7")
+
+;;;;; Section 3.8.3: Time Zone Component Properties
+
+(ical:define-property ical:tzid "TZID"
+  "Time Zone Identifier.
+
+This property specifies the unique identifier for a timezone in
+an `icalendar-vtimezone' component, and is a required property of
+that component. This is an identifier that `icalendar-tzidparam'
+parameters in other components may then refer to."
+  (seq (zero-or-one "/") ical:text)
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.1")
+
+(ical:define-property ical:tzname "TZNAME"
+  "Time Zone Name.
+
+This property specifies a customary name for a time zone in
+`icalendar-daylight' and `icalendar-standard' sub-components."
+  ical:text
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.2")
+
+(ical:define-property ical:tzoffsetfrom "TZOFFSETFROM"
+  "Time Zone Offset (prior to observance).
+
+This property specifies the time zone offset that is in use
+*prior to* this time zone observance. It is used to calculate the
+absolute time at which the observance takes place. It is a
+required property of an `icalendar-vtimezone' component. Positive
+numbers indicate time east of the prime meridian (ahead of UTC).
+Negative numbers indicate time west of the prime meridian (behind
+UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.3")
+
+(ical:define-property ical:tzoffsetto "TZOFFSETTO"
+  "Time Zone Offset (in this observance).
+
+This property specifies the time zone offset that is in use *in*
+this time zone observance. It is used to calculate the absolute
+time at which a new observance takes place. It is a required
+property of `icalendar-standard' and `icalendar-daylight'
+components. Positive numbers indicate time east of the prime
+meridian (ahead of UTC). Negative numbers indicate time west of
+the prime meridian (behind UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.4")
+
+(ical:define-property ical:tzurl "TZURL"
+  "Time Zone URL.
+
+This property specifies a URL where updated versions of an
+`icalendar-vtimezone' component are published."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.5")
+
+;;;;; Section 3.8.4: Relationship Component Properties
+
+(ical:define-property ical:attendee "ATTENDEE"
+  "Attendee.
+
+This property specfies a participant in a `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-valarm'. It is required when the
+containing component represents event, task, or notification for
+a *group* of people, but not for components that simply represent
+these items in a single user's calendar (in that case, it should
+not be specified). The property can be specified multiple times,
+once for each participant in the event or task. In an
+EMAIL-category VALARM component, this property specifies the
+address of the user(s) who should receive the notification email.
+
+The parameters `icalendar-roleparam', `icalendar-partstatparam',
+`icalendar-rsvpparam', `icalendar-delfromparam', and
+`icalendar-deltoparam' are especially relevant for further
+specifying the roles of each participant in the containing
+component."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cutypeparam
+                             ical:memberparam
+                             ical:roleparam
+                             ical:partstatparam
+                             ical:rsvpparam
+                             ical:deltoparam
+                             ical:delfromparam
+                             ical:sentbyparam
+                             ical:cnparam
+                             ical:dirparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1")
+
+(ical:define-property ical:contact "CONTACT"
+  "Contact.
+
+This property provides textual contact information relevant to an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy'."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.2")
+
+(ical:define-property ical:organizer "ORGANIZER"
+  "Organizer.
+
+This property specifies the organizer of a group-scheduled
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'.
+It is required in those components if they represent a calendar
+entity with multiple participants. In an `icalendar-vfreebusy'
+component, it used to specify the user requesting free or busy
+time, or the user who published the calendar that the free/busy
+information comes from."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cnparam
+                             ical:dirparam
+                             ical:sentbyparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3")
+
+(ical:define-property ical:recurrence-id "RECURRENCE-ID"
+  "Recurrence ID.
+
+This property is used together with the `icalendar-uid' and
+`icalendar-sequence' properties to identify a specific instance
+of a recurring `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' component. The property value is the
+original value of the `icalendar-dtstart' property of the
+recurrence instance. Its value must have the same type as that
+property's value, and both must specify times in the same way
+(either local or UTC)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam
+                             ical:tzidparam
+                             ical:rangeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.4")
+
+(ical:define-property ical:related-to "RELATED-TO"
+  "Related To (component UID).
+
+This property specifies the `icalendar-uid' value of a different,
+related calendar component. It can be specified on an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. An `icalendar-reltypeparam' can be used to specify the
+relationship type."
+  ical:text
+  :child-spec (:zero-or-one (ical:reltypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.5")
+
+(ical:define-property ical:url "URL"
+  "Uniform Resource Locator.
+
+This property specifies the URL associated with an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy' component."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6")
+
+;; TODO: UID should probably be its own type
+(ical:define-property ical:uid "UID"
+  "Unique Identifier.
+
+This property specifies a globally unique identifier for the
+containing component, and is required in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vfreebusy'
+component.
+
+RFC5545 requires that the program generating the UID guarantee
+that it be unique, and recommends generating it in a format which
+includes a timestamp on the left hand side of an '@' character,
+and the domain name or IP address of the host on the right-hand
+side."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.7")
+
+;;;;; Section 3.8.5: Recurrence Component Properties
+
+(ical:define-property ical:exdate "EXDATE"
+  "Exception Date-Times.
+
+This property defines a list of exceptions to a recurrence rule
+in an `icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component. Together
+with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-rdate' properties, it defines the recurrence set of
+the component."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1")
+
+(ical:define-property ical:rdate "RDATE"
+  "Recurrence Date-Times.
+
+This property defines a list of date-times or dates on which an
+`icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component recurs.
+Together with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-exdate' properties, it defines the recurrence set of
+the component."
+  (or ical:period
+      ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date ical:period)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.2")
+
+(ical:define-property ical:rrule "RRULE"
+  "Recurrence Rule.
+
+This property defines a rule or repeating pattern for the dates
+and times on which an `icalendar-vevent', `icalendar-todo',
+`icalendar-vjournal', `icalendar-standard', or
+`icalendar-daylight' component recurs. Together with the
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate'
+properties, it defines the recurrence set of the component."
+  ical:recur
+  ;; TODO: faces for subexpressions?
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3")
+
+;;;;; Section 3.8.6: Alarm Component Properties
+
+(ical:define-property ical:action "ACTION"
+  "Action (when alarm triggered).
+
+This property defines the action to be taken when the containing
+`icalendar-valarm' component is triggered. It is a required
+property in an alarm component."
+  (or "AUDIO"
+      "DISPLAY"
+      "EMAIL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  :default-type ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.1")
+
+(ical:define-property ical:repeat "REPEAT"
+  "Repeat Count (after initial trigger).
+
+This property specifies the number of times an `icalendar-valarm'
+should repeat after it is initially triggered. This property,
+along with the `icalendar-duration' property, is required if the
+alarm triggers more than once."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.2")
+
+(ical:define-property ical:trigger "TRIGGER"
+  "Trigger.
+
+This property specifies when an `icalendar-valarm' should
+trigger. If the value is an `icalendar-dur-value', it represents
+a time of that duration relative to the start or end of a related
+`icalendar-vevent' or `icalendar-vtodo'. Whether the trigger
+applies to the start time or end time of the related component
+can be specified with the `icalendar-trigrelparam' parameter. A
+positive duration value triggers after the start or end of the
+related component; a negative duration value triggers before.
+
+If the value is an `icalendar-date-time', it must be in UTC
+format, and it triggers at the specified time."
+  (or ical:dur-value
+      ical:date-time)
+  :default-type ical:dur-value
+  :other-types (ical:date-time)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:trigrelparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:trigger-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.3")
+
+(defun ical:trigger-validator (node)
+  "Additional validator for an `icalendar-trigger' NODE.
+Checks that NODE has valid parameters depending on the type of its value.
+
+This function is called by `icalendar-ast-node-valid-p' for
+TRIGGER nodes; it is not normally necessary to call it directly."
+  (let* ((params (ical:ast-node-children node))
+         (value-node (ical:ast-node-value node))
+         (value-type (and value-node (ical:ast-node-type value-node))))
+    (when (eq value-type 'ical:date-time)
+      (let ((expl-type (ical:value-type-from-params params))
+            (dt-value (ical:ast-node-value value-node)))
+        (unless (eq expl-type 'ical:date-time)
+          (signal 'ical:validation-error
+                  (list (concat "Explicit `icalendar-valuetypeparam' required in "
+                                "`icalendar-trigger' with non-duration value")
+                        node)))
+        (when (ical:ast-node-first-child-of 'ical:trigrelparam node)
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-trigrelparam' not allowed in "
+                                "`icalendar-trigger' with non-duration value"))))
+        (unless (ical:date-time-is-utc-p dt-value)
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-date-time' value of "
+                                "`icalendar-trigger' must be in UTC time")
+                        node)))))
+    ;; success:
+    node))
+
+;;;;; Section 3.8.7: Change Management Component Properties
+
+(ical:define-property ical:created "CREATED"
+  "Date-Time Created.
+
+This property specifies the date and time when the calendar user
+initially created an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' in the calendar database. The value must be
+in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
+
+(ical:define-property ical:dtstamp "DTSTAMP"
+  "Timestamp (of last revision or instance creation).
+
+In an `icalendar-vevent', `icalendar-vtodo',
+`icalendar-vjournal', or `icalendar-vfreebusy', this property
+specifies the date and time when the calendar user last revised
+the component's data in the calendar database. (In this case, it
+is equivalent to the `icalendar-last-modified' property.)
+
+If this property is specified on an `icalendar-vcalendar' object
+which contains an `icalendar-method' property, it specifies the
+date and time when that instance of the calendar object was
+created. In this case, it differs from the `icalendar-creation'
+and `icalendar-last-modified' properties: whereas those specify
+the time the underlying data was created and last modified in the
+calendar database, this property specifies when the calendar
+object *representing* that data was created.
+
+The value must be in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
+
+(ical:define-property ical:last-modified "LAST-MODIFIED"
+  "Last Modified timestamp.
+
+This property specifies when the data in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vtimezone'
+was last modified in the calendar database."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3")
+
+(ical:define-property ical:sequence "SEQUENCE"
+  "Revision Sequence Number.
+
+This property specifies the number of the current revision in a
+sequence of revisions in an `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-vjournal' component. It starts
+at 0 and should be incremented monotonically every time the
+Organizer makes a significant revision to the calendar data that
+component represents."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.4")
+
+;;;;; Section 3.8.8: Miscellaneous Component Properties
+;; IANA and X- properties should be parsed and printed but can be ignored:
+(ical:define-property ical:other-property nil ; don't add to ical:property-types
+  "IANA or X-name property.
+
+This property type corresponds to the IANA Properties and
+Non-Standard Properties defined in RFC5545; it represents
+properties with an unknown name (matching rx
+`icalendar-iana-token' or `icalendar-x-name') whose values must
+be parsed and preserved but not further interpreted. Its value
+may be set to any type with the `icalendar-valuetypeparam'
+parameter."
+  ical:value
+  :default-type ical:text
+  ;; "The default value type is TEXT. The value type can be set to any
+  ;; value type." TODO: should we specify :other-types? Without it, a
+  ;; VALUE param will be required to parse anything other than text,
+  ;; but that seems reasonable.
+  :child-spec (:allow-others t)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8")
+
+(defun ical:read-req-status-info (s)
+  "Read a request status value from S.
+S should have been previously matched against `icalendar-request-status-info'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((code (match-string 11))
+        (desc (match-string 12))
+        (exdata (match-string 13)))
+    (list code (ical:read-text desc) (when exdata (ical:read-text exdata)))))
+
+(defun ical:print-req-status-info (rsi)
+  "Serialize request status info value RSI to a string."
+  (let ((code (car rsi))
+        (desc (cadr rsi))
+        (exdata (caddr rsi)))
+    (if exdata
+        (format "%s;%s;%s" code (ical:print-text desc) (ical:print-text exdata))
+      (format "%s;%s" code (ical:print-text desc)))))
+
+(defun ical:req-status-info-p (val)
+  "Return non-nil if VAL is an `icalendar-request-status-info' value."
+  (and (listp val)
+       (length= val 3)
+       (stringp (car val))
+       (stringp (cadr val))
+       (cl-typep (caddr val) '(or string null))))
+
+(ical:define-type ical:req-status-info nil
+  "Type for REQUEST-STATUS property values.
+
+When read, a list (CODE DESCRIPTION EXCEPTION). CODE is a hierarchical
+numerical code, represented as a string, with the following meanings:
+  1.xx Preliminary success
+  2.xx Successful
+  3.xx Client Error
+  4.xx Scheduling Error
+DESCRIPTION is a longer description of the request status, also a string.
+EXCEPTION (which may be nil) is textual data describing an error.
+
+When printed, the three elements are separated by semicolons, like
+  CODE;DESCRIPTION;EXCEPTION
+or
+  CODE;DESCRIPTION
+if EXCEPTION is nil.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-request-status' property."
+  '(satisfies ical:req-status-info-p)
+  (seq
+   ;; statcode: hierarchical status code
+   (group-n 11
+     (seq (one-or-more digit)
+          (** 1 2 (seq ?. (one-or-more digit)))))
+   ?\;
+   ;; statdesc: status description
+   (group-n 12 ical:text)
+   ;; exdata: exception data
+   (zero-or-one (seq ?\; (group-n 13 ical:text))))
+  :reader ical:read-req-status-info
+  :printer ical:print-req-status-info
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+(ical:define-property ical:request-status "REQUEST-STATUS"
+  "Request status"
+  ical:req-status-info
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+
+;;; Section 3.6: Calendar Components
+
+(defconst ical:component-types nil ;; populated by ical:define-component
+  "Alist mapping printed component names to type symbols")
+
+(defun ical:parse-component (limit)
+  "Parse an iCalendar component from point up to LIMIT.
+Point should be at the start of the component, i.e., at the start
+of a line that looks like \"BEGIN:[COMPONENT-NAME]\". After parsing,
+point is at the beginning of the next line following the component
+(or end of the buffer). Returns a syntax node representing the component."
+  (let ((begin-pos nil)
+        (body-begin-pos nil)
+        (end-pos nil)
+        (body-end-pos nil)
+        (begin-regex (rx line-start "BEGIN:" (group-n 2 ical:name) line-end)))
+
+    (unless (re-search-forward begin-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Not at start of a component at line %d, position %d"
+                            (line-number-at-pos (point))
+                            (point)))))
+
+    (setq begin-pos (match-beginning 0)
+          body-begin-pos (1+ (match-end 0))) ; start of next line
+
+    (let* ((component-name (match-string 2))
+           (known-type (alist-get (upcase component-name)
+                                  ical:component-types
+                                  nil nil #'equal))
+           (component-type (or known-type 'ical:other-component))
+           (children nil))
+
+      ;; Find end of component:
+      (save-excursion
+        (if (re-search-forward
+             (rx-to-string `(seq line-start "END:" ,component-name line-end))
+             limit t)
+            (setq end-pos (match-end 0)
+                  body-end-pos (1- (match-beginning 0))) ; end of prev. line
+          (signal 'ical:parse-error
+                  (list (format (concat "Matching END: of component %s not found "
+                                        "between %d and %d")
+                                component-name begin-pos limit)))))
+
+      (while (not (bolp)) (forward-char))
+
+      ;; Parse the properties and subcomponents of this component:
+      (while (<= (point) body-end-pos)
+        (push (ical:parse-property-or-component end-pos)
+              children))
+
+      ;; Set point up for the next parser:
+      (goto-char end-pos)
+      (while (and (< (point) (point-max)) (not (bolp)))
+        (forward-char))
+
+      ;; Return the syntax node for the component:
+      (ical:make-ast-node component-type
+                          (list
+                           :original-name
+                           (when (eq component-type 'ical:other-component)
+                             component-name)
+                           :buffer (current-buffer)
+                           :begin begin-pos
+                           :end end-pos
+                           :value-begin body-begin-pos
+                           :value-end body-end-pos)
+                          (nreverse children)))))
+
+(defun ical:parse-property-or-component (limit)
+  "Parse a component or a property at point.
+Point should be at the beginning of a line which begins a
+component or contains a property."
+  (cond ((looking-at (rx line-start "BEGIN:" ical:name line-end))
+         (ical:parse-component limit))
+        ((looking-at (rx line-start ical:name))
+         (ical:parse-property (line-end-position)))
+        (t (signal 'ical:parse-error
+                   (list (format (concat "Not at start of property or component "
+                                         "at line %d, position %d")
+                                 (line-number-at-pos (point))
+                                 (point)))))))
+
+(defun ical:print-component-node (node)
+  "Serialize a component syntax node NODE to a string."
+  (let* ((type (ical:ast-node-type node))
+         (name (or (ical:ast-node-meta-get :original-name node)
+                   (car (rassq type ical:component-types))))
+         (children (ical:ast-node-children node)))
+
+    (unless name
+      (signal 'ical:print-error
+              (list (format "Unknown component name for type `%s'" type)
+                    type node)))
+
+    (concat
+     ;; TODO: should line ending be sensitive to buffer coding system?
+     (format "BEGIN:%s\r\n" name)
+     (apply #'concat
+            (mapcar #'ical:print-property-or-component children))
+     (format "END:%s\r\n" name))))
+
+(defun ical:print-property-or-component (node)
+  "Serialize a property or component node NODE to a string."
+  (let ((type (ical:ast-node-type node)))
+    (cond ((get type 'ical:is-property)
+           (ical:print-property-node node))
+          ((get type 'ical:is-component)
+           (ical:print-component-node node))
+          (t (signal 'ical:print-error
+                     (list (format "Not a component or property node")
+                           node))))))
+
+(ical:define-component ical:vevent "VEVENT"
+  "Represents an event.
+
+This component contains properties which describe an event, such
+as its start and end time (`icalendar-dtstart' and
+`icalendar-dtend') and a summary (`icalendar-summary') and
+description (`icalendar-description'). It may also contain
+`icalendar-valarm' components as subcomponents which describe
+reminder notifications related to the event. Event components can
+only be direct children of an `icalendar-vcalendar'; they cannot
+be subcomponents of any other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:dtstart
+                             ;; TODO: dtstart required if METHOD not present
+                             ;; in parent calendar
+                             ical:class
+                             ical:created
+                             ical:description
+                             ical:dtend
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:priority
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:transp
+                             ical:url
+                             ical:recurid
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:rstatus
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vevent-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1")
+
+(defun ical:rrule-validator (node)
+  "When component NODE has an `icalendar-rrule', validate that its
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate' properties
+satisfy the requirements imposed by this rule."
+  (let* ((rrule (ical:ast-node-first-child-of 'ical:rrule node))
+         (recval (when rrule (ical:ast-node-value rrule)))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node))
+         (start (when dtstart (ical:ast-node-value dtstart)))
+         (rdates (ical:ast-node-children-of 'ical:rdate node))
+         (included (when rdates (mapcan #'ical:ast-node-value rdates)))
+         (exdates (ical:ast-node-children-of 'ical:exdate node))
+         (excluded (when exdates (mapcan #'ical:ast-node-value exdates))))
+    (when rrule
+      (unless dtstart
+        (signal 'ical:validation-error
+                (list (concat "An `icalendar-rrule' requires an "
+                              "`icalendar-dtstart' property")
+                      node)))
+      (when included
+        (unless (ical:list-of-p (ical:ast-node-type start) included)
+          (signal 'ical:validation-error
+                 (list (concat "`icalendar-rdate' values must agree with type "
+                              "of `icalendar-dtstart' property")
+                       node)))
+        ;; ""RDATE" in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with local time
+        ;; value, relative to the UTC offset specified in the
+        ;; "TZOFFSETFROM" property.
+        (when (and (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+                   (seq-some #'decoded-time-zone
+                             (mapcar #'ical:ast-node-value included)))
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-rdate' values must be in "
+                                "local time in `icalendar-standard' and "
+                                "`icalendar-daylight' components")
+                        node))))
+
+      (when excluded
+        (unless (ical:list-of-p (ical:ast-node-type start) excluded)
+          (signal 'ical:validation-error
+                 (list (concat "`icalendar-exdate' values must agree with type "
+                              "of `icalendar-dtstart' property")
+                       node))))
+      (let* ((freq (car (alist-get 'FREQ recval)))
+             (until (car (alist-get 'UNTIL recval))))
+        (when (eq 'ical:date (ical:ast-node-type start))
+          (when (or (memq freq '(HOURLY MINUTELY SECONDLY))
+                    (assq 'BYSECOND recval)
+                    (assq 'BYMINUTE recval)
+                    (assq 'BYHOUR recval))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-rrule' must not contain time-based "
+                                  "rules when `icalendar-dtstart' is a plain date")
+                          node))))
+        (when until
+          (unless (eq (ical:ast-node-type start)
+                      (ical:ast-node-type until))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-rrule' UNTIL clause must agree with "
+                                  "type of `icalendar-dtstart' property")
+                          node)))
+          (when (eq 'ical:date-time (ical:ast-node-type until))
+            (let ((until-zone
+                   (decoded-time-zone (ical:ast-node-value until)))
+                  (start-zone
+                   (decoded-time-zone (ical:ast-node-value start))))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with local time, then the UNTIL rule part MUST also
+              ;; be specified as a date with local time":
+              (when (and (null start-zone) (not (null until-zone)))
+                (signal 'ical:validation-error
+                        (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "local time if `icalendar-dtstart' is")
+                              node)))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with UTC time or a date with local time and time zone
+              ;; reference, then the UNTIL rule part MUST be specified
+              ;; as a date with UTC time":
+              (when (and (integerp start-zone)
+                         (not (ical:date-time-is-utc-p until)))
+                (signal 'ical:validation-error
+                        (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "UTC time if `icalendar-dtstart' has a "
+                                      "defined time zone")
+                              node)))))
+          (when (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+            ;; "In the case of the "STANDARD" and "DAYLIGHT"
+            ;; sub-components the UNTIL rule part MUST always be
+            ;; specified as a date with UTC time":
+            (unless (ical:date-time-is-utc-p until)
+              (signal 'ical:validation-error
+                      (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "UTC time in `icalendar-standard' and "
+                                      "`icalendar-daylight' components")
+                            node)))))
+
+        ;; "DTSTART in this usage [i.e., in STANDARD and DAYLIGHT
+        ;; subcomponents] MUST be specified as a date with a local
+        ;; time value."
+        (when (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+          (unless (eq 'ical:date-time (ical:ast-node-type start))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-dtstart' must be an "
+                                  "`icalendar-date-time' in `icalendar-standard' "
+                                  "and `icalendar-daylight' components"))))
+
+          (when (decoded-time-zone (ical:ast-node-value start))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-dtstart' must be in "
+                                  "local time in `icalendar-standard' and "
+                                  "`icalendar-daylight' components")
+                          node))))))
+
+    ;; Success:
+    node))
+
+(defun ical:vevent-validator (node)
+  "Additional validator for an `icalendar-vevent' NODE.
+Checks that NODE has does not have both `icalendar-duration' and
+`icalendar-dtend' properties, and
+calls `icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VEVENT nodes; it is not normally necessary to call it directly."
+  (let* ((duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dur-value (when duration (ical:ast-node-value
+                                     (ical:ast-node-value duration))))
+         (dtend (ical:ast-node-first-child-of 'ical:dtend node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and dtend duration)
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-dtend' and `icalendar-duration' "
+                            "properties must not appear in the same "
+                            "`icalendar-vevent'")
+                    node)))
+    ;; don't allow time-based durations with dates
+    ;; TODO: check that the standard disallows this...?
+    (when (and dtstart duration
+               (eq 'ical:date (ical:ast-node-type dtstart))
+               (or (not (integerp dur-value))
+                   (decoded-time-hour dur-value)
+                   (decoded-time-minute dur-value)
+                   (decoded-time-second dur-value)))
+      (signal 'ical:validation-error
+              (list (concat "Event with `icalendar-date' as DTSTART "
+                            " cannot have time units in DURATION")
+                    node)))
+
+  (ical:rrule-validator node)
+  ;; success:
+  node))
+
+(ical:define-component ical:vtodo "VTODO"
+  "Represents a To-Do item or task.
+
+This component contains properties which describe a to-do item or
+task, such as its due date (`icalendar-due') and a summary
+(`icalendar-summary') and description (`icalendar-description').
+It may also contain `icalendar-valarm' components as
+subcomponents which describe reminder notifications related to
+the task. To-do components can only be direct children of an
+`icalendar-vcalendar'; they cannot be subcomponents of any other
+component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:completed
+                             ical:created
+                             ical:description
+                             ical:dtstart
+                             ical:due
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:percent-complete
+                             ical:priority
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vtodo-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.2")
+
+(defun ical:vtodo-validator (node)
+  "Additional validator for an `icalendar-vtodo' NODE.
+Checks that NODE has conformant `icalendar-due',
+`icalendar-duration', and `icalendar-dtstart' properties, and calls
+`icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTODO nodes; it is not normally necessary to call it directly."
+  (let* ((due (ical:ast-node-first-child-of 'ical:due node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and due duration)
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-due' and `icalendar-duration' properties "
+                            "must not appear in the same `icalendar-vtodo'")
+                    node)))
+    (when (and duration (not dtstart))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-duration' requires `icalendar-dtstart' "
+                            "property in the same `icalendar-vtodo'")
+                    node))))
+  (ical:rrule-validator node)
+  ;; success:
+  node)
+
+(ical:define-component ical:vjournal "VJOURNAL"
+  "Represents a journal entry.
+
+This component contains properties which describe a journal
+entry, which might be any longer-form data (e.g., meeting notes,
+a diary entry, or information needed to complete a task). It can
+be associated with an `icalendar-vevent' or `icalendar-vtodo' via
+the `icalendar-related-to' property. A journal entry does not
+take up time in a calendar, and plays no role in searches for
+free or busy time. Journal components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:created
+                             ical:dtstart
+                             ical:last-modified
+                             ical:organizer
+                             ical:recurid
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:description
+                              ical:exdate
+                              ical:related-to
+                              ical:rdate
+                              ical:rstatus
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3")
+
+(ical:define-component ical:vfreebusy "VFREEBUSY"
+  "Represents a published set of free/busy time blocks, or a request
+or response for such blocks.
+
+The free/busy information is represented by the
+`icalendar-freebusy' property (which may be given more than once)
+and the related `icalendar-fbtype' parameter. Note that
+recurrence properties (`icalendar-rrule', `icalendar-rdate', and
+`icalendar-exdate') are NOT permitted in this component.
+
+When used to publish blocks of free/busy time in a user's
+schedule, the `icalendar-organizer' property specifies the user.
+
+When used to request free/busy time in a user's schedule, or to
+respond to such a request, the `icalendar-attendee' property
+specifies the user whose time is being requested, and the
+`icalendar-organizer' property specifies the user making the
+request.
+
+Free/busy components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component, and cannot contain subcomponents."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:contact
+                             ical:dtstart
+                             ical:dtend
+                             ical:organizer
+                             ical:url)
+               :zero-or-more (ical:attendee
+                              ical:comment
+                              ical:freebusy
+                              ical:rstatus
+                              ical:other-property))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.4")
+
+;; TODO: RFC7808 defines additional properties that are relevant here:
+;; https://www.rfc-editor.org/rfc/rfc7808.html#section-7
+(ical:define-component ical:vtimezone "VTIMEZONE"
+  "Represents a time zone.
+
+A time zone is identified by an `icalendar-tzid' property, which
+is required in this component. Times in other calendar components
+can be specified in local time in this time zone with the
+`icalendar-tzidparam' parameter. An `icalendar-vcalendar' object
+must contain exactly one `icalendar-vtimezone' component for each
+unique timezone identifier used in the calendar.
+
+Besides the time zone identifier, a time zone component must
+contain at least one `icalendar-standard' or `icalendar-daylight'
+subcomponent, which describe the observance of standard or
+daylight time in the time zone, including the dates of the
+observance and the relevant offsets from UTC time."
+  :child-spec (:one (ical:tzid)
+               :zero-or-one (ical:last-modified
+                             ical:tzurl)
+               :zero-or-more (ical:standard
+                              ical:daylight
+                              ical:other-property))
+  :other-validator ical:vtimezone-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(defun ical:vtimezone-validator (node)
+  "Additional validator for an `icalendar-vtimezone' NODE.
+Checks that NODE has at least one `icalendar-standard' or
+`icalendar-daylight' child.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTIMEZONE nodes; it is not normally necessary to call it directly."
+  (let ((child-counts (ical:count-children-by-type node)))
+    (when (and (= 0 (alist-get 'ical:standard child-counts 0))
+               (= 0 (alist-get 'ical:daylight child-counts 0)))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-timezone' must have at least one "
+                            "`icalendar-standard' or `icalendar-daylight' child")
+                    node))))
+  ;; success:
+  node)
+
+(ical:define-component ical:standard "STANDARD"
+  "Represents a Standard Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other explanatory
+comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:daylight "DAYLIGHT"
+  "Represents a Daylight Savings Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other
+explanatory comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:valarm "VALARM"
+  "Represents an alarm.
+
+An alarm is a notification or reminder for an event or task. The
+type of notification is determined by this component's
+`icalendar-action' property: it may be an AUDIO, DISPLAY, or
+EMAIL notification.
+If it is an audio alarm, it can include an
+`icalendar-attach' property specifying the audio to be rendered.
+If it is a DISPLAY alarm, it must include an `icalendar-description'
+property containing the text to be displayed.
+If it is an EMAIL alarm, it must include both an
+`icalendar-summary' and an `icalendar-description', which specify
+the subject and body of the email, and one or more
+`icalendar-attendee' properties, which specify the recipients.
+
+The required `icalendar-trigger' property specifies when the
+alarm triggers. If the alarm repeats, then `icalendar-duration'
+and `icalendar-repeat' properties are also both required.
+
+This component must occur as a direct child of an
+`icalendar-vevent' or `icalendar-vtodo' component, and cannot
+contain any subcomponents."
+  :child-spec (:one (ical:action ical:trigger)
+               :zero-or-one (ical:duration ical:repeat)
+               :zero-or-more (ical:summary
+                              ical:description
+                              ical:attendee
+                              ical:attach
+                              ical:other-property))
+  :other-validator ical:valarm-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.6")
+
+(defun ical:valarm-validator (node)
+  "Additional validator function for `icalendar-valarm' components.
+Checks that NODE has the right properties corresponding to its
+`icalendar-action' type, e.g., that an EMAIL alarm has a
+subject (`icalendar-summary') and recipients (`icalendar-attendee').
+
+This function is called by `icalendar-ast-node-valid-p' for
+VALARM nodes; it is not normally necessary to call it directly."
+  (let* ((action (ical:ast-node-first-child-of 'ical:action node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (repeat (ical:ast-node-first-child-of 'ical:repeat node))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when (and duration (not repeat))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-valarm' node with `icalendar-duration' "
+                            "must also have `icalendar-repeat' property")
+                    node)))
+
+    (when (and repeat (not duration))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-valarm' node with `icalendar-repeat' "
+                            "must also have `icalendar-duration' property")
+                    node)))
+
+    (let ((action-str (upcase (ical:text-to-string
+                               (ical:ast-node-value action)))))
+      (cond ((equal "AUDIO" action-str)
+             (unless (<= (alist-get 'ical:attach child-counts 0) 1)
+               (signal 'ical:validation-error
+                       (list (concat "AUDIO `icalendar-valarm' may not have "
+                                     "more than one `icalendar-attach'")
+                             node)))
+             node)
+
+            ((equal "DISPLAY" action-str)
+             (unless (= 1 (alist-get 'ical:description child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "DISPLAY `icalendar-valarm' must have "
+                                     "exactly one `icalendar-description'")
+                             node)))
+             node)
+
+            ((equal "EMAIL" action-str)
+             (unless (= 1 (alist-get 'ical:summary child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "exactly one `icalendar-summary'")
+                             node)))
+             (unless (= 1 (alist-get 'ical:description child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "exactly one `icalendar-description'")
+                             node)))
+             (unless (<= 1 (alist-get 'ical:attendee child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "at least one `icalendar-attendee'")
+                             node)))
+             node)
+
+            (t
+             ;; "Applications MUST ignore alarms with x-name and iana-token
+             ;; values they don't recognize." So this is not a validation-error:
+             (warn (format "Unknown ACTION value in VALARM: %s" action-str))
+             node)))))
+
+(ical:define-component ical:other-component nil
+  "Component type for unrecognized component names.
+
+This component type corresponds to the IANA and X-name components
+allowed by RFC5545 sec. 3.6; it represents components with an
+unknown name (matching rx `icalendar-iana-token' or
+`icalendar-x-name') which must be parsed and preserved but not
+further interpreted."
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6")
+
+;; Technically VCALENDAR is not a "component", but for the
+;; purposes of parsing and syntax highlighting, it looks just like
+;; one, so we define it as such here.
+;; TODO: if this becomes a problem, modify `ical:component-node-p'
+;; to return nil for VCALENDAR components
+(ical:define-component ical:vcalendar "VCALENDAR"
+  "Calendar Object.
+
+This is the top-level data structure defined by RFC5545. A
+VCALENDAR must contain the calendar properties `icalendar-prodid'
+and `icalendar-version', and may contain the calendar properties
+`icalendar-method' and `icalendar-calscale'.
+
+It must also contain at least one VEVENT, VTODO, VJOURNAL,
+VFREEBUSY, or other component, and for every unique
+`icalendar-tzidparam' value appearing in a property within these
+components, the calendar object must contain an
+`icalendar-vtimezone' defining a timezone with that TZID."
+
+  :child-spec (:one (ical:prodid ical:version)
+               :zero-or-one (ical:calscale ical:method)
+               :zero-or-more (ical:other-property
+                              ical:vevent
+                              ical:vtodo
+                              ical:vjournal
+                              ical:vfreebusy
+                              ical:vtimezone
+                              ical:other-component))
+  :other-validator ical:vcalendar-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.4")
+
+(defun ical:all-tzidparams-in (node)
+  "Recursively search NODE for `icalendar-tzidparam' nodes and
+return a list of their values"
+  (cond ((ical:tzid-param-p node)
+         (list (ical:ast-node-value node)))
+        ((ical:param-node-p node)
+         nil)
+        (t ;; TODO: could prune search here when properties don't allow tzidparam
+         (seq-uniq (mapcan #'ical:all-tzidparams-in
+                           (ical:ast-node-children node))))))
+
+(defun ical:vcalendar-validator (node)
+  "Additional validator for `icalendar-vcalendar' NODE. Checks that
+NODE has at least one component child and that all of the
+`ical-tzidparam' values appearing in subcomponents have a
+corresponding `icalendar-vtimezone' definition.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VCALENDAR nodes; it is not normally necessary to call it directly."
+  (let* ((children (ical:ast-node-children node))
+         (comp-children (seq-filter #'ical:component-node-p children))
+         (tz-children (seq-filter #'ical:vtimezone-component-p children))
+         (defined-tzs
+          (mapcar
+           (lambda (tz)
+             ;; ensure timezone component has a TZID property and
+             ;; extract its string value:
+             (when (ical:ast-node-valid-p tz)
+               (ical:with-component tz ((ical:tzid :value-node tzid-text))
+                 (ical:text-to-string tzid-text))))
+           tz-children))
+         (appearing-tzids (ical:all-tzidparams-in node)))
+    (unless comp-children
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-vcalendar' must contain "
+                            "at least one component")
+                    node)))
+
+    (let ((seen nil))
+      (dolist (tzid appearing-tzids)
+        (unless (member tzid seen)
+          (unless (member tzid defined-tzs)
+            (signal 'ical:validation-error
+                    (list (format "No VTIMEZONE with TZID '%s' in calendar" tzid)
+                          node))))
+        (push tzid seen)))
+
+    ;; success:
+    node))
+
+(declare-function icr:tz-set-zones-in "icalendar-recur")
+
+;; `icalendar-parse-component' is sufficient to parse all the syntax in
+;; a calendar, but a calendar-level parsing function is needed to add
+;; support for time zones. This function ensures that every
+;; `icalendar-tzidparam' in the calendar has a corresponding
+;; `icalendar-vtimezone' component, and modifies the zone information of
+;; the parsed date-time according to the offset in that timezone.
+(defun ical:parse-calendar (limit)
+  "Parse an `icalendar-vcalendar' object from point up to LIMIT.
+Point should be at the start of the calendar object, i.e., at the start
+of a line that looks like \"BEGIN:VCALENDAR\". After parsing, point is
+at the beginning of the next line following the calendar (or end of the
+buffer). Returns a syntax node representing the calendar."
+  (require 'icalendar-recur) ; for icr:tz-set-zones-in; avoids circular require
+  (unless (looking-at-p "^BEGIN:VCALENDAR")
+    (signal 'ical:parse-error
+            (list (format "Not at start of VCALENDAR at line %d, position %d"
+                          (line-number-at-pos (point))
+                          (point)))))
+  (let ((cal-node (ical:parse-component limit)))
+    (when (ical:ast-node-valid-p cal-node t)
+      (ical:with-component cal-node
+        ((ical:vtimezone :all tzs))
+        ;; After parsing and validating the whole calendar, set the zone
+        ;; and dst slots in all date-times which are relative to a time
+        ;; zone defined in the calendar: (TODO: if this proves too slow
+        ;; in general, we should instead do it lazily when individual
+        ;; components are queried somehow)
+        (dolist (comp (ical:ast-node-children cal-node))
+          (unless (ical:vtimezone-component-p comp)
+            (icr:tz-set-zones-in tzs comp)))))
+    cal-node))
+
+;; TODO: should we do anything to *create* VTIMEZONE nodes when they're
+;; required but don't exist?
+(defalias 'ical:print-calendar-node #'ical:print-component-node)
+
+
+;;; Documentation for all of the above via `describe-symbol':
+(defun ical:documented-symbol-p (sym)
+  "iCalendar symbol predicate for `describe-symbol-backends'"
+  (or (get sym 'icalendar-type-documentation)
+      ;; grammatical categories defined with rx-define, but with no
+      ;; other special icalendar docs:
+      (and (get sym 'rx-definition)
+           (length> (symbol-name sym) 10)
+           (equal "icalendar-" (substring (symbol-name sym) 0 10)))))
+
+(defun ical:documentation (sym buf frame)
+  "iCalendar documentation backend for `describe-symbol-backends'"
+  (ignore buf frame) ; Silence the byte compiler
+  (with-help-window (help-buffer)
+    (with-current-buffer standard-output
+      (let* ((type-doc (get sym 'icalendar-type-documentation))
+             (link (get sym 'icalendar-link))
+             (rx-def (get sym 'rx-definition))
+             (rx-doc (when rx-def
+                       (with-output-to-string
+                         (pp rx-def))))
+             (value-rx-def (get sym 'ical:value-rx))
+             (value-rx-doc (when value-rx-def
+                             (with-output-to-string
+                               (pp value-rx-def))))
+             (values-rx-def (get sym 'ical:values-rx))
+             (values-rx-doc (when values-rx-def
+                             (with-output-to-string
+                               (pp values-rx-def))))
+
+             (full-doc
+              (concat
+               (when type-doc
+                 (format "`%s' is an iCalendar type:\n\n%s\n\n"
+                         sym type-doc))
+               (when link
+                 (format "For further information see\nURL `%s'\n\n" link))
+               ;; FIXME: this is probably better done in rx.el!
+               ;; TODO: could also generalize this to recursively
+               ;; search rx-def for any symbol that starts with "icalendar-"...
+               (when rx-def
+                 (format "`%s' is an iCalendar grammar category.
+Its `rx' definition is:\n\n%s%s%s"
+                         sym
+                         rx-doc
+                         (if value-rx-def
+                             (format "\nIndividual values must match:\n%s"
+                                      value-rx-doc)
+                           "")
+                         (if values-rx-def
+                             (format "\nLists of values must match:\n%s"
+                                      values-rx-doc)
+                           "")))
+               "\n")))
+
+        (insert full-doc)
+        full-doc))))
+
+
+(defconst ical:describe-symbol-backend
+  '(nil icalendar-documented-symbol-p icalendar-documentation)
+  "Entry for icalendar documentation in `describe-symbol-backends'")
+
+(push ical:describe-symbol-backend describe-symbol-backends)
+
+;; Unloading:
+(defun ical:parser-unload-function ()
+  "Unload function for `icalendar-parser'."
+  (mapatoms
+   (lambda (sym)
+     (when (string-match "^icalendar-" (symbol-name sym))
+       (unintern sym obarray))))
+
+  (setq describe-symbol-backends
+        (remq ical:describe-symbol-backend describe-symbol-backends))
+  ;; Proceed with normal unloading:
+  nil)
+
+(provide 'icalendar-parser)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-recur-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/icalendar-recur.el b/lisp/calendar/icalendar-recur.el
new file mode 100644
index 00000000000..608b43df363
--- /dev/null
+++ b/lisp/calendar/icalendar-recur.el
@@ -0,0 +1,1968 @@
+;;; icalendar-recur.el --- Support for iCalendar recurrences and time zones -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: December 2024
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This is a sub-library for working with recurrence rules and time
+;; zones, as defined by RFC5545 (see especially Secs. 3.3.10 and
+;; 3.8.5.3, which are required reading before you make any changes to
+;; the code below) and related standards (especially RFC8984 Sec. 4.3,
+;; also strongly recommended reading). Recurrence rules and time zones
+;; are mutually dependent: to calculate the date and time of future
+;; instances of a recurring event, you must be able to apply time zone
+;; rules; and to apply time zone rules, you must be able to calculate
+;; the date and time of recurring events, namely the shifts between
+;; observances of standard and daylight savings time. For example, an
+;; event that occurs "on the last Friday of every month at 11AM" in a
+;; given time zone should recur at 11AM daylight savings time in July,
+;; but 11AM standard time in January, for a typical time zone that
+;; shifts from standard to DST and back once each year. These shifts
+;; occur at, say, "the last Sunday in March at 2AM" and "the first
+;; Sunday in November at 2AM". So to calculate an absolute time for a
+;; given instance of the original event, you first have to calculate the
+;; nearest instance of the shift between standand and daylight savings
+;; time, which itself involves applying a recurrence rule of the same
+;; form.
+;;
+;; This mutual dependence between recurrence rules and time zones is not
+;; a *vicious* circle, because the shifts between time zone observances
+;; have fixed offsets from UTC time which are made explicit in iCalendar
+;; data. But it does make things complicated. RFC5545 focuses on making
+;; recurrence rules expressive enough to cover existing practices,
+;; including time zone observance shifts, rather than on being easy to
+;; implement.
+;;
+;; So be forewarned: here be dragons. The code here was difficult to get
+;; working, in part because this mutual dependence means it is difficult
+;; to implement anything less than the whole system, in part because
+;; recurrence rules are very flexible in order to cover as many
+;; practical uses as possible, in part because time zone practices are
+;; themselves complicated, and in part because there are a *lot* of edge
+;; cases to worry about. Much of it is tedious and repetitive but
+;; doesn't lend itself to further simplification or abstraction. If you
+;; need to make changes, make them slowly, and use the tests in
+;; test/lisp/calendar/icalendar-recur-tests.el to make sure they don't
+;; break anything.
+;;
+;; Notation: `date/time' with a slash in symbol names means "`date' or
+;; `date-time'", i.e., is a way of indicating that a function can
+;; accept either type of value, and `dt' is typically used for an
+;; argument of either type. `date-time' should always refer to *just*
+;; date-time values, not plain (calendar-style) dates.
+
+(require 'icalendar-ast)
+(require 'icalendar-parser)
+(require 'icalendar-utils)
+(require 'cl-lib)
+(require 'calendar)
+(require 'simple)
+(require 'seq)
+(eval-when-compile '(require 'icalendar-macs))
+
+;; FIXME: this function, or something similar, should probably be in
+;; calendar.el. It is the inverse of `calendar-day-number',
+;; extracted from `calendar-goto-day-of-year'.
+(defun ical:calendar-date-from-yearday-number (year dayno)
+  "Return the date of the DAYNO-th day in YEAR. DAYNO must be an
+integer between -366 and 366."
+  (calendar-gregorian-from-absolute
+   (+ (if (< dayno 0)
+          (+ 1 dayno (if (calendar-leap-year-p year) 366 365))
+        dayno)
+      (calendar-absolute-from-gregorian (list 12 31 (1- year))))))
+
+
+;; Recurrence Intervals
+;;
+;; Two important ideas in the following:
+;;
+;; 1) Because recurrence sets are potentially infinite, we always
+;; calculate recurrences within certain upper and lower bounds. These
+;; bounds might be determined by a user interface (e.g. the week or
+;; month displayed in a calendar) or might be derived from the logic of
+;; the recurrence rule itself. In the former case, where the bounds can
+;; be arbitrary, it's called a 'window' here (as in "window of
+;; time"). In the latter case, it's called an 'interval' here (after the
+;; "INTERVAL=..." clause in recurrence rules).
+;;
+;; Unlike a window, an interval must be synced up with the recurrence
+;; rule: its bounds must fall at successive integer multiples of the
+;; product of the recurrence rule's FREQ and INTERVAL values, relative
+;; to a starting date/time. For example, a recurrence rule with a
+;; MONTHLY frequency and INTERVAL=3 will have an interval that is three
+;; months long. If its start date is, e.g., in November, then the first
+;; interval runs from November to February, the next from February to
+;; May, and so on. Because intervals depend only on the starting
+;; date/time, the frequency, and the interval length, it is relatively
+;; straightforward to compute the bounds of the interval surrounding an
+;; arbitrary point in time (without enumerating them successively from
+;; the start time); see `icalendar-recur-find-interval', which calls
+;; this arbitrary point in time the 'target'.
+;;
+;; 2) An interval is the smallest unit of time for which we compute
+;; values of the recurrence set. This is because the "BYSETPOS=..."
+;; clause in a recurrence rule operates on the sequence of recurrences
+;; in a single interval. Since it selects recurrences by their index in
+;; this sequence, the sequence must have a determinate length and known
+;; bounds. The function `icalendar-recur-recurrences-in-interval' is the
+;; main function to compute recurrences in a given interval.
+;;
+;; The way to compute the recurrences in an arbitrary *window* is thus
+;; to find the interval bounds which are closest to the window's lower
+;; and upper bound, and then compute the recurrences for all the
+;; intervals in between, i.e., that "cover" the window. This is what the
+;; function `icalendar-recur-recurrences-in-window' does.
+;;
+;; Note that the recurrence set for a recurrence rule with a COUNT
+;; clause cannot be computed for an arbitrary interval (or window);
+;; instead, the set must be enumerated from the beginning, so that the
+;; enumeration can stop after a fixed number of recurrences. This is
+;; what the function `icalendar-recur-recurrences-to-count' does. But
+;; also in this case, recurrences are generated for one interval at a
+;; time, because a BYSETPOS clause might apply.
+;;
+;; An interval is represented as a list (LOW HIGH NEXT-LOW) of decoded
+;; times. The length of time between LOW and HIGH corresponds to the
+;; FREQ rule part: they are one year apart for a 'YEARLY rule, a month
+;; apart for a 'MONTHLY rule, etc. NEXT-LOW is the upper bound of the
+;; interval: it is equal to LOW in the subsequent interval. When the
+;; INTERVAL rule part is equal to 1 (the default), HIGH and NEXT-LOW are
+;; the same, but if it is > 1, NEXT-LOW is equal to LOW + INTERVAL *
+;; FREQ.  For example, in a 'MONTHLY rule where INTERVAL=3, which means
+;; "every three months", LOW and HIGH bound the first month, while HIGH
+;; and NEXT-LOW bound the following two months.
+;;
+;; The times between LOW and HIGH are candidates for recurrences.  LOW
+;; is an inclusive lower bound, and HIGH is an exclusive upper bound:
+;; LOW <= R < HIGH for each recurrence R in the interval. The times
+;; between HIGH and NEXT-LOW are not candidates for recurrences.
+;;
+;; The following functions deal with constructing intervals, given a
+;; target, a start date/time, and intervalsize, and optionally a time
+;; zone.  The main entry point is `icalendar-recur-find-interval'.
+
+;; Look, dragons already:
+(defun icr:find-absolute-interval (target dtstart intervalsize freqs
+                                   &optional vtimezone)
+  "Find a recurrence interval based on a fixed number of seconds.
+
+INTERVALSIZE should be the total size of the interval in seconds. FREQS
+should be the number of seconds between the lower bound of the interval
+and the upper bound for candidate recurrences; it is the number of
+seconds in the unit of time in a recurrence rule's FREQ part.  The
+returned interval looks like (LOW LOW+FREQS LOW+INTERVALSIZE).  See
+`icalendar-recur-find-interval' for other arguments' meanings."
+  ;; We assume here that the interval needs to be calculated using
+  ;; absolute times for SECONDLY, MINUTELY, and HOURLY rules.
+  ;; There are two reasons for this:
+  ;;
+  ;; 1) Time zone shifts. If we don't use absolute times, and instead
+  ;;    find interval boundaries using local clock times with e.g.
+  ;;    `ical:date/time-add' (as we do with time units of a day or
+  ;;    greater below), we have to adjust for clock time changes.  Using
+  ;;    absolute times is simpler.
+  ;; 2) More problematically, using local clock times, at least in its
+  ;;    most straightforward implementation, has pathological results
+  ;;    when `intervalsize' is relatively prime with 60 (for a SECONDLY
+  ;;    rule, similarly for the others): intervals generated by
+  ;;    successive enumeration from one target value will not in general
+  ;;    align with intervals generated from a different, but nearby,
+  ;;    target value.  (So going this route seems to mean giving up on
+  ;;    the idea that intervals can be calculated just from `target',
+  ;;    `dtstart' and `intervalsize', and instead always enumerating
+  ;;    them from the beginning.)
+  ;;
+  ;; In effect, we are deciding that a rule like "every 3 hours" always
+  ;; means every 3 * 60 * 60 = 10800 seconds after `dtstart', and not
+  ;; "every 10800 seconds, except when there's a time zone observance
+  ;; change".  People who want the latter have another option: use a
+  ;; DAILY rule and specify the (local) times for the hours they want in
+  ;; the BYHOUR clause, etc. (People who want it for a number of hours,
+  ;; e.g. 7, which does not divide 24, unfortunately do *not* have this
+  ;; option, but anyone who wants that but does not want to understand
+  ;; "7 hours" as a fixed number of seconds has a pathology that I
+  ;; cannot cure here.)
+  ;;
+  ;; RFC5545 does not seem to pronounce one way or the other on whether
+  ;; this decision is correct: there are no examples of SECONDLY rules
+  ;; to go on, and the few examples for MINUTELY and HOURLY rules only
+  ;; use "nice" values in the INTERVAL clause (real-life examples
+  ;; probably(?)  will too).  Our assumption has some possibly
+  ;; unintuitive consequences for `intervalsize' values that are not
+  ;; "nice" (basically, whenever intervalsize and either 60 or 24 are
+  ;; relatively prime), and for how interval boundaries behave at the
+  ;; shifts between time zone observances (since local clock times in
+  ;; the interval bounds will shift from what they would have been
+  ;; before the observance change -- arguably correct but possibly
+  ;; surprising, depending on the case). But the alternative seems
+  ;; worse, so until countervailing evidence emerges, this approach
+  ;; seems reasonable.
+  (let* ((given-start-zone (decoded-time-zone dtstart))
+         (start-w/zone (cond (given-start-zone dtstart)
+                             ((ical:vtimezone-component-p vtimezone)
+                              (ical:date-time-variant dtstart :tz vtimezone))
+                             (t
+                              ;; "Floating" time should be interpreted in user's
+                              ;; current time zone; see RFC5545 Sec 3.3.5
+                              (ical:date-time-variant
+                               dtstart :zone (car (current-time-zone))))))
+         (start-abs (ignore-errors
+                      (time-convert (encode-time start-w/zone) 'integer)))
+         (given-target-zone (decoded-time-zone target))
+         (target-w/zone (cond (given-target-zone target)
+                              (vtimezone
+                               (ical:date-time-variant target :tz vtimezone))
+                              (t
+                               (ical:date-time-variant
+                                target :zone (car (current-time-zone))))))
+         (target-abs (ignore-errors
+                         (time-convert (encode-time target-w/zone) 'integer)))
+         low-abs low high next-low)
+
+    (unless (zerop (mod intervalsize freqs))
+      ;; Bad things will happen if intervalsize is not an integer
+      ;; multiple of freqs
+      (error "FREQS=%d does not divide INTERVALSIZE=%d" freqs intervalsize))
+    (unless (and start-abs target-abs)
+      (when (not start-abs)
+        (error "Could not determine an offset for DTSTART=%s" dtstart))
+      (when (not target-abs)
+        (error "Could not determine an offset for TARGET=%s" target)))
+
+    ;; Find the lower bound below target that is the closest integer
+    ;; multiple of intervalsize seconds from dtstart
+    (setq low-abs (- target-abs
+                     (mod (- target-abs start-abs) intervalsize)))
+
+    (if vtimezone
+        (setq low (icr:tz-decode-time low-abs vtimezone)
+              high (icr:tz-decode-time (+ low-abs freqs) vtimezone)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) vtimezone))
+      ;; best we can do is decode into target's zone:
+      (let ((offset (decoded-time-zone target-w/zone)))
+        (setq low (icr:tz-decode-time low-abs offset)
+              high (icr:tz-decode-time (+ low-abs freqs) offset)
+              next-low (icr:tz-decode-time (+ low-abs intervalsize) offset))))
+
+    (unless (and given-start-zone given-target-zone)
+      ;; but if we started with floating times, we should return floating times:
+      (setf (decoded-time-zone low) nil)
+      (setf (decoded-time-dst low) -1)
+      (setf (decoded-time-zone high) nil)
+      (setf (decoded-time-dst high) -1)
+      (setf (decoded-time-zone next-low) nil)
+      (setf (decoded-time-dst next-low) -1))
+
+    (list low high next-low)))
+
+(defun icr:find-secondly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a SECONDLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   dtstart
+   intervalsize
+   1
+   vtimezone))
+
+(defun icr:find-minutely-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a MINUTELY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; A MINUTELY interval always runs from the beginning of a minute to
+   ;; the beginning of the next minute:
+   (ical:date-time-variant dtstart :second 0 :tz 'preserve)
+   (* 60 intervalsize)
+   60
+   vtimezone))
+
+(defun icr:find-hourly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find an HOURLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (icr:find-absolute-interval
+   target
+   ;; An HOURLY interval always runs from the beginning of an hour to
+   ;; the beginning of the next hour:
+   (ical:date-time-variant dtstart :minute 0 :second 0 :tz 'preserve)
+   (* 60 60 intervalsize)
+   (* 60 60)
+   vtimezone))
+
+(defun icr:find-daily-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a DAILY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-absdate (calendar-absolute-from-gregorian
+                         (ical:date/time-to-date dtstart)))
+         (target-absdate (calendar-absolute-from-gregorian
+                          (ical:date/time-to-date target)))
+         ;; low-absdate is the closest absolute date below target that
+         ;; is an integer multiple of intervalsize days from dtstart
+         (low-absdate (- target-absdate
+                         (mod (- target-absdate start-absdate) intervalsize)))
+         (high-absdate (1+ low-absdate))
+         (next-low-absdate (+ low-absdate intervalsize)))
+
+    (let* ((low-dt (ical:date-to-date-time
+                     (calendar-gregorian-from-absolute low-absdate)))
+           (high-dt (ical:date-to-date-time
+                      (calendar-gregorian-from-absolute high-absdate)))
+           (next-low-dt (ical:date-to-date-time
+                          (calendar-gregorian-from-absolute next-low-absdate))))
+
+      (when vtimezone
+        (icr:tz-set-zone low-dt vtimezone)
+        (icr:tz-set-zone high-dt vtimezone)
+        (icr:tz-set-zone next-low-dt vtimezone))
+
+      ;; Return the bounds:
+      (list low-dt high-dt next-low-dt))))
+
+(defun icr:find-weekly-interval (target dtstart intervalsize
+                                 &optional weekstart vtimezone)
+  "Find a WEEKLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((target-date (ical:date/time-to-date target))
+         (start-date (ical:date/time-to-date dtstart))
+         ;; the absolute dates of the week start before target and
+         ;; dtstart; these are always a whole number of weeks apart:
+         (target-week-abs (calendar-nth-named-absday
+                           -1
+                           (or weekstart 1)
+                           (calendar-extract-month target-date)
+                           (calendar-extract-year target-date)
+                           (calendar-extract-day target-date)))
+         (start-abs (calendar-nth-named-absday
+                     -1
+                     (or weekstart 1)
+                     (calendar-extract-month start-date)
+                     (calendar-extract-year start-date)
+                     (calendar-extract-day start-date)))
+         (intsize-days (* 7 intervalsize))
+         ;; the absolute date of the week start before target which is
+         ;; an integer multiple of intervalsize weeks from dtstart:
+         (low-abs (- target-week-abs
+                  (mod (- target-week-abs start-abs) intsize-days)))
+         ;; then use this to find the interval bounds:
+         (low (ical:date-to-date-time
+               (calendar-gregorian-from-absolute low-abs)))
+         (high (ical:date-to-date-time
+               (calendar-gregorian-from-absolute (+ 7 low-abs))))
+         (next-low (ical:date-to-date-time
+                    (calendar-gregorian-from-absolute (+ intsize-days low-abs)))))
+
+    (when vtimezone
+      (icr:tz-set-zone low vtimezone)
+      (icr:tz-set-zone high vtimezone)
+      (icr:tz-set-zone next-low vtimezone))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-monthly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a MONTHLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-month (ical:date/time-month dtstart))
+         (start-year (ical:date/time-year dtstart))
+         ;; we calculate in "absolute months", i.e., number of months
+         ;; since the beginning of the Gregorian calendar, to make
+         ;; finding the lower bound easier:
+         (start-abs-months (+ (* 12 (1- start-year)) (1- start-month)))
+         (target-month (ical:date/time-month target))
+         (target-year (ical:date/time-year target))
+         (target-abs-months (+ (* 12 (1- target-year)) (1- target-month)))
+         ;; number of "absolute months" between start of dtstart's month
+         ;; and start of target's month:
+         (nmonths (- target-abs-months start-abs-months))
+         ;; the number of months after dtstart that is the closest integer
+         ;; multiple of intervalsize months before target:
+         (lmonths (- nmonths (mod nmonths intervalsize)))
+         ;; convert these "absolute months" back to Gregorian month and year:
+         (mod-month (mod (+ start-month lmonths) 12))
+         (low-month (if (zerop mod-month) 12 mod-month))
+         (low-year (+ (/ lmonths 12) start-year
+                      ;; iff we cross a year boundary moving forward in
+                      ;; time from start-month to target-month, we need
+                      ;; to add one to the year:
+                      (if (<= start-month target-month) 0 1)))
+         ;; and now we can use these to calculate the interval bounds:
+         (low (ical:make-date-time :year low-year :month low-month :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezone))
+         (high (ical:date/time-add low :month 1 vtimezone))
+         (next-low (ical:date/time-add low :month intervalsize vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-yearly-interval (target dtstart intervalsize &optional vtimezone)
+  "Find a YEARLY recurrence interval. See
+`icalendar-recur-find-interval' for arguments' meanings."
+  (let* ((start-year (ical:date/time-year dtstart))
+         (target-year (ical:date/time-year target))
+         ;; The year before target that is the closest integer multiple
+         ;; of intervalsize years after dtstart:
+         (low-year (- target-year
+                      (mod (- target-year start-year) intervalsize)))
+         (low (ical:make-date-time :year low-year :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0 :tz vtimezone))
+         (high (ical:make-date-time :year (1+ low-year) :month 1 :day 1
+                                    :hour 0 :minute 0 :second 0 :tz vtimezone))
+         (next-low (ical:make-date-time :year (+ low-year intervalsize)
+                                        :month 1 :day 1 :hour 0 :minute 0 :second 0
+                                        :tz vtimezone)))
+
+    ;; Return the bounds:
+    (list low high next-low)))
+
+(defun icr:find-interval (target dtstart recur-value &optional vtimezone)
+  "Return the recurrence interval around TARGET.
+
+TARGET and DTSTART should be `icalendar-date' or `icalendar-date-time'
+values. RECUR-VALUE should be an `icalendar-recur'.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which
+represents the lower and upper bounds of a recurrence interval around
+TARGET. For some N, LOW is equal to START + N*INTERVALSIZE units, HIGH
+is equal to START + (N+1)*INTERVALSIZE units, and LOW <= TARGET < HIGH.
+START here is a time derived from DTSTART depending on RECUR-VALUE's
+FREQ part: the first day of the year for a \\='YEARLY rule, first day
+of the month for a \\='MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\='MONTHLY, etc.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (let ((freq (ical:recur-freq recur-value))
+        (intsize (ical:recur-interval-size recur-value))
+        (weekstart (ical:recur-weekstart recur-value)))
+    (cl-case freq
+      (SECONDLY (icr:find-secondly-interval target dtstart intsize vtimezone))
+      (MINUTELY (icr:find-minutely-interval target dtstart intsize vtimezone))
+      (HOURLY (icr:find-hourly-interval target dtstart intsize vtimezone))
+      (DAILY (icr:find-daily-interval target dtstart intsize vtimezone))
+      (WEEKLY (icr:find-weekly-interval target dtstart intsize
+                                        weekstart vtimezone))
+      (MONTHLY (icr:find-monthly-interval target dtstart intsize vtimezone))
+      (YEARLY (icr:find-yearly-interval target dtstart intsize vtimezone)))))
+
+(defun icr:nth-interval (n dtstart recur-value &optional vtimezone)
+  "Return the Nth recurrence interval after DTSTART.
+
+The returned value is a list (LOW HIGH NEXT-LOW) which represent the Nth
+recurrence interval after DTSTART.  LOW is equal to START +
+N*INTERVALSIZE units, HIGH is equal to START + (N+1)*INTERVALSIZE units,
+and LOW <= TARGET < HIGH.  START here is a time derived from DTSTART
+depending on RECUR-VALUE's FREQ part: the first day of the year for a
+\\='YEARLY rule, first day of the month for a \\='MONTHLY rule, etc.
+
+RECUR-VALUE's interval determines INTERVALSIZE, and its frequency
+determines the units: a month for \\='MONTHLY, etc.
+
+N should be a non-negative integer. Interval 0 is the interval
+containing DTSTART.  DTSTART should be an `icalendar-date' or
+`icalendar-date-time' value.  RECUR-VALUE should be an
+`icalendar-recur'.
+
+If VTIMEZONE is provided, it is used to set time zone information in the
+returned interval bounds. Otherwise, the bounds contain no time zone
+information and represent floating local times."
+  (when (< n 0) (error "Recurrence interval undefined for negative N"))
+  (let* ((start-dt (if (cl-typep dtstart 'ical:date)
+                       (ical:date-to-date-time dtstart :tz vtimezone)
+                     dtstart))
+         (freq (ical:recur-freq recur-value))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (target (ical:date/time-add start-dt unit (* n intervalsize) vtimezone)))
+    (icr:find-interval target dtstart recur-value vtimezone)))
+
+(defun icr:next-interval (interval recur-value &optional vtimezone)
+  "Given a recurrence INTERVAL (LOW HIGH NEXT), return the next interval
+(NEXT HIGHER HIGHER-NEXT), where HIGHER and HIGHER-NEXT are determined
+by the frequency and interval sizes of RECUR-VALUE."
+  (let* ((new-low (caddr interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone))
+         (new-next (ical:date/time-add new-low unit intervalsize vtimezone)))
+
+    (when vtimezone
+      (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      ;; (icr:tz-set-zone new-next vtimezone)
+      )
+
+    (list new-low new-high new-next)))
+
+(defun icr:previous-interval (interval recur-value dtstart &optional vtimezone)
+  "Given a recurrence INTERVAL, return the previous interval.
+
+For an interval (LOW HIGH NEXT-LOW), the previous interval is
+(PREV-LOW PREV-HIGH LOW), where PREV-LOW and PREV-HIGH are determined by
+the frequency and interval sizes of RECUR-VALUE (see
+`icalendar-recur-find-interval').  If the resulting period of time
+between PREV-LOW and PREV-HIGH occurs entirely before DTSTART, then the
+interval does not exist; in this case nil is returned."
+  (let* ((upper (car interval))
+         (freq (ical:recur-freq recur-value))
+         (unit (cl-case freq
+                 (YEARLY :year)
+                 (MONTHLY :month)
+                 (WEEKLY :week)
+                 (DAILY :day)
+                 (HOURLY :hour)
+                 (MINUTELY :minute)
+                 (SECONDLY :second)))
+         (intervalsize (ical:recur-interval-size recur-value))
+         (new-low (ical:date/time-add upper unit (* -1 intervalsize) vtimezone))
+         (new-high (ical:date/time-add new-low unit 1 vtimezone)))
+
+    (when vtimezone
+      ;; (icr:tz-set-zone new-low vtimezone)
+      ;; (icr:tz-set-zone new-high vtimezone)
+      (icr:tz-set-zone upper vtimezone))
+
+    (unless (ical:date-time< new-high dtstart)
+      (list new-low new-high upper))))
+
+
+
+;; Refining intervals into subintervals
+;;
+;; For a given interval, the various BY*=... clauses in a recurrence
+;; rule specify the recurrences in that interval.
+;;
+;; RFC5545 unfortunately has an overly-complicated conceptual model for
+;; how recurrences are to be calculated which is based on "expanding" or
+;; "limiting" the recurrence set for each successive clause. This model
+;; is difficult to think about and implement, and the text of the
+;; standard is ambiguous. I did not succeed in producing a working
+;; implementation based on the description in the standard, and the
+;; existing implementations don't seem to agree on how it's to be
+;; implemented anyway.
+;;
+;; Fortunately, RFC8984 (JSCalendar) is a forthcoming standard which
+;; attempts to resolve the ambiguities while being semantically
+;; backward-compatible with RFC5545. It provides a much cleaner
+;; conceptual model: the recurrence set is generated by starting with a
+;; list of candidates, which consist of every second in (what is here
+;; called) an interval, and then filtering out any candidates which do
+;; not match the rule's clauses. The most straightforward implementation
+;; of this model, however, is unusably slow in typical cases. Consider
+;; for example the case of calculating the onset of daylight savings
+;; time in a given year: the interval is a year long, so it consists of
+;; over 31 million seconds. Although it's easy to generate Lisp
+;; timestamps for each of those seconds, filtering them through the
+;; various BY* clauses means decoding each of those timestamps, which
+;; means doing a fairly expensive computation over 31 million times, and
+;; then throwing away the result in all but one case. When I implemented
+;; this model, I was not patient enough to sit through the calculations
+;; for even MONTHLY rules (which on my laptop took minutes).
+;;
+;; So instead of implementing RFC8984's model directly, the strategy
+;; here is to do something equivalent but much more efficient: rather
+;; than thinking of an interval as consisting of a set of successive
+;; seconds, we think of it as described by its bounds; and for each BY*
+;; clause, we *refine* the interval into subintervals by computing the
+;; bounds of each subinterval corresponding to the value(s) in that
+;; clause. For example, in a YEARLY rule, the initial interval is one
+;; year long, say all of 2025. If it has a "BYMONTH=4,10" clause, then
+;; we refine this interval into two subintervals, each one month long:
+;; one for April 2025 and one for October 2025.  This is much more
+;; efficient in the typical case, because the number of bounds which
+;; describe the final set of subintervals is usually *much* smaller than
+;; the number of seconds in the original interval.
+;;
+;; The following functions are responsible for computing these
+;; refinements. The main entry point here is
+;; `icalendar-recur-refine-from-clauses', which takes care of
+;; successively refining the interval both by the explicit values in the
+;; rule's clauses and by the implicit values in DTSTART. (There, too,
+;; RFC8984 is helpful: it gives a much more explicit description of how
+;; the information in DTSTART interacts with the BY* clauses to further
+;; refine the subintervals.)
+
+(defun icr:refine-byyearday (interval yeardays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching YEARDAYS.
+
+YEARDAYS should be a list of values from a recurrence rule's
+BYYEARDAY=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-ydays (sort yeardays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 366 a)))
+                                            (pos-b (if (< 0 b) b (+ 366 b))))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<= start-year end-year)
+      ;; For each year in the interval...
+      (dolist (n sorted-ydays)
+        ;; ...the subinterval is one day long on the nth yearday
+        (let* ((nthday (ical:calendar-date-from-yearday-number start-year n))
+               (low (ical:make-date-time :year start-year
+                                         :month (calendar-extract-month nthday)
+                                         :day (calendar-extract-day nthday)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval.  Careful! This clipping can lead to high <= low,
+          ;; so need to check it is still the case that low < high
+          ;; before pushing the subinterval
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<= interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<= high interval-end))
+            (push (list low high) subintervals))))
+
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byweekno (interval weeknos &optional weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching WEEKNOS.
+
+WEEKNOS should be a list of values from a recurrence rule's
+BYWEEKNO=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-weeknos (sort weeknos
+                               :lessp (lambda (a b)
+                                        (let ((pos-a (if (< 0 a) a (+ 53 a)))
+                                              (pos-b (if (< 0 b) b (+ 53 b))))
+                                          (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<= start-year end-year)
+      ;; For each year in the interval...
+      (dolist (wn sorted-weeknos)
+        ;; ...the subinterval is one week long in the wn-th week
+        (let* ((nth-wstart (ical:start-of-weekno wn start-year weekstart))
+               (low (ical:make-date-time :year (calendar-extract-year nth-wstart)
+                                         :month (calendar-extract-month nth-wstart)
+                                         :day (calendar-extract-day nth-wstart)
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :day 7 vtimezone)))
+          ;; "Clip" the subinterval bounds if they fall outside the
+          ;; interval, as above. This can happen often here because week
+          ;; boundaries generally do not align with year boundaries.
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date-time<= interval-start low)
+                     (ical:date-time< low high)
+                     (ical:date-time<= high interval-end))
+              (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonth (interval months &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHS.
+
+MONTHS should be a list of values from a recurrence rule's
+BYMONTH=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-months (sort months))
+         (interval-start (car interval))
+         (start-year (decoded-time-year interval-start))
+         (interval-end (cadr interval))
+         (end-year (decoded-time-year interval-end))
+         (subintervals nil))
+    (while (<= start-year end-year)
+      ;; For each year in the interval...
+      (dolist (m sorted-months)
+        ;; ...the subinterval is from the first day of the given month
+        ;; to the first day of the next
+        (let* ((low (ical:make-date-time :year start-year :month m :day 1
+                                         :hour 0 :minute 0 :second 0
+                                         :tz vtimezone))
+               (high (ical:date/time-add low :month 1 vtimezone)))
+
+          ;; Clip the subinterval bounds, as above
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<= interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<= high interval-end))
+            (push (list low high) subintervals))))
+      (setq start-year (1+ start-year)))
+
+    (nreverse subintervals)))
+
+(defun icr:refine-bymonthday (interval monthdays &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MONTHDAYS.
+
+MONTHDAYS should be a list of values from a recurrence rule's
+BYMONTHDAY=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-mdays (sort monthdays
+                             :lessp (lambda (a b)
+                                      (let ((pos-a (if (< 0 a) a (+ 31 a)))
+                                            (pos-b (if (< 0 b) b (+ 31 b))))
+                                        (< pos-a pos-b)))))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<= interval-start interval-end)
+      ;; For each month in the interval...
+      (dolist (m sorted-mdays)
+        ;; ...the subinterval is one day long on the given monthday
+        (let* ((month (ical:date/time-month interval-start))
+               (year (ical:date/time-year interval-start))
+               (monthday (if (< 0 m) m
+                           (+ m 1 (calendar-last-day-of-month month year))))
+               (low (ical:date-time-variant interval-start :day monthday
+                                            :hour 0 :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :day 1 vtimezone)))
+
+          (ignore-errors ; ignore invalid dates, e.g. 2025-02-29
+            ;; Clip subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<= high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start
+            (ical:date/time-add interval-start :month 1 vtimezone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byday (interval weekdays &optional in-month vtimezone)
+  "Refine INTERVAL to days matching the given WEEKDAYS.
+
+WEEKDAYS should be a list of values from a recurrence rule's
+BYDAY=... clause; see `icalendar-recur' for the possible values.
+
+If WEEKDAYS contains pairs (DOW . OFFSET), then IN-MONTH indicates
+whether OFFSET is relative to the month of the start of the interval. If
+it is nil, OFFSET will be relative to the year, rather than the month."
+  (let* ((sorted-weekdays (sort (seq-filter #'natnump weekdays)))
+         (with-offsets (sort (seq-filter #'consp weekdays)
+                             :lessp (lambda (w1 w2) (and (< (car w1) (car w2))))))
+         (interval-start (car interval))
+         (start-abs (calendar-absolute-from-gregorian
+                     (ical:date-time-to-date interval-start)))
+         (interval-end (cadr interval))
+         (end-abs (calendar-absolute-from-gregorian
+                   (ical:date-time-to-date interval-end)))
+         (subintervals nil))
+
+    ;; For days where an offset was given, the subinterval is a single
+    ;; weekday relative to the month or year of interval-start:
+    (dolist (wo with-offsets)
+      (let* ((dow (car wo))
+             (offset (cdr wo))
+             (low-date
+              (ical:nth-weekday-in offset dow
+                                   (ical:date/time-year interval-start)
+                                   (when in-month
+                                     (ical:date/time-month interval-start))))
+             (low (ical:date-to-date-time low-date :tz vtimezone))
+             (high (ical:date/time-add low :day 1 vtimezone)))
+        (when (ical:date/time< low interval-start)
+          (setq low interval-start))
+        (when (ical:date/time< interval-end high)
+          (setq high interval-end))
+        (when vtimezone
+          (icr:tz-set-zone low vtimezone)
+          (icr:tz-set-zone high vtimezone))
+        (when (and (ical:date/time<= interval-start low)
+                   (ical:date/time<= high interval-end)
+                   (ical:date/time< low high))
+          (push (list low high) subintervals))))
+
+    ;; When no offset was given, for each day in the interval...
+    (while (and (<= start-abs end-abs)
+                sorted-weekdays)
+      ;; ...the subinterval is one day long on matching weekdays.
+      (let* ((gdate (calendar-gregorian-from-absolute start-abs)))
+        (when (memq (calendar-day-of-week gdate) sorted-weekdays)
+          (let* ((low (ical:date-to-date-time gdate))
+                 (high (ical:date/time-add low :day 1 vtimezone)))
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when vtimezone
+              (icr:tz-set-zone low vtimezone)
+              (icr:tz-set-zone high vtimezone))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time<= high interval-end)
+                       (ical:date/time< low high))
+              (push (list low high) subintervals)))))
+      (setq start-abs (1+ start-abs)))
+
+    ;; Finally, sort and return all subintervals:
+    (sort subintervals
+          :lessp (lambda (int1 int2)
+                   (ical:date-time< (car int1) (car int2)))
+          :in-place t)))
+
+(defun icr:refine-byhour (interval hours &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching HOURS.
+
+HOURS should be a list of values from a recurrence rule's
+BYHOUR=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-hours (sort hours))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         (subintervals nil))
+    (while (ical:date-time<= interval-start interval-end)
+      ;; For each day in the interval...
+      (dolist (h sorted-hours)
+        ;; ...the subinterval is one hour long in the given hour
+        (let* ((low (ical:date-time-variant interval-start
+                                            :hour h :minute 0 :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :hour 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting times
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<= high interval-end))
+              (push (list low high) subintervals)))))
+      (setq interval-start (ical:date/time-add interval-start :day 1 vtimezone)))
+    (nreverse subintervals)))
+
+(defun icr:refine-byminute (interval minutes &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching MINUTES.
+
+MINUTES should be a list of values from a recurrence rule's
+BYMINUTE=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-minutes (sort minutes))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<= low-ts end-ts)
+      ;; For each hour in the interval...
+      (dolist (m sorted-minutes)
+        ;; ...the subinterval is one minute long in the given minute
+        (let* ((low (ical:date-time-variant interval-start :minute m :second 0
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :minute 1 vtimezone)))
+          (ignore-errors ; do not generate subintervals for nonexisting times
+            ;; Clip the subinterval, as above
+            (when (ical:date/time< low interval-start)
+              (setq low interval-start))
+            (when (ical:date/time< interval-end high)
+              (setq high interval-end))
+            (when (and (ical:date/time<= interval-start low)
+                       (ical:date/time< low high)
+                       (ical:date/time<= high interval-end))
+              (push (list low high) subintervals)))))
+      (setq low-ts (+ low-ts (* 60 60))
+            interval-start (if vtimezone (icr:tz-decode-time low-ts vtimezone)
+                             (ical:date/time-add interval-start :hour 1))))
+    (nreverse subintervals)))
+
+(defun icr:refine-bysecond (interval seconds &optional vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching SECONDS.
+
+SECONDS should be a list of values from a recurrence rule's
+BYSECOND=... clause; see `icalendar-recur' for the possible values."
+  (let* ((sorted-seconds (sort seconds))
+         (interval-start (car interval))
+         (interval-end (cadr interval))
+         ;; we use absolute times (in seconds) for the loop variables in
+         ;; case the interval crosses the boundary between two observances:
+         (low-ts (time-convert (encode-time interval-start) 'integer))
+         (end-ts (time-convert (encode-time interval-end) 'integer))
+         (subintervals nil))
+    (while (<= low-ts end-ts)
+      ;; For each minute in the interval...
+      (dolist (s sorted-seconds)
+        ;; ...the subinterval is one second long: the given second
+        (let* ((low (ical:date-time-variant interval-start :second s
+                                            :tz vtimezone))
+               (high (ical:date/time-add low :second 1 vtimezone)))
+          (when (ical:date/time< low interval-start)
+            (setq low interval-start))
+          (when (ical:date/time< interval-end high)
+            (setq high interval-end))
+          (when (and (ical:date/time<= interval-start low)
+                     (ical:date/time< low high)
+                     (ical:date/time<= high interval-end))
+            (push (list low high) subintervals))))
+      (setq low-ts (+ low-ts 60)
+            interval-start (if vtimezone
+                               (icr:tz-decode-time low-ts vtimezone)
+                             (ical:date/time-add interval-start :minute 1))))
+    (nreverse subintervals)))
+
+;; TODO: should this just become a generic function, with the above
+;; refine-by* functions becoming its methods?
+(defun icr:refine-by (unit interval values
+                      &optional byday-inmonth weekstart vtimezone)
+  "Resolve INTERVAL into a list of subintervals matching VALUES for UNIT."
+  (cl-case unit
+    (BYYEARDAY (icr:refine-byyearday interval values vtimezone))
+    (BYWEEKNO (icr:refine-byweekno interval values weekstart vtimezone))
+    (BYMONTH (icr:refine-bymonth interval values vtimezone))
+    (BYMONTHDAY (icr:refine-bymonthday interval values vtimezone))
+    (BYDAY (icr:refine-byday interval values byday-inmonth vtimezone))
+    (BYHOUR (icr:refine-byhour interval values vtimezone))
+    (BYMINUTE (icr:refine-byminute interval values vtimezone))
+    (BYSECOND (icr:refine-bysecond interval values vtimezone))))
+
+(defun icr:make-bysetpos-filter (setpos)
+  "Return a filter on values for the indices in SETPOS.
+
+SETPOS should be a list of positive or negative integers between -366
+and 366, indicating a fixed index in a set of recurrences for *one
+interval* of a recurrence set, as found in the BYSETPOS=...  clause of
+an `icalendar-recur'. For example, in a YEARLY recurrence rule with an
+INTERVAL of 1, the SETPOS represent indices in the recurrence instances
+generated for a single year.
+
+The returned value is a closure which can be called on the list of
+recurrences for one interval to filter it by index."
+  (lambda (dts)
+    (let* ((len (length dts))
+           (keep-indices (mapcar
+                          (lambda (pos)
+                            ;; sequence indices are 0-based, POS's are 1-based:
+                            (if (< pos 0)
+                                (+ pos len)
+                              (1- pos)))
+                          setpos)))
+      (delq nil
+        (seq-map-indexed
+         (lambda (dt index)
+           (when (memq index keep-indices)
+                 dt))
+         dts)))))
+
+(defun icr:refine-from-clauses (interval recur-value dtstart
+                                &optional vtimezone)
+  "Resolve INTERVAL into subintervals based on the clauses in RECUR-VALUE.
+
+The resulting list of subintervals represents all times in INTERVAL
+which match the BY* clauses of RECUR-VALUE except BYSETPOS, as well as
+the constraints implicit in DTSTART. (For example, if there is no
+BYMINUTE clause, subintervals will have the same minute value as
+DTSTART.)
+
+If specified, VTIMEZONES should be a list of `icalendar-vtimezone'
+components and TZID should be the `icalendar-tzid' property value of one
+of those timezones. In this case, TZID states the time zone of DTSTART,
+and the offsets effective in that time zone on the dates and times of
+recurrences will be local to that time zone."
+  (let ((freq (ical:recur-freq recur-value))
+        (weekstart (ical:recur-weekstart recur-value))
+        (subintervals (list interval)))
+
+    (dolist (byunit (list 'BYMONTH 'BYWEEKNO
+                          'BYYEARDAY 'BYMONTHDAY 'BYDAY
+                          'BYHOUR 'BYMINUTE 'BYSECOND))
+      (let ((values (ical:recur-by* byunit recur-value))
+            (in-month nil))
+        ;; When there is no explicit BY* clause, use the value implicit
+        ;; in DTSTART. (These conditions are adapted from RFC8984:
+        ;;   https://www.rfc-editor.org/rfc/rfc8984.html#section-4.3.3.1-4.3.1
+        ;; Basically, the conditions are somewhat complicated because
+        ;; the meanings of various BY* clauses are not independent and
+        ;; so we have to be careful about the information we take to be
+        ;; implicit in DTSTART, especially with MONTHLY and YEARLY
+        ;; rules. For example, we *do* want to take the weekday of
+        ;; DTSTART as an implicit constraint if a BYWEEKNO clause is
+        ;; present, but not if an explicit BYDAY or BYMONTHDAY clause is
+        ;; also present, since they might contain conflicting
+        ;; constraints.)
+        (when (and (eq byunit 'BYSECOND)
+                   (not (eq freq 'SECONDLY))
+                   (not values))
+          (setq values (list (ical:date/time-second dtstart))))
+        (when (and (eq byunit 'BYMINUTE)
+                   (not (memq freq '(SECONDLY MINUTELY)))
+                   (not values))
+          (setq values (list (ical:date/time-minute dtstart))))
+        (when (and (eq byunit 'BYHOUR)
+                   (not (memq freq '(SECONDLY MINUTELY HOURLY)))
+                   (not values))
+          (setq values (list (ical:date/time-hour dtstart))))
+        (when (and (eq byunit 'BYDAY)
+                   (eq freq 'WEEKLY)
+                   (not values))
+          (setq values (list (ical:date/time-weekday dtstart))))
+        (when (and (eq byunit 'BYMONTHDAY)
+                   (eq freq 'MONTHLY)
+                   (not (ical:recur-by* 'BYDAY recur-value))
+                   (not values))
+          (setq values (list (ical:date/time-monthday dtstart))))
+        (when (and (eq freq 'YEARLY)
+                   (not (ical:recur-by* 'BYYEARDAY recur-value)))
+          (when (and (eq byunit 'BYMONTH)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (or (ical:recur-by* 'BYMONTHDAY recur-value)
+                         (not (ical:recur-by* 'BYDAY recur-value))))
+            (setq values (list (ical:date/time-month dtstart))))
+          (when (and (eq byunit 'BYMONTHDAY)
+                     (not values)
+                     (not (ical:recur-by* 'BYWEEKNO recur-value))
+                     (not (ical:recur-by* 'BYDAY recur-value)))
+            (setq values (list (ical:date/time-monthday dtstart))))
+          (when (and (eq byunit 'BYDAY)
+                     (not values)
+                     (ical:recur-by* 'BYWEEKNO recur-value)
+                     (not (ical:recur-by* 'BYMONTHDAY recur-value)))
+            (setq values (list (ical:date/time-weekday dtstart)))))
+
+        ;; Handle offsets in a BYDAY clause:
+        ;; "If present, this [offset] indicates the nth occurrence of a
+        ;; specific day within the MONTHLY or YEARLY "RRULE".  For
+        ;; example, within a MONTHLY rule, +1MO (or simply 1MO)
+        ;; represents the first Monday within the month, whereas -1MO
+        ;; represents the last Monday of the month.  The numeric value
+        ;; in a BYDAY rule part with the FREQ rule part set to YEARLY
+        ;; corresponds to an offset within the month when the BYMONTH
+        ;; rule part is present"
+        (when (and (eq byunit 'BYDAY)
+                   (or (eq freq 'MONTHLY)
+                       (and (eq freq 'YEARLY)
+                            (ical:recur-by* 'BYMONTH recur-value))))
+          (setq in-month t))
+
+        ;; On each iteration of the loop, we refine the subintervals
+        ;; with these explicit or implicit values:
+        (when values
+          (setq subintervals
+                (delq nil
+                      (mapcan (lambda (in)
+                                (icr:refine-by byunit in values in-month
+                                               weekstart vtimezone))
+                              subintervals))))))
+
+    ;; Finally return the refined subintervals after we've looked at all
+    ;; clauses:
+    subintervals))
+
+;; Once we have refined an interval into a final set of subintervals, we
+;; need to convert those subintervals into a set of recurrences. For a
+;; recurrence set where DTSTART and the recurrences are date-times, the
+;; recurrence set (in this interval) consists of every date-time
+;; corresponding to each second of any subinterval. When DTSTART and the
+;; recurrences are plain dates, the recurrence set consists of each
+;; distinct date in any subinterval.
+(defun icr:subintervals-to-date-times (subintervals &optional vtimezone)
+  "Transform SUBINTERVALS into a list of `icalendar-date-time' recurrences.
+
+The returned list of recurrences contains one date-time value for each
+second of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (dt start)
+             ;; use absolute times for the loop in case the subinterval
+             ;; crosses the boundary between two observances
+             ;; TODO: what if we only have floating times?
+             (end (time-convert (encode-time (cadr int)) 'integer))
+             (tick (time-convert (encode-time start) 'integer)))
+        (while (time-less-p tick end)
+          (push dt recurrences)
+          (setq tick (1+ tick)
+                dt (if vtimezone (icr:tz-decode-time tick vtimezone)
+                     (ical:date/time-add dt :second 1))))))
+    (nreverse recurrences)))
+
+(defun icr:subintervals-to-dates (subintervals)
+  "Transform SUBINTERVALS into a list of `icalendar-date' recurrences.
+
+The returned list of recurrences contains one date value for each
+day of each subinterval."
+  (let (recurrences)
+    (dolist (int subintervals)
+      (let* ((start (car int))
+             (start-abs (calendar-absolute-from-gregorian
+                         (ical:date-time-to-date start)))
+             (end (cadr int))
+             (end-abs (calendar-absolute-from-gregorian
+                       (ical:date-time-to-date end)))
+             ;; number-sequence needs an *inclusive* upper bound:
+             (bound (if (< start-abs end-abs) (1- end-abs)
+                      ;; otherwise start-abs == end-abs, so
+                      ;; make sure we don't exclude that day:
+                      end-abs)))
+        (setq recurrences
+              (append recurrences
+                      (mapcar #'calendar-gregorian-from-absolute
+                              (number-sequence start-abs bound))))))
+    recurrences))
+
+(defun icr:subintervals-to-recurrences (subintervals dtstart &optional vtimezone)
+  "Transform SUBINTERVALS into a list of recurrences.
+
+The returned list of recurrences contains all distinct values in each
+subinterval of the same type as DTSTART."
+  (if (cl-typep dtstart 'ical:date)
+      (icr:subintervals-to-dates subintervals)
+    (icr:subintervals-to-date-times subintervals vtimezone)))
+
+
+;; Calculating recurrences in a given interval or window
+;;
+;; We can now put all of the above together to compute the set of
+;; recurrences in a given interval (`icr:recurrences-in-interval'), and
+;; thereby in a given window (`icr:recurences-in-window'); or, if the
+;; rule describing the set has a COUNT clause, we can enumerate the
+;; recurrences in each interval starting from the beginning of the set
+;; (`icr:recurrences-to-count').
+(defun icr:recurrences-in-interval (interval component &optional vtimezone nmax)
+  "Return a list of the recurrences of COMPONENT in INTERVAL.
+
+INTERVAL should be a list (LOW HIGH NEXT) of date-times which bound a
+single recurrence interval, as returned e.g. by
+`icalendar-recur-find-interval'. (To find the recurrences in an
+arbitrary window of time, rather than between interval boundaries, see
+`icalendar-recur-recurrences-in-window'.)
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and either an
+`icalendar-rrule' or `icalendar-rdate' property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone.
+
+If specified, NMAX should be a positive integer containing a maximum
+number of recurrences to return from this interval. In this case, if the
+interval contains more than NMAX recurrences, only the first NMAX
+recurrences will be returned; otherwise all recurrences in the interval
+are returned. (The NMAX argument mainly exists to support recurrence
+rules with a COUNT clause; see `icalendar-recur-recurrences-to-count'.)
+
+The computed recurrences for INTERVAL are cached in COMPONENT and
+retrieved on subsequent calls with the same arguments."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :values rdates)
+       (ical:exdate :values exdates))
+    (unless (or recur-value rdates)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylight))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (cl-labels ((get-interval (apply-partially #'icr:-set-get-interval component))
+                (put-interval (apply-partially #'icr:-set-put-interval component)))
+      (let ((cached (get-interval interval)))
+        (cond ((eq cached :none) nil)
+              (cached cached)
+              (t
+               (let* (;; Start by generating all the recurrences matching the
+                      ;; BY* clauses except for BYSETPOS:
+                      (subs (icr:refine-from-clauses interval recur-value dtstart
+                                                     vtimezone))
+                      (sub-recs (icr:subintervals-to-recurrences subs dtstart
+                                                                 vtimezone))
+                      ;; Apply any BYSETPOS clause to this set:
+                      (keep-indices (ical:recur-by* 'BYSETPOS recur-value))
+                      (pos-recs
+                       (if keep-indices
+                           (funcall (icr:make-bysetpos-filter keep-indices)
+                                    sub-recs)
+                         sub-recs))
+                      ;; Remove any recurrences before DTSTART or after UNTIL
+                      ;; (both of which are inclusive bounds):
+                      (until (ical:recur-until recur-value))
+                      (until-recs
+                       (seq-filter
+                        (lambda (rec) (and (ical:date/time<= dtstart rec)
+                                           (or (not until)
+                                               (ical:date/time<= rec until))))
+                        pos-recs))
+                      ;; Include any values in the interval from the
+                      ;; RDATE property:
+                      (low (car interval))
+                      (high (cadr interval))
+                      (interval-rdates
+                       (seq-filter
+                        (lambda (rec) (and (ical:date/time<= low rec)
+                                           (ical:date/time< high rec)))
+                        rdates))
+                      (included-recs (append until-recs interval-rdates))
+                      ;; Exclude any values from the EXDATE property; this
+                      ;; gives us the complete set of recurrences in this interval:
+                      (all-recs
+                       (if exdates
+                           (seq-filter
+                            (lambda (rec) (not (member rec exdates)))
+                            included-recs)
+                         included-recs))
+                      ;; Limit to the first NMAX recurrences if requested.
+                      ;; `icr:recurrences-to-count' provides NMAX so as not to
+                      ;; store more recurrences in the final interval than the
+                      ;; COUNT clause allows:
+                      (nmax-recs
+                       (if nmax (seq-take all-recs nmax)
+                         all-recs)))
+                 ;; Store and return the computed recurrences:
+                 (put-interval interval (or nmax-recs :none))
+                 nmax-recs)))))))
+
+(defun icr:recurrences-in-window (lower upper component &optional vtimezone)
+  "Return the recurrences of COMPONENT in the window between LOWER and UPPER.
+
+LOWER and UPPER may be arbitrary `icalendar-date' or
+`icalendar-date-time' values. COMPONENT should be an iCalendar component
+node representing a recurring event: it should contain at least an
+`icalendar-dtstart' and either an `icalendar-rrule' or `icalendar-rdate'
+property.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :values rdates))
+    (unless (or recur-value rdates)
+      (error "No recurrence data in component: %s" component))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylight))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+
+    (let* (;; don't look for nonexistent intervals:
+           (low-start (if (ical:date/time< lower dtstart) dtstart lower))
+           (until (ical:recur-until recur-value))
+           (high-end (if (and until (ical:date/time< until upper)) until upper))
+           (curr-interval (icr:find-interval low-start dtstart recur-value
+                                             vtimezone))
+           (high-interval (icr:find-interval high-end dtstart recur-value
+                                             vtimezone))
+           (high-intbound (cadr high-interval))
+           (recurrences nil))
+
+      (while (ical:date-time< (car curr-interval) high-intbound)
+        (setq recurrences
+              (append
+               (icr:recurrences-in-interval curr-interval component vtimezone)
+               recurrences))
+        (setq curr-interval (icr:next-interval curr-interval recur-value
+                                               vtimezone)))
+
+      ;; exclude any recurrences inside the first and last intervals but
+      ;; outside the window before returning:
+      (seq-filter
+       (lambda (dt)
+         (and (ical:date/time<= lower dt)
+              (ical:date/time< dt upper)))
+       recurrences))))
+
+(defun icr:recurrences-to-count (component &optional vtimezone)
+  "Return all the recurrences in COMPONENT up to COUNT in its recurrence rule.
+
+COMPONENT should be an iCalendar component node representing a recurring
+event: it should contain at least an `icalendar-dtstart' and an
+`icalendar-rrule', which must contain a COUNT=... clause.
+
+Warning: this function finds *all* the recurrences in COMPONENT's
+recurrence set. If the value of COUNT is large, this can be slow.
+
+If specified, VTIMEZONE should be an `icalendar-vtimezone' component.
+In this case, the dates and times of recurrences will be computed with
+UTC offsets local to that time zone."
+  (ical:with-component component
+      ((ical:dtstart :value dtstart)
+       (ical:tzoffsetfrom :value offset-from)
+       (ical:rrule :value recur-value)
+       (ical:rdate :values rdates))
+    (when (memq (ical:ast-node-type component) '(ical:standard ical:daylight))
+      ;; in time zone observances, set the zone field in dtstart
+      ;; from the TZOFFSETFROM property:
+      (setq dtstart
+            (ical:date-time-variant dtstart
+                                    :zone offset-from
+                                    :dst (not (ical:daylight-component-p
+                                               component)))))
+    (unless (or recur-value rdates)
+      (error "No recurrence data in component: %s" component))
+    (unless (ical:recur-count recur-value)
+      (error "Recurrence rule has no COUNT clause"))
+    (let ((count (ical:recur-count recur-value))
+          (int (icr:nth-interval 0 dtstart recur-value vtimezone))
+          recs)
+      (while (length< recs count)
+        (setq recs
+              (append recs (icr:recurrences-in-interval int component vtimezone
+                                                        (- count (length recs)))))
+        (setq int (icr:next-interval int recur-value vtimezone)))
+      recs)))
+
+
+;; Recurrence set representation
+;;
+;; We represent a recurrence set as a map from intervals to the
+;; recurrences in that interval. The primary purpose of this
+;; representation is to memoize the computation of recurrences, since
+;; the computation is relatively expensive and the results are needed
+;; repeatedly, particularly for time zone observances. The map is stored
+;; in the `:recurrence-set' property of the iCalendar component which
+;; represents the recurring event.
+;;
+;; The macro `icalendar-recur-with-recurrence-set' makes it easy to work
+;; with these maps. Given a component representing a recurring event, it
+;; binds the values for all the recurrence-related properties in the
+;; component, as well as two functions to store and retrieve recurrences
+;; by interval.
+;;
+;; Internally, we use a hash table for the map, since the set can grow
+;; quite large. We use the start date-times of intervals as the keys,
+;; since these uniquely identify intervals within a given component; we
+;; ignore the weekday, zone and dst fields in the keys, mostly to avoid
+;; cache misses during time zone observance lookups, which must generate
+;; intervals with different zone values.
+;;
+;; In order to avoid repeating the computation of recurrences, we store
+;; the keyword `:none' as the value when there are no recurrences in a
+;; given interval. This distinguishes the value from nil, so that,
+;; whereas (gethash some-key the-map) => nil means "We haven't computed
+;; recurrences yet for this interval", (gethash some-key the-map) =>
+;; :none means "We've computed that there are no recurrences in this
+;; interval", and can skip the computation of recurrences. See
+;; `icalendar-recur-recurrences-in-interval', which performs the check.
+
+(defun icr:-make-set ()
+  (make-hash-table :test #'equal))
+
+(defsubst icr:-key-from-interval (interval)
+  (take 6 (car interval))) ; (secs mins hours day month year)
+
+(defun icr:-set-get-interval (component interval)
+  (let ((set (ical:ast-node-meta-get :recurrence-set component))
+        (key (icr:-key-from-interval interval)))
+    (when (hash-table-p set)
+      (gethash key set))))
+
+(defun icr:-set-put-interval (component interval recurrences)
+  (let ((set (or (ical:ast-node-meta-get :recurrence-set component)
+                 (icr:-make-set)))
+        (key (icr:-key-from-interval interval)))
+    (setf (gethash key set) recurrences)
+    (ical:ast-node-meta-set component :recurrence-set set)))
+
+(defun icr:-set-complete-p (component)
+  (let* ((set (ical:ast-node-meta-get :recurrence-set component))
+         (recur-value (ical:with-property-of component 'ical:recur nil value))
+         (count (ical:recur-count recur-value))
+         (n 0)
+         (until (ical:recur-until recur-value))
+         (has-until nil))
+    (when (hash-table-p set)
+      (dolist (recs (hash-table-values set))
+        (cl-incf n (length recs))
+        ;; TODO: This isn't right; the set is only complete if it has
+        ;; recurrences for *all* intervals *up to* UNTIL. May not be
+        ;; worth computing this.
+        (when (and (listp recs) (member until recs))
+          (setq has-until t)))
+      (cond (count (= count n))
+            (until has-until)
+            (t nil)))))
+
+;; TODO: this needs more thought.
+;; The byte compiler doesn't like all these implicit bindings.
+;; Instead, with-component should offer the option to bind
+;; get-interval and put-interval; or these
+;; TODO: get/put-interval might need better names, and should be bound with
+;; cl-labels instead of let, so that they can be put directly in function position
+(defmacro icr:with-recurrence-set (component &rest body)
+  "Execute BODY with bindings for recurrence set properties in COMPONENT.
+
+This macro facilitates memoized computations of the values in
+COMPONENT's recurrence set.
+
+Within BODY, the following symbols are bound as follows:
+ dtstart - the value in COMPONENT's `icalendar-dtstart' property.
+   In `icalendar-standard' and `icalendar-daylight' components,
+   this value includes the UTC offset in the `icalendar-tzoffsetfrom' property.
+ recur-value - the value in COMPONENT's `icalendar-rrule' property.
+ rdates - the values in COMPONENT's `icalendar-rdate' property.
+ exdates - the values in COMPONENT's `icalendar-exdate' property.
+ get-interval - a function which, given an interval, returns the
+   recurrences in that interval cached in COMPONENT's recurrence set.
+   It should be called like:
+   (funcall get-interval INTERVAL)
+ put-interval - a function which, given an interval and a list of recurrences
+   in that interval, caches those recurrences in COMPONENT's recurrence set.
+   It should be called like:
+   (funcall put-interval INTERVAL RECURRENCES)
+"
+  (let ((comp (gensym "icalendar-component"))
+        (offset (gensym "tzoffset"))
+        (recset (gensym "recurrence-set")))
+  `(let ((,comp ,component))
+     (ical:with-component ,comp
+       ((ical:dtstart :value dtstart :value-type dtstart-type)
+        (ical:tzoffsetfrom :value ,offset)
+        (ical:rrule :value recur-value)
+        (ical:rdate :values rdates)
+        (ical:exdate :values exdates))
+       (when (memq (ical:ast-node-type ,comp) '(ical:standard ical:daylight))
+         ;; in time zone observances, set the zone field in dtstart
+         ;; from the TZOFFSETFROM property:
+         (setq dtstart (ical:date-time-variant dtstart :zone ,offset
+                                               :dst (not (ical:daylight-component-p ,comp)))))
+       (if (or recur-value rdates)
+         (let ((,recset (ical:ast-node-meta-get :recurrence-set ,comp)))
+           (unless ,recset
+             (setq ,recset (icr:-make-set))
+             (ical:ast-node-meta-set ,comp :recurrence-set ,recset))
+           (let ((get-interval (apply-partially #'icr:-set-get-interval ,comp))
+                 (put-interval (apply-partially #'icr:-set-put-interval ,comp)))
+             ;; TODO: further functions to test for membership, query by
+             ;; date, etc.?
+             ,@body))
+         ;; TODO: what's the most sensible thing when there's no RRULE or RDATE?
+         ;; Should we still execute body?
+         (error "No recurrence data in this component"))))))
+
+
+;; Timezones:
+
+(define-error 'ical:tz-nonexistent-time "Date-time does not exist")
+
+(define-error 'ical:tz-no-observance "No observance found for date-time")
+
+;; In RFC5545 Section 3.3.10, we read: "If the computed local start time
+;; of a recurrence instance does not exist ... the time of the
+;; recurrence instance is interpreted in the same manner as an explicit
+;; DATE-TIME value describing that date and time, as specified in
+;; Section 3.3.5." which in turn says:
+;; "If, based on the definition of the referenced time zone, the local
+;; time described occurs more than once (when changing from daylight to
+;; standard time), the DATE-TIME value refers to the first occurrence of
+;; the referenced time.  Thus, TZID=America/New_York:20071104T013000
+;; indicates November 4, 2007 at 1:30 A.M. EDT (UTC-04:00).  If the
+;; local time described does not occur (when changing from standard to
+;; daylight time), the DATE-TIME value is interpreted using the UTC
+;; offset before the gap in local times. Thus,
+;; TZID=America/New_York:20070311T023000 indicates March 11, 2007 at
+;; 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST (UTC-05:00)."
+
+;; TODO: verify that these functions are correct for time zones other
+;; than US Eastern.
+(defun icr:nonexistent-date-time-p (dt obs-onset observance)
+  "Return non-nil if DT does not exist in a given OBSERVANCE.
+
+Some local date-times do not exist in a given time zone.  When switching
+from standard to daylight savings time, the local clock time jumps over
+a certain range of times. This function tests whether DT is one of those
+non-existent local times.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted using the UTC offset in effect prior to the onset of
+OBSERVANCE. For example, at the switch from Standard to Daylight Savings
+time in US Eastern, the nonexistent time 2:30AM (Standard) must be
+re-interpreted as 3:30AM DST."
+  (when (ical:daylight-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (= (decoded-time-year dt) (decoded-time-year obs-onset))
+           (= (decoded-time-month dt) (decoded-time-month obs-onset))
+           (= (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (jumped (abs (- offset-from offset-to)))
+                  (after-jumped (+ onset-secs jumped)))
+             (and
+              (<= onset-secs dt-secs)
+              (< dt-secs after-jumped)))))))
+
+(defun icr:date-time-occurs-twice-p (dt obs-onset observance)
+  "Return non-nil if DT represents a local clock time that occurs twice in
+a given observance.
+
+Some local date-times occur twice in a given time zone.  When switching
+from daylight savings to standard time time, the local clock time is
+typically set back, so that a certain range of clock times occurs twice,
+once in daylight savings time and once in standard time. This function
+tests whether DT is one of those local times which occur twice.
+
+DT and OBS-ONSET should be `icalendar-date-time' values; OBS-ONSET
+should be the (local) time immediately at the relevant onset of the
+OBSERVANCE. OBSERVANCE should be an `icalendar-standard' or
+`icalendar-daylight' component.
+
+If this function returns t, then per RFC5545 Sec. 3.3.5, DT must be
+interpreted as the first occurrence of this clock time, i.e., in
+daylight savings time, prior to OBS-ONSET."
+  (when (ical:standard-component-p observance)
+    (ical:with-component observance
+        ((ical:tzoffsetfrom :value offset-from)
+         (ical:tzoffsetto :value offset-to))
+      (and (= (decoded-time-year dt) (decoded-time-year obs-onset))
+           (= (decoded-time-month dt) (decoded-time-month obs-onset))
+           (= (decoded-time-day dt) (decoded-time-day obs-onset))
+           (let* ((onset-secs (+ (decoded-time-second obs-onset)
+                                 (* 60 (decoded-time-minute obs-onset))
+                                 (* 60 60 (decoded-time-hour obs-onset))))
+                  (dt-secs (+ (decoded-time-second dt)
+                              (* 60 (decoded-time-minute dt))
+                              (* 60 60 (decoded-time-hour dt))))
+                  (repeated (abs (- offset-from offset-to)))
+                  (start-repeateds (- onset-secs repeated)))
+             (and
+              (<= start-repeateds dt-secs)
+              (< dt-secs onset-secs)))))))
+
+(defun icr:tz--get-updated-in (dt obs-onset observance)
+  "Determine how to update DT's zone and dst slots from OBSERVANCE.
+
+DT should be an `icalendar-date-time', OBSERVANCE an
+`icalendar-standard' or `icalendar-daylight', and OBS-ONSET the nearest
+onset of OBSERVANCE before DT. Returns an `icalendar-date-time' that can
+be used to update DT.
+
+In most cases, the return value will contain a zone offset equal to
+OBSERVANCE's `icalendar-tzoffsetto' value.
+
+However, when DT falls within a range of nonexistent times after
+OBS-ONSET, or a range of local times that occur twice (see
+`icalendar-recur-nonexistent-date-time-p' and
+`icalendar-recur-date-time-occurs-twice-p'), it needs to be interpreted
+with the UTC offset in effect prior to the OBS-ONSET of OBSERVANCE (see
+RFC5545 Sec. 3.3.5).  So e.g. at the switch from Standard to Daylight in
+US Eastern, 2:30AM EST (a nonexistent time) becomes 3:30AM EDT, and at
+the switch from Daylight to Standard, 1:30AM (which occurs twice)
+becomes 1:30AM EDT, the first occurence."
+  (ical:with-component observance
+      ((ical:tzoffsetfrom :value offset-from)
+       (ical:tzoffsetto :value offset-to))
+    (let* ((is-daylight (ical:daylight-component-p observance))
+           (to-dt (ical:date-time-variant dt :dst is-daylight :zone offset-to))
+           (from-dt (ical:date-time-variant dt :dst (not is-daylight)
+                                            :zone offset-from))
+          updated)
+      (cond ((icr:nonexistent-date-time-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we take the same
+             ;; point in absolute time as from-dt, but re-decode it into
+             ;; to-dt's zone:
+             (setq updated (decode-time (encode-time from-dt) offset-to))
+             (setf (decoded-time-dst updated) is-daylight))
+            ((icr:date-time-occurs-twice-p to-dt obs-onset observance)
+             ;; In this case, RFC5545 requires that we interpret dt as
+             ;; from-dt, since that is the first occurrence of the clock
+             ;; time in the zone:
+             (setq updated from-dt))
+            (t
+             ;; Otherwise we interpret dt as to-dt, i.e., with the
+             ;; offset effective within the observance:
+             (setq updated to-dt)))
+      updated)))
+
+(defun icr:tz-for (tzid vtimezones)
+  "Return the `icalendar-vtimezone' for the TZID.
+
+VTIMEZONES should be a list of `icalendar-vtimezone' components.  TZID
+should be a time zone identifier, as found e.g. in an
+`icalendar-tzidparam' parameter. The first time zone in VTIMEZONES whose
+`icalendar-tzid' value matches this parameter's value is returned."
+  (catch 'found
+    (dolist (tz vtimezones)
+      (ical:with-component tz
+          ((ical:tzid :value tzidval))
+        (when (equal tzidval tzid)
+          (throw 'found tz))))))
+
+;; DRAGONS DRAGONS DRAGONS
+(defun icr:tz-observance-on (dt vtimezone &optional update nonexisting)
+  "Return the time zone observance in effect on DT in VTIMEZONE.
+
+If there is such an observance, the returned value is a list (OBSERVANCE
+ONSET). OBSERVANCE is an `icalendar-standard' or `icalendar-daylight'
+component node. ONSET is the recurrence of OBSERVANCE (an
+`icalendar-date-time') which occurs closest in time, but before, DT.
+
+If there is no such observance in VTIMEZONE, the returned value is nil.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node.
+
+DT may be an an `icalendar-date-time' or a Lisp timestamp. If it is a
+date-time, it represents a local time assumed to be in VTIMEZONE. Any
+existing offset in DT is ignored, and DT is compared with the local
+clock time at the start of each observance in VTIMEZONE to determine the
+correct observance and onset. (This is so that the correct observance
+can be found for clock times generated during recurrence rule
+calculations.)
+
+If UPDATE is non-nil, the observance found will be used to update the
+offset value in DT (as a side effect) before returning the observance
+and onset.
+
+If UPDATE is non-nil, NONEXISTING specifies how to handle clock times
+that do not exist in the observance (see
+`icalendar-recur-tz-nonexistent-date-time-p').  The keyword `:error'
+means to signal an \\='icalendar-tz-nonexistent-time error, without
+modifying any of the fields in DT.  Otherwise, the default is to
+interpret DT using the offset from UTC before the onset of the found
+observance, and then reset the clock time in DT to the corresponding
+existing time after the onset of the observance.  For example, the
+nonexisting time 2:30AM in Standard time on the day of the switch to
+Daylight time in the US Eastern time zone will be reset to 3:30AM
+Eastern Daylight time.
+
+If DT is a Lisp timestamp, it represents an absolute time and
+comparisons with the onsets in VTIMEZONE are performed with absolute
+times. UPDATE and NONEXISTING have no meaning in this case and are
+ignored."
+  (ical:with-component vtimezone
+    ((ical:standard :all stds)
+     (ical:daylight :all dls))
+    (let (given-abs-time     ;; = `dt', if given a Lisp timestamp
+          given-clock-time   ;; = `dt', if given a decoded time
+          nearest-observance ;; the observance we're looking for
+          nearest-onset      ;; latest onset of this observance before `dt'
+          updated)           ;; stores how `dt's fields should be updated
+                             ;; in line with this observance, if requested
+
+      (if (cl-typep dt 'ical:date-time)
+          ;; We were passed a date-time with local clock time, not an
+          ;; absolute time; in this case, we must make local clock time
+          ;; comparisons with the observance onset start and recurrences
+          ;; (in order to determine the correct offset for it within the
+          ;; zone)
+          (setq given-clock-time dt
+                given-abs-time nil)
+        ;; We were passed an absolute time, not a date-time; in this
+        ;; case, we can make comparisons in absolute time with
+        ;; observance onset start and recurrences (in order to determine
+        ;; the correct offset for decoding it)
+        (setq given-abs-time dt
+              given-clock-time nil))
+
+      (dolist (obs (append stds dls))
+        (ical:with-component obs
+          ((ical:dtstart :value start)
+           (ical:rrule :value recur-value)
+           (ical:rdate :values rdates)
+           (ical:tzoffsetfrom :value offset-from))
+          ;; DTSTART of the observance must be given as local time, and is
+          ;; combined with TZOFFSETFROM to define the effective onset
+          ;; for the observance in absolute time.
+          (let* ((is-daylight (ical:daylight-component-p obs))
+                 (effective-start
+                  (ical:date-time-variant start :zone offset-from
+                                          :dst (not is-daylight)))
+                 (observance-might-apply
+                  (if given-clock-time
+                      (ical:date-time-locally<= effective-start given-clock-time)
+                    (ical:time<= (encode-time effective-start) given-abs-time))))
+
+            (when observance-might-apply
+              ;; Initialize our return values on the first iteration
+              ;; where an observance potentially applies:
+              (unless nearest-onset
+                (setq nearest-onset effective-start
+                      nearest-observance obs)
+                (when (and update given-clock-time)
+                  (setq updated
+                        (icr:tz--get-updated-in given-clock-time
+                                                effective-start obs))))
+
+              ;; We first check whether any RDATEs in the observance are
+              ;; the relevant onset:
+              (dolist (rd rdates)
+                (let* ((effective-rd
+                        (ical:date-time-variant rd :zone offset-from
+                                                :dst (not is-daylight)))
+                       (onset-applies
+                        (if given-clock-time
+                            (ical:date-time-locally<= effective-rd
+                                                      given-clock-time)
+                          (ical:time<= (encode-time effective-rd)
+                                       given-abs-time))))
+
+                  (when (and onset-applies nearest-onset
+                             (ical:date-time< nearest-onset effective-rd))
+                    (setq nearest-onset effective-rd
+                          nearest-observance obs)
+
+                    (when (and update given-clock-time)
+                      (setq updated
+                            (icr:tz--get-updated-in given-clock-time
+                                                    effective-rd obs))))))
+
+              ;; If the observance has a recurrence value, it's the
+              ;; relevant observance if it:
+              ;; (1) has a recurrence which starts before dt
+              ;; (2) that recurrence is the nearest in the zone
+              ;;     which starts before dt
+              ;; Note that we intentionally do *not* pass `vtimezone'
+              ;; through here to find-interval, recurrences-in-interval,
+              ;; etc. so as not to cause infinite recursion. Instead we
+              ;; directly pass `offset-from' (the offset from UTC at the
+              ;; start of each observance onset), which
+              ;; `icr:tz-set-zone' knows to handle specially without
+              ;; calling this function.
+              (when recur-value
+                (let* ((target (or given-clock-time
+                                   (decode-time given-abs-time offset-from)))
+                       (int (icr:find-interval
+                             target effective-start recur-value offset-from))
+                       (int-recs (icr:recurrences-in-interval
+                                  int obs offset-from))
+                       ;; The closest observance onset before `dt' might
+                       ;; actually be in the previous interval, e.g.
+                       ;; if `dt' is in January after an annual change to
+                       ;; Standard Time in November. So check that as well.
+                       (prev-int (icr:previous-interval int recur-value
+                                                        effective-start
+                                                        offset-from))
+                       (prev-recs (when prev-int
+                                    (icr:recurrences-in-interval
+                                     prev-int obs offset-from)))
+                       (recs (append prev-recs int-recs))
+                       (keep-recs<=given
+                        (if given-clock-time
+                            (lambda (rec)
+                              (ical:date-time-locally<= rec given-clock-time))
+                          (lambda (rec)
+                            (ical:time<= (encode-time rec) given-abs-time))))
+                       (srecs (sort (seq-filter ; (1)
+                                     keep-recs<=given
+                                     recs)
+                                    :lessp #'ical:date-time<
+                                    :in-place t :reverse t))
+                       (latest-rec (car srecs)))
+
+                  (when (and latest-rec
+                             (ical:date-time< nearest-onset latest-rec)) ; (2)
+                    (setf (decoded-time-dst latest-rec)
+                          ;; if obs is a DAYLIGHT observance, latest-rec
+                          ;; represents the last moment of standard time, and
+                          ;; vice versa
+                          (not is-daylight))
+                    (setq nearest-onset latest-rec
+                          nearest-observance obs)
+                    (when (and update given-clock-time)
+                      (setq updated
+                            (icr:tz--get-updated-in given-clock-time
+                                                    latest-rec obs))))))))))
+
+      ;; We've now found the nearest observance, if there was one.
+      ;; Update `dt' as a side effect if requested.  This saves
+      ;; repeating a lot of the above in a separate function.
+      (when (and update given-clock-time nearest-observance updated)
+        ;; signal an error when `dt' does not exist if requested, so the
+        ;; nonexistence can be handled further up the stack:
+        (when (and (eq :error nonexisting)
+                   (not (ical:date-time-locally-simultaneous-p dt updated)))
+          (signal 'ical:tz-nonexistent-time
+                  (list (format "%d-%02d-%02d %02d:%02d:%02d does not exist in %s"
+                                (decoded-time-year dt)
+                                (decoded-time-month dt)
+                                (decoded-time-day dt)
+                                (decoded-time-hour dt)
+                                (decoded-time-minute dt)
+                                (decoded-time-second dt)
+                                (or
+                                 (ical:with-property-of nearest-observance
+                                                        'ical:tzname nil value)
+                                 "time zone observance"))
+                        dt nearest-observance)))
+        ;; otherwise we copy `updated' over to `dt', which resets the
+        ;; clock time in `dt' if it did not exist:
+        (setf (decoded-time-zone dt) (decoded-time-zone updated))
+        (setf (decoded-time-dst dt) (decoded-time-dst updated))
+        (setf (decoded-time-second dt) (decoded-time-second updated))
+        (setf (decoded-time-minute dt) (decoded-time-minute updated))
+        (setf (decoded-time-hour dt) (decoded-time-hour updated))
+        (setf (decoded-time-day dt) (decoded-time-day updated))
+        (setf (decoded-time-month dt) (decoded-time-month updated))
+        (setf (decoded-time-year dt) (decoded-time-year updated))
+        (setf (decoded-time-weekday dt)
+              (calendar-day-of-week (ical:date-time-to-date updated))))
+
+      ;; Return the observance and onset if found, nil if not:
+      (when nearest-observance
+        (list nearest-observance nearest-onset)))))
+
+(defun icr:tz-offset-in (observance)
+  "Return the offset (in seconds) from UTC in effect during OBSERVANCE.
+
+OBSERVANCE should be an `icalendar-standard' or `icalendar-daylight'
+subcomponent of a particular `icalendar-vtimezone'. The returned value
+is the value of its `icalendar-tzoffsetto' property."
+  (ical:with-property-of observance 'ical:tzoffsetto nil value))
+
+(defun icr:tz-decode-time (ts vtimezone)
+  "Decode Lisp timestamp TS with the appropriate offset in VTIMEZONE.
+
+VTIMEZONE should be an `icalendar-vtimezone' component node. The correct
+observance for TS will be looked up in VTIMEZONE, TS will be decoded
+with the UTC offset of that observance, and its dst slot will be set
+based on whether the observance is an `icalendar-standard' or
+`icalendar-daylight' component.  If VTIMEZONE does not have an
+observance that applies to TS, it is decoded into UTC time.
+
+VTIMEZONE may also be an `icalendar-utc-offset'. In this case TS is
+decoded directly into this UTC offset, and its dst slot is set to -1."
+  (let* ((observance (when (ical:vtimezone-component-p vtimezone)
+                       (car (icr:tz-observance-on ts vtimezone))))
+         (offset (cond (observance (icr:tz-offset-in observance))
+                       ((cl-typep vtimezone 'ical:utc-offset)
+                        vtimezone)
+                       (t 0))))
+
+    (ical:date-time-variant ; ensures weekday gets set, too
+     (decode-time ts offset)
+     :zone offset
+     :dst (if observance (ical:daylight-component-p observance)
+            -1))))
+
+(defun icr:tz-set-zone (dt vtimezone &optional nonexisting)
+  "Set the time zone offset and dst flag in DT based on VTIMEZONE.
+
+DT should be an `icalendar-date-time' and VTIMEZONE should be an
+`icalendar-vtimezone'. VTIMEZONE can also be an `icalendar-utc-offset',
+in which case this value is directly set in DT's zone field (without
+changing its dst flag). The updated DT is returned.
+
+This function generally sets only the zone and dst slots of DT, without
+changing the other slots; its main purpose is to adjust date-times
+generated from other date-times during recurrence rule calculations,
+where a different time zone observance may be in effect in the original
+date-time. It cannot be used to re-decode a fixed point in time into a
+different time zone; for that, see `icalendar-recur-tz-decode-time'.
+
+If given, NONEXISTING is a keyword that specifies what to do if DT
+represents a clock time that does not exist according to the relevant
+observance in VTIMEZONE. The value :error means to signal an
+\\='icalendar-tz-nonexistent-time error, and nil means to reset the
+clock time in DT to an existing one; see
+`icalendar-recur-tz-observance-on'."
+  (if (cl-typep vtimezone 'ical:utc-offset)
+      ;; This is where the recurrence rule/time zone mutual dependence
+      ;; bottoms out; don't remove this conditional!
+      (setf (decoded-time-zone dt) vtimezone)
+
+    ;; Otherwise, if there's already zone information in dt, trust it
+    ;; without looking up the observance.  This is partly a performance
+    ;; optimization (because the lookup is expensive) and partly about
+    ;; avoiding problems: looking up the observance uses the clock time
+    ;; in dt without considering the zone information, and doing this
+    ;; when dt has already been adjusted to contain valid zone
+    ;; information can invalidate that information.
+    ;;
+    ;; It's reliable to skip the lookup when dt already contains zone
+    ;; information only because `icalendar-make-date-time',
+    ;; `icalendar-date/time-add', and in particular
+    ;; `icalendar-date-time-variant' are careful to remove the UTC
+    ;; offset and DST information in the date-times they construct,
+    ;; unless provided with enough information to fill those slots.
+    (unless (and (cl-typep dt 'ical:date-time)
+                 (decoded-time-zone dt)
+                 (booleanp (decoded-time-dst dt)))
+      ;; This updates the relevant slots in dt as a side effect:
+      ;; TODO: if no observance is found, is it ever sensible to signal an error,
+      ;; instead of just leaving the zone slot unset?
+      (icr:tz-observance-on dt vtimezone t nonexisting)))
+    dt)
+
+(defun icr:tz-set-zones-in (vtimezones node)
+  "Recursively set time zone offset and dst flags in times in NODE.
+
+VTIMEZONES should be a list of the `icalendar-vtimezone' components in
+the calendar containing NODE. NODE can be any iCalendar syntax node. If
+NODE is a property node with an `icalendar-tzidparam' parameter and an
+`icalendar-date-time' value, the appropriate time zone observance for
+its value is looked up in VTIMEZONES, and used to the set the zone and
+dst slots in its value. Otherwise, the function is called recursively for "
+  (cond
+   ((ical:property-node-p node)
+    (ical:with-property node
+      ((ical:tzidparam :value tzid))
+      (when (and tzid (cl-typep value 'ical:date-time))
+        (let* ((tz (icr:tz-for tzid vtimezones))
+               (updated (icr:tz-set-zone value tz)))
+          (ical:ast-node-meta-set node :value updated)))))
+   ((ical:component-node-p node)
+    (mapc (apply-partially #'icr:tz-set-zones-in vtimezones)
+          (ical:ast-node-children node)))
+   (t nil)))
+
+
+(provide 'icalendar-recur)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-") ("icr:" . "icalendar-recur-"))
+;; End:
+;;; icalendar-recur.el ends here
diff --git a/lisp/calendar/icalendar-uri-schemes.el b/lisp/calendar/icalendar-uri-schemes.el
new file mode 100644
index 00000000000..c94a36c13d8
--- /dev/null
+++ b/lisp/calendar/icalendar-uri-schemes.el
@@ -0,0 +1,444 @@
+;;; icalendar-uri-schemes.el --- URI schemes in iCalendar -*- lexical-binding:t -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Maintainer: emacs-devel@HIDDEN
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines one (large) regular expression, ical:uri-scheme,
+;; to match URI schemes registered with IANA.
+;;
+;; The schemes are listed at:
+;;   https://www.iana.org/assignments/uri-schemes/uri-schemes.txt
+;; Note the licensing terms for this list, available at:
+;;   https://www.iana.org/help/licensing-terms
+;; which as of 2024-10-24 says:
+;;
+;;   IANA and IETF desire to (a) dedicate any applicable copyright
+;;   rights that they may own in the Protocol Registries to the public
+;;   domain, and (b) license any copyright or related rights for which
+;;   they are a licensee (with a right to sublicense) to the broadest
+;;   extent that they are permitted to do so. Accordingly, both IANA
+;;   and IETF affirm that any applicable rights that they may have in
+;;   the Protocol Registries are subject to the Creative Commons CC0
+;;   1.0 dedication found at
+;;   https://creativecommons.org/publicdomain/zero/1.0/legalcode
+;;
+;; This file is current as of 2024-10-24.
+
+;;; Code:
+(require 'rx)
+
+(rx-define ical:uri-scheme (or
+"aaa"
+"aaas"
+"about"
+"acap"
+"acct"
+"acd"
+"acr"
+"adiumxtra"
+"adt"
+"afp"
+"afs"
+"aim"
+"amss"
+"android"
+"appdata"
+"apt"
+"ar"
+"ark"
+"at"
+"attachment"
+"aw"
+"barion"
+"bb"
+"beshare"
+"bitcoin"
+"bitcoincash"
+"blob"
+"bluetooth"
+"bolo"
+"brid"
+"browserext"
+"cabal"
+"calculator"
+"callto"
+"cap"
+"cast"
+"casts"
+"chrome"
+"chrome-extension"
+"cid"
+"coap"
+"coap+tcp"
+"coap+ws"
+"coaps"
+"coaps+tcp"
+"coaps+ws"
+"com-eventbrite-attendee"
+"content"
+"content-type"
+"crid"
+"cstr"
+"cvs"
+"dab"
+"dat"
+"data"
+"dav"
+"dhttp"
+"diaspora"
+"dict"
+"did"
+"dis"
+"dlna-playcontainer"
+"dlna-playsingle"
+"dns"
+"dntp"
+"doi"
+"dpp"
+"drm"
+"drop"
+"dtmi"
+"dtn"
+"dvb"
+"dvx"
+"dweb"
+"ed2k"
+"eid"
+"elsi"
+"embedded"
+"ens"
+"ethereum"
+"example"
+"facetime"
+"fax"
+"feed"
+"feedready"
+"fido"
+"file"
+"filesystem"
+"finger"
+"first-run-pen-experience"
+"fish"
+"fm"
+"ftp"
+"fuchsia-pkg"
+"geo"
+"gg"
+"git"
+"gitoid"
+"gizmoproject"
+"go"
+"gopher"
+"graph"
+"grd"
+"gtalk"
+"h323"
+"ham"
+"hcap"
+"hcp"
+"hs20"
+"http"
+"https"
+"hxxp"
+"hxxps"
+"hydrazone"
+"hyper"
+"iax"
+"icap"
+"icon"
+"im"
+"imap"
+"info"
+"iotdisco"
+"ipfs"
+"ipn"
+"ipns"
+"ipp"
+"ipps"
+"irc"
+"irc6"
+"ircs"
+"iris"
+"iris.beep"
+"iris.lwz"
+"iris.xpc"
+"iris.xpcs"
+"isostore"
+"itms"
+"jabber"
+"jar"
+"jms"
+"keyparc"
+"lastfm"
+"lbry"
+"ldap"
+"ldaps"
+"leaptofrogans"
+"lid"
+"lorawan"
+"lpa"
+"lvlt"
+"machineProvisioningProgressReporter"
+"magnet"
+"mailserver"
+"mailto"
+"maps"
+"market"
+"matrix"
+"message"
+"microsoft.windows.camera"
+"microsoft.windows.camera.multipicker"
+"microsoft.windows.camera.picker"
+"mid"
+"mms"
+"modem"
+"mongodb"
+"moz"
+"ms-access"
+"ms-appinstaller"
+"ms-browser-extension"
+"ms-calculator"
+"ms-drive-to"
+"ms-enrollment"
+"ms-excel"
+"ms-eyecontrolspeech"
+"ms-gamebarservices"
+"ms-gamingoverlay"
+"ms-getoffice"
+"ms-help"
+"ms-infopath"
+"ms-inputapp"
+"ms-launchremotedesktop"
+"ms-lockscreencomponent-config"
+"ms-media-stream-id"
+"ms-meetnow"
+"ms-mixedrealitycapture"
+"ms-mobileplans"
+"ms-newsandinterests"
+"ms-officeapp"
+"ms-people"
+"ms-personacard"
+"ms-project"
+"ms-powerpoint"
+"ms-publisher"
+"ms-recall"
+"ms-remotedesktop"
+"ms-remotedesktop-launch"
+"ms-restoretabcompanion"
+"ms-screenclip"
+"ms-screensketch"
+"ms-search"
+"ms-search-repair"
+"ms-secondary-screen-controller"
+"ms-secondary-screen-setup"
+"ms-settings"
+"ms-settings-airplanemode"
+"ms-settings-bluetooth"
+"ms-settings-camera"
+"ms-settings-cellular"
+"ms-settings-cloudstorage"
+"ms-settings-connectabledevices"
+"ms-settings-displays-topology"
+"ms-settings-emailandaccounts"
+"ms-settings-language"
+"ms-settings-location"
+"ms-settings-lock"
+"ms-settings-nfctransactions"
+"ms-settings-notifications"
+"ms-settings-power"
+"ms-settings-privacy"
+"ms-settings-proximity"
+"ms-settings-screenrotation"
+"ms-settings-wifi"
+"ms-settings-workplace"
+"ms-spd"
+"ms-stickers"
+"ms-sttoverlay"
+"ms-transit-to"
+"ms-useractivityset"
+"ms-virtualtouchpad"
+"ms-visio"
+"ms-walk-to"
+"ms-whiteboard"
+"ms-whiteboard-cmd"
+"ms-word"
+"msnim"
+"msrp"
+"msrps"
+"mss"
+"mt"
+"mtqp"
+"mumble"
+"mupdate"
+"mvn"
+"mvrp"
+"mvrps"
+"news"
+"nfs"
+"ni"
+"nih"
+"nntp"
+"notes"
+"num"
+"ocf"
+"oid"
+"onenote"
+"onenote-cmd"
+"opaquelocktoken"
+"openid"
+"openpgp4fpr"
+"otpauth"
+"p1"
+"pack"
+"palm"
+"paparazzi"
+"payment"
+"payto"
+"pkcs11"
+"platform"
+"pop"
+"pres"
+"prospero"
+"proxy"
+"pwid"
+"psyc"
+"pttp"
+"qb"
+"query"
+"quic-transport"
+"redis"
+"rediss"
+"reload"
+"res"
+"resource"
+"rmi"
+"rsync"
+"rtmfp"
+"rtmp"
+"rtsp"
+"rtsps"
+"rtspu"
+"sarif"
+"secondlife"
+"secret-token"
+"service"
+"session"
+"sftp"
+"sgn"
+"shc"
+"shttp"
+"sieve"
+"simpleledger"
+"simplex"
+"sip"
+"sips"
+"skype"
+"smb"
+"smp"
+"sms"
+"smtp"
+"snews"
+"snmp"
+"soap.beep"
+"soap.beeps"
+"soldat"
+"spiffe"
+"spotify"
+"ssb"
+"ssh"
+"starknet"
+"steam"
+"stun"
+"stuns"
+"submit"
+"svn"
+"swh"
+"swid"
+"swidpath"
+"tag"
+"taler"
+"teamspeak"
+"tel"
+"teliaeid"
+"telnet"
+"tftp"
+"things"
+"thismessage"
+"thzp"
+"tip"
+"tn3270"
+"tool"
+"turn"
+"turns"
+"tv"
+"udp"
+"unreal"
+"upt"
+"urn"
+"ut2004"
+"uuid-in-package"
+"v-event"
+"vemmi"
+"ventrilo"
+"ves"
+"videotex"
+"vnc"
+"view-source"
+"vscode"
+"vscode-insiders"
+"vsls"
+"w3"
+"wais"
+"web3"
+"wcr"
+"webcal"
+"web+ap"
+"wifi"
+"wpid"
+"ws"
+"wss"
+"wtai"
+"wyciwyg"
+"xcon"
+"xcon-userid"
+"xfire"
+"xmlrpc.beep"
+"xmlrpc.beeps"
+"xmpp"
+"xftp"
+"xrcp"
+"xri"
+"ymsgr"
+"z39.50"
+"z39.50r"
+"z39.50s"
+))
+
+(provide 'icalendar-uri-schemes)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-uri-schemes.el ends here
diff --git a/lisp/calendar/icalendar-utils.el b/lisp/calendar/icalendar-utils.el
new file mode 100644
index 00000000000..193e5d32f75
--- /dev/null
+++ b/lisp/calendar/icalendar-utils.el
@@ -0,0 +1,603 @@
+;;; icalendar-utils.el --- iCalendar utility functions  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: January 2025
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+(require 'cl-lib)
+(require 'icalendar-macs)
+(require 'icalendar-parser)
+
+;; Accessors for commonly used properties
+
+(defun ical:component-dtstart (component)
+  "Return the value of the `icalendar-dtstart' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtstart nil value))
+
+(defun ical:component-dtend (component)
+  "Return the value of the `icalendar-dtend' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:dtend nil value))
+
+(defun ical:component-rdate (component)
+  "Return the value of the `icalendar-rdate' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:rdate nil value))
+
+(defun ical:component-summary (component)
+  "Return the value of the `icalendar-summary' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:summary nil value))
+
+(defun ical:component-description (component)
+  "Return the value of the `icalendar-description' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:description nil value))
+
+(defun ical:component-uid (component)
+  "Return the value of the `icalendar-uid' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:uid nil value))
+
+(defun ical:component-url (component)
+  "Return the value of the `icalendar-url' property of COMPONENT.
+COMPONENT can be any component node."
+  (ical:with-property-of component 'ical:url nil value))
+
+;; Date/time
+;; TODO: could turn all the 'date/time' functions into methods dispatched by type
+(defun ical:date-time-to-date (dt)
+  "Convert an `icalendar-date-time' value DT to an `icalendar-date'"
+  (list (decoded-time-month dt)
+        (decoded-time-day dt)
+        (decoded-time-year dt)))
+
+(cl-defun ical:date-to-date-time (dt &key (hour 0) (minute 0) (second 0) (tz nil))
+  "Convert an `icalendar-date' value DT to an `icalendar-date-time'.
+
+The following keyword arguments are accepted:
+  :hour, :minute, :second - integers representing a local clock time on date DT
+  :tz - an `icalendar-vtimezone' in which to interpret this clock time
+
+If these arguments are all unspecified, the hour, minute, and second
+slots of the returned date-time will be zero, and it will contain no
+time zone information. See `icalendar-make-date-time' for more on these
+arguments."
+  (ical:make-date-time
+   :year (calendar-extract-year dt)
+   :month (calendar-extract-month dt)
+   :day (calendar-extract-day dt)
+   :hour hour
+   :minute minute
+   :second second
+   :tz tz))
+
+(defun ical:date/time-to-date (dt)
+  "Extract a Gregorian date from DT.  An `icalendar-date'
+value is returned unchanged; an `icalendar-date-time' value is
+converted to an `icalendar-date'."
+  (if (cl-typep dt 'ical:date)
+      dt
+    (ical:date-time-to-date dt)))
+
+;; Type-aware accessors for date/time slots that work for both
+;; ical:date and ical:date-time:
+;; NOTE: cl-typecase ONLY works here if dt is a valid decoded-time with all slots!
+;; May need to adjust this if it's necessary to work with incomplete decoded-times
+(defun ical:date/time-year (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-year dt))
+    (ical:date-time (decoded-time-year dt))))
+
+(defun ical:date/time-month (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-month dt))
+    (ical:date-time (decoded-time-month dt))))
+
+(defun ical:date/time-monthday (dt)
+  (cl-typecase dt
+    (ical:date (calendar-extract-day dt))
+    (ical:date-time (decoded-time-day dt))))
+
+(defun ical:date/time-weekno (dt &optional weekstart)
+  ;; TODO: Add support for weekstart.
+  ;; calendar-iso-from-absolute doesn't support this yet.
+  (when (and weekstart (not (= weekstart 1)))
+    (error "Support for WEEKSTART other than 1 (=Monday) not implemented yet"))
+  (let* ((gdate (ical:date/time-to-date dt))
+         (isodate (calendar-iso-from-absolute
+                   (calendar-absolute-from-gregorian gdate)))
+         (weekno (car isodate)))
+    weekno))
+
+(defun ical:date/time-weekday (dt)
+  (cl-typecase dt
+    (ical:date (calendar-day-of-week dt))
+    (ical:date-time
+     (or (decoded-time-weekday dt)
+         ;; compensate for possibly-nil weekday slot if the date-time
+         ;; has been constructed by `make-decoded-time'; cf. comment
+         ;; in `icalendar--decoded-date-time-p':
+         (calendar-day-of-week (ical:date-time-to-date dt))))))
+
+(defun ical:date/time-hour (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-hour dt)))
+
+(defun ical:date/time-minute (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-minute dt)))
+
+(defun ical:date/time-second (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-second dt)))
+
+(defun ical:date/time-zone (dt)
+  (when (cl-typep dt 'ical:date-time)
+    (decoded-time-zone dt)))
+
+;;; Date/time comparisons and arithmetic:
+(defun ical:date< (dt1 dt2)
+  "Return non-nil if date DT1 is strictly earlier than date DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YEAR)."
+  (< (calendar-absolute-from-gregorian dt1)
+     (calendar-absolute-from-gregorian dt2)))
+
+(defun ical:date<= (dt1 dt2)
+  "Return non-nil if date DT1 is earlier than or the same date as DT2.
+DT1 and DT2 must both be `icalendar-date' values of the form (MONTH DAY YEAR)."
+  (or (calendar-date-equal dt1 dt2) (ical:date< dt1 dt2)))
+
+(defun ical:date-time-locally-earlier (dt1 dt2 &optional or-equal)
+  "Return non-nil if date-time DT1 is locally earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information.
+
+If OR-EQUAL is non-nil, this function acts like `<=' rather than `<':
+it will return non-nil if DT1 and DT2 are locally the same time."
+  (let ((year1 (decoded-time-year dt1))
+        (year2 (decoded-time-year dt2))
+        (month1 (decoded-time-month dt1))
+        (month2 (decoded-time-month dt2))
+        (day1 (decoded-time-day dt1))
+        (day2 (decoded-time-day dt2))
+        (hour1 (decoded-time-hour dt1))
+        (hour2 (decoded-time-hour dt2))
+        (minute1 (decoded-time-minute dt1))
+        (minute2 (decoded-time-minute dt2))
+        (second1 (decoded-time-second dt1))
+        (second2 (decoded-time-second dt2)))
+    (or (< year1 year2)
+        (and (= year1 year2)
+             (or (< month1 month2)
+                 (and (= month1 month2)
+                      (or (< day1 day2)
+                          (and (= day1 day2)
+                               (or (< hour1 hour2)
+                                   (and (= hour1 hour2)
+                                        (or (< minute1 minute2)
+                                            (and (= minute1 minute2)
+                                                 (if or-equal
+                                                     (<= second1 second2)
+                                                   (< second1 second2))))))))))))))
+
+(defun ical:date-time-locally< (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally strictly earlier than DT2.
+
+Unlike `icalendar-date-time<', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 nil))
+
+(defun ical:date-time-locally<= (dt1 dt2)
+  "Return non-nil if date-time DT1 is locally earlier than, or equal to, DT2.
+
+Unlike `icalendar-date-time<=', this function assumes both times are
+local to some time zone and does not consider their zone information."
+  (ical:date-time-locally-earlier dt1 dt2 t))
+
+(defun ical:date-time< (dt1 dt2)
+  "Return non-nil if date-time DT1 is strictly earlier than DT2.
+
+DT1 and DT2 must both be decoded times, and either both or neither
+should have time zone information.
+
+If one has a time zone offset and the other does not, the offset
+returned from `current-time-zone' is used as the missing offset; if
+`current-time-zone' cannot provide this information, an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-less-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (ical:date-time-locally< dt1 dt2))
+          (t
+           ;; Cf. RFC5545 Sec. 3.3.5:
+           ;; "The recipient of an iCalendar object with a property value
+           ;; consisting of a local time, without any relative time zone
+           ;; information, SHOULD interpret the value as being fixed to whatever
+           ;; time zone the "ATTENDEE" is in at any given moment.  This means
+           ;; that two "Attendees", in different time zones, receiving the same
+           ;; event definition as a floating time, may be participating in the
+           ;; event at different actual times.  Floating time SHOULD only be
+           ;; used where that is the reasonable behavior."
+           ;; I'm interpreting this to mean that if we get here, where
+           ;; one date-time has zone information and the other doesn't,
+           ;; we should use the offset from (current-time-zone).
+           (let* ((user-tz (current-time-zone))
+                  (user-offset (car user-tz))
+                  (dt1z (ical:date-time-variant dt1 :zone (or zone1 user-offset)))
+                  (dt2z (ical:date-time-variant dt2 :zone (or zone2 user-offset))))
+             (if user-offset
+                 (time-less-p (encode-time dt1z) (encode-time dt2z))
+               (error "Too little zone information for comparison: %s %s"
+                      dt1 dt2)))))))
+
+;; Two different notions of equality are relevant to decoded times:
+;; strict equality (`icalendar-date-time=') of all slots, or
+;; simultaneity (`icalendar-date-time-simultaneous-p').
+;; Most tests probably want the strict notion, because it distinguishes
+;; between simultaneous events decoded into different time zones,
+;; whereas most user-facing functions (e.g. sorting events by date and time)
+;; probably want simultaneity.
+(defun ical:date-time= (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are decoded-times with identical slot values.
+
+Note that this function returns nil if DT1 and DT2 represent times in
+different time zones, even if they are simultaneous. For the latter, see
+`icalendar-date-time-simultaneous-p'."
+  (equal dt1 dt2))
+
+(defun ical:date-time-locally-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are locally simultaneous date-times.
+Note that this function ignores zone information in dt1 and dt2. It
+returns non-nil if DT1 and DT2 represent the same clock time in
+different time zones, even if they encode to different absolute times."
+  (and (eq (decoded-time-year dt1)   (decoded-time-year dt2))
+       (eq (decoded-time-month dt1)  (decoded-time-month dt2))
+       (eq (decoded-time-day dt1)    (decoded-time-day dt2))
+       (eq (decoded-time-hour dt1)   (decoded-time-hour dt2))
+       (eq (decoded-time-minute dt1) (decoded-time-minute dt2))
+       (eq (decoded-time-second dt1) (decoded-time-second dt2))))
+
+(defun ical:date-time-simultaneous-p (dt1 dt2)
+  "Return non-nil if DT1 and DT2 are simultaneous date-times.
+
+This function returns non-nil if DT1 and DT2 encode to the same Lisp
+timestamp. Thus they can count as simultaneous even if they represent
+times in different timezones. If both date-times lack an offset from
+UTC, they are treated as simultaneous if they encode to the same
+timestamp in UTC.
+
+If only one date-time has an offset, they are treated as
+non-simultaneous if they represent different clock times according to
+`icalendar-date-time-locally-simultaneous-p'.  Otherwise an error is
+signaled."
+  (let ((zone1 (decoded-time-zone dt1))
+        (zone2 (decoded-time-zone dt2)))
+    (cond ((and (integerp zone1) (integerp zone2))
+           (time-equal-p (encode-time dt1) (encode-time dt2)))
+          ((and (null zone1) (null zone2))
+           (time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0))
+                         (encode-time (ical:date-time-variant dt2 :zone 0))))
+          (t
+           ;; Best effort:
+           ;; TODO: I'm not convinced this is the right thing to do yet.
+           ;; Might want to be stricter here and fix the problem of comparing
+           ;; times with and without zone information elsewhere.
+           (if (ical:date-time-locally-simultaneous-p dt1 dt2)
+               (error "Missing zone information: %s %s" dt1 dt2)
+             nil)))))
+
+(defun ical:date-time<= (dt1 dt2)
+  "Return non-nil if DT1 is earlier than, or simultaneous with, DT2.
+DT1 and DT2 must both be decoded times, and either both or neither must have
+time zone information."
+  (or (ical:date-time< dt1 dt2)
+      (ical:date-time-simultaneous-p dt1 dt2)))
+
+;; TODO: do we still need the constraint that both be of the same type, given
+;; the accessors above? Couldn't we compare a date with a date-time on the basis
+;; of the date fields alone, or assume a plain date has a time of 00:00:00?
+(defun ical:date/time< (dt1 dt2)
+  "Return non-nil if the date or date-time DT1 is strictly earlier than DT2.
+DT1 and DT2 must be either calendar.el dates or decoded-time
+values, and both must be of the same type."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date< dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time< dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))))
+
+
+(defun ical:date/time<= (dt1 dt2)
+  "Return non-nil if the date or date-time DT1 is earlier or simultaneous to DT2.
+DT1 and DT2 must be either calendar.el dates or decoded-time
+values, and both must be of the same type."
+  (cl-typecase dt1
+    (ical:date
+     (if (cl-typep dt2 'ical:date)
+         (ical:date<= dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))
+
+    (ical:date-time
+     (if (cl-typep dt2 'ical:date-time)
+         (ical:date-time<= dt1 dt2)
+       (error "Dates must be of the same type: %s, %s" dt1 dt2)))))
+
+(defun ical:date-add (date unit n)
+  "Add N UNITs to DATE.
+
+UNIT should be `:year', `:month', `:week', or `:day'; time units will be
+ignored. N may be a positive or negative integer."
+  (if (memq unit '(:hour :minute :second))
+      date
+    (let* ((dt (ical:make-date-time :year (calendar-extract-year date)
+                                    :month (calendar-extract-month date)
+                                    :day (calendar-extract-day date)))
+           (delta (if (eq unit :week)
+                      (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n)))
+           (new-dt (decoded-time-add dt delta)))
+      (ical:date-time-to-date new-dt))))
+
+(declare-function icalendar-recur-tz-decode-time "icalendar-recur")
+
+(defun ical:date-time-add (dt delta &optional vtimezone)
+  "Like `decoded-time-add', but also updates weekday and time zone slots.
+
+DT and DELTA should be `icalendar-date-time' values (decoded times), as
+in `decoded-time-add'.  VTIMEZONE, if given, should be an
+`icalendar-vtimezone'. The resulting date-time will be given the offset
+determined by VTIMEZONE at the local time determined by adding DELTA to
+DT.
+
+This function assumes that time units in DELTA larger than an hour
+should not affect the local clock time in the result, even when crossing
+an observance boundary in VTIMEZONE. This means that e.g. if DT is at
+9AM daylight savings time on the day before the transition to standard
+time, then the result of adding a DELTA of two days will be at 9AM
+standard time, even though this is not exactly 48 hours later. Adding a
+DELTA of 48 hours, on the other hand, will result in a time exactly 48
+hours later, but at a different local time."
+  (require 'icalendar-recur) ; for icr:tz-decode-time; avoids circular requires
+  (if (not vtimezone)
+      ;; the simple case: we have no time zone info, so just use
+      ;; `decoded-time-add':
+      (let ((sum (decoded-time-add dt delta)))
+        (ical:date-time-variant sum))
+    ;; `decoded-time-add' does not take time zone shifts into account,
+    ;; so we need to do the adjustment ourselves. We first add the units
+    ;; larger than an hour using `decoded-time-add', holding the clock
+    ;; time fixed, as described in the docstring. Then we add the time
+    ;; units as a fixed number of seconds and re-decode the resulting
+    ;; absolute time into the time zone.
+    (let* ((cal-delta (make-decoded-time :year (or (decoded-time-year delta) 0)
+                                         :month (or (decoded-time-month delta) 0)
+                                         :day (or (decoded-time-day delta) 0)))
+           (cal-sum (decoded-time-add dt cal-delta))
+           (dt-w/zone (ical:date-time-variant cal-sum
+                                              :tz vtimezone))
+           (secs-delta (+ (or (decoded-time-second delta) 0)
+                          (* 60 (or (decoded-time-minute delta) 0))
+                          (* 60 60 (or (decoded-time-hour delta) 0))))
+           (sum-ts (time-add (encode-time dt-w/zone) secs-delta)))
+      (icalendar-recur-tz-decode-time sum-ts vtimezone))))
+
+(defun ical:date/time-add (dt unit n &optional vtimezone)
+  "Add N UNITs to DT.
+
+DT should be an `icalendar-date' or `icalendar-date-time'. UNIT should
+be `:year', `:month', `:week', `:day', `:hour', `:minute', or `:second';
+time units will be ignored if DT is an `icalendar-date'. N may be a
+positive or negative integer."
+  (cl-typecase dt
+    (ical:date-time
+     (let ((delta (if (eq unit :week) (make-decoded-time :day (* 7 n))
+                    (make-decoded-time unit n))))
+       (ical:date-time-add dt delta vtimezone)))
+    (ical:date (ical:date-add dt unit n))))
+
+(cl-defun ical:make-date-time (&key second minute hour day month year
+                                    (dst -1 given-dst) zone tz)
+  "Make an `icalendar-date-time' from the given keyword arguments.
+
+This function is like `make-decoded-time', except that it automatically
+sets the weekday slot set based on the date arguments, and it accepts an
+additional keyword argument: `:tz'. If provided, its value should be an
+`icalendar-vtimezone', and the `:zone' and `:dst' arguments should not
+be provided.  In this case, the zone and dst slots in the returned
+date-time will be adjusted to the correct values in the given time zone
+for the local time represented by the remaining arguments."
+  (when (and tz (or zone given-dst))
+    (error "Possibly conflicting time zone data in args"))
+  (apply #'ical:date-time-variant (make-decoded-time)
+         `(:second ,second :minute ,minute :hour ,hour
+           :day ,day :month ,month :year ,year
+           ;; Don't pass these keywords unless they were given explicitly.
+           ;; TODO: is there a cleaner way to write this?
+           ,@(when tz (list :tz tz))
+           ,@(when given-dst (list :dst dst))
+           ,@(when zone (list :zone zone)))))
+
+(declare-function icalendar-recur-tz-set-zone "icalendar-recur")
+
+(cl-defun ical:date-time-variant (dt &key second minute hour
+                                          day month year
+                                          (dst -1 given-dst)
+                                          (zone nil given-zone)
+                                          tz)
+  "Return a variant of DT with slots modified as in the given arguments.
+
+DT should be an `icalendar-date-time'; the keyword arguments have the
+same meanings as in `make-decoded-time'.  The returned variant will have
+slot values as specified by the arguments or copied from DT, except that
+the weekday slot will be updated if necessary, and the zone and dst
+fields will not be set unless given explicitly (because varying the date
+and clock time generally invalidates the time zone information in DT).
+
+One additional keyword argument is accepted: `:tz'. If provided, its
+value should be an `icalendar-vtimezone', an `icalendar-utc-offset', or
+the symbol \\='preserve.  If it is a time zone component, the zone and
+dst slots in the returned variant will be adjusted to the correct
+values in the given time zone for the local time represented by the
+variant. If it is a UTC offset, the variant's zone slot will contain
+this value, but its dst slot will not be adjusted.  If it is the symbol
+\\='preserve, then both the zone and dst fields are copied from DT into
+the variant."
+  (require 'icalendar-recur) ; for icr:tz-set-zone; avoids circular requires
+  (let ((variant
+         (make-decoded-time :second (or second (decoded-time-second dt))
+                            :minute (or minute (decoded-time-minute dt))
+                            :hour (or hour (decoded-time-hour dt))
+                            :day (or day (decoded-time-day dt))
+                            :month (or month (decoded-time-month dt))
+                            :year (or year (decoded-time-year dt))
+                            ;; For zone and dst slots, trust the value
+                            ;; if explicitly specified or explicitly
+                            ;; requested to preserve, but not otherwise
+                            :dst (cond (given-dst dst)
+                                       ((eq 'preserve tz) (decoded-time-dst dt))
+                                       (t -1))
+                            :zone (cond (given-zone zone)
+                                        ((eq 'preserve tz) (decoded-time-zone dt))
+                                        (t nil)))))
+    ;; update weekday slot when possible, since it depends on the date
+    ;; slots, which might have changed. (It's not always possible,
+    ;; because pure time values are also represented as decoded-times,
+    ;; with empty date slots.)
+    (unless (or (null (decoded-time-year variant))
+                (null (decoded-time-month variant))
+                (null (decoded-time-day variant)))
+      (setf (decoded-time-weekday variant)
+            (calendar-day-of-week (ical:date-time-to-date variant))))
+    ;; if given a time zone or UTC offset, update zone and dst slots,
+    ;; which also might have changed:
+    (when (and tz (not (eq 'preserve tz)))
+      (icalendar-recur-tz-set-zone variant tz))
+    variant))
+
+;; TODO: surely this exists already?
+(defun ical:time<= (a b)
+  "Compare two lisp timestamps A and B: is A <= B?"
+  (or (time-equal-p a b)
+      (time-less-p a b)))
+
+(defun ical:number-of-weeks (year &optional weekstart)
+  "Return the number of weeks in (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (= 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (= Sunday)
+and 6 (= Saturday)."
+  ;; There are 53 weeks in a year if Jan 1 is the fourth day after
+  ;; WEEKSTART, e.g. if the week starts on Monday and Jan 1 is a
+  ;; Thursday, or in a leap year if Jan 1 is the third day after WEEKSTART
+  (let* ((jan1wd (calendar-day-of-week (list 1 1 year)))
+         (delta (mod (- jan1wd (or weekstart 1)) 7)))
+    (if (or (= 4 delta)
+            (and (= 3 delta) (calendar-leap-year-p year)))
+        53
+      52)))
+
+(defun ical:start-of-weekno (weekno year &optional weekstart)
+  "Return the start of the WEEKNOth week in the (Gregorian) YEAR.
+
+RFC5545 defines week 1 as the first week to include at least four days
+in the year. Weeks are assumed to start on Monday (= 1) unless WEEKSTART
+is specified, in which case it should be an integer between 0 (= Sunday)
+and 6 (= Saturday). The returned value is an `icalendar-date'.
+
+If WEEKNO is negative, it refers to the WEEKNOth week before the end of
+the year: -1 is the last week of the year, -2 second to last, etc."
+  (calendar-gregorian-from-absolute
+   (+
+    (* 7 (if (< 0 weekno)
+             (1- weekno)
+           (+ 1 weekno (ical:number-of-weeks year weekstart))))
+    (calendar-dayname-on-or-before
+     (or weekstart 1)
+     ;; Three days after Jan 1. gives us the nearest occurrence;
+     ;; see `calendar-dayname-on-or-before'
+     (+ 3 (calendar-absolute-from-gregorian (list 1 1 year)))))))
+
+(defun ical:nth-weekday-in (n weekday year &optional month)
+  "Return the Nth WEEKDAY in YEAR or MONTH.
+
+If MONTH is specified, it refers to MONTH in YEAR, and N acts as an
+index for WEEKDAYs within the month. Otherwise, N acts as an index for
+WEEKDAYs within the entire YEAR.
+
+N should be an integer. If N<0, it counts from the end of the month or
+year: if N=-1, it refers to the last WEEKDAY in the month or year, if
+N=-2 the second to last, and so on."
+  (if month
+      (calendar-nth-named-day n weekday month year)
+    (let* ((jan1 (calendar-absolute-from-gregorian (list 1 1 year)))
+           (dec31 (calendar-absolute-from-gregorian (list 12 31 year))))
+      ;; Adapted from `calendar-nth-named-absday'.
+      ;; TODO: we could generalize that function to make month an optional
+      ;; argument, but that would mean changing its interface.
+      (calendar-gregorian-from-absolute
+       (if (> n 0)
+           (+ (* 7 (1- n))
+              (calendar-dayname-on-or-before
+               weekday
+               (+ 6 jan1)))
+         (+ (* 7 (1+ n))
+            (calendar-dayname-on-or-before
+             weekday
+             dec31)))))))
+
+;; Parsing, printing and testing utilities
+;; TODO: should this live in icalendar-parser instead?
+(defun ical:parse-from-string (type s)
+  "Parse string S to an iCalendar syntax node of type TYPE."
+  (with-temp-buffer
+    (insert s)
+    (goto-char (point-min))
+    ;; TODO: unfold?
+    (cond ((ical:component-type-symbol-p type)
+           (ical:parse-component (point-max)))
+          ((ical:property-type-symbol-p type)
+           (ical:parse-property (point-max)))
+          ((ical:param-type-symbol-p type)
+           (insert ";")
+           (backward-char)
+           (ical:parse-params (point-max)))
+          ((ical:value-type-symbol-p type)
+           (ical:parse-value-node type (point-max)))
+          (t
+           (error "Don't know how to parse type %s" type)))))
+
+(provide 'icalendar-utils)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-utils.el ends here
diff --git a/test/lisp/calendar/icalendar-parser-tests.el b/test/lisp/calendar/icalendar-parser-tests.el
new file mode 100644
index 00000000000..e05ae5e90a3
--- /dev/null
+++ b/test/lisp/calendar/icalendar-parser-tests.el
@@ -0,0 +1,1803 @@
+;;; tests/icalendar-parser.el --- Tests for icalendar-parser  -*- lexical-binding: t; -*-
+
+(eval-when-compile (require 'cl-lib))
+(require 'ert)
+(require 'icalendar-parser)
+
+(cl-defmacro ict:parse/print-test (string &key expected parser type printer source)
+  "Create a test which parses STRING, prints the resulting parse
+tree, and compares the printed version with STRING (or with
+EXPECTED, if given). If they are the same, the test passes.
+PARSER and PRINTER should be the parser and printer functions
+appropriate to STRING. TYPE, if given, should be the type of
+object PARSER is expected to parse; it will be passed as PARSER's
+first argument. SOURCE should be a symbol; it is used to name the
+test."
+  (let ((parser-form
+         (if type
+             `(funcall (function ,parser) (quote ,type) (point-max))
+           `(funcall (function ,parser) (point-max)))))
+    `(ert-deftest ,(intern (concat "ict:parse/print-" (symbol-name source))) ()
+       ,(format "Parse and reprint example from `%s'; pass if they match" source)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (print-buf (get-buffer-create "*iCalendar Print Test*"))
+              (unparsed ,string)
+              (expected (or ,expected unparsed))
+              (printed nil))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let ((parsed ,parser-form))
+           (should (icalendar-ast-node-valid-p parsed))
+           (set-buffer print-buf)
+           (erase-buffer)
+           (insert (funcall (function ,printer) parsed))
+           ;; TODO: this may need adjusting if printers become coding-system aware
+           (decode-coding-region (point-min) (point-max) 'utf-8-dos)
+           (setq printed (buffer-substring-no-properties (point-min) (point-max)))
+           (should (equal expected printed)))))))
+
+(ict:parse/print-test
+"ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/1)
+
+(ict:parse/print-test
+"RDATE;VALUE=DATE:19970304,19970504,19970704,19970904\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/2)
+
+(ict:parse/print-test
+"ATTACH:http://example.com/public/quarterly-report.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/1)
+
+(ict:parse/print-test
+;; Corrected. The original contains invalid base64 data; it was
+;; missing the final "=", as noted in errata ID 5602.
+;; The decoded string should read:
+;; The quick brown fox jumps over the lazy dog.
+"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4=\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/2)
+
+(ict:parse/print-test
+"DESCRIPTION;ALTREP=\"cid:part1.0001@HIDDEN\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2/1)
+
+(ict:parse/print-test
+"DESCRIPTION;ALTREP=\"CID:part3.msg.970415T083000@HIDDEN\": Project XYZ Review Meeting will include the following agenda items: (a) Market Overview\\, (b) Finances\\, (c) Project Management\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.1/1)
+
+(ict:parse/print-test
+"ORGANIZER;CN=\"John Smith\":mailto:jsmith@HIDDEN\n"
+;; CN param value does not require quotes, so they're missing when
+;; re-printed:
+:expected "ORGANIZER;CN=John Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.2/1)
+
+(ict:parse/print-test
+"ATTENDEE;CUTYPE=GROUP:mailto:ietf-calsch@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.3/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-FROM=\"mailto:jsmith@HIDDEN\":mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.4/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-TO=\"mailto:jdoe@HIDDEN\",\"mailto:jqpublic@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.5/1)
+
+(ict:parse/print-test
+"ORGANIZER;DIR=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.6/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2ljaW5nIGVsaXQsIHNlZCBkbyBlaXVzbW9kIHRlbXBvciBpbmNpZGlkdW50IHV0IGxhYm9yZSBldCBkb2xvcmUgbWFnbmEgYWxpcXVhLiBVdCBlbmltIGFkIG1pbmltIHZlbmlhbSwgcXVpcyBub3N0cnVkIGV4ZXJjaXRhdGlvbiB1bGxhbWNvIGxhYm9yaXMgbmlzaSB1dCBhbGlxdWlwIGV4IGVhIGNvbW1vZG8gY29uc2VxdWF0LiBEdWlzIGF1dGUgaXJ1cmUgZG9sb3IgaW4gcmVwcmVoZW5kZXJpdCBpbiB2b2x1cHRhdGUgdmVsaXQgZXNzZSBjaWxsdW0gZG9sb3JlIGV1IGZ1Z2lhdCBudWxsYSBwYXJpYXR1ci4gRXhjZXB0ZXVyIHNpbnQgb2NjYWVjYXQgY3VwaWRhdGF0IG5vbiBwcm9pZGVudCwgc3VudCBpbiBjdWxwYSBxdWkgb2ZmaWNpYSBkZXNlcnVudCBtb2xsaXQgYW5pbSBpZCBlc3QgbGFib3J1bS4=\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.7/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=application/msword:ftp://example.com/pub/docs/agenda.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.8/1)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=BUSY:19980415T133000Z/19980415T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.9/1)
+
+(ict:parse/print-test
+"SUMMARY;LANGUAGE=en-US:Company Holiday Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/1)
+
+(ict:parse/print-test
+"LOCATION;LANGUAGE=en:Germany\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/2)
+
+(ict:parse/print-test
+"LOCATION;LANGUAGE=no:Tyskland\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/3)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:ietf-calsch@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/1)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:projectA@HIDDEN\",\"mailto:projectB@HIDDEN\":mailto:janedoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/2)
+
+(ict:parse/print-test
+"ATTENDEE;PARTSTAT=DECLINED:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.12/1)
+
+(ict:parse/print-test
+"RECURRENCE-ID;RANGE=THISANDFUTURE:19980401T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.13/1)
+
+(ict:parse/print-test
+"TRIGGER;RELATED=END:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.14/1)
+
+(ict:parse/print-test
+"RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.15/1)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=CHAIR:mailto:mrbig@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.16/1)
+
+(ict:parse/print-test
+"ATTENDEE;RSVP=TRUE:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.17/1)
+
+(ict:parse/print-test
+"ORGANIZER;SENT-BY=\"mailto:sray@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.18/1)
+
+(ict:parse/print-test
+"DTSTART;TZID=America/New_York:19980119T020000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/1)
+
+(ict:parse/print-test
+"DTEND;TZID=America/New_York:19980119T030000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/2)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=image/vnd.microsoft.icon;ENCODING=BASE64;VALUE=BINARY:AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgIAAAICAgADAwMAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAAAAABNEMQAAAAAAAkQgAAAAAAJEREQgAAACECQ0QgEgAAQxQzM0E0AABERCRCREQAADRDJEJEQwAAAhA0QwEQAAAAAEREAAAAAAAAREQAAAAAAAAkQgAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.1/1)
+
+(ict:parse/print-test
+"TRUE"
+:type icalendar-boolean
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.2/1)
+
+(ict:parse/print-test
+"mailto:jane_doe@HIDDEN"
+:type icalendar-cal-address
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.3/1)
+
+(ict:parse/print-test
+"19970714"
+:type icalendar-date
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.4/1)
+
+(ict:parse/print-test
+;; 'Floating' time:
+"19980118T230000"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/1)
+
+(ict:parse/print-test
+;; UTC time:
+"19980119T070000Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/2)
+
+(ict:parse/print-test
+;; Leap second (seconds = 60)
+"19970630T235960Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/3)
+
+(ict:parse/print-test
+;; Local time:
+"DTSTART:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/4)
+
+(ict:parse/print-test
+;; UTC time:
+"DTSTART:19970714T173000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/5)
+
+(ict:parse/print-test
+;; Local time with TZ identifier:
+"DTSTART;TZID=America/New_York:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/6)
+
+(ict:parse/print-test
+"P15DT5H0M20S"
+:expected "P15DT5H20S"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/1)
+
+(ict:parse/print-test
+"P7W"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/2)
+
+(ict:parse/print-test
+"1000000.0000001"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/1)
+
+(ict:parse/print-test
+"1.333"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/2)
+
+(ict:parse/print-test
+"-3.14"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/3)
+
+(ict:parse/print-test
+"1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/1)
+
+(ict:parse/print-test
+"-1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/2)
+
+(ict:parse/print-test
+"+1234567890"
+;; "+" sign isn't required, so it's not re-printed:
+:expected "1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/3)
+
+(ict:parse/print-test
+"432109876"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/4)
+
+(ict:parse/print-test
+"19970101T180000Z/19970102T070000Z"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/1)
+
+(ict:parse/print-test
+"19970101T180000Z/PT5H30M"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/2)
+
+(ict:parse/print-test
+"FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/1)
+
+(ict:parse/print-test
+"FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/2)
+
+(ict:parse/print-test
+"FREQ=DAILY;COUNT=10;INTERVAL=2"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/3)
+
+(ict:parse/print-test
+"Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared."
+:type icalendar-text
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.11/1)
+
+(ict:parse/print-test
+;; Local time:
+"230000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/1)
+
+(ict:parse/print-test
+;; UTC time:
+"070000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/2)
+
+(ict:parse/print-test
+;; Local time:
+"083000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/3)
+
+(ict:parse/print-test
+;; UTC time:
+"133000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/4)
+
+(ict:parse/print-test
+;; Local time with TZ identifier:
+"SOMETIMEPROP;TZID=America/New_York;VALUE=TIME:083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.12/5)
+
+(ict:parse/print-test
+"http://example.com/my-report.txt"
+:type icalendar-uri
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.13/1)
+
+(ict:parse/print-test
+"-0500"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.14/1)
+
+(ict:parse/print-test
+"+0100"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc55453.3.14/1)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@HIDDEN
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec3.4/1)
+
+(ict:parse/print-test
+"DTSTART:19960415T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.5/1)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123401@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970903T163000Z
+DTEND:19970903T190000Z
+SUMMARY:Annual Employee Review
+CLASS:PRIVATE
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/1)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123402@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970401T163000Z
+DTEND:19970402T010000Z
+SUMMARY:Laurel is in sensitivity awareness class.
+CLASS:PUBLIC
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/2)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123403@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=DATE:19971102
+SUMMARY:Our Blissful Anniversary
+TRANSP:TRANSPARENT
+CLASS:CONFIDENTIAL
+CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION
+RRULE:FREQ=YEARLY
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/3)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:20070423T123432Z-541111@HIDDEN
+DTSTAMP:20070423T123432Z
+DTSTART;VALUE=DATE:20070628
+DTEND;VALUE=DATE:20070709
+SUMMARY:Festival International de Jazz de Montreal
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/4)
+
+(ict:parse/print-test
+"BEGIN:VTODO
+UID:20070313T123432Z-456553@HIDDEN
+DTSTAMP:20070313T123432Z
+DUE;VALUE=DATE:20070501
+SUMMARY:Submit Quebec Income Tax Return for 2006
+CLASS:CONFIDENTIAL
+CATEGORIES:FAMILY,FINANCE
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/1)
+
+(ict:parse/print-test
+"BEGIN:VTODO
+UID:20070514T103211Z-123404@HIDDEN
+DTSTAMP:20070514T103211Z
+DTSTART:20070514T110000Z
+DUE:20070709T130000Z
+COMPLETED:20070707T100000Z
+SUMMARY:Submit Revised Internet-Draft
+PRIORITY:1
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/2)
+
+(ict:parse/print-test
+"BEGIN:VJOURNAL
+UID:19970901T130000Z-123405@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=DATE:19970317
+SUMMARY:Staff meeting minutes
+DESCRIPTION:1. Staff meeting: Participants include Joe\\,Lisa\\, and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\\n 2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday.\\n3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 555-2323 (tel).
+END:VJOURNAL
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.3/1)
+
+(ict:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T082949Z-FA43EF@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTART:19971015T050000Z
+DTEND:19971016T050000Z
+DTSTAMP:19970901T083000Z
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/1)
+
+(ict:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T095957Z-76A912@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTAMP:19970901T100000Z
+FREEBUSY:19971015T050000Z/PT8H30M,19971015T160000Z/PT5H30M,19971015T223000Z/PT6H30M
+URL:http://example.com/pub/busy/jpublic-01.ifb
+COMMENT:This iCalendar file contains busy time information for the next three months.
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/2)
+
+(ict:parse/print-test
+;; Corrected. Original has invalid value in ORGANIZER
+"BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/3)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/1)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:STANDARD
+DTSTART:20071104T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/2)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+TZURL:http://zones.example.com/tz/America-New_York.ics
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/3)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/4)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19990424T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/5)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER;VALUE=DATE-TIME:19970317T133000Z
+REPEAT:4
+DURATION:PT15M
+ACTION:AUDIO
+ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/1)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER:-PT30M
+REPEAT:2
+DURATION:PT15M
+ACTION:DISPLAY
+DESCRIPTION:Breakfast meeting with executive\\nteam at 8:30 AM EST.
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/2)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER;RELATED=END:-P2D
+ACTION:EMAIL
+ATTENDEE:mailto:john_doe@HIDDEN
+SUMMARY:*** REMINDER: SEND AGENDA FOR WEEKLY STAFF MEETING ***
+DESCRIPTION:A draft agenda needs to be sent out to the attendees to the weekly managers meeting (MGR-LIST). Attached is a pointer the document template for the agenda file.
+ATTACH;FMTTYPE=application/msword:http://example.com/templates/agenda.doc
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/3)
+
+(ict:parse/print-test
+"CALSCALE:GREGORIAN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.1/1)
+
+(ict:parse/print-test
+"METHOD:REQUEST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.2/1)
+
+(ict:parse/print-test
+"PRODID:-//ABC Corporation//NONSGML My Product//EN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.3/1)
+
+(ict:parse/print-test
+"VERSION:2.0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7./1)
+
+(ict:parse/print-test
+"ATTACH:CID:jsmith.part3.960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/reports/r-960812.ps\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/2)
+
+(ict:parse/print-test
+"CATEGORIES:APPOINTMENT,EDUCATION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/1)
+
+(ict:parse/print-test
+"CATEGORIES:MEETING\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/2)
+
+(ict:parse/print-test
+"CLASS:PUBLIC\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.3/1)
+
+(ict:parse/print-test
+"COMMENT:The meeting really needs to include both ourselves and the customer. We can't hold this meeting without them. As a matter of fact\\, the venue for the meeting ought to be at their site. - - John\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.4/1)
+
+(ict:parse/print-test
+"DESCRIPTION:Meeting to provide technical review for \"Phoenix\" design.\\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\\nRSVP to team leader.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.5/1)
+
+(ict:parse/print-test
+"GEO:37.386013;-122.082932\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.6/1)
+
+(ict:parse/print-test
+"LOCATION:Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/1)
+
+(ict:parse/print-test
+"LOCATION;ALTREP=\"http://xyzcorp.com/conf-rooms/f123.vcf\":Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/2)
+
+(ict:parse/print-test
+"PERCENT-COMPLETE:39\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.8/1)
+
+(ict:parse/print-test
+"PRIORITY:1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/1)
+
+(ict:parse/print-test
+"PRIORITY:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/2)
+
+(ict:parse/print-test
+"PRIORITY:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/3)
+
+(ict:parse/print-test
+"RESOURCES:EASEL,PROJECTOR,VCR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/1)
+
+(ict:parse/print-test
+"RESOURCES;LANGUAGE=fr:Nettoyeur haute pression\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/2)
+
+(ict:parse/print-test
+"STATUS:TENTATIVE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/1)
+
+(ict:parse/print-test
+"STATUS:NEEDS-ACTION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/2)
+
+(ict:parse/print-test
+"STATUS:DRAFT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/3)
+
+(ict:parse/print-test
+"SUMMARY:Department Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.12/1)
+
+(ict:parse/print-test
+"COMPLETED:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.1/1)
+
+(ict:parse/print-test
+"DTEND:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/1)
+
+(ict:parse/print-test
+"DTEND;VALUE=DATE:19980704\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/2)
+
+(ict:parse/print-test
+"DUE:19980430T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.3/1)
+
+(ict:parse/print-test
+"DTSTART:19980118T073000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.4/1)
+
+(ict:parse/print-test
+"DURATION:PT1H0M0S\n"
+;; 0M and 0S are not re-printed because they don't contribute to the duration:
+:expected "DURATION:PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/1)
+
+(ict:parse/print-test
+"DURATION:PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/2)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/1)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/2)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/3)
+
+(ict:parse/print-test
+"TRANSP:TRANSPARENT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/1)
+
+(ict:parse/print-test
+"TRANSP:OPAQUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/2)
+
+(ict:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/1)
+
+(ict:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/2)
+
+(ict:parse/print-test
+"TZID:/example.org/America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/3)
+
+(ict:parse/print-test
+"TZNAME:EST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/1)
+
+(ict:parse/print-test
+"TZNAME;LANGUAGE=fr-CA:HNE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/2)
+
+(ict:parse/print-test
+"TZOFFSETFROM:-0500\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/1)
+
+(ict:parse/print-test
+"TZOFFSETFROM:+1345\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/2)
+
+(ict:parse/print-test
+"TZOFFSETTO:-0400\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/1)
+
+(ict:parse/print-test
+"TZOFFSETTO:+1245\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/2)
+
+(ict:parse/print-test
+"TZURL:http://timezones.example.org/tz/America-Los_Angeles.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.5/1)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:DEV-GROUP@HIDDEN\":mailto:joecool@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-FROM=\"mailto:immud@HIDDEN\":mailto:ildoit@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/2)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Henry Cabot:mailto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/3)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM=\"mailto:bob@HIDDEN\";PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/4)
+
+(ict:parse/print-test
+"ATTENDEE;CN=John Smith;DIR=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/5)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;DELEGATED-FROM=\"mailto:iamboss@HIDDEN\";CN=Henry Cabot:mailto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/6)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=NON-PARTICIPANT;PARTSTAT=DELEGATED;DELEGATED-TO=\"mailto:hcabot@HIDDEN\";CN=The Big Cheese:mailto:iamboss@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/7)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/8)
+
+(ict:parse/print-test
+;; Corrected. Original lacks quotes around SENT-BY address.
+"ATTENDEE;SENT-BY=\"mailto:jan_doe@HIDDEN\";CN=John Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/9)
+
+(ict:parse/print-test
+"CONTACT:Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/1)
+
+(ict:parse/print-test
+;; Corrected. Original contained unallowed backslash in ldap: URI
+"CONTACT;ALTREP=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":Jim Dolittle\\, ABC Industries\\,+1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/2)
+
+(ict:parse/print-test
+"CONTACT;ALTREP=\"CID:part3.msg970930T083000SILVER@HIDDEN\":Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/3)
+
+(ict:parse/print-test
+"CONTACT;ALTREP=\"http://example.com/pdi/jdoe.vcf\":Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/4)
+
+(ict:parse/print-test
+"ORGANIZER;CN=John Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/1)
+
+(ict:parse/print-test
+"ORGANIZER;CN=JohnSmith;DIR=\"ldap://example.com:6666/o=DC%20Associates,c=US???(cn=John%20Smith)\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/2)
+
+(ict:parse/print-test
+"ORGANIZER;SENT-BY=\"mailto:jane_doe@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/3)
+
+(ict:parse/print-test
+"RECURRENCE-ID;VALUE=DATE:19960401\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/1)
+
+(ict:parse/print-test
+"RECURRENCE-ID;RANGE=THISANDFUTURE:19960120T120000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/2)
+
+(ict:parse/print-test
+"RELATED-TO:jsmith.part7.19960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/1)
+
+(ict:parse/print-test
+"RELATED-TO:19960401-080045-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/2)
+
+(ict:parse/print-test
+"URL:http://example.com/pub/calendars/jsmith/mytime.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.6/1)
+
+(ict:parse/print-test
+"UID:19960401T080045Z-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.7/1)
+
+(ict:parse/print-test
+"EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.1/1)
+
+(ict:parse/print-test
+"RDATE:19970714T123000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/1)
+
+(ict:parse/print-test
+"RDATE;TZID=America/New_York:19970714T083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/2)
+
+(ict:parse/print-test
+"RDATE;VALUE=PERIOD:19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/3)
+
+(ict:parse/print-test
+"RDATE;VALUE=DATE:19970101,19970120,19970217,19970421,19970526,19970704,19970901,19971014,19971128,19971129,19971225\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/4)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;COUNT=10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/1)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;UNTIL=19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/2)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;INTERVAL=2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/3)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/4)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/5)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/6)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;COUNT=10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/7)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/8)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/9)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/10)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/11)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/12)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/13)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/14)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/15)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/16)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/17)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYMONTHDAY=-3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/18)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/19)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/20)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/21)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/22)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/23)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/24)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/25)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYDAY=20MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/26)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/27)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/28)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/29)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/30)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/31)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/32)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/34)
+
+(ict:parse/print-test
+"RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/35)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/36)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/37)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/38)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/40)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/41)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/42)
+
+(ict:parse/print-test
+"ACTION:AUDIO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/1)
+
+(ict:parse/print-test
+"ACTION:DISPLAY\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/2)
+
+(ict:parse/print-test
+"REPEAT:4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.2/1)
+
+(ict:parse/print-test
+"TRIGGER:-PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/1)
+
+(ict:parse/print-test
+"TRIGGER;RELATED=END:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/2)
+
+(ict:parse/print-test
+"TRIGGER;VALUE=DATE-TIME:19980101T050000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/3)
+
+(ict:parse/print-test
+"CREATED:19960329T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.1/1)
+
+(ict:parse/print-test
+"DTSTAMP:19971210T080000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.2/1)
+
+(ict:parse/print-test
+"LAST-MODIFIED:19960817T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.3/1)
+
+(ict:parse/print-test
+"SEQUENCE:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/1)
+
+(ict:parse/print-test
+"SEQUENCE:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/2)
+
+(ict:parse/print-test
+"DRESSCODE:CASUAL\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/1)
+
+(ict:parse/print-test
+"NON-SMOKING;VALUE=BOOLEAN:TRUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/2)
+
+(ict:parse/print-test
+"X-ABC-MMSUBJ;VALUE=URI;FMTTYPE=audio/basic:http://www.example.org/mysubj.au\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.2/1)
+
+(ict:parse/print-test
+"REQUEST-STATUS:2.0;Success\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/1)
+
+(ict:parse/print-test
+"REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/2)
+
+(ict:parse/print-test
+"REQUEST-STATUS:2.8; Success\\, repeating event ignored. Scheduled as a single event.;RRULE:FREQ=WEEKLY\\;INTERVAL=2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/3)
+
+(ict:parse/print-test
+"REQUEST-STATUS:4.1;Event conflict.  Date-time is busy.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/4)
+
+(ict:parse/print-test
+"REQUEST-STATUS:3.7;Invalid calendar user;ATTENDEE:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/5)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congress Center\\nAtlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/1)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//RDU Software//NONSGML HandCal//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:19981025T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19990404T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:19980309T231000Z
+UID:guid-1.example.com
+ORGANIZER:mailto:mrbig@HIDDEN
+ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:employee-A@HIDDEN
+DESCRIPTION:Project XYZ Review Meeting
+CATEGORIES:MEETING
+CLASS:PUBLIC
+CREATED:19980309T130000Z
+SUMMARY:XYZ Project Review
+DTSTART;TZID=America/New_York:19980312T083000
+DTEND;TZID=America/New_York:19980312T093000
+LOCATION:1CP Conference Room 4350
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/2)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@HIDDEN
+ORGANIZER:mailto:jdoe@HIDDEN
+ATTENDEE;RSVP=TRUE:mailto:jsmith@HIDDEN
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/conf/bkgrnd.ps
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/3)
+
+(ict:parse/print-test
+;; Corrected. The TRIGGER property originally did not specify
+;; VALUE=DATE-TIME, which is required since it is not the default type.
+;; See https://www.rfc-editor.org/errata/eid2039
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VTODO
+DTSTAMP:19980130T134500Z
+SEQUENCE:2
+UID:uid4@HIDDEN
+ORGANIZER:mailto:unclesam@HIDDEN
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@HIDDEN
+DUE:19980415T000000
+STATUS:NEEDS-ACTION
+SUMMARY:Submit Income Taxes
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER;VALUE=DATE-TIME:19980403T120000Z
+ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-files/ssbanner.aud
+REPEAT:4
+DURATION:PT1H
+END:VALARM
+END:VTODO
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/4)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VJOURNAL
+DTSTAMP:19970324T120000Z
+UID:uid5@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+STATUS:DRAFT
+CLASS:PUBLIC
+CATEGORIES:Project Report,XYZ,Weekly Meeting
+DESCRIPTION:Project xyz Review Meeting Minutes\\nAgenda\\n1. Review of project version 1.0 requirements.\\n2.Definitionof project processes.\\n3. Review of project schedule.\\nParticipants: John Smith\\, Jane Doe\\, Jim Dandy\\n-It was decided that the requirements need to be signed off byproduct marketing.\\n-P roject processes were accepted.\\n-Project schedule needs to account for scheduled holidaysand employee vacation time. Check with HR for specificdates.\\n-New schedule will be distributed by Friday.\\n-Next weeks meeting is cancelled. No meeting until 3/23.
+END:VJOURNAL
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/5)
+
+(ict:parse/print-test
+;; Corrected. Original text in the standard is missing UID and DTSTAMP.
+;; See https://www.rfc-editor.org/errata/eid4149
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//RDU Software//NONSGML HandCal//EN
+BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+END:VCALENDAR
+"
+:parser icalendar-parse-calendar
+:printer icalendar-print-calendar-node
+:source rfc5545-sec4/6)
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-"))
+;; End:
+;;; tests/icalendar-parser.el ends here
diff --git a/test/lisp/calendar/icalendar-recur-tests.el b/test/lisp/calendar/icalendar-recur-tests.el
new file mode 100644
index 00000000000..7f73f227a26
--- /dev/null
+++ b/test/lisp/calendar/icalendar-recur-tests.el
@@ -0,0 +1,2871 @@
+;;; icalendar-recur-tests.el --- Tests for icalendar-recur  -*- lexical-binding: t; -*-
+
+(require 'cl-lib)
+(require 'ert)
+(require 'icalendar-recur)
+(require 'icalendar-utils)
+(require 'icalendar-parser)
+
+;; Some constants for tests that use time zones:
+(defconst ict:tz-eastern
+  (ical:parse-from-string 'ical:vtimezone
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+")
+"`icalendar-vtimezone' representing America/New_York (Eastern) time.")
+
+(defconst ict:est-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:standard :all stds))
+    (seq-find (lambda (obs)
+                (ical:date-time=
+                 (ical:make-date-time :year 2007 :month 11 :day 4
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              stds))
+  "The observance of Eastern Standard Time which began 2007-11-04")
+
+(defconst ict:edt-latest
+  (ical:with-component ict:tz-eastern
+      ((ical:daylight :all dls))
+    (seq-find (lambda (obs)
+                (ical:date-time=
+                 (ical:make-date-time :year 2007 :month 3 :day 11
+                                      :hour 2 :minute 0 :second 0)
+                 (ical:with-property-of obs 'ical:dtstart nil value)))
+              dls))
+  "The observance of Eastern Daylight Time which began 2007-03-11")
+
+(defconst ict:est -18000  ;; = -0500
+  "UTC offset for Eastern Standard Time")
+
+(defconst ict:edt -14400 ;; = -0400
+  "UTC offset for Eastern Daylight Time")
+
+
+;; Tests for basic functions:
+
+(ert-deftest ict:recur-bysetpos-filter ()
+  "Test that `icr:make-bysetpos-filter' filters correctly by position"
+  (let* ((t1 (list 1 1 2024))
+         (t2 (list 2 1 2024))
+         (t3 (list 12 30 2024))
+         (dts (list t1 t2 t3))
+         (filter (icr:make-bysetpos-filter (list 1 -1)))
+         (filtered (funcall filter dts)))
+    (should (member t1 filtered))
+    (should (member t3 filtered))
+    (should-not (member t2 filtered))))
+
+(ert-deftest ict:recur-yearday-number ()
+  "Test that `icr:calendar-date-from-yearday-number' finds correct dates"
+  (let* ((year 2025)
+         (daynos (list '(1 . (1 1 2025))
+                       '(8 . (1 8 2025))
+                       '(-1 . (12 31 2025))
+                       '(363 . (12 29 2025)))))
+    (dolist (d daynos)
+      (let ((dayno (car d))
+            (date (cdr d)))
+        (should
+         (equal date (ical:calendar-date-from-yearday-number year dayno)))))))
+
+(ert-deftest ict:date-time-add ()
+  "Does `ical:date-time-add' correctly handle time zone transitions?"
+  ;; A sum that does not use a time zone at all:
+  (let* ((dt (ical:make-date-time :year 2007 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3)))
+    (should (equal expected (ical:date-time-add dt delta))))
+
+  ;; A sum that does not cross an observance boundary:
+  (let* ((dt (ical:make-date-time :year 2007 :month 2 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 3 :tz 'preserve)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should preserve clock time:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 12 :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the Std->DST boundary and should be exactly 48 hours later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 12 :hour 13
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should preserve clock time:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :day 2))
+         (expected (ical:date-time-variant dt :day 5 :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that crosses the DST->Std boundary and should be exactly 48 hours later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 48))
+         (expected (ical:date-time-variant dt :day 5 :hour 11
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the Std->DST boundary and should result
+  ;; in a clock time one hour later:
+  (let* ((dt (ical:make-date-time :year 2007 :month 3 :day 10
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 11 :hour 3
+                                           :zone ict:edt :dst t)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern))))
+
+  ;; A sum that lands exactly on the DST->Std boundary and should result
+  ;; in a clock time one hour earlier:
+  (let* ((dt (ical:make-date-time :year 2007 :month 11 :day 3
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (delta (make-decoded-time :hour 24))
+         (expected (ical:date-time-variant dt :day 4 :hour 1
+                                           :zone ict:est :dst nil)))
+    (should (equal expected (ical:date-time-add dt delta ict:tz-eastern)))))
+
+(ert-deftest ict:recur-nonexistent-date-time-p ()
+  "Does `icr:nonexistent-date-time-p' correctly identify nonexistent times?"
+  (let*  ((dst-onset (ical:make-date-time :year 2025 :month 3 :day 9
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:est :dst nil))
+          ;; 2:30 AM falls into the gap when shifting from 2AM EST to 3AM EDT:
+          (nonexistent1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                             :hour 2 :minute 30 :second 0
+                                             :zone ict:est :dst nil))
+          (nonexistent2 (ical:date-time-variant nonexistent1
+                                                :zone ict:edt :dst t))
+          (std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:30AM around the shift back to EST exists twice (once in
+          ;; EDT, once in EST) and should not be nonexistent:
+          (existent1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:edt :dst t))
+          (existent2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 1 :minute 30 :second 0
+                                          :zone ict:est :dst nil)))
+    (should (icr:nonexistent-date-time-p nonexistent1 dst-onset ict:edt-latest))
+    (should (icr:nonexistent-date-time-p nonexistent2 dst-onset ict:edt-latest))
+    (should-not
+     (icr:nonexistent-date-time-p existent1 std-onset ict:est-latest))
+    (should-not
+     (icr:nonexistent-date-time-p existent2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-date-time-occurs-twice-p ()
+  "Does `icr:date-time-occurs-twice-p' correctly identify times that occur twice?"
+  (let*  ((std-onset (ical:make-date-time :year 2025 :month 11 :day 2
+                                          :hour 2 :minute 0 :second 0
+                                          :zone ict:edt :dst t))
+          ;; 1:00, 1:30 AM occur twice when shifting from 2AM EDT to 1AM EST:
+          (twice1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 0 :second 0))
+          (twice2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                       :hour 1 :minute 30 :second 0))
+          ;; 12:59 AM, 2AM should not occur twice:
+          (once1 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 0 :minute 59 :second 0
+                                      :zone ict:edt :dst t))
+          (once2 (ical:make-date-time :year 2025 :month 11 :day 2
+                                      :hour 2 :minute 0 :second 0
+                                      :zone ict:est :dst nil)))
+    (should (icr:date-time-occurs-twice-p twice1 std-onset ict:est-latest))
+    (should (icr:date-time-occurs-twice-p twice2 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once1 std-onset ict:est-latest))
+    (should-not
+     (icr:date-time-occurs-twice-p once2 std-onset ict:est-latest))))
+
+(ert-deftest ict:recur-find-secondly-interval ()
+  "Does `icr:find-secondly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0 :second 0
+                                       ;; Use UTC for the tests with no
+                                       ;; time zone, so that the results
+                                       ;; don't depend on system's local time
+                                       :zone 0))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 0 :tz 'preserve)
+             (ical:date-time-variant target :second 1 :tz 'preserve)
+             (ical:date-time-variant target :second 10 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone 0))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 10 :tz 'preserve)
+             (ical:date-time-variant target :second 11 :tz 'preserve)
+             (ical:date-time-variant target :second 20 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart 10))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :second 20
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 20 :tz 'preserve)
+             (ical:date-time-variant target :second 21 :tz 'preserve)
+             (ical:date-time-variant target :second 30 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :second 30
+                                           :zone ict:edt :dst t))
+           (expected-int
+            (list
+             (ical:date-time-variant target :second 30 :tz 'preserve)
+             (ical:date-time-variant target :second 31 :tz 'preserve)
+             (ical:date-time-variant target :second 40 :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observance]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :second 1
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :second 10
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz 10 ict:tz-eastern))))
+
+    ;; With timezone, with a "pathological" interval size of 59 seconds.
+    ;; There should be no problem with this case, because the interval
+    ;; bounds calculation is done in absolute time, but it's annoying to
+    ;; calculate the expected interval by hand:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2031 :month 4 :day 15
+                                           :hour 12 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+           (intsize 59)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 59 :second 16
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 11 :minute 59 :second 17
+                                     :tz 'preserve)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 15
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-secondly-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-minutely-interval ()
+  "Does `icr:find-minutely-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0 :minute 0
+                                       ;; make sure intervals are
+                                       ;; bounded on whole minutes:
+                                       :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just a fixed offset, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :minute 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 0 :second 0)
+             (ical:date-time-variant target :minute 1 :second 0)
+             (ical:date-time-variant target :minute 10 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :minute 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 10 :second 0)
+             (ical:date-time-variant target :minute 11 :second 0)
+             (ical:date-time-variant target :minute 20 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :minute 20
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 20 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 21 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :minute 30
+                                           :zone ict:edt :dst t))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observance]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 30 :second 0
+                                           :zone ict:est :dst nil))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 30 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 3 :minute 31 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target
+                                     :hour 3 :minute 40 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-minutely-interval target dtstart/tz intsize
+                                          ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-hourly-interval ()
+  "Does `icr:find-hourly-interval' find correct intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       :hour 0
+                                       ;; make sure intervals are bounded on
+                                       ;; whole hours:
+                                       :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :hour 5))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 1 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :hour 10))
+           (intsize 10)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0)
+             (ical:date-time-variant target :hour 20 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2028 :month 2 :hour 10
+                                           :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 10 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:est :dst nil)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:est :dst nil))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift. Note that
+    ;; because of our decision to calculate with absolute times in
+    ;; SECONDLY/MINUTELY/HOURLY rules (see `icr:find-secondly-recurrence-rule')
+    ;; the interval clock times shift an hour here:
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :hour 12
+                                           :zone ict:edt :dst t))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 11 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 12 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 13 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))
+
+    ;; With timezone, around a target that falls into the gap in local
+    ;; times and thus does not exist as a local time. In this case, what
+    ;; is supposed to happen is that the clock time value in the [observance]
+    ;; recurrences "is interpreted using the UTC offset before the gap
+    ;; in local times."  So we should get the same absolute times back,
+    ;; but re-decoded into the new observance, i.e., one hour later.
+    (let* ((target (ical:make-date-time :year 2030 :month 3 :day 10
+                                        :hour 2 :minute 30 :second 30
+                                        :zone ict:est :dst nil))
+           (intsize 2)
+           (expected-int
+            (list
+             (ical:date-time-variant target :hour 3 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 4 :minute 0 :second 0
+                                     :zone ict:edt :dst t)
+             (ical:date-time-variant target :hour 5 :minute 0 :second 0
+                                     :zone ict:edt :dst t))))
+      (should
+       (equal expected-int
+              (icr:find-hourly-interval target dtstart/tz intsize
+                                        ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date ()
+  "Does `icr:find-daily-interval' find correct date intervals?"
+  (let* ((dtstart (list 1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target (list 1 9 2026))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 7
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 8
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 14
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-daily-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-daily-interval-w/date-time ()
+  "Does `icr:find-daily-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wednesday
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart
+                                              :year 2026 :month 1 :day 9))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 8 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 14
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; No timezone, just clock time, around a target that does fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :day 6))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 6 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 13 :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart intsize))))
+
+    ;; With timezone, around a target that falls on an interval
+    ;; boundary, in the same observance:
+    (let* ((target (ical:date-time-variant dtstart/tz :year 2028 :month 2 :day 2
+                                           :zone ict:est :dst nil))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 3 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize ict:tz-eastern))))
+
+    ;; With time zone, around a target that does not fall on an interval
+    ;; boundary, and after the time zone observance shift.
+    (let* ((target (ical:date-time-variant dtstart/tz
+                                           :year 2029 :month 5 :day 28
+                                           :zone ict:edt :dst t))
+           (intsize 7)
+           (expected-int
+            (list
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 24 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve)
+             (ical:date-time-variant target :day 30 :hour 0 :minute 0 :second 0
+                                     :tz 'preserve))))
+      (should
+       (equal expected-int
+              (icr:find-daily-interval target dtstart/tz intsize
+                                       ict:tz-eastern))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date ()
+  "Does `icr:find-weekly-interval' find correct date intervals?"
+  (let* ((dtstart '(1 8 2025)))
+    ;; Since all the results should be the same after the initial
+    ;; calculation of the absolute dates DTSTART and TARGET, we just
+    ;; test one simple case here and test with date-times more
+    ;; thoroughly below.
+
+    ;; A target that doesn't fall on an interval boundary:
+    (let* ((target '(1 9 2026))
+           (intsize 2)
+           (expected-int-mon
+            (list
+             (ical:make-date-time :year 2026 :month 1 :day 5
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 12
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2026 :month 1 :day 19
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int-mon
+                     (icr:find-weekly-interval target dtstart intsize))))))
+
+(ert-deftest ict:recur-find-weekly-interval-w/date-time ()
+  "Does `icr:find-weekly-interval' find correct date-time intervals?"
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 8 ; a Wednesday
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23)))
+
+    ;; Year numbers are monotonically increasing in the following test cases,
+    ;; to make it easy to tell which of them fails.
+
+    ;; No timezone, just clock time, around a target that doesn't fall on
+    ;; an interval boundary:
+    (let* ((target (ical:date-time-variant dtstart :year 2026 :month 1 :day 9))
+           (intsize 2)
+           (weds 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :day 5 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 12 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 19 :hour 0 :minute 0 :second 0)))
+           ;; expected interval for Wednesday week start:
+           (expected-int-wed
+            (list
+             (ical:date-time-variant target :day 7 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 14 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 21 :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize)))
+      (should
+       (equal expected-int-wed
+              (icr:find-weekly-interval target dtstart intsize weds))))
+
+    ;; Around a target that does fall on an interval boundary, Monday week start:
+    (let* ((target (ical:date-time-variant dtstart :year 2027 :month 1 :day 4))
+           (intsize 3)
+           ;; expected interval for Monday (default) week start:
+           (expected-int-mon
+            (list
+             (ical:date-time-variant target :year 2026 :month 12 :day 21
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :year 2026 :month 12 :day 28
+                                     :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 11
+                                     :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-mon
+              (icr:find-weekly-interval target dtstart intsize))))
+
+    ;; Around a target that does fall on an interval boundary, Sunday week start:
+    (let* ((target (ical:date-time-variant dtstart :year 2028 :month 1 :day 2))
+           (intsize 3)
+           (sun 0)
+           ;; expected interval for Sunday week start:
+           (expected-int-sun
+            (list
+             (ical:date-time-variant target :day 2 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 9 :hour 0 :minute 0 :second 0)
+             (ical:date-time-variant target :day 23 :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int-sun
+              (icr:find-weekly-interval target dtstart intsize sun))))))
+
+(ert-deftest ict:recur-find-monthly-interval ()
+  "Does `icr:find-monthly-interval' find correct intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+           (intsize 5)
+           (expected-int
+            (list
+             (ical:make-date-time :year 2025 :month 6 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 7 :day 1
+                                  :hour 0 :minute 0 :second 0)
+             (ical:make-date-time :year 2025 :month 11 :day 1
+                                  :hour 0 :minute 0 :second 0))))
+      (should (equal expected-int
+                     (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that doesn't fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2026 :month 3 :day 9))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :day 1 :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does fall on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2025 :month 1 :day 1
+                                       ;; make sure intervals are bounded on
+                                       ;; whole days:
+                                       :hour 7 :minute 11 :second 23))
+         (target (ical:date-time-variant dtstart :year 2027 :month 5 :day 1))
+         (intsize 7)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2027 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2027 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+    (should
+     (equal expected-int
+            (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that does not fall on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2028 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11))
+         (target (ical:date-time-variant dtstart
+                                         :year 2029 :month 4 :day 15))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2029 :month 3 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 4 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2029 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month > target month
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart
+                                         :year 2030 :month 5 :day 1))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2030 :month 5 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 6 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2030 :month 7 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize))))
+
+  ;; Around a target that falls on an interval boundary, where
+  ;; start month = target month
+  (let* ((dtstart (ical:make-date-time :year 2031 :month 11 :day 11
+                                       :hour 11 :minute 11 :second 11 ))
+         (target (ical:date-time-variant dtstart :year 2032 :month 11 :day 11))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:date-time-variant target :year 2032 :month 11 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2032 :month 12 :day 1
+                                   :hour 0 :minute 0 :second 0)
+           (ical:date-time-variant target :year 2033 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))))
+      (should
+       (equal expected-int
+              (icr:find-monthly-interval target dtstart intsize)))))
+
+(ert-deftest ict:recur-find-yearly-interval ()
+  "Does `icr:find-yearly-interval' find correct date intervals?"
+  ;; Year numbers are monotonically increasing in the following test cases,
+  ;; to make it easy to tell which of them fails.
+
+  ;; One test with dates, to make sure that works:
+  (let* ((dtstart '(1 8 2025))
+         (target '(10 9 2025))
+         (intsize 2)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2025 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target not on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2026 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2026 :month 7 :day 28
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 3)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2026 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target on an interval boundary:
+  (let* ((dtstart (ical:make-date-time :year 2027 :month 3 :day 1
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2028 :month 1 :day 1
+                                      :hour 0 :minute 0 :second 0))
+         (intsize 4)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2027 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2028 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2031 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize))))
+
+  ;; A target earlier than dtstart but in the same year;
+  ;; it's important that this works when looking up recurrences of
+  ;; time zone observance onsets
+  (let* ((dtstart (ical:make-date-time :year 2029 :month 5 :day 28
+                                       :hour 1 :minute 2 :second 3))
+         (target (ical:make-date-time :year 2029 :month 2 :day 14
+                                      :hour 11 :minute 58 :second 0))
+         (intsize 1)
+         (expected-int
+          (list
+           (ical:make-date-time :year 2029 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0)
+           (ical:make-date-time :year 2030 :month 1 :day 1
+                                :hour 0 :minute 0 :second 0))))
+    (should (equal expected-int
+                   (icr:find-yearly-interval target dtstart intsize)))))
+
+;; Subintervals:
+
+(ert-deftest ict:recur-refine-byyearday ()
+  "Does `icr:refine-byyearday' correctly refine by yeardays?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (yeardays (list 2 -7))
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 25)
+                     (ical:date-time-variant low :month 12 :day 26)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byyearday interval yeardays)))))
+
+(ert-deftest ict:recur-refine-bymonth ()
+  "Does `icr:refine-bymonth' correctly refine by months?"
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (months (list 9 2))
+         (sub1 (list (ical:date-time-variant low :month 2 :day 1)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (sub2 (list (ical:date-time-variant low :month 9 :day 1)
+                     (ical:date-time-variant low :month 10 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonth interval months)))))
+
+(ert-deftest ict:recur-refine-bymonthday ()
+  "Does `icr:refine-bymonthday' correctly refine by days of the month?"
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (monthdays (list -1 2 29))
+         ;; N.B. we should get no subinterval for Feb. 29, 2025
+         (sub1 (list (ical:date-time-variant low :day 2)
+                     (ical:date-time-variant low :day 3)))
+         (sub2 (list (ical:date-time-variant low :day 28)
+                     (ical:date-time-variant low :month 3 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bymonthday interval monthdays)))))
+
+(ert-deftest ict:recur-refine-byday ()
+  "Does `icr:refine-byday' correctly refine by days of the week?"
+  ;; The simple case: just day names
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 3 ; a Monday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 7))
+         (interval (list low high high))
+         (days (list 0 6)) ; just the weekend, please!
+         (sub1 (list (ical:date-time-variant low :day 8)
+                     (ical:date-time-variant low :day 9)))
+         (sub2 (list (ical:date-time-variant low :day 9)
+                     (ical:date-time-variant low :day 10)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days))))
+
+  ;; Day names with offsets within the month
+  (let* ((low (ical:make-date-time :year 2025 :month 3 :day 1 ; a Saturday
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :month 1))
+         (interval (list low high high))
+         (days (list '(1 . 2) '(1 . -1)))  ; second and last Monday
+         (sub1 (list (ical:date-time-variant low :day 10)
+                     (ical:date-time-variant low :day 11)))
+         (sub2 (list (ical:date-time-variant low :day 31)
+                     (ical:date-time-variant low :month 4 :day 1)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days t))))
+
+  ;; Day names with offsets within the year
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :year 1))
+         (interval (list low high high))
+         (days (list '(5 . 1) '(5 . -1)))  ; first and last Friday
+         (sub1 (list (ical:date-time-variant low :day 3)
+                     (ical:date-time-variant low :day 4)))
+         (sub2 (list (ical:date-time-variant low :month 12 :day 26)
+                     (ical:date-time-variant low :month 12 :day 27)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byday interval days nil)))))
+
+(ert-deftest ict:recur-refine-byhour ()
+  "Does `icr:refine-byhour' correctly refine by hours?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 1 :day 1
+                                   :hour 0 :minute 0 :second 0))
+         (high (ical:date/time-add low :day 1))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2)
+                     (ical:date-time-variant low :hour 3)))
+         (sub2 (list (ical:date-time-variant low :hour 19)
+                     (ical:date-time-variant low :hour 20)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 0 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :day 1 ict:tz-eastern))
+         (interval (list low high high))
+         (hours (list 2 19))
+         (sub1 (list (ical:date-time-variant low :hour 2 :tz 'preserve)
+                     (ical:date-time-variant low :hour 3 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :hour 19 :tz 'preserve)
+                     (ical:date-time-variant low :hour 20 :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byhour interval hours ict:tz-eastern)))))
+
+(ert-deftest ict:recur-refine-byminute ()
+  "Does `icr:refine-byminute' correctly refine by minutes?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 0 :second 0))
+         (high (ical:date/time-add low :hour 1))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7)
+                     (ical:date-time-variant low :minute 8)))
+         (sub2 (list (ical:date-time-variant low :minute 59)
+                     (ical:date-time-variant low :hour 14 :minute 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 0 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :hour 1 ict:tz-eastern))
+         (interval (list low high high))
+         (minutes (list 7 59))
+         (sub1 (list (ical:date-time-variant low :minute 7 :tz 'preserve)
+                     (ical:date-time-variant low :minute 8 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :minute 59 :tz 'preserve)
+                     (ical:date-time-variant low :hour 14 :minute 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-byminute interval minutes ict:tz-eastern)))))
+
+(ert-deftest ict:recur-refine-bysecond ()
+  "Does `icr:refine-bysecond' correctly refine by seconds?"
+  ;; No time zone, just clock times:
+  (let* ((low (ical:make-date-time :year 2025 :month 5 :day 1
+                                   :hour 13 :minute 59 :second 0))
+         (high (ical:date/time-add low :minute 1))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24)
+                     (ical:date-time-variant low :second 25)))
+         (sub2 (list (ical:date-time-variant low :second 59)
+                     (ical:date-time-variant low :hour 14 :minute 0 :second 0)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds))))
+
+  ;; With time zone, but without crossing an observance boundary:
+  (let* ((low (ical:make-date-time :year 2025 :month 2 :day 1
+                                   :hour 13 :minute 19 :second 0
+                                   :zone ict:est :dst nil))
+         (high (ical:date/time-add low :minute 1 ict:tz-eastern))
+         (interval (list low high high))
+         (seconds (list 24 59))
+         (sub1 (list (ical:date-time-variant low :second 24 :tz 'preserve)
+                     (ical:date-time-variant low :second 25 :tz 'preserve)))
+         (sub2 (list (ical:date-time-variant low :second 59 :tz 'preserve)
+                     (ical:date-time-variant low :minute 20 :second 0
+                                             :tz 'preserve)))
+         (expected-subintervals (list sub1 sub2)))
+    (should (equal expected-subintervals
+                   (icr:refine-bysecond interval seconds ict:tz-eastern)))))
+
+(ert-deftest ict:recur-subintervals-to-dates ()
+  "Does `icr:subintervals-to-dates' correctly generate recurrences?"
+  ;; Two subintervals, the first three days long, the second less than a single day
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :day 3))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 31
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :hour 3)) ; later but on the same day
+         (sub2 (list low2 high2))
+         (low-date1 (ical:date-time-to-date low1))
+         (low-date2 (ical:date-time-to-date low2))
+         (expected-recs (list low-date1
+                              (ical:date/time-add low-date1 :day 1)
+                              (ical:date/time-add low-date1 :day 2)
+                              low-date2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-dates (list sub1 sub2))))))
+
+(ert-deftest ict:recur-subintervals-to-date-times ()
+  "Does `icr:subintervals-to-date-times' correctly generate recurrences?"
+  ;; Two subintervals, each one second long, no time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 5 :day 1
+                                    :hour 13 :minute 59 :second 0))
+         (high1 (ical:date/time-add low1 :second 1))
+         (sub1 (list low1 high1))
+         (low2 (ical:make-date-time :year 2025 :month 5 :day 2
+                                    :hour 14 :minute 0 :second 0))
+         (high2 (ical:date/time-add low2 :second 1))
+         (sub2 (list low2 high2))
+         (expected-recs (list low1 low2)))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1 sub2)))))
+
+  ;; A subinterval five seconds long, with time zone
+  (let* ((low1 (ical:make-date-time :year 2025 :month 6 :day 1
+                                    :hour 13 :minute 59 :second 0
+                                    :zone ict:edt :dst t))
+         (high1 (ical:date/time-add low1 :second 5 ict:tz-eastern))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date/time-add low1 :second 1 ict:tz-eastern)
+                (ical:date/time-add low1 :second 2 ict:tz-eastern)
+                (ical:date/time-add low1 :second 3 ict:tz-eastern)
+                (ical:date/time-add low1 :second 4 ict:tz-eastern))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-eastern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set forward an hour; these seconds should therefore be in EDT:
+  (let* ((low1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:est :dst nil))
+         (high1 (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 3 :minute 0 :second 3
+                                     :zone ict:edt :dst t))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve)
+                (ical:date-time-variant high1 :second 2 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-eastern))))
+
+  ;; A subinterval five seconds long, with time zone, which crosses an
+  ;; observance boundary where the final three seconds occur after
+  ;; clocks are set back an hour; these seconds should therefore be in
+  ;; EST:
+  (let* ((low1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                    :hour 1 :minute 59 :second 58
+                                    :zone ict:edt :dst t))
+         (high1 (ical:make-date-time :year 2024 :month 11 :day 3
+                                     :hour 1 :minute 0 :second 2
+                                     :zone ict:est :dst nil))
+         (sub1 (list low1 high1))
+         (expected-recs
+          (list low1
+                (ical:date-time-variant low1 :second 59 :tz 'preserve)
+                (ical:date-time-variant high1 :second 0 :tz 'preserve)
+                (ical:date-time-variant high1 :second 1 :tz 'preserve))))
+    (should (equal expected-recs
+                   (icr:subintervals-to-date-times (list sub1) ict:tz-eastern)))))
+
+;; Tests for time zone functions:
+
+(ert-deftest ict:recur-tz-observance-on/nonexistent ()
+  "Does `icr:tz-observance-on' correctly interpret nonexistent times?"
+  (let* ((onset-start (ical:make-date-time :year 2030 :month 3 :day 10
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:est :dst nil))
+         (start-shifted (ical:date-time-variant onset-start :hour 3
+                                                :zone ict:edt :dst t))
+         ;; 2:30AM falls into the gap when the clock jumps from 2AM to 3AM:
+         (nonexistent (ical:date-time-variant onset-start :minute 30
+                                              :zone ict:est :dst nil))
+         (nonexistent-shifted (ical:date-time-variant nonexistent :hour 3
+                                                      :zone ict:edt :dst t)))
+    (icr:tz-observance-on onset-start ict:tz-eastern t) ;; updates the time to EDT
+    (icr:tz-observance-on nonexistent ict:tz-eastern t) ;; updates the time to EDT
+    (should (equal onset-start start-shifted))
+    (should (equal nonexistent nonexistent-shifted))))
+
+(ert-deftest ict:recur-tz-observance-on/occurs-twice ()
+  "Does `icr:tz-observance-on' correctly interpret times that occur twice?"
+  (let* ((onset-start (ical:make-date-time :year 2025 :month 11 :day 2
+                                           :hour 2 :minute 0 :second 0
+                                           :zone ict:edt :dst t))
+         ;; 1:30AM occurs twice when the clock is set back from 2AM to 1AM:
+         (no-zone (ical:date-time-variant onset-start :hour 1 :minute 30))
+         (first (ical:date-time-variant onset-start :hour 1 :minute 30
+                                        :zone ict:edt :dst t))
+         (second (ical:date-time-variant first :zone ict:est :dst nil))
+         (first+1h (ical:date/time-add first :hour 1 ict:tz-eastern)))
+    (icr:tz-observance-on no-zone ict:tz-eastern t) ;; sets zone
+    (should (equal first no-zone))
+    (should (equal second first+1h))))
+
+(ert-deftest ict:recur-tz-observance-on ()
+  "Does `icr:tz-observance-on' correctly find observances?"
+
+  ;; A date before the start of all observances in the timezone.
+  ;; In this case, there is no matching observance, so we should get nil.
+  (let* ((dt (ical:make-date-time :year 1900 :month 1 :day 1
+                                  :hour 12 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (ts (encode-time dt)))
+    (should (null (icr:tz-observance-on dt ict:tz-eastern)))
+    (should (null (icr:tz-observance-on ts ict:tz-eastern))))
+
+  ;; A date matching the start of one of the STANDARD observances:
+  (let* ((dt (ical:make-date-time :year 1967 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the start of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1967 :month 4 :day 30
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an RDATE of a DAYLIGHT observance:
+  (let* ((dt (ical:make-date-time :year 1975 :month 2 :day 23
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a STANDARD observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 6 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 10 :day 29
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching the end of a DAYLIGHT observance:
+  (let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 7 :minute 0 :second 0
+                                  :zone 0 :dst nil)) ; UNTIL is in UTC
+         (dt (ical:make-date-time :year 2006 :month 4 :day 2
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (ical:date-time-simultaneous-p ut dt))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset in the middle of a DAYLIGHT observance
+  ;; which has ended:
+  (let* ((dt (ical:make-date-time :year 1980 :month 4 :day 27
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (end (ical:make-date-time :year 1986 :month 4 :day 27
+                                   :hour 7 :minute 0 :second 0
+                                   :zone 0)) ; UNTIL is in UTC
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal end (ical:recur-until
+                        (ical:with-property-of obs 'ical:rrule nil value))))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date matching an onset of the DAYLIGHT observance which is
+  ;; ongoing:
+  (let* ((dt (ical:make-date-time :year 2025 :month 3 :day 9
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal dt onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the DAYLIGHT observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 3 :day 9
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:est :dst nil))
+         (dt (ical:make-date-time :year 2025 :month 5 :day 28
+                                  :hour 2 :minute 0 :second 0
+                                  :zone ict:edt :dst t))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:daylight (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; A date in the middle of the STANDARD observance which is ongoing:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  ;; The following two tests were useful in detecting a broken optimization:
+  (let* ((start (ical:make-date-time :year 2006 :month 10 :day 29
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2006 :month 11 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+  (let* ((start (ical:make-date-time :year 2007 :month 11 :day 4
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2008 :month 2 :day 1
+                                  :hour 12 :minute 30 :second 0
+                                  :zone ict:est :dst nil))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset))
+         ;; make sure we get the same result with an absolute time:
+         (ts (encode-time dt))
+         (ts-obs/onset (icr:tz-observance-on ts ict:tz-eastern)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))
+    (should (equal obs/onset ts-obs/onset)))
+
+
+  ;; A date in the middle of the STANDARD observance which is ongoing;
+  ;; test that the update flag correctly sets the zone information:
+  (let* ((start (ical:make-date-time :year 2025 :month 11 :day 2
+                                     :hour 2 :minute 0 :second 0
+                                     :zone ict:edt :dst t))
+         (dt (ical:make-date-time :year 2026 :month 1 :day 28
+                                  :hour 12 :minute 30 :second 0
+                                  ;; no zone information
+                                  ))
+         (obs/onset (icr:tz-observance-on dt ict:tz-eastern t))
+         (obs (car obs/onset))
+         (onset (cadr obs/onset)))
+    (should (eq 'ical:standard (ical:ast-node-type obs)))
+    (should (equal start onset))))
+
+
+;; Tests for recurrence rule interpretation:
+(cl-defmacro ict:rrule-test (recur-string doc
+                             &key dtstart
+                             (low dtstart)
+                             high
+                             tz
+                             rdates
+                             exdates
+                             members
+                             nonmembers
+                             size
+                             source)
+
+  "Create a test which parses RECUR-STRING to an `icalendar-recur',
+creates an event with a recurrence set from this value, and checks
+various properties of the recurrence set.
+
+DTSTART should be an `icalendar-date' or `icalendar-date-time'
+  value appropriate to the RECUR-STRING. The value will be
+  bound to the symbol `dtstart'; this symbol can thus be used inside
+  the expressions for MEMBERS and NONMEMBERS.
+LOW and HIGH should be the bounds of the window in which to compute
+  recurrences. LOW defaults to DTSTART.
+TZ, if present, should be an `icalendar-vtimezone'.
+  Date-times in the recurrence set will be calculated relative to this
+  time zone.
+RDATES, if present, should be a list of additional
+  `icalendar-date' or `icalendar-date-time' values to be added to
+  the recurrence set *in addition to* those generated by the
+  recurrence rule (see `icalendar-rdate').
+EXDATES, if present, should be a list of `icalendar-date' or
+  `icalendar-date-time' values to be excluded from the recurrence
+  set, *even if* they are in RDATES or generated by the
+  recurrence rule (see `icalendar-exdate').
+MEMBERS, if present, should be a list of values that are expected
+  to be present in the recurrence set.
+NONMEMBERS, if present, should be a list of values that are expected
+  to be excluded from the recurrence set.
+SIZE, if present, should be a positive integer representing the
+  expected size of the recurrence set. Defaults to the value of the
+  COUNT clause in the recurrence rule, if any.
+SOURCE should be a symbol; it is used to name the test."
+  `(ert-deftest ,(intern (concat "ict:rrule-test-" (symbol-name source))) ()
+     ,(format "Parse and evaluate recur-value example from `%s':\n%s"
+              source doc)
+     (let* ((parsed (ical:parse-from-string 'ical:recur ,recur-string))
+            (recvalue (ical:ast-node-value parsed))
+            (until (ical:recur-until recvalue))
+            (count (ical:recur-count recvalue))
+            (dtstart ,dtstart)
+            (recset-size (or ,size count))
+            (vevent ;; TODO: an ical:make-vevent (etc.) macro would be nice...
+             (ical:make-ast-node 'icalendar-vevent nil
+               (list
+                (ical:make-ast-node
+                 'ical:dtstart
+                 (list :value (ical:make-ast-node
+                               (if (cl-typep dtstart 'ical:date) 'ical:date 'ical:date-time)
+                               (list :value dtstart))))
+                (ical:make-ast-node
+                 'ical:rrule
+                 (list :value parsed))
+                (ical:make-ast-node
+                 'ical:rdate
+                 (list :value
+                       (mapcar
+                        (lambda (d)
+                          (ical:make-ast-node
+                           (if (cl-typep d 'ical:date) 'ical:date 'ical:date-time)
+                           (list :value d)))
+                        ,rdates)))
+                (ical:make-ast-node
+                 'ical:exdate
+                 (list :value
+                       (mapcar
+                        (lambda (d)
+                          (ical:make-ast-node
+                           (if (cl-typep d 'ical:date) 'ical:date 'ical:date-time)
+                           (list :value d)))
+                        ,exdates))))))
+            ;; default for HIGH: UNTIL or DTSTART+3*INTERVAL
+            (win-high
+             (or ,high
+                 until
+                 (cadr
+                  (icr:nth-interval 2 ,dtstart recvalue))))
+            (recs
+             (if count
+                 (icr:recurrences-to-count vevent ,tz)
+               (icr:recurrences-in-window ,low win-high vevent ,tz))))
+       (should (ical:ast-node-valid-p parsed))
+       (when ,members
+         (dolist (dt ,members)
+           (should (member dt recs))))
+       (when ,nonmembers
+         (dolist (dt ,nonmembers)
+           (should-not (member dt recs))))
+       (when recset-size
+         (should (length= recs recset-size))))))
+
+(ict:rrule-test
+ "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1"
+ "Last non-weekend day of the month"
+ :dtstart '(3 31 2025)
+ :high '(6 1 2025)
+ :members '((3 31 2025) (4 30 2025) (5 30 2025))
+ :nonmembers '((5 31 2025)) ;; 5/31/2025 is a Saturday
+ :source rfc5545-sec3.3.10/1)
+
+(ict:rrule-test
+ "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30"
+ "Every Sunday in January at 8:30AM and 9:30AM, every other year"
+ :dtstart (ical:read-date-time "20250105T083000")
+ :high (ical:read-date-time "20271231T000000")
+ :members
+ (let ((jan3-27 (ical:make-date-time :year 2027 :month 1 :day 3
+                                     :hour 8 :minute 30 :second 0)))
+   (list dtstart
+         ;; 2025: Jan 5, 12, 19, 26
+         (ical:date-time-variant dtstart :hour 9)
+         (ical:date-time-variant dtstart :day 12)
+         (ical:date-time-variant dtstart :day 12 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 19)
+         (ical:date-time-variant dtstart :day 19 :hour 9)
+         (ical:date-time-variant dtstart :day 26)
+         (ical:date-time-variant dtstart :day 26 :hour 9)
+         ;; 2027: Jan 3, 10, 17, 24, 31
+         (ical:date-time-variant jan3-27 :hour 9)
+         (ical:date-time-variant jan3-27 :day 10)
+         (ical:date-time-variant jan3-27 :day 10 :hour 9)
+         (ical:date-time-variant jan3-27 :day 17)
+         (ical:date-time-variant jan3-27 :day 17 :hour 9)
+         (ical:date-time-variant jan3-27 :day 24)
+         (ical:date-time-variant jan3-27 :day 24 :hour 9)
+         (ical:date-time-variant jan3-27 :day 31)
+         (ical:date-time-variant jan3-27 :day 31 :hour 9)))
+ :nonmembers
+ (list
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 8 :minute 30 :second 0)
+  (ical:make-date-time :year 2026 :month 1 :day 4
+                       :hour 9 :minute 30 :second 0))
+ :source rfc5545-sec3.3.10/2)
+
+(ict:rrule-test
+ "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1"
+ "Every year on the last day in February"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 28 2025) (2 28 2026) (2 28 2027) (2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/1)
+
+(ict:rrule-test
+ "FREQ=YEARLY;INTERVAL=4;BYMONTH=2;BYMONTHDAY=29"
+ "Every four years on February 29"
+ :dtstart '(2 29 2024)
+ :high '(3 1 2028)
+ :members '((2 29 2028))
+ :nonmembers '((2 28 2028))
+ :source leap-day/2)
+
+(ict:rrule-test
+"FREQ=DAILY;COUNT=10"
+"Daily for 10 occurrences"
+:dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                              :hour 9 :minute 0 :second 0)
+:members
+;; (1997 9:00 AM EDT) September 2-11
+(mapcar
+ (lambda (day) (ical:date-time-variant dtstart :day day))
+ (number-sequence 2 11))
+:source rfc5545-sec3.3.10/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY"
+ "Every year on a specific date, e.g. an anniversary"
+ :dtstart '(11 11 2024)
+ :high '(10 1 2030)
+ :members '((11 11 2024)
+            (11 11 2025)
+            (11 11 2026)
+            (11 11 2027)
+            (11 11 2028)
+            (11 11 2029))
+ :nonmembers '((11 11 2030))
+ :source rfc5545-sec3.6.1/3)
+
+;; Time zone tests
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z"
+ "Every year on the last Sunday of April (through 1973-04-29) at 2AM.
+(Onset of US Eastern Daylight Time.)"
+ :tz ict:tz-eastern
+ ;; DTSTART and all the times below are at *3*AM EDT, because 2AM EST
+ ;; (the onset of the observance) does not exist as a local time:
+ :dtstart (ical:make-date-time :year 1967 :month 4 :day 30
+                               :hour 3 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 1973 :month 4 :day 30
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1968 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1969 :day 27 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1970 :day 26 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1971 :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1972 :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1973 :day 29 :tz 'preserve))
+ :source rfc5545-sec3.6.5/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU"
+ "Every year on the first Sunday of November at 2AM.
+(Onset of Eastern Standard Time)."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 11 :day 4
+                               :hour 2 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:date-time-variant dtstart :year 2010 :month 11 :day 8
+                               :zone ict:est :dst nil)
+ :members
+ ;; all the times below are at *1*AM EST, because 2AM EDT (the onset of
+ ;; the observance) is when clocks get set back:
+ (list (ical:date-time-variant dtstart
+                               :year 2008 :month 11 :day 2
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2009 :month 11 :day 1
+                               :zone ict:est :dst nil)
+       (ical:date-time-variant dtstart
+                               :year 2010 :month 11 :day 7
+                               :zone ict:est :dst nil))
+ :source rfc5545-sec3.6.5/3.1)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=3;BYDAY=1SU"
+ "Every three months on the first Sunday of the month."
+ :dtstart '(1 5 2025)
+ :high '(1 1 2026)
+ :members (list '(4 6 2025)
+                '(7 6 2025)
+                '(10 5 2025))
+ :nonmembers (list '(1 12 2025) ;; second Sun.
+                   '(2 2 2025) ;; first Sun. in Feb.
+                   '(4 5 2025)) ;; Sat.
+ :source monthly/interval)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;COUNT=10\n"
+ "Daily for 10 occurrences"
+ :dtstart (ical:read-date-time "19970902T090000")
+ :members
+ (mapcar
+  (lambda (day) (ical:date-time-variant dtstart :day day))
+  (number-sequence 2 11))
+ :nonmembers (list (ical:date-time-variant dtstart :day 12))
+ :high (ical:read-date-time "19970912T090000")
+ :source rfc5545-sec3.8.5.3/1)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;UNTIL=19971224T000000Z\n"
+ "Daily at 9AM until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+   ;; EDT:
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 2 30)) ;; Sept. 2--30
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :month 10 :day day
+                                         :tz 'preserve))
+   (number-sequence 1 25)) ;; Oct. 1--25
+  ;; EST:
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :zone ict:est :dst nil))
+   (number-sequence 26 31))) ;; Oct. 26--31
+ :source rfc5545-sec3.8.5.3/2)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;INTERVAL=2\n"
+ "Every other day - forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 12 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,4,6,8...24,26,28,30;
+  ;;                    October 2,4,6...20,22,24
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n) :tz 'preserve))
+   (number-sequence 1 12))
+  ;; (1997 9:00 AM EST) October 26,28,30;
+  ;;                    November 1,3,5,7...25,27,29;
+  ;;                    December 1,3,...
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 10 :day (* 2 n)
+                             :zone ict:est :dst nil))
+   (number-sequence 13 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 11 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 15))
+  (mapcar
+   (lambda (n)
+     (ical:date-time-variant dtstart :month 12 :day (1- (* 2 n))
+                             :zone ict:est :dst nil))
+   (number-sequence 1 2)))
+
+ :nonmembers
+ (list
+  ;; e.g.
+  (ical:make-date-time :year 1997 :month 10 :day 27
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/3)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5\n"
+ "Every ten days for five recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members ;; (1997 9:00 AM EDT) September 2,12,22; October 2,12
+ (list
+  (ical:make-date-time :year 1997 :month 9 :day 2
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 9 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 9 :day 22
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 2
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t)
+  (ical:make-date-time :year 1997 :month 10 :day 12
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:edt :dst t))
+ :source rfc5545-sec3.8.5.3/4)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n"
+ "Every day in January, for three years (weekdays explicit)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/5)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1\n"
+ "Every day in January, for three years (weekdays implicit)"
+ ;; TODO: as things are currently implemented, this way of expressing
+ ;; the rule is quite expensive, since we end up computing intervals and
+ ;; recurrences for every day of the year, even though the only relevant
+ ;; days are in January and there are no recurrences on the other days.
+ ;; We could try to optimize e.g. icr:refine-from-clauses to deal with such
+ ;; cases.
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1998 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2000 :month 2 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ ;; (1998 9:00 AM EST)January 1-31
+ ;; (1999 9:00 AM EST)January 1-31
+ ;; (2000 9:00 AM EST)January 1-31
+ (append
+  (mapcar
+   (lambda (day) (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 1999 :day day :tz 'preserve))
+   (number-sequence 1 31))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :year 2000 :day day :tz 'preserve))
+   (number-sequence 1 31)))
+ :source rfc5545-sec3.8.5.3/6)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;COUNT=10\n"
+ "Weekly for ten occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :day day :tz 'preserve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (day)
+     (ical:date-time-variant dtstart :month 10 :day day :tz 'preserve))
+   (list 7 14 21))
+  ;; (1997 9:00 AM EST) October 28;November 4
+  (list
+   (ical:make-date-time :year 1997 :month 10 :day 28
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)
+   (ical:make-date-time :year 1997 :month 11 :day 4
+                        :hour 9 :minute 0 :second 0
+                        :zone ict:est :dst nil)))
+ :source rfc5545-sec3.8.5.3/7)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z\n"
+ "Every week until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (let ((oct97 (ical:date-time-variant dtstart :month 10
+                                      :zone ict:edt :dst t))
+       (nov97 (ical:date-time-variant dtstart :month 11
+                                      :zone ict:est :dst nil))
+       (dec97 (ical:date-time-variant dtstart :month 12
+                                      :zone ict:est :dst nil)))
+   (append
+    ;; (1997 9:00 AM EDT) September 2,9,16,23,30;
+    ;;                    October 7,14,21
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dtstart :day day :tz 'preserve))
+     (list 2 9 16 23 30))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant oct97 :day day :tz 'preserve))
+     (list 7 14 21))
+    ;; (1997 9:00 AM EST) October 28;
+    ;;                    November 4,11,18,25;
+    ;;                    December 2,9,16,23
+    (list (ical:date-time-variant oct97 :day 28 :zone ict:est :dst nil))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant nov97 :day day :tz 'preserve))
+     (list 4 11 18 25))
+    (mapcar
+     (lambda (day)
+       (ical:date-time-variant dec97 :day day :tz 'preserve))
+     (list 2 9 16 23))))
+ :source rfc5545-sec3.8.5.3/8)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU\n"
+ "Every other week - forever; Weekstart on Sunday"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 9 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,16,30;
+  ;;                        October 14
+  dtstart
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+  ;;    (1997 9:00 AM EST) October 28;
+  ;;                       November 11,25;
+  ;;                       December 9,23
+  (ical:date-time-variant dtstart :month 10 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  ;;    (1998 9:00 AM EST) January 6,20;
+  ;;                       February 3, 17
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 17
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/9)
+
+(ict:rrule-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using UNTIL"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/10)
+
+(ict:rrule-test
+"RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH\n"
+"Weekly on Tuesday and Thursday for five weeks, using COUNT"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 10 :day 8
+                            :hour 0 :minute 0 :second 0 :zone 0)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
+  ;;                        October 2
+  dtstart
+  (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 25 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/11)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR\n"
+ "Every other week on Monday, Wednesday, and Friday until December 24,
+1997, starting on Monday, September 1, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  dtstart
+  ;; ==> (1997 9:00 AM EDT) September 1,3,5,15,17,19,29;
+  ;;                        October 1,3,13,15,17
+  (ical:date-time-variant dtstart :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 5 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 29 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 13 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 17 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 27,29,31;
+  ;;                        November 10,12,14,24,26,28;
+  ;;                        December 8,10,12,22
+  (ical:date-time-variant dtstart :month 10 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 29 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 10 :day 31 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 14 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 26 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 8  :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 10 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 12 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22 :zone ict:est :dst nil))
+ :nonmembers
+ (list
+  ;; These match the rule, but are just past the UNTIL date:
+  (ical:date-time-variant dtstart :month 12 :day 24 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 26 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/12)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH\n"
+ "Every other week on Tuesday and Thursday, for 8 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ ;; ==> (1997 9:00 AM EDT) September 2,4,16,18,30;
+ ;;                        October 2,14,16
+ (list
+   dtstart
+   (ical:date-time-variant dtstart :day 4 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 18 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :month 10 :day 16 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/13)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR\n"
+ "Monthly on the first Friday for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 5;October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2;February 6;March 6;April 3
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 3
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 1;June 5
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 1 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 5 :tz 'preserve))
+  :source rfc5545-sec3.8.5.3/14)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR\n"
+ "Monthly on the first Friday until December 24, 1997"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 5; October 3
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 3 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 7;December 5
+  (ical:date-time-variant dtstart :month 11 :day 7 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 5 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/15)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU\n"
+ "Every other month on the first and last Sunday of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 7
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 7,28
+  dtstart
+  (ical:date-time-variant dtstart :day 28 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,30
+  (ical:date-time-variant dtstart :month 11 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 4,25;March 1,29
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 25
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) May 3,31
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 3 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 31 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/16)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO\n"
+ "Monthly on the second-to-last Monday of the month for 6 months"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 22
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 22;October 20
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 20 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 17;December 22
+  (ical:date-time-variant dtstart :month 11 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 22
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 19;February 16
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 19
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 16
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/17)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYMONTHDAY=-3\n"
+ "Monthly on the third-to-the-last day of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 28
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 3 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 28
+  dtstart
+  ;;     (1997 9:00 AM EST) October 29;November 28;December 29
+  (ical:date-time-variant dtstart :month 10 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 28
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 29
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/18)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15\n"
+ "Monthly on the 2nd and 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,15;October 2,15
+  dtstart
+  (ical:date-time-variant dtstart :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 2 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 10 :day 15 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 2,15;December 2,15
+  (ical:date-time-variant dtstart :month 11 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 15
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 15
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 2,15
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 2
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 15
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/19)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1\n"
+ "Monthly on the first and last day of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 30
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 30;October 1
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 1 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) October 31;November 1,30;December 1,31
+  (ical:date-time-variant dtstart :month 10 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 30
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 31
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 1,31;February 1
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 1
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 31
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 1
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/20)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15\n"
+ "Every 18 months on the 10th thru 15th of the month for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  (list
+   ;; ==> (1997 9:00 AM EDT) September 10,11,12,13,14,15
+   dtstart
+   (ical:date-time-variant dtstart :day 11 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 12 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 13 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 14 :tz 'preserve)
+   (ical:date-time-variant dtstart :day 15 :tz 'preserve))
+
+  ;;     (1999 9:00 AM EST) March 10,11,12,13
+  (let ((mar99 (ical:make-date-time :year 1999 :month 3 :day 10
+                                    :hour 9 :minute 0 :second 0
+                                    :zone ict:est :dst nil)))
+    (list
+     mar99
+     (ical:date-time-variant mar99 :day 11 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 12 :tz 'preserve)
+     (ical:date-time-variant mar99 :day 13 :tz 'preserve))))
+ :nonmembers
+ (list
+  ;; These match the rule but are excluded by the COUNT clause:
+  (ical:make-date-time :year 1999 :month 3 :day 14
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil)
+  (ical:make-date-time :year 1999 :month 3 :day 15
+                       :hour 9 :minute 0 :second 0
+                       :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/21)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU\n"
+ "Every Tuesday, every other month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 2,9,16,23,30
+  dtstart
+  (ical:date-time-variant dtstart :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 16 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 23 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 4,11,18,25
+  (ical:date-time-variant dtstart :month 11 :day 4
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 11
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 25
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 6,13,20,27;March 3,10,17,24,31
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 6
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 20
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 27
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 3
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 17
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 24
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 31
+                          :zone ict:est :dst nil))
+ :nonmembers
+ ;; e.g. Tuesdays in December 1997:
+ (list
+  (ical:date-time-variant dtstart :month 12 :day 2 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 9 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 16 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 23 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/22)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7\n"
+ "Yearly in June and July for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ ;; Note: Since none of the BYDAY, BYMONTHDAY, or BYYEARDAY
+ ;; clauses are specified, the month day is gotten from "DTSTART"
+ :members
+ ;; ==> (1997 9:00 AM EDT) June 10;July 10
+ ;;     (1998 9:00 AM EDT) June 10;July 10
+ ;;     (1999 9:00 AM EDT) June 10;July 10
+ ;;     (2000 9:00 AM EDT) June 10;July 10
+ ;;     (2001 9:00 AM EDT) June 10;July 10
+ (mapcan
+  (lambda (y)
+    (list
+     (ical:date-time-variant dtstart :year y :month 6 :tz 'preserve)
+     (ical:date-time-variant dtstart :year y :month 7 :tz 'preserve)))
+  (number-sequence 1997 2001))
+ :source rfc5545-sec3.8.5.3/23)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3\n"
+ "Every other year on January, February, and March for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 10
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ ;; ==> (1997 9:00 AM EST) March 10
+ ;;     (1999 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2001 9:00 AM EST) January 10;February 10;March 10
+ ;;     (2003 9:00 AM EST) January 10;February 10;March 10
+ (cons
+  dtstart
+  (mapcan
+   (lambda (y)
+     (list
+      (ical:date-time-variant dtstart :year y :month 1 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 2 :tz 'preserve)
+      (ical:date-time-variant dtstart :year y :month 3 :tz 'preserve)))
+   (list 1999 2001 2003)))
+ :source rfc5545-sec3.8.5.3/24)
+
+(ict:rrule-test
+"RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200\n"
+"Every third year on the 1st, 100th, and 200th day for 10 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 1 :day 1
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EST) January 1
+  dtstart
+  ;;     (1997 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :month 4 :day 10 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 7 :day 19 :zone ict:edt :dst t)
+  ;;     (2000 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2000 :tz 'preserve)
+  ;;     (2000 9:00 AM EDT) April 9;July 18
+  (ical:date-time-variant dtstart :year 2000 :month 4 :day 9 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :year 2000 :month 7 :day 18 :zone ict:edt :dst t)
+  ;;     (2003 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2003 :tz 'preserve)
+  ;;     (2003 9:00 AM EDT) April 10;July 19
+  (ical:date-time-variant dtstart :year 2003 :month 4 :day 10 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :year 2003 :month 7 :day 19 :zone ict:edt :dst t)
+  ;;     (2006 9:00 AM EST) January 1
+  (ical:date-time-variant dtstart :year 2006 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/25)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYDAY=20MO\n"
+ "Every 20th Monday of the year, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 5 :day 19
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) May 19
+  ;;     (1998 9:00 AM EDT) May 18
+  ;;     (1999 9:00 AM EDT) May 17
+  ;;     ...
+  dtstart
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/26)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO\n"
+ "Every year on Monday in Week 20 (where the week starts Monday), forever"
+ :tz ict:tz-eastern
+ :dtstart
+ (ical:make-date-time :year 1997 :month 5 :day 12
+                      :hour 9 :minute 0 :second 0
+                      :zone ict:edt :dst t)
+ :members
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1999 :day 17 :tz 'preserve))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :year 1998 :day 12 :tz 'preserve) ; a Tuesday
+  (ical:date-time-variant dtstart :year 1998 :day 18 :tz 'preserve)) ; wrong weekno
+ :source rfc5545-sec3.8.5.3/27)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH\n"
+ "Every Thursday in March, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 3 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :members
+ (append
+  ;; ==> (1997 9:00 AM EST) March 13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 13 20 27))
+  ;;     (1998 9:00 AM EST) March 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 5 12 19 26))
+  ;;     (1999 9:00 AM EST) March 4,11,18,25
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 4 11 18 25)))
+ :source rfc5545-sec3.8.5.3/28)
+
+(ict:rrule-test
+"RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8\n"
+"Every Thursday, but only during June, July, and August, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 6 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (append
+  ;; ==> (1997 9:00 AM EDT) June 5,12,19,26;July 3,10,17,24,31;
+  ;;                        August 7,14,21,28
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :day d :tz 'preserve))
+   (list 5 12 19 26))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 7 :day d :tz 'preserve))
+   (list 3 10 17 24 31))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :month 8 :day d :tz 'preserve))
+   (list 7 14 21 28))
+  ;;     (1998 9:00 AM EDT) June 4,11,18,25;July 2,9,16,23,30;
+  ;;                        August 6,13,20,27
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :day d :tz 'preserve))
+   (list 4 11 18 25))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 7 :day d :tz 'preserve))
+   (list 2 9 16 23 30))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1998 :month 8 :day d :tz 'preserve))
+   (list 6 13 20 27))
+  ;;     (1999 9:00 AM EDT) June 3,10,17,24;July 1,8,15,22,29;
+  ;;                        August 5,12,19,26
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :day d :tz 'preserve))
+   (list 3 10 17 24))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 7 :day d :tz 'preserve))
+   (list 1 8 15 22 29))
+  (mapcar
+   (lambda (d)
+     (ical:date-time-variant dtstart :year 1999 :month 8 :day d :tz 'preserve))
+   (list 5 12 19 26)))
+ :source rfc5545-sec3.8.5.3/29)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13\n"
+ "Every Friday the 13th, forever, *excluding* DTSTART "
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 2000 :month 10 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :exdates (list dtstart)
+ :members
+ (list
+  ;; ==> (1998 9:00 AM EST) February 13;March 13;November 13
+  ;;     (1999 9:00 AM EDT) August 13
+  ;;     (2000 9:00 AM EDT) October 13
+  ;;     ...
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 11 :day 13
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1999 :month 8 :day 13 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 2000 :month 10 :day 13 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/30)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13\n"
+ "The first Saturday that follows the first Sunday of the month, forever"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 13
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 6 :day 14
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 13;October 11
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 11 :tz 'preserve)
+  ;;     (1997 9:00 AM EST) November 8;December 13
+  (ical:date-time-variant dtstart :month 11 :day 8 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 13 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 10;February 7;March 7
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 10
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 7
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 7
+                          :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EDT) April 11;May 9;June 13...
+  (ical:date-time-variant dtstart :year 1998 :month 4 :day 11 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 5 :day 9 :tz 'preserve)
+  (ical:date-time-variant dtstart :year 1998 :month 6 :day 13 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/31)
+
+(ict:rrule-test
+ "RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8\n"
+ "Every 4 years, the first Tuesday after a Monday in November, forever
+(U.S. Presidential Election day)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1996 :month 11 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+
+ :members
+ (list
+  ;; ==> (1996 9:00 AM EST) November 5
+  dtstart
+  ;;     (2000 9:00 AM EST) November 7
+  (ical:date-time-variant dtstart :year 2000 :day 7 :tz 'preserve)
+  ;;     (2004 9:00 AM EST) November 2
+  (ical:date-time-variant dtstart :year 2004 :day 2 :tz 'preserve))
+  :source rfc5545-sec3.8.5.3/32)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3\n"
+ "The third instance into the month of one of Tuesday, Wednesday, or
+Thursday, for the next 3 months"
+ ;; TODO: Yikes, why is this so slow??
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 4
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 4;October 7
+  ;;     (1997 9:00 AM EST) November 6
+  dtstart
+  (ical:date-time-variant dtstart :month 10 :day 7 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 11 :day 6 :zone ict:est :dst nil))
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2\n"
+ "The second-to-last weekday of the month"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 29
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1998 :month 4 :day 1
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:est :dst nil)
+ :members
+ (list
+  ;; ==> (1997 9:00 AM EDT) September 29
+  dtstart
+  ;;     (1997 9:00 AM EST) October 30;November 27;December 30
+  (ical:date-time-variant dtstart :month 10 :day 30 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 11 :day 27 :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :month 12 :day 30 :zone ict:est :dst nil)
+  ;;     (1998 9:00 AM EST) January 29;February 26;March 30
+  (ical:date-time-variant dtstart :year 1998 :month 1 :day 29
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 2 :day 26
+                          :zone ict:est :dst nil)
+  (ical:date-time-variant dtstart :year 1998 :month 3 :day 30
+                          :zone ict:est :dst nil))
+ :source rfc5545-sec3.8.5.3/34)
+
+(ict:rrule-test
+ ;; corrected, see Errata ID 3883: https://www.rfc-editor.org/errata/eid3883
+ "RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T210000Z\n"
+ "Every 3 hours from 9:00 AM to 5:00 PM on a specific day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; ==> (September 2, 1997 EDT) 09:00,12:00,15:00
+  dtstart
+  (ical:date-time-variant dtstart :hour 12 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/35)
+
+(ict:rrule-test
+ "RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6\n"
+ "Every 15 minutes for 6 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+
+ :members
+ (list
+  ;; ==> (September 2, 1997 EDT) 09:00,09:15,09:30,09:45,10:00,10:15
+  dtstart
+  (ical:date-time-variant dtstart :minute 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :minute 45 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 10 :minute 15 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/36)
+
+(ict:rrule-test
+ "RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4\n"
+ "Every hour and a half for 4 occurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (September 2, 1997 EDT) 09:00,10:30;12:00;13:30
+  dtstart
+  (ical:date-time-variant dtstart :hour 10 :minute 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 12 :minute 0 :tz 'preserve)
+  (ical:date-time-variant dtstart :hour 13 :minute 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/37)
+
+(ict:rrule-test
+ "RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; ==> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'preserve)))
+   (number-sequence 9 16)))
+ :source rfc5545-sec3.8.5.3/38)
+
+(ict:rrule-test
+ "RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16\n"
+ "Every 20 minutes from 9:00 AM to 4:40 PM every day
+(Alternative rule for the previous example)"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 9 :day 2
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :high (ical:make-date-time :year 1997 :month 9 :day 4
+                            :hour 0 :minute 0 :second 0
+                            :zone ict:edt :dst t)
+ :members
+ (append
+  ;; ==> (September 2, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ... 16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :minute 40 :tz 'preserve)))
+   (number-sequence 9 16))
+  ;;     (September 3, 1997 EDT) 9:00,9:20,9:40,10:00,10:20,
+  ;;                             ...16:00,16:20,16:40
+  (mapcan
+   (lambda (h)
+     (list
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 0 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 20 :tz 'preserve)
+      (ical:date-time-variant dtstart :hour h :day 3 :minute 40 :tz 'preserve)))
+   (number-sequence 9 16)))
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Monday, for four recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 EDT) August 5,10,19,24
+  dtstart
+  (ical:date-time-variant dtstart :day 10 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 24 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/40)
+
+(ict:rrule-test
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU\n"
+ "An example where the days generated makes a difference because of WKST:
+every other week on Tuesday and Sunday, week start Sunday, for four recurrences"
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 1997 :month 8 :day 5
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (1997 EDT) August 5,17,19,31
+  dtstart
+  (ical:date-time-variant dtstart :day 17 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 19 :tz 'preserve)
+  (ical:date-time-variant dtstart :day 31 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/41)
+
+(ict:rrule-test
+ "RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5\n"
+ "An example where an invalid date (i.e., February 30) is ignored."
+ :tz ict:tz-eastern
+ :dtstart (ical:make-date-time :year 2007 :month 1 :day 15
+                               :hour 9 :minute 0 :second 0
+                               :zone ict:est :dst nil)
+ :high (ical:make-date-time :year 2007 :month 4 :day 1
+                               :hour 0 :minute 0 :second 0
+                               :zone ict:edt :dst t)
+ :members
+ (list
+  ;; ==> (2007 EST) January 15,30
+  ;;     (2007 EST) February 15
+  ;;     (2007 EDT) March 15,30
+  dtstart
+  (ical:date-time-variant dtstart :day 30 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 15 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 3 :day 15 :zone ict:edt :dst t)
+  (ical:date-time-variant dtstart :month 3 :day 30 :zone ict:edt :dst t))
+ :nonmembers
+ (list
+  (ical:date-time-variant dtstart :month 2 :day 28 :tz 'preserve)
+  (ical:date-time-variant dtstart :month 2 :day 30 :tz 'preserve))
+ :source rfc5545-sec3.8.5.3/42)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-") ("icr:" . "icalendar-recur-") ("ical:" . "icalendar-"))
+;; End:
+;;; tests/icalendar-recur.el ends here
-- 
2.39.5


--=-=-=--




Information forwarded to rwl@HIDDEN, bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 23 Jan 2025 18:48:50 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Thu Jan 23 13:48:50 2025
Received: from localhost ([127.0.0.1]:42362 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tb2G2-0006OH-9P
	for submit <at> debbugs.gnu.org; Thu, 23 Jan 2025 13:48:50 -0500
Received: from fhigh-b2-smtp.messagingengine.com ([202.12.124.153]:46517)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1tb2Fz-0006Nv-1J
 for 74994 <at> debbugs.gnu.org; Thu, 23 Jan 2025 13:48:48 -0500
Received: from phl-compute-01.internal (phl-compute-01.phl.internal
 [10.202.2.41])
 by mailfhigh.stl.internal (Postfix) with ESMTP id AF90F25401B0;
 Thu, 23 Jan 2025 13:48:40 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-01.internal (MEProxy); Thu, 23 Jan 2025 13:48:40 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1737658120;
 x=1737744520; bh=Qw/iVEtbXBfmHniNqQ96x4u2fv7/LNJwWEc0rYEh/7c=; b=
 Yvy2vkz6PTfiWRB0lpuM3upr4Rem2TZGTVVUUqFVzBlqDYY2pSQSqzglgQfadmi+
 /8QQ6WUwt7VZ6YacsvB7wEQhalxv1J8msB2ZGOc2ZGhHrnC7zcJvBJMmdre06izt
 uL1kGuxThEx27VF+3XmvdAwxPEs33oxl2jw2q2Cjt6CqBf91YIiX94oXvCS4l8dC
 p+C6LmLpBbXXtOjIzho5Nt08g/xUucJSBDpMAtTnnIePiUpwWxDb0NS9wfrwigyl
 jAMB9VTp3tOKs1pVvCS6iF7hd6LH8QW7x6JbfUfdsFKBxy2hbQR4wrHnb1SlXHZn
 3haw+6sD80gclL2vriczPQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1737658120; x=1737744520; bh=Qw/iVEtbXBfmHniNqQ96x4u2fv7/LNJwWEc
 0rYEh/7c=; b=Fuj2/stSBhnu7rNlG+ESTHl1RaLjfiHnR6z5pgmtdb8G2394cdz
 1YRveTpJfr0Froz7lbcJagauWz3x5po7EqRqVPITqMfpB57ZHhaXvZIuYdiQRlS1
 U7adZPI/fcNWnkGO6gTllql/FKKgn2Cpq2rVG+220BZAdltQU1lsrJaKRnEgbN5C
 vYK5c8eoimHe8ZD0pI63XPjaC2/qxEIwAItbvw3lhQsNgj4cSc0gbZOgMpu5d2yl
 aYSytPHNYD5Av0J5Fk0TCmTQ56Qt6DnMlrQARJ9mN5hdGBYhecxHo+9aVDa750JJ
 CBcVw7dS5LMRGjrQLSDiUELPQag8vkzBkkg==
X-ME-Sender: <xms:CI-SZ69zYval50TwJK2GVqjkguYYDJaWo7zPCHsnsR_0IggQJ3-osQ>
 <xme:CI-SZ6uXJAtnq4WP6nB6f4fzeNYWy7UO7hjqlueoWw32ZkO_ZPxktH9dl1L9Ih0Ar
 mq6MVU7yntJY5x8MA>
X-ME-Received: <xmr:CI-SZwCO3LHK76iB400uT0Xjnw_N9Ja8oLDSHfpunemNu4ZZtIASSVRRKg>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddrudejgedgvdegvdcutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecusecvtfgvtghiphhivg
 hnthhsucdlqddutddtmdenucfjughrpefhvfevufgjfhffkfggtgesthdtredttddttden
 ucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggvuceorhiflhesrhgvtghurhhsvg
 ifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvghrnhepfeeuffdvfeeffedtuedt
 gfejtedtudevuddufeejtdekjefgheehvdfggfffudfhnecuvehluhhsthgvrhfuihiivg
 eptdenucfrrghrrghmpehmrghilhhfrhhomheprhiflhesrhgvtghurhhsvgifihhthhhl
 vghsshdrnhgvthdpnhgspghrtghpthhtohepvddpmhhouggvpehsmhhtphhouhhtpdhrtg
 hpthhtohepvghlihiisehgnhhurdhorhhgpdhrtghpthhtohepjeegleelgeesuggvsggs
 uhhgshdrghhnuhdrohhrgh
X-ME-Proxy: <xmx:CI-SZyfiXw_t-GTUiNh2zaVZCEqjtRvPU6bJLl-YmQHeRvCthm-2Ag>
 <xmx:CI-SZ_MYOjvFynqW0ADD9MK7xsRK67_ljlD-vtXSULwYJikpomAKcQ>
 <xmx:CI-SZ8mNEaWArLtm6o2HHm1WKIZ9po64qZaGBO8OKEka-RJHJ0kcWg>
 <xmx:CI-SZxuGxYMxua7AsPl1Ez4sK1dzkIuVBA8chzmrPNWymsn0dr-mtg>
 <xmx:CI-SZ4Yx5vxUfJafqb4sYzo4YYFdA3KbOP3qVLaPq2qIiC1lqoI43z7D>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Thu,
 23 Jan 2025 13:48:39 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <86r04v2aei.fsf@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
 <87a5bj9vog.fsf@HIDDEN> <86r04v2aei.fsf@HIDDEN>
Date: Thu, 23 Jan 2025 19:48:33 +0100
Message-ID: <87h65p5ptq.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Eli Zaretskii <eliz@HIDDEN> writes:
 
>> Is this more about maintaining the source history, or about not wanting
>> to break the setups of people who have (require 'icalendar) or similar
>> in their configuration?
>
> The former.  As good as current VCS forensic tools are, some of the
> relevant VCS commands still don't work across renames.  So it is
> preferable to rename only when necessary.
> ...
> If there's a lot of stuff to be moved out of the file, I think it's
> better to leave the file alone, and instead start new files (as you
> have evidently done).

OK, got it. I'll see what I can do to clean up and generalize
icalendar.el without moving the existing code.

To be honest, though, I've found this rough going over the last few days
(in agreement with my previous attempts). The code in icalendar.el is
not easy for me to understand, even now; much of it appears to have been
written by someone who was more used to writing in a language like C,
and also had apprehensions about long variable names, the overhead of
accessor functions which aren't "car" or "cdr", the use of other data
structures when strings will do, etc. I'm finding it hard to integrate
the new parser with the existing code without just wholesale replacing
entire function bodies, and I find myself constantly wondering if it's
worth the effort. I may end up just leaving icalendar.el untouched and
writing an alternative diary-icalendar feature from scratch; certainly
that seems easier to me right now. If I decide that's the best route,
would anything stand in the way of that new file becoming the
default way of doing things, recommended in the manual, etc.?






Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 23 Jan 2025 18:12:39 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Thu Jan 23 13:12:39 2025
Received: from localhost ([127.0.0.1]:42291 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tb1h0-0004f1-3T
	for submit <at> debbugs.gnu.org; Thu, 23 Jan 2025 13:12:39 -0500
Received: from fout-b1-smtp.messagingengine.com ([202.12.124.144]:54853)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1tb1gw-0004ed-7E
 for 74994 <at> debbugs.gnu.org; Thu, 23 Jan 2025 13:12:36 -0500
Received: from phl-compute-11.internal (phl-compute-11.phl.internal
 [10.202.2.51])
 by mailfout.stl.internal (Postfix) with ESMTP id 3545B1140151;
 Thu, 23 Jan 2025 13:12:28 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-11.internal (MEProxy); Thu, 23 Jan 2025 13:12:28 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1737655948;
 x=1737742348; bh=lbr16PtjA/Wg8vSJTc8edjgII96znKC50sRyB4hUrf4=; b=
 Bob7b+UnzFy3THdOIctvALNNINeyhZwowSSaOHEXmaktSlBoyPTK/towwOKRmfZK
 l19d7Ve1OkhT6qDVobELEVCPMNmSIMIp0yfWKOdAe2fUIEYbb0n5OeHvEO4fihVT
 vP+/fBIAHxdFuavne5Y3nCdTzdB/dAPdEXlDG58Z1jZtLH++GWwCm0rv+mpesO/d
 alXaM2XWZQhvMzs9eteDACjmzJs24NjIYeulrZSDLjVULI8yjNhEi4iL5Q0f9F2e
 AfHaaSQ3rKwSpXanSCVigJiqPF9w1zsPTd4MXCEZljpmWDpTlPUjJHZoh6y7e2wz
 FyzfEFoNgogRG5+q3iuFOg==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1737655948; x=1737742348; bh=lbr16PtjA/Wg8vSJTc8edjgII96znKC50sR
 yB4hUrf4=; b=qdoYBFfl2/Lq6Q0B4+kKAe0mpwr245WHMceb2vkp/RZSEJARYrn
 BNu0wFSAwyKkdeZ4T4Hu99Qdjdh+WgvjjU+z0gwvPA67AvXwvzdBOUvKFHoEiXCO
 U3aJqHyP718xCs3URid0JDyCE+VD+/WasN7yG3Bbje4cRi1ApPDB0TaaYPhCXN1V
 DT761a2VObAcPqLw5Ndxs41G9ausiSCobyRSugqz+AsHq3Zoxjwtvjaq7vFpGbDT
 DGBLxF9C3Sc4FlyW8HZU7EXOyaiWqnwVttEb+b6v5u5qFbwdZinocWD1MTmzyCZY
 EnAfHhR5m0VMMSMdtYKJxUk9jmcZyBl4jvA==
X-ME-Sender: <xms:i4aSZzMIFFsPrjz5PjBK_5E6aQwgqR7E1xIKuc-7-mzQVKswOd6sEw>
 <xme:i4aSZ99r39c9NFdzS5-ohIZM8PJy8azxwUBlgZ4J1ZoS6MoB2mvq7L2vZiAPksZMD
 EwGj30tgk91RcnXYA>
X-ME-Received: <xmr:i4aSZySg_284Kb2phgC0IbBP5PFIgNnev_FlhOsgZLR5oG3HZOYh3BJmNA>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddrudejgedgvdefhecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecusecvtfgvtghiphhivg
 hnthhsucdlqddutddtmdenucfjughrpefhvfevufgjfhffkfggtgesthdtredttddttden
 ucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggvuceorhiflhesrhgvtghurhhsvg
 ifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvghrnhepfeeuffdvfeeffedtuedt
 gfejtedtudevuddufeejtdekjefgheehvdfggfffudfhnecuvehluhhsthgvrhfuihiivg
 eptdenucfrrghrrghmpehmrghilhhfrhhomheprhiflhesrhgvtghurhhsvgifihhthhhl
 vghsshdrnhgvthdpnhgspghrtghpthhtohepvddpmhhouggvpehsmhhtphhouhhtpdhrtg
 hpthhtohepmhhonhhnihgvrhesihhrohdruhhmohhnthhrvggrlhdrtggrpdhrtghpthht
 ohepjeegleelgeesuggvsggsuhhgshdrghhnuhdrohhrgh
X-ME-Proxy: <xmx:i4aSZ3vzyneBaHvuplPYalOB0sHdbPCE1oN1lwuoqm4_oW093MQkIg>
 <xmx:i4aSZ7fx0bODa-BFaEFZpmt51CEVeNgel4R30vIWUoOnvyBtDLZ4Cg>
 <xmx:i4aSZz3xKDdSkdVPEBhNDKU5zfJ4uVy1w34DG-T-S9yUqoY9Oqb_Pw>
 <xmx:i4aSZ38WBQKJo3ys5JZzE3bxACT9YahTBF5FR_5vkV0OjpJd2Y7bUw>
 <xmx:jIaSZ8rebuhku7b58RPL3gVrtUIVem4w40SgcNus2jL-2cmDPRjeaIBd>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Thu,
 23 Jan 2025 13:12:27 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Stefan Monnier <monnier@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <jwvtt9rkz5y.fsf-monnier+emacs@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
 <jwv8qr3ojbw.fsf-monnier+emacs@HIDDEN>
 <877c6n9tud.fsf@HIDDEN>
 <jwvtt9rkz5y.fsf-monnier+emacs@HIDDEN>
Date: Thu, 23 Jan 2025 19:12:20 +0100
Message-ID: <87jzal5ri3.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: 0.0 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Stefan Monnier <monnier@HIDDEN> writes:

>> The standard says that long lines need to be "folded" (wrapped) by
>> inserting a CR-LF-space sequence. It defines long lines as those longer
>> than 75 *bytes*, and explicitly says that implementations need to handle
>> the case where the line-wrapping sequence occurs in the middle of a
>> multi-byte character. So the only safe way to unwrap lines is before a
>> buffer gets decoded.
>
> Eww!

Yes indeed. 

>> So far the best user interface I could come up with was to check for
>> long lines when icalendar-mode starts and ask the user whether they want
>> to unwrap them. If they do, it re-loads the raw data into a new buffer,
>> unwraps the lines, decodes the buffer, and then re-starts icalendar-mode
>> in the new buffer. But I find this pretty awkward in practice, because
>> you end up with two buffers containing the same data (modulo whitespace)
>> and visiting the same file, and I'm not sure how to improve this.
>
> Maybe strongly encourage the user to save the result back into the
> original file?

Yes, that's already what I do, setting buffer-file-name to point to the
original file in the new buffer as well; and there's a prompt to re-wrap
lines on save. I suppose what I could do is unconditionally kill the old
buffer and then steal its name for the new one (or just erase it and
reload the data into the same buffer), so that from the user's
perspective, it's "the same" buffer. Does that seem better?

> How common is it for multibyte sequences to split in this way?

No idea. Probably not common. 

> Is it always UTF8?

Alas, no. According to the standard, UTF-8 is the "default" encoding,
and implementations must support it, but as far as I can tell, the
standard allows using another encoding via the MIME charset parameter
(I infer this from section 8.1, which mentions the possibility).  

> If it's always UTF8, then multibyte sequences split
> in two *will* result in "eight-bit" byte chars, so you should be able to
> recognize them reliably even in the already-decoded buffer with a regexp
> along the lines of "[\200-\377]+\n [\200-\377]+" and you should then be
> able handle them "directly/locally" without reloading the undecoded file.
>
> Something like:
>
>     (while (re-search-forward "[\200-\377]+\n [\200-\377]+" nil t)
>       (delete-region (1- (line-beginning-position))
>                      (1+ (line-beginning-position)))
>       (decode-coding-region (match-beg 0) (- (match-end 0) 2) 'utf-8))

Hmm, that's an interesting idea, thanks. I will look into plugging this
into the unwrapping code, at least when the coding system is known to be
UTF-8.




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 22 Jan 2025 14:26:07 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Wed Jan 22 09:26:07 2025
Received: from localhost ([127.0.0.1]:32778 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tabgF-0004Y6-1N
	for submit <at> debbugs.gnu.org; Wed, 22 Jan 2025 09:26:07 -0500
Received: from eggs.gnu.org ([2001:470:142:3::10]:45144)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <eliz@HIDDEN>) id 1tabgA-0004XZ-LD
 for 74994 <at> debbugs.gnu.org; Wed, 22 Jan 2025 09:26:05 -0500
Received: from fencepost.gnu.org ([2001:470:142:3::e])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <eliz@HIDDEN>)
 id 1tabg4-0004MP-E5; Wed, 22 Jan 2025 09:25:56 -0500
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=gnu.org;
 s=fencepost-gnu-org; h=References:Subject:In-Reply-To:To:From:Date:
 mime-version; bh=QojHVfYjZmc+k4SaejnNfUgT3ix+45r9jOej0dYIUOs=; b=o3Sb6BCOy8kl
 IHC9edqeqp7JwmVgRh1WI0Na/P8lnn67RqNY+jKMR7XVR5e0euG/2JxsPtGLsUZXOwdFTyOfRj+Aw
 uZMhVuBmwKJteXhnmBpaAG7F9SuPIYMwGssnL8R1cTuCpZoNjQOB0J0NpOsBjyiJf1yP2AbvZJxhS
 C/8ifOXsFzL1DZR2jAyIYwmHeUcDyaKkafYZIDo1NEzQWCb++tlyJ/YS9DmGILYLzJZOCnARGwoPQ
 4jFM5XZ2AKB8Dng0KwK18r0mOUc/nhig3e5yEm61j7rDZGChQBWAKaUK9CNfCAYU5a4wV+aIFdmsb
 TIl/H6YRpbLlr7v1ETzuyw==;
Date: Wed, 22 Jan 2025 16:25:25 +0200
Message-Id: <86r04v2aei.fsf@HIDDEN>
From: Eli Zaretskii <eliz@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
In-Reply-To: <87a5bj9vog.fsf@HIDDEN> (message from Richard
 Lawrence on Wed, 22 Jan 2025 08:03:59 +0100)
Subject: Re: bug#74994: Improve Emacs iCalendar support
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
 <87a5bj9vog.fsf@HIDDEN>
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: monnier@HIDDEN, 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

> From: Richard Lawrence <rwl@HIDDEN>
> Cc: 74994 <at> debbugs.gnu.org
> Date: Wed, 22 Jan 2025 08:03:59 +0100
> 
> Eli Zaretskii <eliz@HIDDEN> writes:
> 
> >> From: Richard Lawrence <rwl@HIDDEN>
> 
> >> I've been thinking it would make sense eventually to:
> >>   - rename icalendar.el to something like diary-icalendar.el
> >>   - rewrite the code there to use the new parser in icalendar-parser.el
> >>   - rename the user-facing functions
> >>     (icalendar-{import/export}-file and their variants)
> >>     to make clear that they are diary-specific
> >>   - delete(?)/deprecate the old parsing code
> >
> > I'd prefer to do this without renaming the old file.  That is, rename
> > your new implementation to, say, icalendar2.el.  The rest of your plan
> > sounds good to me.
> 
> Is this more about maintaining the source history, or about not wanting
> to break the setups of people who have (require 'icalendar) or similar
> in their configuration?

The former.  As good as current VCS forensic tools are, some of the
relevant VCS commands still don't work across renames.  So it is
preferable to rename only when necessary.

> So far, all my new code lives in files with names like
> icalendar-parser.el and there is no contention for the name
> icalendar.el.
> 
> My thought was that icalendar.el could/should primarily serve as a
> top-level module for the library, require-ing all the new modules
> itself, so that users of the library can just (require 'icalendar) and
> not have to worry about how the code is organized.
> 
> My reason for "renaming" was simply to move the existing diary-specific
> parts of icalendar.el (which is most of the file) to diary-icalendar.el,
> to be consistent with gnus-icalendar.el in Gnus, ox-icalendar.el in Org,
> and the organization of the other new files.

If there's a lot of stuff to be moved out of the file, I think it's
better to leave the file alone, and instead start new files (as you
have evidently done).




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 22 Jan 2025 09:12:13 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Wed Jan 22 04:12:13 2025
Received: from localhost ([127.0.0.1]:59806 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1taWmT-0004ZJ-73
	for submit <at> debbugs.gnu.org; Wed, 22 Jan 2025 04:12:13 -0500
Received: from mailscanner.iro.umontreal.ca ([132.204.25.50]:24418)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <monnier@HIDDEN>)
 id 1taWmQ-0004Yx-P4
 for 74994 <at> debbugs.gnu.org; Wed, 22 Jan 2025 04:12:11 -0500
Received: from pmg3.iro.umontreal.ca (localhost [127.0.0.1])
 by pmg3.iro.umontreal.ca (Proxmox) with ESMTP id 279C74412C5;
 Wed, 22 Jan 2025 04:12:04 -0500 (EST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=iro.umontreal.ca;
 s=mail; t=1737537122;
 bh=3ItEQjucevfz8Ed9qW015M9qTeCLxKsHZF7KUa3r/SI=;
 h=From:To:Cc:Subject:In-Reply-To:References:Date:From;
 b=m6xPxdJN3IKbGdty6bcMQWz5u3iQW22zbTmGdBMWuL2siRowBnKa81orNEdGMzdo1
 3qXqHdpQYYr/Yx8eYL5D6KocNuwwLzq2aXAidLQf9cMXNpSniPBC9DIsy67MBgrx5K
 xalJ84zIpOjwei5Yax+ctvOh+TUeTu7aQ1Xv+nOihuJ9LWEVM0uHR9dDSb4l44qrRK
 nHSf7oD88e8jsEdFAIbCMksi6jOpt5pPbMV2gC0MlQGTmKfWRPnlLhQ8LEOMlmqJlO
 bkJYf/Aq5f4TBE9qBETvV1JysTJ2nTrMmWGMfYJAEQrlNCgZIZpF7TresE0/wTtUJr
 yvCEMMzkw1/nw==
Received: from mail01.iro.umontreal.ca (unknown [172.31.2.1])
 by pmg3.iro.umontreal.ca (Proxmox) with ESMTP id 9376244126E;
 Wed, 22 Jan 2025 04:12:02 -0500 (EST)
Received: from asado (dyn.144-85-147-102.dsl.vtx.ch [144.85.147.102])
 by mail01.iro.umontreal.ca (Postfix) with ESMTPSA id 7C642120A47;
 Wed, 22 Jan 2025 04:12:01 -0500 (EST)
From: Stefan Monnier <monnier@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <877c6n9tud.fsf@HIDDEN> (Richard Lawrence's message
 of "Wed, 22 Jan 2025 08:43:38 +0100")
Message-ID: <jwvtt9rkz5y.fsf-monnier+emacs@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
 <jwv8qr3ojbw.fsf-monnier+emacs@HIDDEN>
 <877c6n9tud.fsf@HIDDEN>
Date: Wed, 22 Jan 2025 04:11:59 -0500
User-Agent: Gnus/5.13 (Gnus v5.13)
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
X-SPAM-INFO: Spam detection results:  0
 ALL_TRUSTED                -1 Passed through trusted hosts only via SMTP
 AWL 0.172 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DKIM_SIGNED               0.1 Message has a DKIM or DK signature,
 not necessarily valid
 DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature
 DKIM_VALID_AU -0.1 Message has a valid DKIM or DK signature from author's
 domain
 DKIM_VALID_EF -0.1 Message has a valid DKIM or DK signature from envelope-from
 domain
X-SPAM-LEVEL: 
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: Eli Zaretskii <eliz@HIDDEN>, 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

>> In `bindat.el` I used a similar approach except that each construct (I
>> guess in your case, that means each "type") is stored as a method (in
>> a generic function) instead of a property of a symbol.  I'm not sure
>> it's the perfect solution, but it's nice that `C-h o` on the generic
>> function can then provide a documentation of each of the constructs.
>
> This would mean relying more heavily on cl-lib, correct? Generic
> functions and methods are part of cl-lib's CLOS implementation?

No.  They (ab)use the "cl-" prefix for historical reasons (EIEIO had
already used the non-prefixed `defgeneric/defmethod` names), but
`cl-generic.el` is not part of cl-lib (and it is preloaded into Emacs).

> C-h o already works with my code (see the describe-symbol backend at the
> end of icalendar-parser.el), but maybe the generic functions approach is
> cleaner. I'll think about it.
>
>> Other options we use elsewhere is to use function names constructed from
>> a constant prefix plus the name of the construct, so instead of
>>
>>     (funcall (get 'foo 'bar) ...)
>>
>> you might be able to macroexpand to something like
>>
>>     (,(intern (format "bar %s" 'foo)) ...)
>>
>> so you get (for free) compile-time warnings when using a construct that
>> doesn't exist, and you avoid a `get` at runtime (IIRC, we use that
>> approach in `peg.el`).
> I hadn't thought of that. Would this prevent users of the library from
> defining new types after the library is compiled, though?

No, tho when the above `'foo` part is not a constant but is computed
dynamically, the code is less efficient than a funcall+get.

> It's certainly useful when debugging. Calling a pure function with M-:
> or e in the debugger to make sure it's doing what I expect is generally
> a lot easier than getting a whole buffer into the right parsing state.

=F0=9F=99=82

> If I can declare them pure, it might also have some performance
> benefits.

I'd be surprised if it makes a measurable difference, tho.
The debugging argument is much more compelling.

> The standard says that long lines need to be "folded" (wrapped) by
> inserting a CR-LF-space sequence. It defines long lines as those longer
> than 75 *bytes*, and explicitly says that implementations need to handle
> the case where the line-wrapping sequence occurs in the middle of a
> multi-byte character. So the only safe way to unwrap lines is before a
> buffer gets decoded.

Eww!

> So far the best user interface I could come up with was to check for
> long lines when icalendar-mode starts and ask the user whether they want
> to unwrap them. If they do, it re-loads the raw data into a new buffer,
> unwraps the lines, decodes the buffer, and then re-starts icalendar-mode
> in the new buffer. But I find this pretty awkward in practice, because
> you end up with two buffers containing the same data (modulo whitespace)
> and visiting the same file, and I'm not sure how to improve this.

Maybe strongly encourage the user to save the result back into the
original file?

How common is it for multibyte sequences to split in this way?

Is it always UTF8?  If it's always UTF8, then multibyte sequences split
in two *will* result in "eight-bit" byte chars, so you should be able to
recognize them reliably even in the already-decoded buffer with a regexp
along the lines of "[\200-\377]+\n [\200-\377]+" and you should then be
able handle them "directly/locally" without reloading the undecoded file.
Something like:

    (while (re-search-forward "[\200-\377]+\n [\200-\377]+" nil t)
      (delete-region (1- (line-beginning-position))
                     (1+ (line-beginning-position)))
      (decode-coding-region (match-beg 0) (- (match-end 0) 2) 'utf-8))


- Stefan





Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 22 Jan 2025 07:44:00 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Wed Jan 22 02:43:59 2025
Received: from localhost ([127.0.0.1]:59499 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1taVP5-00081a-Bn
	for submit <at> debbugs.gnu.org; Wed, 22 Jan 2025 02:43:59 -0500
Received: from fout-b4-smtp.messagingengine.com ([202.12.124.147]:49983)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1taVP2-00081D-6v
 for 74994 <at> debbugs.gnu.org; Wed, 22 Jan 2025 02:43:56 -0500
Received: from phl-compute-06.internal (phl-compute-06.phl.internal
 [10.202.2.46])
 by mailfout.stl.internal (Postfix) with ESMTP id 8B8C41140223;
 Wed, 22 Jan 2025 02:43:49 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-06.internal (MEProxy); Wed, 22 Jan 2025 02:43:49 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1737531829;
 x=1737618229; bh=CxhpZ1xlskALBIhX71njrwuObovW/VEzLpXLQQyX6O0=; b=
 Dc/8ogX/rT7fLhitymtm8CxWt1wH1k9xMEd3gAhTUfiX/Ydr10Icvt+T+FAFifoV
 yoV5BPBGRetO3M87B52gA1k532lNio1LNSkto3dWZ8U022JjVP8D/WRGRmO0Fz5k
 SRFQpr01o69vggqXV4io+i9iXNCT2asJ423oCM6E7nz7py5PDPqBlPwJL25548cI
 PkLeKrB+BscfsIL7n9u5hEudRncNRXlwHHzgAOC9+VZgK74A2e6YXPOeNhCaqwL5
 +lguKL1tA+lmRPic6W8NTmX4yNigllkR0kRNU0S5FD3XlmSJgIp7KxS/00hRdHMd
 qKrnwVbMxwDyWCTu0m8mAA==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1737531829; x=1737618229; bh=CxhpZ1xlskALBIhX71njrwuObovW/VEzLpX
 LQQyX6O0=; b=Z3fPZBJd+Bk1YrE5/Wn+tv6PZ/uB8vkA657v22xal8i9aNcg1wa
 ojtgXRaTRJ3YMcAO0iOcS0knWodJLQIkAAnTQH4hVkrYhqZew8GN9LlUE0Byjc3S
 nNgxc+s6LY9BZ90VThVW3PUKSgrcYmFXE49i8hkEosIyAzW3xz44HnTuCWwwNmTT
 ZRO6BcF1U6nYHRAGcnr3O/a2RuRcyjKfjPROXO6eCoE9GlG1WggfanGWsNYuLS47
 yxQoVne2c6liobrYG/ovO36dL1WN4AteV767EgXUsZiFTQBq3JZTnyTk7SV6We4V
 cQ4c2f7xGdvIGH0xDERhEdyrKT4RCFOUmTQ==
X-ME-Sender: <xms:tKGQZ5Anc822FmmRJLeftVPVY69B6UQyk3h_LjV8b5hWGUd6-0dl3Q>
 <xme:tKGQZ3hUSZCYT0uILnwHPvFryo5W-f2XRf5L4xfiBpC3LLO1AWnYM5R6CJC8ikFd9
 _bpmnNrK9ZHW8QUlA>
X-ME-Received: <xmr:tKGQZ0kfHTd1i5ftyfe6Rps3qc8lnnk64m4VuJavlduEBhXv800qYrWJpA>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddrudejfedguddtlecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecusecvtfgvtghiphhivg
 hnthhsucdlqddutddtmdenucfjughrpefhvfevufgjfhffkfggtgesthdtredttddttden
 ucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggvuceorhiflhesrhgvtghurhhsvg
 ifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvghrnhepfeeuffdvfeeffedtuedt
 gfejtedtudevuddufeejtdekjefgheehvdfggfffudfhnecuvehluhhsthgvrhfuihiivg
 eptdenucfrrghrrghmpehmrghilhhfrhhomheprhiflhesrhgvtghurhhsvgifihhthhhl
 vghsshdrnhgvthdpnhgspghrtghpthhtohepfedpmhhouggvpehsmhhtphhouhhtpdhrtg
 hpthhtohepmhhonhhnihgvrhesihhrohdruhhmohhnthhrvggrlhdrtggrpdhrtghpthht
 ohepvghlihiisehgnhhurdhorhhgpdhrtghpthhtohepjeegleelgeesuggvsggsuhhgsh
 drghhnuhdrohhrgh
X-ME-Proxy: <xmx:taGQZzxUsQsTtOM4zPFG9-FqC26buF6cF-27BNxU7b7Rkin3qurRvw>
 <xmx:taGQZ-SJwjeZEZ4ThL7hWecuinCbYanYa0hLbdKdhFH1e9aAR51uHQ>
 <xmx:taGQZ2YqztxRbUAT0x9e5uFRxDGT9-4FetHVsnLBYo_5QEscpLwhmg>
 <xmx:taGQZ_RChTrSIhMPIBNS1qWC_nEK9xgZIsisL11yoGsUoIFPTDCKJQ>
 <xmx:taGQZ8c2T-Mpm3BFpK4f8y0cNXG02O32pifGD8-AagFDRZELPB_IdORb>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Wed,
 22 Jan 2025 02:43:48 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Stefan Monnier <monnier@HIDDEN>, Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <jwv8qr3ojbw.fsf-monnier+emacs@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
 <jwv8qr3ojbw.fsf-monnier+emacs@HIDDEN>
Date: Wed, 22 Jan 2025 08:43:38 +0100
Message-ID: <877c6n9tud.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Hi Stefan,

thanks for your feedback!

Stefan Monnier <monnier@HIDDEN> writes:

>>>   - I've used macros (see icalendar-macs.el) to create a small "DSL" for
>>>     defining iCalendar types. These macros store parsing-related information for
>>>     each type as properties of the symbols which name them. There's a lot of
>>>     dynamic dispatch in the parser based on these type symbols' properties.
>>>     This adds some complexity but (I hope) makes the parser more "atomic"/
>>>     extensible. Does this seem like a reasonable approach in general?
>
> It sounds like a reasonable design, yes.
>
> In `bindat.el` I used a similar approach except that each construct (I
> guess in your case, that means each "type") is stored as a method (in
> a generic function) instead of a property of a symbol.  I'm not sure
> it's the perfect solution, but it's nice that `C-h o` on the generic
> function can then provide a documentation of each of the constructs.

This would mean relying more heavily on cl-lib, correct? Generic
functions and methods are part of cl-lib's CLOS implementation?

C-h o already works with my code (see the describe-symbol backend at the
end of icalendar-parser.el), but maybe the generic functions approach is
cleaner. I'll think about it.

> Other options we use elsewhere is to use function names constructed from
> a constant prefix plus the name of the construct, so instead of
>
>     (funcall (get 'foo 'bar) ...)
>
> you might be able to macroexpand to something like
>
>     (,(intern (format "bar %s" 'foo)) ...)
>
> so you get (for free) compile-time warnings when using a construct that
> doesn't exist, and you avoid a `get` at runtime (IIRC, we use that
> approach in `peg.el`).

I hadn't thought of that. Would this prevent users of the library from
defining new types after the library is compiled, though? The iCalendar
standard allows extensions in "X-" properties and components; I don't
want to do anything that would make it difficult e.g. for Org to use
these to encode its own data structures.

>>>   - I ran into one issue that feels like a design flaw: the parser separates
>>>     "reading" (converting a string to an Elisp value) into a function
>>>     distinct from the parsing function which matches that string (see e.g.
>>>     ical:parse-property-value in icalendar-parser.el, which calls
>>>     ical:read-property-value). In simple cases this nicely factors out a pure
>>>     function from one which depends on a lot of global buffer state;
>>>     but in more complicated cases the "pure" reader function depends on
>>>     the match data and so isn't pure at all (see e.g. ical:read-dur-value).
>>>     Is there a better way to do this? (Not make the distinction? Pass
>>>     the match data explicitly? ...?)
>
> Is the separation useful to users (including internal users) of the
> parser? This kind of problem doesn't directly ring a bell, so I don't
> have a good suggestion to make.

It's certainly useful when debugging. Calling a pure function with M-:
or e in the debugger to make sure it's doing what I expect is generally
a lot easier than getting a whole buffer into the right parsing state.
If I can declare them pure, it might also have some performance
benefits.

>>>   - whether there's a better solution to the problem of needing to unfold
>>>     lines *before* a buffer containing iCalendar data is decoded
>>>     (is there anything like a hook that runs before decoding?)
>
> [ Sorry, I don't understand this question.  ]

The standard says that long lines need to be "folded" (wrapped) by
inserting a CR-LF-space sequence. It defines long lines as those longer
than 75 *bytes*, and explicitly says that implementations need to handle
the case where the line-wrapping sequence occurs in the middle of a
multi-byte character. So the only safe way to unwrap lines is before a
buffer gets decoded.

So far the best user interface I could come up with was to check for
long lines when icalendar-mode starts and ask the user whether they want
to unwrap them. If they do, it re-loads the raw data into a new buffer,
unwraps the lines, decodes the buffer, and then re-starts icalendar-mode
in the new buffer. But I find this pretty awkward in practice, because
you end up with two buffers containing the same data (modulo whitespace)
and visiting the same file, and I'm not sure how to improve this.

Thanks again for your thoughts!

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 22 Jan 2025 07:04:14 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Wed Jan 22 02:04:14 2025
Received: from localhost ([127.0.0.1]:59367 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1taUmc-0005lE-Dc
	for submit <at> debbugs.gnu.org; Wed, 22 Jan 2025 02:04:14 -0500
Received: from fhigh-b3-smtp.messagingengine.com ([202.12.124.154]:39233)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1taUma-0005ky-5I
 for 74994 <at> debbugs.gnu.org; Wed, 22 Jan 2025 02:04:12 -0500
Received: from phl-compute-05.internal (phl-compute-05.phl.internal
 [10.202.2.45])
 by mailfhigh.stl.internal (Postfix) with ESMTP id 7DB71254016E;
 Wed, 22 Jan 2025 02:04:06 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-05.internal (MEProxy); Wed, 22 Jan 2025 02:04:06 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm2; t=1737529446;
 x=1737615846; bh=KQWpARak5lQ1dBpjMHnmbZFYrgXGuMb+iR6qm4HLBv8=; b=
 0EZCqwTbj5jRxhxzCplomUsQv2YILniY+1Oa2Ip0Uoc22RkrgZR+KzRz/kd50O4T
 VeVa3lqNb8vIuKFInx12uPmgw5ZQPqQ23icg1cMutlrRRoKXI8U5MwrSWxGwVANF
 ufJ3cUxfl2+dkGoAebkmwyvFTtV6xiOLqocwsPHNPWVeyks1j8qU3CvQ5JG2baJ5
 kwg/ReiJJ9+Y+tBqtStHV6k0cqOB4//CFRY+jJ9cevo4ySFkbGNP3fDcMgFtkQmB
 dWHRAH6fPZyVWVnZnl4QlBZNh5VBASq2HIJ7T0xWGWLuu9ruAimdbphwqAwEJ0yu
 /p1C37Q22b/JdfR9m4a1IQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm3; t=
 1737529446; x=1737615846; bh=KQWpARak5lQ1dBpjMHnmbZFYrgXGuMb+iR6
 qm4HLBv8=; b=BxoTLyzT58tNINpnKKd49UHws6BQgts0sOP2HY6zr8q2Bxxsmso
 QoSolROpMjONRTKfhlKU5jY6hLtt9J2cdgyJg41mWZ2qGU7V5Upn7SE1swHuoIAV
 za2MjvPAAMTjkx8+vjfegQoud/U8kbO5KtN63dd9AEYmivwhAbgM5A/CkMQU3Ol0
 yvNLWrE0JjBXXhmuc1oFXjGnb7x7C5jioviDi8mKZzJmmzXlkoJg89vn1cKuVcU+
 IfNPv9J5UUJ/K8AyYastrGOFhxJIDjBYbMylk5VkNGSPiN8E/k5tUe9uM2+CXr8o
 ne7d4W7iXOnNhuyF8MpQdZN+FCH1ytJPjIQ==
X-ME-Sender: <xms:ZpiQZyExA9YiffXCf67qoayEeHai1B2Hbyn5kos9sL9uqoQRviz96Q>
 <xme:ZpiQZzWt4S10s1tpxivendqMYwYuQhsJOd_nucWE3IlRfltP3biuClZAca3Q5zeTm
 Zg7e_sUlzOrp00kfQ>
X-ME-Received: <xmr:ZpiQZ8K18OQvbFMj45RBJDs1qRxr8Hp6zfPnZXdKApYpBkz2NFmsE3rOvA>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddrudejfedguddtudcutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecusecvtfgvtghiphhivg
 hnthhsucdlqddutddtmdenucfjughrpefhvfevufgjfhffkfggtgesthdtredttddttden
 ucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggvuceorhiflhesrhgvtghurhhsvg
 ifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvghrnhepvdegtdfhffefhfefjeeu
 ueejheegffdvkeeiieeugfdugefhueejleegheeufefhnecuffhomhgrihhnpegvlhdrmh
 ihnecuvehluhhsthgvrhfuihiivgeptdenucfrrghrrghmpehmrghilhhfrhhomheprhif
 lhesrhgvtghurhhsvgifihhthhhlvghsshdrnhgvthdpnhgspghrtghpthhtohepfedpmh
 houggvpehsmhhtphhouhhtpdhrtghpthhtohepvghlihiisehgnhhurdhorhhgpdhrtghp
 thhtohepmhhonhhnihgvrhesihhrohdruhhmohhnthhrvggrlhdrtggrpdhrtghpthhtoh
 epjeegleelgeesuggvsggsuhhgshdrghhnuhdrohhrgh
X-ME-Proxy: <xmx:ZpiQZ8Ebhc2q5QXPV3yPbGMjOMD3jZRNtU9nmxKgMARULxJHqVi4eg>
 <xmx:ZpiQZ4X7y0hfLGdaoer7Exoi-vrsLWbiVkIyEsl4rkBVezIkE3AQZw>
 <xmx:ZpiQZ_MxVvjhRHkBVviCBGFeMY0oubNA5VYWjT8oUJ8JECvh7bYJOQ>
 <xmx:ZpiQZ_0nNWaPf2knCZbuPKHUeTQsDPoBSpGutZ-M7vHQC3UhsgGrIg>
 <xmx:ZpiQZ_TC6ZZMJkJEfnQbxwbtM8MU-rc-akGIVEdVlq0s4xtKX13tmNyX>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Wed,
 22 Jan 2025 02:04:05 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>, Stefan Monnier <monnier@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <86ldv57kcf.fsf@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
Date: Wed, 22 Jan 2025 08:03:59 +0100
Message-ID: <87a5bj9vog.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.8 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.8 (-)

Eli Zaretskii <eliz@HIDDEN> writes:

>> From: Richard Lawrence <rwl@HIDDEN>

>> I've been thinking it would make sense eventually to:
>>   - rename icalendar.el to something like diary-icalendar.el
>>   - rewrite the code there to use the new parser in icalendar-parser.el
>>   - rename the user-facing functions
>>     (icalendar-{import/export}-file and their variants)
>>     to make clear that they are diary-specific
>>   - delete(?)/deprecate the old parsing code
>
> I'd prefer to do this without renaming the old file.  That is, rename
> your new implementation to, say, icalendar2.el.  The rest of your plan
> sounds good to me.

Is this more about maintaining the source history, or about not wanting
to break the setups of people who have (require 'icalendar) or similar
in their configuration?

So far, all my new code lives in files with names like
icalendar-parser.el and there is no contention for the name
icalendar.el.

My thought was that icalendar.el could/should primarily serve as a
top-level module for the library, require-ing all the new modules
itself, so that users of the library can just (require 'icalendar) and
not have to worry about how the code is organized.

My reason for "renaming" was simply to move the existing diary-specific
parts of icalendar.el (which is most of the file) to diary-icalendar.el,
to be consistent with gnus-icalendar.el in Gnus, ox-icalendar.el in Org,
and the organization of the other new files.

If backward compatibility for users is the concern, icalendar.el could
itself (require 'diary-icalendar) and then there's no problem.

If instead it's about keeping the old code in the same file for the sake
of history, I understand; I don't have to move it, just thought it would
be cleaner if I did.




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 21 Jan 2025 23:33:33 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Tue Jan 21 18:33:33 2025
Received: from localhost ([127.0.0.1]:58057 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1taNkT-0006Na-0c
	for submit <at> debbugs.gnu.org; Tue, 21 Jan 2025 18:33:33 -0500
Received: from mailscanner.iro.umontreal.ca ([132.204.25.50]:22244)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <monnier@HIDDEN>)
 id 1taNkA-0006MR-9C
 for 74994 <at> debbugs.gnu.org; Tue, 21 Jan 2025 18:33:18 -0500
Received: from pmg2.iro.umontreal.ca (localhost.localdomain [127.0.0.1])
 by pmg2.iro.umontreal.ca (Proxmox) with ESMTP id 282D4807D7;
 Tue, 21 Jan 2025 18:33:08 -0500 (EST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=iro.umontreal.ca;
 s=mail; t=1737502387;
 bh=NAWYTY/8Ls9psbbN2bmDPkuMZJ94OdbLmeb7QFodrgk=;
 h=From:To:Cc:Subject:In-Reply-To:References:Date:From;
 b=f0WtBhUtH5dRWaRCb2FXJwlB0dbFb3NNy7YIxAtCzGI6+L4va8IyxB2IEjv7c4kVn
 Q22UNo0A7A/DkFhpUfQ6e3hWNd5OTssqqGr1/kNZ7XzBzX77c2LMBNaunbYNx77Sce
 kC3jfuikrJDSDeFyQbGXFsrgqYNAyelyVE0XhHYAclxJQuCq+Ls6ITaJl/PYT65g/H
 3PPnLdca1OmcgrV5JXEJASmnQ67RAUnbv3eCKXehT8OBBb1qyfsw8OaH2QHYpmQZnh
 +MLHFtrcAxxyJnAiPh/kFJISFpNsqqxhbZFR3eX5vUtOv/GOGz1svDTx9Ro9uPcuJn
 yrFg5lYeFqiWA==
Received: from mail01.iro.umontreal.ca (unknown [172.31.2.1])
 by pmg2.iro.umontreal.ca (Proxmox) with ESMTP id 1F5988027D;
 Tue, 21 Jan 2025 18:33:07 -0500 (EST)
Received: from asado (dyn.144-85-147-102.dsl.vtx.ch [144.85.147.102])
 by mail01.iro.umontreal.ca (Postfix) with ESMTPSA id 7047112033F;
 Tue, 21 Jan 2025 18:33:05 -0500 (EST)
From: Stefan Monnier <monnier@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <86ldv57kcf.fsf@HIDDEN> (Eli Zaretskii's message of "Mon, 20 Jan
 2025 14:14:56 +0200")
Message-ID: <jwv8qr3ojbw.fsf-monnier+emacs@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN> <86ldv57kcf.fsf@HIDDEN>
Date: Tue, 21 Jan 2025 18:33:02 -0500
User-Agent: Gnus/5.13 (Gnus v5.13)
MIME-Version: 1.0
Content-Type: text/plain
X-SPAM-INFO: Spam detection results:  0
 ALL_TRUSTED                -1 Passed through trusted hosts only via SMTP
 AWL 0.159 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DKIM_SIGNED               0.1 Message has a DKIM or DK signature,
 not necessarily valid
 DKIM_VALID -0.1 Message has at least one valid DKIM or DK signature
 DKIM_VALID_AU -0.1 Message has a valid DKIM or DK signature from author's
 domain
 DKIM_VALID_EF -0.1 Message has a valid DKIM or DK signature from envelope-from
 domain
X-SPAM-LEVEL: 
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org, Richard Lawrence <rwl@HIDDEN>
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

>> I've been thinking it would make sense eventually to:
>>   - rename icalendar.el to something like diary-icalendar.el
>>   - rewrite the code there to use the new parser in icalendar-parser.el

I don't have an opinion on the file naming part, but I suggest you scrap
the "eventually" above, otherwise there's a strong risk it'll never
happen (either for lack of interest or because the API that doesn't
quite fit).

>> 2) It would be nice if someone who has more experience writing parsers
>> could give me some feedback about the overall design of the parser. In
>> particular:

[ Note: I have not yet had time to look at the code.  ]

>>   - I've used macros (see icalendar-macs.el) to create a small "DSL" for
>>     defining iCalendar types. These macros store parsing-related information for
>>     each type as properties of the symbols which name them. There's a lot of
>>     dynamic dispatch in the parser based on these type symbols' properties.
>>     This adds some complexity but (I hope) makes the parser more "atomic"/
>>     extensible. Does this seem like a reasonable approach in general?

It sounds like a reasonable design, yes.

In `bindat.el` I used a similar approach except that each construct (I
guess in your case, that means each "type") is stored as a method (in
a generic function) instead of a property of a symbol.  I'm not sure
it's the perfect solution, but it's nice that `C-h o` on the generic
function can then provide a documentation of each of the constructs.

Other options we use elsewhere is to use function names constructed from
a constant prefix plus the name of the construct, so instead of

    (funcall (get 'foo 'bar) ...)

you might be able to macroexpand to something like

    (,(intern (format "bar %s" 'foo)) ...)

so you get (for free) compile-time warnings when using a construct that
doesn't exist, and you avoid a `get` at runtime (IIRC, we use that
approach in `peg.el`).

>>   - I ran into one issue that feels like a design flaw: the parser separates
>>     "reading" (converting a string to an Elisp value) into a function
>>     distinct from the parsing function which matches that string (see e.g.
>>     ical:parse-property-value in icalendar-parser.el, which calls
>>     ical:read-property-value). In simple cases this nicely factors out a pure
>>     function from one which depends on a lot of global buffer state;
>>     but in more complicated cases the "pure" reader function depends on
>>     the match data and so isn't pure at all (see e.g. ical:read-dur-value).
>>     Is there a better way to do this? (Not make the distinction? Pass
>>     the match data explicitly? ...?)

Is the separation useful to users (including internal users) of the
parser?  This kind of problem doesn't directly ring a bell, so I don't
have a good suggestion to make.  Maybe when I get time to read the
code...  In the mean time, I can just mention that it reminds of the PEG
parsers generated by `peg.el` which use a limited form of backtracking:
they have a pure(ish) part that "explores" and accumulates "actions to
perform if we find a match" and only once we found a global parse do we
actually run the (side-effecting) actions which might build a parse tree.

>>   - whether an icalendar-mode is even a useful thing to have, and what
>>     could make it more useful

I don't think we need to be shy about major modes.  If you don't use it,
it doesn't cost much (because it's not loaded), so count me as "+1".

>>   - whether the approach to syntax highlighting is reasonable (the
>>     icalendar-define-* macros define matching functions for each
>>     individual icalendar type, which are eventually piled
>>     into a long list of matchers in the mode's value for font-lock-keywords)
>>   - what still needs to be done to make the code in icalendar-mode.el
>>     compliant with Emacs' major mode conventions (see the long list of
>>     TODOs in the mode definition)

[ Will see when I finally read the code.  ]

>>   - whether there's a better solution to the problem of needing to unfold
>>     lines *before* a buffer containing iCalendar data is decoded
>>     (is there anything like a hook that runs before decoding?)

[ Sorry, I don't understand this question.  ]


        Stefan





Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 20 Jan 2025 12:15:24 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Mon Jan 20 07:15:24 2025
Received: from localhost ([127.0.0.1]:49258 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tZqgd-0005lV-OD
	for submit <at> debbugs.gnu.org; Mon, 20 Jan 2025 07:15:24 -0500
Received: from eggs.gnu.org ([2001:470:142:3::10]:56986)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <eliz@HIDDEN>) id 1tZqga-0005l4-PT
 for 74994 <at> debbugs.gnu.org; Mon, 20 Jan 2025 07:15:21 -0500
Received: from fencepost.gnu.org ([2001:470:142:3::e])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <eliz@HIDDEN>)
 id 1tZqgU-0000dk-Bv; Mon, 20 Jan 2025 07:15:14 -0500
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=gnu.org;
 s=fencepost-gnu-org; h=References:Subject:In-Reply-To:To:From:Date:
 mime-version; bh=lI/9ma1Nh0ZczX+81HnwNduSTNTtKK+EzU5oNC06N+k=; b=R178uyUTUF6A
 6kp6qHdMn823cIVfwyYm4iCMURmGlCwoEQ56c6kzOsAo1EqnqDO/BTDwxOmYpmgh9EjS955qnLY+K
 7Ue3SP7fe4Jpo0kXVA1p/ZzmDD+Nhxq1gi9uLEY2Arfd7JgKrBDLWk+u57lIFmVyp9G3iuklKTjui
 vElA/VgAufuiqB44uHvhUrHB6yafHmlbdkjwPgxpe1iRB/YeW5RaqDYB0ARaSh85BUbwzArJ9YksS
 tf0+UT3Wmc02jdcwJyAWA//kUxMIPyVr5McBd7rr02FY6EUrN1XC67JZMF3XamRUO3AxXGaUXJCNE
 GbH4cAGwKv+VVIqp8PJvLg==;
Date: Mon, 20 Jan 2025 14:14:56 +0200
Message-Id: <86ldv57kcf.fsf@HIDDEN>
From: Eli Zaretskii <eliz@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>,
 Stefan Monnier <monnier@HIDDEN>
In-Reply-To: <87r04ya6xn.fsf@HIDDEN> (message from Richard
 Lawrence on Sun, 19 Jan 2025 21:24:04 +0100)
Subject: Re: bug#74994: Improve Emacs iCalendar support
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
 <87r04ya6xn.fsf@HIDDEN>
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

> From: Richard Lawrence <rwl@HIDDEN>
> Cc: 74994 <at> debbugs.gnu.org
> Date: Sun, 19 Jan 2025 21:24:04 +0100
> 
> 1) Is it alright that this is a ground-up rewrite, rather than an
> evolution of icalendar.el? And how should I be thinking about the future
> of icalendar.el?
> 
> Having written this code, I can now understand and appreciate some of
> the code in icalendar.el better, but am still of the opinion that it is
> too closely tied to diary import/export to be as useful to other parts
> of Emacs (especially Org and Gnus) as it could be.

If you still think it's better to have a complete implementation, I
think you've answered your question, and I don't see anyone around who
knows this better and could challenge your opinions.

> I've been thinking it would make sense eventually to:
>   - rename icalendar.el to something like diary-icalendar.el
>   - rewrite the code there to use the new parser in icalendar-parser.el
>   - rename the user-facing functions
>     (icalendar-{import/export}-file and their variants)
>     to make clear that they are diary-specific
>   - delete(?)/deprecate the old parsing code

I'd prefer to do this without renaming the old file.  That is, rename
your new implementation to, say, icalendar2.el.  The rest of your plan
sounds good to me.

> 2) It would be nice if someone who has more experience writing parsers
> could give me some feedback about the overall design of the parser. In
> particular:
> 
>   - I've used macros (see icalendar-macs.el) to create a small "DSL" for
>     defining iCalendar types. These macros store parsing-related information for
>     each type as properties of the symbols which name them. There's a lot of
>     dynamic dispatch in the parser based on these type symbols' properties.
>     This adds some complexity but (I hope) makes the parser more "atomic"/
>     extensible. Does this seem like a reasonable approach in general?
>   - I ran into one issue that feels like a design flaw: the parser separates
>     "reading" (converting a string to an Elisp value) into a function
>     distinct from the parsing function which matches that string (see e.g.
>     ical:parse-property-value in icalendar-parser.el, which calls
>     ical:read-property-value). In simple cases this nicely factors out a pure
>     function from one which depends on a lot of global buffer state;
>     but in more complicated cases the "pure" reader function depends on
>     the match data and so isn't pure at all (see e.g. ical:read-dur-value).
>     Is there a better way to do this? (Not make the distinction? Pass
>     the match data explicitly? ...?)
>   - I haven't really thought about performance at all. My parser is
>     definitely slower/more memory intensive than icalendar.el's at the moment
>     (though hopefully this is mostly because it has more to offer). Are there
>     any things I should already be thinking about to make it more efficient?
> 
> 3) Likewise, part of my patch was icalendar-mode.el, and it would be
> useful to have some feedback about the major mode:
> 
>   - whether an icalendar-mode is even a useful thing to have, and what
>     could make it more useful
>   - whether the approach to syntax highlighting is reasonable (the
>     icalendar-define-* macros define matching functions for each
>     individual icalendar type, which are eventually piled
>     into a long list of matchers in the mode's value for font-lock-keywords)
>   - whether there's a better solution to the problem of needing to unfold
>     lines *before* a buffer containing iCalendar data is decoded
>     (is there anything like a hook that runs before decoding?)
>   - what still needs to be done to make the code in icalendar-mode.el
>     compliant with Emacs' major mode conventions (see the long list of
>     TODOs in the mode definition)

I added Stefan to the discussion, in the hope that he could give you
some useful advice about (some of) these aspects.

> > I can see already that your doc strings are not always according to
> > our conventions (the first line must be a single complete sentence),
> > and also the commit log messages need to be more detailed.  But these
> > are aspects best deferred until you have a version close to the final
> > one, now is too early for that.
> 
> OK, but that's good to keep in mind, thanks.
> 
> (Re: commit messages: should I be making commits in Emacs' ChangeLog
> format as I go along, and post a whole new patch series here after
> revisions? Or does it make more sense to squash everything at the end
> into one or a few commits, and only format the log messages at that
> point?)

We usually do the latter.




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 19 Jan 2025 20:24:24 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sun Jan 19 15:24:24 2025
Received: from localhost ([127.0.0.1]:47556 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tZbqJ-0007iP-Uf
	for submit <at> debbugs.gnu.org; Sun, 19 Jan 2025 15:24:24 -0500
Received: from fhigh-a7-smtp.messagingengine.com ([103.168.172.158]:59079)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1tZbqG-0007i7-R4
 for 74994 <at> debbugs.gnu.org; Sun, 19 Jan 2025 15:24:21 -0500
Received: from phl-compute-10.internal (phl-compute-10.phl.internal
 [10.202.2.50])
 by mailfhigh.phl.internal (Postfix) with ESMTP id 8087011400D9;
 Sun, 19 Jan 2025 15:24:14 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-10.internal (MEProxy); Sun, 19 Jan 2025 15:24:14 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm1; t=1737318254;
 x=1737404654; bh=SQKDyS7D1/o53LbORxMcLka7GAWNZqZzJ52iJwL+RnE=; b=
 dmnRCfRNyIaAsTWYWsydSp6NGqqUNyG9sow97AFsnoCIHpwJr+N5lh+kLT++6QA5
 hYYgYpFDSQr5ol8TqR5ACxJo8kF0AXy2IeCKRkLrD9DRj/QjVmj4z74a2ZGCI/Bf
 dUdcnvFWRkWO5HdJwLV4oYhPH4oopBOt11p1VrUEYxc2vyCm/4Aib/mRGdXKV7dm
 lcG0L7/8X45dI0OiIbugYzISAmf652BDkdVz10a0ygmzQpbn+r4KPHjhUOiBzMab
 imbR8AUBe6aPAN8BQYXWsgnnDnFWicfPcjFjaA3z1f3tWQP8fAm2Ro3kNrLYCl2b
 XA9G/Lww+jc3gtC4je1UtQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm2; t=
 1737318254; x=1737404654; bh=SQKDyS7D1/o53LbORxMcLka7GAWNZqZzJ52
 iJwL+RnE=; b=cc+K2AZnyJ6A4uj0JAC+3XpVhT0yzNRynNSOaBA0arnS7AmlHgD
 E+Y4VRscN92+mBvb/VymRkjFI3DAgso6kdhICCFhirVpjZXgABgeONvxJglLDgAu
 ABHiPVAjoBANf/sgdhyoBbh9qhW/dTXyIiMBVw2J8LACe4SgYt6A43RdiCEfkEqF
 i4l5r+ZaTR99Y39RCFkdsU1rFVxkcARL4qOd2juLDFWk9x01MF6FQQoSMxwF2JnR
 SvSKwKDfv3KDMAm5ICBaa1ijA0peYccPAoDcLQCM4RoRqNsIwcunPr/z/vIbTCAP
 zVY8aOjFnTY9CTmEvjLr1d8e37bBcnxhDkA==
X-ME-Sender: <xms:bl-NZ7oLiO96ZADer4CqZBLsmyKM3u3DBmACkdRrYrVEiyakMt0-yw>
 <xme:bl-NZ1o25JpYnK8dvOMihIm4s-2C45qbVePInZZex5bXWvUcfRc2_-CHTcz8zLgRv
 -dSwg8a2xPzciynbw>
X-ME-Received: <xmr:bl-NZ4NYWBqwUJlPy_laK35p7reb7jiiy-q5R1egl-ib6Zs_WDy86-93XQ>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddrudeijedgudefhecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecusecvtfgvtghiphhivg
 hnthhsucdlqddutddtmdenucfjughrpefhvfevufgjfhffkfggtgesthdtredttddttden
 ucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggvuceorhiflhesrhgvtghurhhsvg
 ifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvghrnhepfeeuffdvfeeffedtuedt
 gfejtedtudevuddufeejtdekjefgheehvdfggfffudfhnecuvehluhhsthgvrhfuihiivg
 eptdenucfrrghrrghmpehmrghilhhfrhhomheprhiflhesrhgvtghurhhsvgifihhthhhl
 vghsshdrnhgvthdpnhgspghrtghpthhtohepvddpmhhouggvpehsmhhtphhouhhtpdhrtg
 hpthhtohepvghlihiisehgnhhurdhorhhgpdhrtghpthhtohepjeegleelgeesuggvsggs
 uhhgshdrghhnuhdrohhrgh
X-ME-Proxy: <xmx:bl-NZ-7Nyg4LdLLoFrrzBcSFTev4plK8XzUZUIQZu8Z1ilQgwOHY7A>
 <xmx:bl-NZ65GCfmYkgU3-YjP3wS7IBqXffiOkrUwCs0ouYSuUIC0Tsv_Vg>
 <xmx:bl-NZ2gIwus3lUt4ztuZEruZXroBCfTkBGm_xKnCcVTctjnSkRzrRQ>
 <xmx:bl-NZ86VqBdoETHsd9PGwZWrGGrE6egVdtHz42X8DEB9Zbh_b6Mrww>
 <xmx:bl-NZ6E7CeOwBNn83HL3HkZ0JHdsh9tu-nstMaHx4XS0b7cTzq8En9Lg>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Sun,
 19 Jan 2025 15:24:13 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Eli Zaretskii <eliz@HIDDEN>
Subject: Re: bug#74994: Acknowledgement (Improve Emacs iCalendar support)
In-Reply-To: <86h65v88ge.fsf@HIDDEN>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN> <86h65v88ge.fsf@HIDDEN>
Date: Sun, 19 Jan 2025 21:24:04 +0100
Message-ID: <87r04ya6xn.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Eli Zaretskii <eliz@HIDDEN> writes:

>> From: Richard Lawrence <rwl@HIDDEN>
>> Date: Sun, 19 Jan 2025 08:59:30 +0100
>> 
>> I know this bug was opened right before the holidays, and it's a big
>> patch, so I understand if no one's had time to look at it yet. Before I
>> put any more work into this, though, it would useful if someone could
>> just give this a quick look and tell me if it seems like I'm headed in
>> the right direction: a "yes, this looks reasonable, let us know when
>> you're finished" or a "there's no way this will get merged, please start
>> over with a new approach" would be helpful feedback to have at this
>> point. (Of course, more detailed feedback is welcome too, if someone has
>> time.)
>
> You posted 2 patches, both of them quite large.  We don't have
> icalendar experts on board whom we could ask to review the patches.

Right, I guess at this point that "expert" may be me. :)

I wasn't looking for any very detailed feedback, especially about
anything iCalendar-specific, just a quick check-in to make sure I
haven't already made some decision that would prevent the code from
being merged, out of my own inexperience.

> Are there any specific aspects of the patches you'd like us to look
> into, or some specific design issues you'd like to discuss?  That'd
> make the review more focused.

Here are a few things I've been wondering about, for anyone who has time
to take a look:

1) Is it alright that this is a ground-up rewrite, rather than an
evolution of icalendar.el? And how should I be thinking about the future
of icalendar.el?

Having written this code, I can now understand and appreciate some of
the code in icalendar.el better, but am still of the opinion that it is
too closely tied to diary import/export to be as useful to other parts
of Emacs (especially Org and Gnus) as it could be.

I've been thinking it would make sense eventually to:
  - rename icalendar.el to something like diary-icalendar.el
  - rewrite the code there to use the new parser in icalendar-parser.el
  - rename the user-facing functions
    (icalendar-{import/export}-file and their variants)
    to make clear that they are diary-specific
  - delete(?)/deprecate the old parsing code

But let me know if that is not in the cards.

2) It would be nice if someone who has more experience writing parsers
could give me some feedback about the overall design of the parser. In
particular:

  - I've used macros (see icalendar-macs.el) to create a small "DSL" for
    defining iCalendar types. These macros store parsing-related information for
    each type as properties of the symbols which name them. There's a lot of
    dynamic dispatch in the parser based on these type symbols' properties.
    This adds some complexity but (I hope) makes the parser more "atomic"/
    extensible. Does this seem like a reasonable approach in general?
  - I ran into one issue that feels like a design flaw: the parser separates
    "reading" (converting a string to an Elisp value) into a function
    distinct from the parsing function which matches that string (see e.g.
    ical:parse-property-value in icalendar-parser.el, which calls
    ical:read-property-value). In simple cases this nicely factors out a pure
    function from one which depends on a lot of global buffer state;
    but in more complicated cases the "pure" reader function depends on
    the match data and so isn't pure at all (see e.g. ical:read-dur-value).
    Is there a better way to do this? (Not make the distinction? Pass
    the match data explicitly? ...?)
  - I haven't really thought about performance at all. My parser is
    definitely slower/more memory intensive than icalendar.el's at the moment
    (though hopefully this is mostly because it has more to offer). Are there
    any things I should already be thinking about to make it more efficient?

3) Likewise, part of my patch was icalendar-mode.el, and it would be
useful to have some feedback about the major mode:

  - whether an icalendar-mode is even a useful thing to have, and what
    could make it more useful
  - whether the approach to syntax highlighting is reasonable (the
    icalendar-define-* macros define matching functions for each
    individual icalendar type, which are eventually piled
    into a long list of matchers in the mode's value for font-lock-keywords)
  - whether there's a better solution to the problem of needing to unfold
    lines *before* a buffer containing iCalendar data is decoded
    (is there anything like a hook that runs before decoding?)
  - what still needs to be done to make the code in icalendar-mode.el
    compliant with Emacs' major mode conventions (see the long list of
    TODOs in the mode definition)

> I can see already that your doc strings are not always according to
> our conventions (the first line must be a single complete sentence),
> and also the commit log messages need to be more detailed.  But these
> are aspects best deferred until you have a version close to the final
> one, now is too early for that.

OK, but that's good to keep in mind, thanks.

(Re: commit messages: should I be making commits in Emacs' ChangeLog
format as I go along, and post a whole new patch series here after
revisions? Or does it make more sense to squash everything at the end
into one or a few commits, and only format the log messages at that
point?)

> One other comment is to be sure the many cl-isms you use don't get in
> the way of debugging the code, since Edebug is known to provide weaker
> support for some cl-lib construct and cl-macs macros.  So if you find
> some macros or constructs that would make debugging harder, and can
> replace them with simpler code without sacrificing too much, that
> would be appreciated.

Yes, this is another thing I was wondering about, so thanks for bringing
it up. I have actually tried to keep cl-isms to a minimum: I am only
using cl-defmacro (for keyword arguments) and the cl type system (mostly
cl-deftype and cl-typep, since the parser needs some way of representing
the types defined by the iCalendar standard, and checking that values
conform to those types). So far this hasn't gotten in the way of
debugging for me, though I am not a heavy Edebug user. (The posted patch
also uses one cl-defstruct but my recent exchange with Ihor Radchenko on
emacs-devel has convinced me that I can move to using
org-element-ast.el, which will eliminate that.)

I am using read-symbol-shorthands to turn the CL-style "ical:" prefix
into "icalendar-", which to me is more readable, but that's easy to
change/eliminate if others don't like it.

Thanks!
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 19 Jan 2025 09:22:05 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sun Jan 19 04:22:04 2025
Received: from localhost ([127.0.0.1]:44881 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tZRVM-0002AX-J0
	for submit <at> debbugs.gnu.org; Sun, 19 Jan 2025 04:22:04 -0500
Received: from eggs.gnu.org ([2001:470:142:3::10]:40288)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <eliz@HIDDEN>) id 1tZRVK-00029x-5U
 for 74994 <at> debbugs.gnu.org; Sun, 19 Jan 2025 04:22:02 -0500
Received: from fencepost.gnu.org ([2001:470:142:3::e])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <eliz@HIDDEN>)
 id 1tZRVE-0004Nm-2L; Sun, 19 Jan 2025 04:21:56 -0500
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=gnu.org;
 s=fencepost-gnu-org; h=References:Subject:In-Reply-To:To:From:Date:
 mime-version; bh=LYDqqz0Jg9NoNNoebPwzeGGr4SaV60tGPVBKdbiNLcY=; b=ICOmW6mq4+pQ
 sAucPuxjSH5DXUBPq3la2fu8Me5yeka8fWB7T5FZQ+ANKyASNygAWe8upniJj6Sj17EAsFs/BOuzF
 UzBqhZFQe+wfWOVM3UeTAIZ0HMPElYMrNzONRCCbXykSrE6pRLa5s+ZKYVepGvCNYKsBhkExTPd58
 eHlYk6UnEB4G8T/VuItB4QfY66r/1wkg4HOLOt4rSS8jR3PaJ9YX6K1B90kjMm4/yanfOsyPrqKxU
 a5Yid31z7TkuZP1bNE4uYm506kcY14DHHtuSdLZhW5CgQaptNFH8kAfseupoPBt4LWW35qpyu6InD
 PQ3L5mEZAna2CCdv3TC0Bw==;
Date: Sun, 19 Jan 2025 11:21:53 +0200
Message-Id: <86h65v88ge.fsf@HIDDEN>
From: Eli Zaretskii <eliz@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
In-Reply-To: <874j1vb5el.fsf@HIDDEN> (message from Richard
 Lawrence on Sun, 19 Jan 2025 08:59:30 +0100)
Subject: Re: bug#74994: Acknowledgement (Improve Emacs iCalendar support)
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
 <874j1vb5el.fsf@HIDDEN>
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

> From: Richard Lawrence <rwl@HIDDEN>
> Date: Sun, 19 Jan 2025 08:59:30 +0100
> 
> I know this bug was opened right before the holidays, and it's a big
> patch, so I understand if no one's had time to look at it yet. Before I
> put any more work into this, though, it would useful if someone could
> just give this a quick look and tell me if it seems like I'm headed in
> the right direction: a "yes, this looks reasonable, let us know when
> you're finished" or a "there's no way this will get merged, please start
> over with a new approach" would be helpful feedback to have at this
> point. (Of course, more detailed feedback is welcome too, if someone has
> time.)

You posted 2 patches, both of them quite large.  We don't have
icalendar experts on board whom we could ask to review the patches.
Are there any specific aspects of the patches you'd like us to look
into, or some specific design issues you'd like to discuss?  That'd
make the review more focused.

I can see already that your doc strings are not always according to
our conventions (the first line must be a single complete sentence),
and also the commit log messages need to be more detailed.  But these
are aspects best deferred until you have a version close to the final
one, now is too early for that.

One other comment is to be sure the many cl-isms you use don't get in
the way of debugging the code, since Edebug is known to provide weaker
support for some cl-lib construct and cl-macs macros.  So if you find
some macros or constructs that would make debugging harder, and can
replace them with simpler code without sacrificing too much, that
would be appreciated.

Other than that, please help us provide more focused review by
pointing out the issues and aspects for which you'd like our feedback.

Thanks.




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 19 Jan 2025 07:59:47 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sun Jan 19 02:59:47 2025
Received: from localhost ([127.0.0.1]:44801 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tZQDj-0006fN-0H
	for submit <at> debbugs.gnu.org; Sun, 19 Jan 2025 02:59:47 -0500
Received: from fout-b8-smtp.messagingengine.com ([202.12.124.151]:49239)
 by debbugs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.84_2) (envelope-from <rwl@HIDDEN>)
 id 1tZQDg-0006f8-Oa
 for 74994 <at> debbugs.gnu.org; Sun, 19 Jan 2025 02:59:45 -0500
Received: from phl-compute-02.internal (phl-compute-02.phl.internal
 [10.202.2.42])
 by mailfout.stl.internal (Postfix) with ESMTP id 86813114013D
 for <74994 <at> debbugs.gnu.org>; Sun, 19 Jan 2025 02:59:38 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-02.internal (MEProxy); Sun, 19 Jan 2025 02:59:38 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm1; t=1737273578;
 x=1737359978; bh=OEW0vqy/P3I2d8LOr23STmeDuIOoq3s6p4+TYSaA2gc=; b=
 rGIryont9CO7tPmLw6VQIi37mclRGDFJUlBqQ+LA9mTW4jTu59jIhmgphIG/Qlij
 HTe3zoNndo8tJfFBMcrGKEm0Z3bwICzilF8tTTTACd/n8rL2RmhAetYPDebLqfv+
 tcfxTVIaSVBAu3grRFCPhaz3R48muZLKdGDIqB3910BqO2gz1860xotKY+SbqpBr
 6J2OSM68mMAtgcWbIU4CGdPWDGig4BaG0E06ecOhBa7trDROuXK4g7qAayK+cR4s
 MJdiG5OdOQXLmCgdg5C0mB8+YIHx3VadsZv+qsC89ktMW7NAxU00Ymql2adwXCbb
 gDcD9ozjY1d2vozI3nCncQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm2; t=
 1737273578; x=1737359978; bh=OEW0vqy/P3I2d8LOr23STmeDuIOoq3s6p4+
 TYSaA2gc=; b=sJHou0Eniwy+dtBS3DRcsoTCOwaz+KQYGvcwolkWTgwOXx898xj
 mkFE5pJ0rd04uCkEL0l33AWo5NDVDQw4FafNMnLtEpIc2dgMZPI+sMklrQgptPYs
 LeE7Pe5Lvvpni5XV/xHCaEKRuTmHhJ4DgOGHMLras9lUk00l9WBfegz0Vn1HBoV/
 Z4Ps9ARGx/OLiXNQ7Kt6r0tNFR6SPIBZVFnY5E7TydJCoEn797pIdMLoKpOYilzn
 T2SS5zAbpjazevf5wzWrwsita7hjeeHSGHGE0uACWQc3GwBnKQIvGiR7f9nouXeM
 N0RARWmROK7ocCi4ktwdFipilTrU2036wtQ==
X-ME-Sender: <xms:6rCMZ8-Mg9jYUct4DJadoAMSDjvc1pgffOdCWRffN4Z5Dizhc2mgyg>
 <xme:6rCMZ0sKT_nF-RpMegwc5iiHWZ-IVpXeRo4JkLztVovTTEYSscv1OOe3yb-bVTA0V
 uR2CYXUTaWINtDDAA>
X-ME-Received: <xmr:6rCMZyAca6ifrsASYlKI_yD9VBrBbCGDUIiaV67YT0A0oUq3P_QtYQZoeg>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddrudeiiedgudduudcutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvuf
 gjfhffkfggtgesthdtredttddttdenucfhrhhomheptfhitghhrghrugcunfgrfihrvghn
 tggvuceorhiflhesrhgvtghurhhsvgifihhthhhlvghsshdrnhgvtheqnecuggftrfgrth
 htvghrnhepffduvdeitdevjeetgefgffffudfhheelhfffieevgfeljedvfeekvefgkedt
 fffhnecuvehluhhsthgvrhfuihiivgeptdenucfrrghrrghmpehmrghilhhfrhhomheprh
 iflhesrhgvtghurhhsvgifihhthhhlvghsshdrnhgvthdpnhgspghrtghpthhtohepuddp
 mhhouggvpehsmhhtphhouhhtpdhrtghpthhtohepjeegleelgeesuggvsggsuhhgshdrgh
 hnuhdrohhrgh
X-ME-Proxy: <xmx:6rCMZ8clQ-OOgbLCJrR_RcGQZoM6lCMyoMNs7kreCvAhaF6MqfRqcA>
 <xmx:6rCMZxOkP7lneVBl0zgXTuM1lB8JiGWDDx2l__pi5JtzBkVmzT3CPw>
 <xmx:6rCMZ2mK7EmFhtgt4NAd-BbXWND4WW-4_xtT1kYbWYE7CZmOwcaGaQ>
 <xmx:6rCMZzvWeheahmFkovxMJe422a52xevQGOgxVoBJKMWpIvbsbIrWmg>
 <xmx:6rCMZ_2COCreJ7Osjsf_URm2bzgfkTEMDqn7aXkWlGeX38Fnlb8W2VoX>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA for
 <74994 <at> debbugs.gnu.org>; Sun, 19 Jan 2025 02:59:37 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: 74994 <at> debbugs.gnu.org
Subject: Re: bug#74994: Acknowledgement (Improve Emacs iCalendar support)
In-Reply-To: <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
References: <87bjx6mrjp.fsf@HIDDEN>
 <handler.74994.B.173470005831292.ack <at> debbugs.gnu.org>
Date: Sun, 19 Jan 2025 08:59:30 +0100
Message-ID: <874j1vb5el.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Dear Emacs maintainers,

I know this bug was opened right before the holidays, and it's a big
patch, so I understand if no one's had time to look at it yet. Before I
put any more work into this, though, it would useful if someone could
just give this a quick look and tell me if it seems like I'm headed in
the right direction: a "yes, this looks reasonable, let us know when
you're finished" or a "there's no way this will get merged, please start
over with a new approach" would be helpful feedback to have at this
point. (Of course, more detailed feedback is welcome too, if someone has
time.)

Thank you!

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 30 Dec 2024 17:16:40 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Mon Dec 30 12:16:40 2024
Received: from localhost ([127.0.0.1]:59680 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tSJNg-0004qs-4i
	for submit <at> debbugs.gnu.org; Mon, 30 Dec 2024 12:16:40 -0500
Received: from mout02.posteo.de ([185.67.36.66]:45893)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <yantar92@HIDDEN>) id 1tSJNd-0004qS-KW
 for 74994 <at> debbugs.gnu.org; Mon, 30 Dec 2024 12:16:38 -0500
Received: from submission (posteo.de [185.67.36.169]) 
 by mout02.posteo.de (Postfix) with ESMTPS id 26BD2240101
 for <74994 <at> debbugs.gnu.org>; Mon, 30 Dec 2024 18:16:31 +0100 (CET)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=posteo.net; s=2017;
 t=1735578992; bh=UjEJLoc4FPdTcB82uF9OxX96yEx3VezDW/YiDTDkRPc=;
 h=From:To:Cc:Subject:Date:Message-ID:MIME-Version:Content-Type:
 From;
 b=PyyWRK4+E6htqJE7QBA499sq8NB8NJt0vn/+2vR1NHgsHhJfYVvTcAUUUtNCmFY9/
 ONGSfpjOwQ19MdXFXF+8k2/9D6H1XyuriJlXB4NomyWRqmYyBFnhxdQ4gm/Z9u1Fzg
 131d9ZPhDthBQHYQvOdfRaiaer6kzKfN+983cXaDZBF7077ApV+rmpdKOukKMhwIS0
 wFHcJ2v3dM/L4+bbuie7RvuO+0m2PlXp2ellKpSpRxD3WTilzW9+K70VggrLjwRIfn
 FDe44XkJ/V2bVUHl97ZtKTkPrhYQtMAuGqtSPN8eqhQAURDrhyB9rF6gpm02RqP633
 1Df1vub0L2zQQ==
Received: from customer (localhost [127.0.0.1])
 by submission (posteo.de) with ESMTPSA id 4YMN730Gczz9rxD;
 Mon, 30 Dec 2024 18:16:30 +0100 (CET)
From: Ihor Radchenko <yantar92@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
Subject: Re: Upstreaming org-element-ast (was: Improving Emacs' iCalendar
 support)
In-Reply-To: <87r05qtdm3.fsf@HIDDEN>
References: <87ed4dss2x.fsf@HIDDEN>
 <87mshq9w5c.fsf@HIDDEN> <86ed31j6zk.fsf@HIDDEN>
 <87ldx9vsnb.fsf@localhost> <868qt8kj6f.fsf@HIDDEN>
 <87ikscx5io.fsf@localhost> <867c8skhy6.fsf@HIDDEN>
 <87frngx4fx.fsf@localhost> <864j3wkczm.fsf@HIDDEN>
 <87cyhg0zjz.fsf@localhost> <87ttamtf7g.fsf@HIDDEN>
 <87r05qtdm3.fsf@HIDDEN>
Date: Mon, 30 Dec 2024 17:18:01 +0000
Message-ID: <87ed1p2ipi.fsf@localhost>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org, emacs-devel@HIDDEN
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

Richard Lawrence <rwl@HIDDEN> writes:

> Ah, looking over this again, there was one thing that I felt didn't fit
> very well with org-element-ast: in iCalendar, "properties" can have both
> a "value" and a list of "parameters". These are different types of
> objects and it's most natural and useful not to lump them together, but
> in the Org element AST they would probably both just be "contents" of a
> property node (i.e., child nodes). Thus, in my draft icalendar-ast.el,
> instead of using
>
> (TYPE PROPERTIES CONTENTS)
>
> as the basic representation for a node, I used
>
> (TYPE META VALUE CHILDREN).
>
> The org-element-ast representation is obviously capable of making the
> distinction between values and parameters in some other way; but it's
> pretty much the only example I can think of where I felt the
> org-element-ast representation might not be the best for the needs of
> iCalendar.

Have you looked into secondary nodes?

-- 
Ihor Radchenko // yantar92,
Org mode maintainer,
Learn more about Org mode at <https://orgmode.org/>.
Support Org development at <https://liberapay.com/org-mode>,
or support my work at <https://liberapay.com/yantar92>




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 30 Dec 2024 17:14:52 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Mon Dec 30 12:14:52 2024
Received: from localhost ([127.0.0.1]:59671 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tSJLv-0004hP-FK
	for submit <at> debbugs.gnu.org; Mon, 30 Dec 2024 12:14:51 -0500
Received: from mout01.posteo.de ([185.67.36.65]:59747)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <yantar92@HIDDEN>) id 1tSJLs-0004h9-RD
 for 74994 <at> debbugs.gnu.org; Mon, 30 Dec 2024 12:14:49 -0500
Received: from submission (posteo.de [185.67.36.169]) 
 by mout01.posteo.de (Postfix) with ESMTPS id 90F27240027
 for <74994 <at> debbugs.gnu.org>; Mon, 30 Dec 2024 18:14:42 +0100 (CET)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=posteo.net; s=2017;
 t=1735578882; bh=U7XJiQURvyB9bpLSEv2Bs7eXTcW/kHKYjNj+t40UToo=;
 h=From:To:Cc:Subject:Date:Message-ID:MIME-Version:Content-Type:
 From;
 b=oFC/LxGnyp23+e8Cec0N/nYHbw/4uKErsIrKE/xGRFdyp7v/o/67QI4PGiuWEwCbn
 BPb/OJZ517LQ1g+fgL0NkNT5YqduKazhUGLi7qQORyEYtIiZTd75yD9ltgy2K6E/PE
 khkQdG2XWolgckgzYzurYFo7bkkp9agToqlFucdpFgxDx68guYCXEHMnjgheyJBV60
 hKuSnKgxcRbIR7Nr3BWPj5XF8i9VAm+FvUzDRPScMty3iUXjULn/IJV3eCSv0JjGMe
 fuTW1NbvcmDrm7BX0dxZCs1C+kVtGqIwySiQj+BCTUuqX37MExXWjVPq8muSxJOaW+
 UFxSHFAwKiJRw==
Received: from customer (localhost [127.0.0.1])
 by submission (posteo.de) with ESMTPSA id 4YMN4x4hnhz9rxF;
 Mon, 30 Dec 2024 18:14:41 +0100 (CET)
From: Ihor Radchenko <yantar92@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
Subject: Re: Upstreaming org-element-ast (was: Improving Emacs' iCalendar
 support)
In-Reply-To: <87ttamtf7g.fsf@HIDDEN>
References: <87ed4dss2x.fsf@HIDDEN>
 <87mshq9w5c.fsf@HIDDEN> <86ed31j6zk.fsf@HIDDEN>
 <87ldx9vsnb.fsf@localhost> <868qt8kj6f.fsf@HIDDEN>
 <87ikscx5io.fsf@localhost> <867c8skhy6.fsf@HIDDEN>
 <87frngx4fx.fsf@localhost> <864j3wkczm.fsf@HIDDEN>
 <87cyhg0zjz.fsf@localhost> <87ttamtf7g.fsf@HIDDEN>
Date: Mon, 30 Dec 2024 17:16:11 +0000
Message-ID: <87h66l2isk.fsf@localhost>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -2.3 (--)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org, emacs-devel@HIDDEN
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -3.3 (---)

Richard Lawrence <rwl@HIDDEN> writes:

>> Richard, is there any specific reason why you had to make things from
>> scratch? May org-element-ast be changed to fit your needs?
> ...
> No, it was more that, as things stood, I wanted to wait and see what
> would happen with upstreaming org-element-ast. My idea was to make it
> easy to switch once that happened, but not to wait to make progress in
> the meantime, that's all.

org-element-ast is already a part of Emacs. The process of upstreaming
in this particular case is simply a question of (1) making the library
more useful outside Org mode; (2) renaming it.

Renaming is trivial.
The main question is making things usable outside Org mode.
And that's where your work is the most valuable.
So, it was me who is waiting for your input before upstreaming :)

> One thing about your question confuses me, namely: 
>
>> ...instead implement a custom parser generator.
>
> As I understand org-element-ast, it basically just defines the parse
> tree representation and various accessors for working with it, not the
> parser itself. Was your suggestion that I could also use the Org parser,
> not just the parse tree representation? If so, then I misunderstood, and
> presumably more code is involved than is found in org-element-ast.el,
> right?

No, I did not mean that you should use org-element to generate parser.
I mostly referred to the way you implement the parser where part of the
parser configuration is stored in the AST. But I was reading your patch
very quickly and could have misunderstood something.

-- 
Ihor Radchenko // yantar92,
Org mode maintainer,
Learn more about Org mode at <https://orgmode.org/>.
Support Org development at <https://liberapay.com/org-mode>,
or support my work at <https://liberapay.com/yantar92>




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 29 Dec 2024 20:53:56 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sun Dec 29 15:53:56 2024
Received: from localhost ([127.0.0.1]:56361 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tS0IO-0004ph-3f
	for submit <at> debbugs.gnu.org; Sun, 29 Dec 2024 15:53:56 -0500
Received: from fhigh-b2-smtp.messagingengine.com ([202.12.124.153]:45291)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tS0IL-0004pH-Jf
 for 74994 <at> debbugs.gnu.org; Sun, 29 Dec 2024 15:53:54 -0500
Received: from phl-compute-04.internal (phl-compute-04.phl.internal
 [10.202.2.44])
 by mailfhigh.stl.internal (Postfix) with ESMTP id 3BC402540071;
 Sun, 29 Dec 2024 15:53:47 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-04.internal (MEProxy); Sun, 29 Dec 2024 15:53:47 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm1; t=1735505627;
 x=1735592027; bh=3h2QYcwK6vfTxHYHG6dpJ6s+NwHwBQLmGGsnxQq7Bp8=; b=
 IoKwG9D+FXnDgyB4kt6G+8UaHHBL+yxunD/FwTDRs3q41SVdirCjg3cMOhPo/lf4
 QCvn+3svEtQ51AmVjHmJi/Zn3S3sjyuI/d08tWXpRUpfaMdvzNMgBI+hRuz/+FUV
 j56L1UPLYUe6KA+QR3oeCDdmjXMqSzelsoAN1Pvwz8c26G0YuJulPVGkosiX1iku
 9cOnH4PVJ0BtJS03CApy5a3gdZ9/o5X9/Cvnk6KSRcwikpeAJHY0vR9R9k3cVqBW
 rlEB8xEuUWFjAHgosqxWJ+7ZkZOphjkrZS/DyrIUyyE7/Uh1i2dBd9uSbNMQlek7
 CfGuS/8WRgIjXCMZi3FZWg==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm2; t=
 1735505627; x=1735592027; bh=3h2QYcwK6vfTxHYHG6dpJ6s+NwHwBQLmGGs
 nxQq7Bp8=; b=f/0O9fUJx2IVXQzL6AOzQS4a0Wr9zKp2p7uCT53cgFv1njJNPc8
 WlMjjKshAhx2NCfTSOAwWomc+YBdkoBg0+PAKI12w9m3ofnH/TLECne2G0XdcVxp
 iOxZvxskR11adkDRULEok465MY0DqSMKs3dUd0jMTPSnYEK3wya+55r7L8inVLEN
 x3TKSOXJzg66MPKns9Gl2sz0Xrou+AVSNBBJ/OyMrFFPO4PPrN8AkPctd5jY+fVA
 wTKccVBTGZSXYJkGVb9fW6rZiD2cl59olgD1tuyDlRP0Kp0WXy1Og+646Y4bg7gK
 JS7HfQELk1IWFZMJ5qT/tscRY0cOQlrhPOA==
X-ME-Sender: <xms:2rZxZ4j7-4Yz7uM2FCXpMBmA3F70t6ugMl8czifHK1ErDZ2NdUBNIQ>
 <xme:2rZxZxBlnPbaS5Zrwe6fjxzfs7mnupY4xEcQ_Rsu_ppi1vgIdFHhoSXdsej8QofSZ
 y-DQ8NYm09j0R4dNA>
X-ME-Received: <xmr:2rZxZwHAg4VtCZKsCUBV3fA3WYRN83UBsBjG5eAwmZcS2Ok1sUk0GqlVu2PvO-c885rRP9QWjpCAvIiCvNbfyRXP>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddvgedgudegiecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvve
 fujghffffkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgv
 nhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrg
 htthgvrhhnpeefueffvdeffeeftdeutdfgjeettdduveduudefjedtkeejgfehhedvgffg
 ffduhfenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrihhlfhhrohhmpe
 hrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgtphhtthhopeef
 pdhmohguvgepshhmthhpohhuthdprhgtphhtthhopeihrghnthgrrhelvdesphhoshhtvg
 hordhnvghtpdhrtghpthhtohepvghmrggtshdquggvvhgvlhesghhnuhdrohhrghdprhgt
 phhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:2rZxZ5QSaVsNR1x40AAMiYXzkj6el7XNAyEKXh1mvb0N4m5PsevVFg>
 <xmx:2rZxZ1ylIQeO5iJMcOZ4DHCSzJDi0ftqs7C6EY9YVi-kLib3HoBpEQ>
 <xmx:2rZxZ36smv1SBgL-6XlZ8_LyAVs39qHfiyuKkQ29eN2k2aVZakcXBw>
 <xmx:2rZxZyw3mqx1JEp3K-yPbo2L6qj1oEIWMUEfgYZ5ZDVyMVbjDtRVoQ>
 <xmx:27ZxZ9_j8NeqHKdVad8lxrmO7nA7_vWLljFnhL_RefPsQjj8S5fbs84n>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Sun,
 29 Dec 2024 15:53:46 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Ihor Radchenko <yantar92@HIDDEN>
Subject: Re: Upstreaming org-element-ast (was: Improving Emacs' iCalendar
 support)
In-Reply-To: <87ttamtf7g.fsf@HIDDEN>
References: <87ed4dss2x.fsf@HIDDEN>
 <87mshq9w5c.fsf@HIDDEN> <86ed31j6zk.fsf@HIDDEN>
 <87ldx9vsnb.fsf@localhost> <868qt8kj6f.fsf@HIDDEN>
 <87ikscx5io.fsf@localhost> <867c8skhy6.fsf@HIDDEN>
 <87frngx4fx.fsf@localhost> <864j3wkczm.fsf@HIDDEN>
 <87cyhg0zjz.fsf@localhost> <87ttamtf7g.fsf@HIDDEN>
Date: Sun, 29 Dec 2024 21:53:40 +0100
Message-ID: <87r05qtdm3.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org, emacs-devel@HIDDEN
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Richard Lawrence <rwl@HIDDEN> writes:

> Ihor Radchenko <yantar92@HIDDEN> writes:
>
>> In bug#74994, Richard made a decision not to use org-element-ast and
>> instead implement a custom parser generator.
>>
>> Richard, is there any specific reason why you had to make things from
>> scratch? May org-element-ast be changed to fit your needs?
>>
>> If org-element-ast is not going to be useful outside Org mode, I see no
>> good reason to invest time into upstreaming it, after all.
>
> No, it was more that, as things stood, I wanted to wait and see what
> would happen with upstreaming org-element-ast. My idea was to make it
> easy to switch once that happened, but not to wait to make progress in
> the meantime, that's all.

Ah, looking over this again, there was one thing that I felt didn't fit
very well with org-element-ast: in iCalendar, "properties" can have both
a "value" and a list of "parameters". These are different types of
objects and it's most natural and useful not to lump them together, but
in the Org element AST they would probably both just be "contents" of a
property node (i.e., child nodes). Thus, in my draft icalendar-ast.el,
instead of using

(TYPE PROPERTIES CONTENTS)

as the basic representation for a node, I used

(TYPE META VALUE CHILDREN).

The org-element-ast representation is obviously capable of making the
distinction between values and parameters in some other way; but it's
pretty much the only example I can think of where I felt the
org-element-ast representation might not be the best for the needs of
iCalendar.

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 29 Dec 2024 20:19:31 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Sun Dec 29 15:19:31 2024
Received: from localhost ([127.0.0.1]:56326 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tRzl4-0002zZ-Oi
	for submit <at> debbugs.gnu.org; Sun, 29 Dec 2024 15:19:31 -0500
Received: from fhigh-b1-smtp.messagingengine.com ([202.12.124.152]:43267)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tRzl2-0002zJ-Ko
 for 74994 <at> debbugs.gnu.org; Sun, 29 Dec 2024 15:19:29 -0500
Received: from phl-compute-05.internal (phl-compute-05.phl.internal
 [10.202.2.45])
 by mailfhigh.stl.internal (Postfix) with ESMTP id D5123254011D;
 Sun, 29 Dec 2024 15:19:22 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-05.internal (MEProxy); Sun, 29 Dec 2024 15:19:22 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm1; t=1735503562;
 x=1735589962; bh=pPi0YGHGnSmTFZ+xL9rlu4RxyGVGV9NR6+im2RnZMK0=; b=
 QBDLLfxGTssNYfYV6NqjVLK0Ixs+C4XMYspUSB/jecky7AIkfoqiDeDq9fadjaFo
 H6+TIQT3WYRCMbGo7s9ymQUCnjjcUfoBLmeDaSLVa0LVZhem6SoZIvScwqDtkoMf
 KRiMnDm4AIC8KVPdACchH4W86WoyoeKTTWmgpOTwtwrzTBNVyk7/2rQxxBjghz0d
 /m28HidV7csd+ONYkD847WzD9Ch2kNWi9sxXItjZ1lZcv6GrsqADgt5SAVgAnbbI
 ovHSdpUKfcjHEunDMjq0PZdyt1odPVLnM/H7f+bhEH2+tvdTGVoBw3aGFAjKGM8F
 sgLy83eQ5q2iGjI8CQs0Sw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm2; t=
 1735503562; x=1735589962; bh=pPi0YGHGnSmTFZ+xL9rlu4RxyGVGV9NR6+i
 m2RnZMK0=; b=Xyg9azQ8bZ4urcHAMbKORsVMlbmI9HOKJo7xKX1cawzL9hw6wj9
 VQvycQM9Ob7kssTt2rzcd68PVdhgV+PgBqxsWSrd0g4ytmUdN/xxAQxk1shf1cpF
 HT3yJ5XSghAezL/NDpujVDcl1gn33r8iMKPa/lUhby2oUWWRh7nd/jgVdtNPGR6p
 q07paXkAL1J0oQ+btTcr17tS0D7LsBdnyjf8Lo/u9AqIyScWHFlCQcgPXBiIl6aq
 fu5iuw48D+EXjfWfZNABixDgGJQHsomxQEldlxJPMASUQaYDrbiFCvh60hRw+xhC
 cMvQ3q3YuGICMP2cDshN7wF+V63HSUR1vxQ==
X-ME-Sender: <xms:yq5xZ4UfJ8tfeHwWJvHsux8eQk98jUNq6FRcXfa_4823EGAHP58Tyw>
 <xme:yq5xZ8kFjFIZeg8VLAKDw_NqtAYQBYw-_df_pl0iKF_kwNHUZCm09aSHnM0QmEYlw
 nd70Bn4ihL4cNBOCQ>
X-ME-Received: <xmr:yq5xZ8ZkfY34ExRbwcOcURkYQh56EKZJOjrKyZpP9shXAQmTx-fnzG-zMMtb2DDyzsmGl4-CYZNN_lS3TYsZL_mk>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddvgedgudefkecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvve
 fujghffffkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgv
 nhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrg
 htthgvrhhnpeefueffvdeffeeftdeutdfgjeettdduveduudefjedtkeejgfehhedvgffg
 ffduhfenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrihhlfhhrohhmpe
 hrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgtphhtthhopeef
 pdhmohguvgepshhmthhpohhuthdprhgtphhtthhopeihrghnthgrrhelvdesphhoshhtvg
 hordhnvghtpdhrtghpthhtohepvghmrggtshdquggvvhgvlhesghhnuhdrohhrghdprhgt
 phhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:yq5xZ3U4WcZ5pF9nqKRpdS4gvZgTPSR6mata1TSSNEoZlwf4-Ego-A>
 <xmx:yq5xZyns1Vq-RXX5CrbSd4piF2K7ecbBiT5-s0ZPDC2pnoiYHJBzNw>
 <xmx:yq5xZ8f_9KOUpjR3v0a2aQnwS_WjMGM7kggXQ0fngf2kfCQ66E8oCg>
 <xmx:yq5xZ0FDL72RETpSPh5jlPyMq6J8N8KbWuUKM7k6egUECRRMylTwMQ>
 <xmx:yq5xZ0j33MMhnBfUNVM_MhQCMf80wtClScxsiWr2G52jq7NVSpU28kL3>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Sun,
 29 Dec 2024 15:19:22 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Ihor Radchenko <yantar92@HIDDEN>
Subject: Re: Upstreaming org-element-ast (was: Improving Emacs' iCalendar
 support)
In-Reply-To: <87cyhg0zjz.fsf@localhost>
References: <87ed4dss2x.fsf@HIDDEN>
 <87mshq9w5c.fsf@HIDDEN> <86ed31j6zk.fsf@HIDDEN>
 <87ldx9vsnb.fsf@localhost> <868qt8kj6f.fsf@HIDDEN>
 <87ikscx5io.fsf@localhost> <867c8skhy6.fsf@HIDDEN>
 <87frngx4fx.fsf@localhost> <864j3wkczm.fsf@HIDDEN>
 <87cyhg0zjz.fsf@localhost>
Date: Sun, 29 Dec 2024 21:19:15 +0100
Message-ID: <87ttamtf7g.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org, emacs-devel@HIDDEN
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Ihor Radchenko <yantar92@HIDDEN> writes:

> In bug#74994, Richard made a decision not to use org-element-ast and
> instead implement a custom parser generator.
>
> Richard, is there any specific reason why you had to make things from
> scratch? May org-element-ast be changed to fit your needs?
>
> If org-element-ast is not going to be useful outside Org mode, I see no
> good reason to invest time into upstreaming it, after all.

No, it was more that, as things stood, I wanted to wait and see what
would happen with upstreaming org-element-ast. My idea was to make it
easy to switch once that happened, but not to wait to make progress in
the meantime, that's all.

One thing about your question confuses me, namely: 

> ...instead implement a custom parser generator.

As I understand org-element-ast, it basically just defines the parse
tree representation and various accessors for working with it, not the
parser itself. Was your suggestion that I could also use the Org parser,
not just the parse tree representation? If so, then I misunderstood, and
presumably more code is involved than is found in org-element-ast.el,
right?

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 24 Dec 2024 08:08:40 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Tue Dec 24 03:08:40 2024
Received: from localhost ([127.0.0.1]:59222 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tPzy3-0000wS-Ih
	for submit <at> debbugs.gnu.org; Tue, 24 Dec 2024 03:08:39 -0500
Received: from fhigh-b8-smtp.messagingengine.com ([202.12.124.159]:53865)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tPzy0-0000wB-Lc
 for 74994 <at> debbugs.gnu.org; Tue, 24 Dec 2024 03:08:37 -0500
Received: from phl-compute-05.internal (phl-compute-05.phl.internal
 [10.202.2.45])
 by mailfhigh.stl.internal (Postfix) with ESMTP id 47F0F25401D4;
 Tue, 24 Dec 2024 03:08:30 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-05.internal (MEProxy); Tue, 24 Dec 2024 03:08:30 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm1; t=1735027710;
 x=1735114110; bh=P78Z6oczkGcWnhgI7RoN3e19UrRKBVK7gkSrTI1NFvw=; b=
 2QzqRPNo9wssz/o8X3e1n3GpcrJ3eZfTxuwusKeVYn4EsypWW4qqwsBt+xgHL6VT
 LjncqcP1S+ZvqiKJ3RgyKE3AjWi/v3+A7HnalpjUZliXllQJS+DNymGh/mGhYz0Y
 oHLX0QHDRsu//2sOOvp1eOBgeaeHsxOpKx3WnmwjcL7+GCQTtrN6eM4w4QDbWQMl
 uQoDNFK3vC3iKKYI0i4K2S8yiT7NQKrwF3S3Ks74PpRGbHg5KQX8QDn47Lo1bBjA
 BIfQrLiT2pMnDwcn939+J4VoczxAfvtBJQFPTUHsJvxc/djgWeSDEbSoqHntkOHT
 gUf4xc0t1Ltt3LcejdbA8A==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm2; t=
 1735027710; x=1735114110; bh=P78Z6oczkGcWnhgI7RoN3e19UrRKBVK7gkS
 rTI1NFvw=; b=ZoFqBf6OFpYo4GSADzjgevVuASRsQ0SCWXmH441d9bLIJqh7zhk
 /a7Z/omPoDMQsQmZhdgDtc7QMFfGWiW5s3ITYK+YvebeuGDCRaTeTkyfrGnjzbEQ
 l2740v68hWMbMV9uUSMV9zhv9YwY9ufIX2lZT1j4RmrODsGAS+s9aPznGwD6mbz3
 wVRKcftvad7F9UcMD7hocwyz25dR2q4SBTP9FMNAZTTYLiBxTTT1rSP5qGujolgv
 ghtRATBhpsocu8Nt4FitGodSB8+I7aFJSFjGBBegZqLSyXGMybzWglGFxehdOGBp
 /+U3R5pxUwWLhWYwCKEQaXimZIxD16iJ1fA==
X-ME-Sender: <xms:_WtqZyvL0KV8Ws5H2IpelbkBSbZtSYt7WJME29mBNoBBJxXOxmRsjw>
 <xme:_WtqZ3c_CQ4HVm8Nq77RWF3Az96Gv8BLiTdE_sWQPBNPxKP_WFyO1PL66P9kR9LX-
 072uRO9AWWismQ8iw>
X-ME-Received: <xmr:_WtqZ9xHCfRABehujpHyY3cHNJtJNSvtwzmlwG15RU-weVm1iikRS7EG9QFnKtQEeHM3owHYhuHt4eCHL9WySW0BMw>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddufedguddugecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvve
 fujghffffkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgv
 nhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrg
 htthgvrhhnpeefueffvdeffeeftdeutdfgjeettdduveduudefjedtkeejgfehhedvgffg
 ffduhfenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrihhlfhhrohhmpe
 hrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgtphhtthhopedv
 pdhmohguvgepshhmthhpohhuthdprhgtphhtthhopegsuhhgshesghhnuhdrshhuphhpoh
 hrthdprhgtphhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:_WtqZ9OsNooMq-0jm5j6YqBBNFtmuUbL40vsuW5dUgjx2jXRETyB9Q>
 <xmx:_WtqZy_sVIG_kxzom7_P1kA3Q81NdQv8r2Gk7vpsqk5DRspEW8Sd8A>
 <xmx:_WtqZ1VmlVvcReX_d8OhWCdzZq1dbdv8RXufBdNjYjz20uHfGdRhuA>
 <xmx:_WtqZ7eBhre0K_G7fP7-yTZhj6Z3GiQkPnZZobz-v9e7p493Cvc3Eg>
 <xmx:_mtqZ0KwOXuz57nK3M-prOfb0qAPPI_3n19OOkw4esXzJW0T7V6W9jjQ>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Tue,
 24 Dec 2024 03:08:26 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Jean Louis <bugs@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <Z2l4cENHlenfcpSI@lco2>
References: <87bjx6mrjp.fsf@HIDDEN> <Z2kEQOIHC_NNPaBN@lco2>
 <87ttaun617.fsf@HIDDEN> <Z2l4cENHlenfcpSI@lco2>
Date: Tue, 24 Dec 2024 09:08:18 +0100
Message-ID: <87bjx1fqq5.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Jean Louis <bugs@HIDDEN> writes:

> I am using development Emacs version, is patch for it?

Yes, it was generated against Emacs master (commit 07cc8abca75 at the
time I generated the patches).

> I don't know how to apply your patch. Can you tell me step by step?

To be honest I'm new to the patch workflow myself but it should be:

  git apply path/to/the/patch

run from the repository root, on the master branch. If you want to try
out icalendar-mode, do this for both patches in succession (the second
patch, which provides icalendar-mode, requires the code introduced in
the first).

To run the test suite and verify that it works: make check

(Please let me know here if you see any errors in
test/lisp/calendar/icalendar-parser-tests.log)

There aren't many user-facing functions yet, but if you want to test the
code with some iCalendar data you have in file, do M-x find-file
path/to/file; activate icalendar-mode if it doesn't activate
automatically; say "y" to unfold lines if asked (Note: there is
currently a bug where you may get asked multiple times; if you've
already got the data in an unfolded buffer and get asked again, you can
say "n"; I still need to look into this); and then, in the unfolded
buffer, try calling functions like this (e.g. with M-:): 

(icalendar-parse-component (point-max)), with point at the start of a
"BEGIN:..." line, e.g. at the start of BEGIN:VCALENDAR if you want to
parse a whole calendar.

(icalendar-parse-property (line-end-position)), with point at the start
of a property line 

If you get any parse errors, please let me know here!

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 23 Dec 2024 14:49:35 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Mon Dec 23 09:49:34 2024
Received: from localhost ([127.0.0.1]:53871 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tPjkU-0000vH-L5
	for submit <at> debbugs.gnu.org; Mon, 23 Dec 2024 09:49:34 -0500
Received: from stw1.rcdrun.com ([217.170.207.13]:38783)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <bugs@HIDDEN>) id 1tPjkS-0000v5-8E
 for 74994 <at> debbugs.gnu.org; Mon, 23 Dec 2024 09:49:33 -0500
Received: from localhost ([::ffff:41.75.183.124])
 (AUTH: PLAIN admin, TLS: TLS1.3,256bits,ECDHE_RSA_AES_256_GCM_SHA384)
 by stw1.rcdrun.com with ESMTPSA
 id 000000000007DC8B.0000000067697879.0010F3AF; Mon, 23 Dec 2024 07:49:28 -0700
Date: Mon, 23 Dec 2024 17:49:20 +0300
From: Jean Louis <bugs@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
Message-ID: <Z2l4cENHlenfcpSI@lco2>
References: <87bjx6mrjp.fsf@HIDDEN> <Z2kEQOIHC_NNPaBN@lco2>
 <87ttaun617.fsf@HIDDEN>
Mime-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Content-Disposition: inline
In-Reply-To: <87ttaun617.fsf@HIDDEN>
User-Agent: Mutt/2.2.12 (2023-09-09)
X-Spam-Score: 0.0 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.0 (-)

* Richard Lawrence <rwl@HIDDEN> [2024-12-23 11:44]:
> Anyway, as I said in my initial post, I think that a more robust
> iCalendar implementation belongs in Emacs core. There are already
> *three* partial implementations in Emacs, but they are all incomplete
> and support different applications (Gnus, Org, and diary). The reason
> for this, I believe, is that the current icalendar.el is not well
> documented and somewhat difficult to extend. The idea here is to provide
> a library that all three of these applications, as well as third party
> packages, can more easily use.

I find iCalendar for compatibility with other software very useful.

I don't know how to apply your patch. Can you tell me step by step?

I am using development Emacs version, is patch for it?

-- 
Jean Louis




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 23 Dec 2024 08:43:49 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Mon Dec 23 03:43:49 2024
Received: from localhost ([127.0.0.1]:53265 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tPe2W-0000Wf-Qk
	for submit <at> debbugs.gnu.org; Mon, 23 Dec 2024 03:43:49 -0500
Received: from fout-a5-smtp.messagingengine.com ([103.168.172.148]:43847)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tPe2U-0000WH-4E
 for 74994 <at> debbugs.gnu.org; Mon, 23 Dec 2024 03:43:47 -0500
Received: from phl-compute-06.internal (phl-compute-06.phl.internal
 [10.202.2.46])
 by mailfout.phl.internal (Postfix) with ESMTP id AA36513801CA;
 Mon, 23 Dec 2024 03:43:39 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-06.internal (MEProxy); Mon, 23 Dec 2024 03:43:39 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:cc:content-type:content-type:date:date
 :from:from:in-reply-to:in-reply-to:message-id:mime-version
 :references:reply-to:subject:subject:to:to; s=fm1; t=1734943419;
 x=1735029819; bh=bJtGnPCfg2DG2+49x153iBciHo3cp9sTqmIvJgt9RKA=; b=
 UWgPKtWDogNJAvzV1P8qi4vQKlhDKGtFp/xl1/Aig2DpwX8yng3D9xVlmWiRF/yg
 HSbeSX6LcgIxsQLXWfUhqDoTDU0rkfkJrG6ldsiw5ykqF5AmtobqDYgK6hNasa/+
 Pi2ZkTyY9AG6LS+jIxELCpMGdy092G1zQA413YGW5RSddFHC3vnNf9nMcmxoiVpo
 rzt0yHZYmurc6CNnn0uM4K1jvI+raunmimYHquZyFCisIpW2kUH4EkpE/OFfOdun
 MM1SUf/5Dn6zbXsmHODJm+7vFAvody557uqLF90ivE1doAjujm4kOS6gsytoPV6D
 qKLHAGfooYDThHQ+7P1iUw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:in-reply-to
 :message-id:mime-version:references:reply-to:subject:subject:to
 :to:x-me-proxy:x-me-sender:x-me-sender:x-sasl-enc; s=fm2; t=
 1734943419; x=1735029819; bh=bJtGnPCfg2DG2+49x153iBciHo3cp9sTqmI
 vJgt9RKA=; b=t9Ce2i+ECUgVKPgR4gSeOvVfXdK4Cq3kkITkxbbvVborwCXwxzW
 FVI54JpmgYMcpN/CDytGTIr8L0y8YrgkIQyxRyASpL5RMzWP3+Kgf41JaBquHwQs
 oL/F1Y8bwO8TbHeanp4k4tYyigTiUDWQgg1e+VIm50S/yd6lH3w2P8zN968RpAdB
 gHcn5FmqGtiOIvkMK80jdhI2bEDWlll50ksAIpJ/fLhKph/NAZG5xmpkEUiy3E7Q
 SBYQRA+WGpWRmKVGcZbptKsiSsaVmNPSE2D9r/uXt2ZWixGhG8Pj1FGPggTDnkOv
 je4TcOcqMAK/A6bn4lmA/mC07KaLnTeAkgQ==
X-ME-Sender: <xms:uiJpZ7chjZ93CW6hNC9_nmmoCrG2Z6xJB4e4RH3tpeNlsle4YCHS5Q>
 <xme:uiJpZxPhC8qBRviofpxEe1_-oovicjbAQq5OxCM65vw7_JpMD0WzudfaxqKjbVY8m
 AZ_rIQPZyJrxZ-DVw>
X-ME-Received: <xmr:uiJpZ0g_KqKupGH-_3sNBwxQJjE31xdIlpe-8eruuR6YaaPFHM_vK_LoihErek45Edd44PFsO1NiJu8xjqudfTF9KA>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddtledguddvudcutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvve
 fujghffffkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgv
 nhgtvgcuoehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrg
 htthgvrhhnpeefueffvdeffeeftdeutdfgjeettdduveduudefjedtkeejgfehhedvgffg
 ffduhfenucevlhhushhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrihhlfhhrohhmpe
 hrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgtphhtthhopedv
 pdhmohguvgepshhmthhpohhuthdprhgtphhtthhopegsuhhgshesghhnuhdrshhuphhpoh
 hrthdprhgtphhtthhopeejgeelleegseguvggssghughhsrdhgnhhurdhorhhg
X-ME-Proxy: <xmx:uiJpZ88NGwwnQjDv2wnCPxKvZCD6iQGmWl-xrmi7z4UiqHVD9YTgLw>
 <xmx:uiJpZ3ufBO8RDvejK9wqVV3U8pPgFvzEHA27g7ppmPRTgSYfR8ZAsg>
 <xmx:uiJpZ7EFixwORXDDEHOFitZb05t8qGE3U45ZNfhDZL1BW-HRpilMlg>
 <xmx:uiJpZ-MDGD49ss5Ft9JIEEIr2Wl8I18_0Zc0LrNIIrxDsp_-_nuXAw>
 <xmx:uyJpZw4BON0vHVQRcGzb5hwi8VDZplWAmWVkX59VeG7F8r2Tq-aO1Dk_>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA; Mon,
 23 Dec 2024 03:43:38 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: Jean Louis <bugs@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
In-Reply-To: <Z2kEQOIHC_NNPaBN@lco2>
References: <87bjx6mrjp.fsf@HIDDEN> <Z2kEQOIHC_NNPaBN@lco2>
Date: Mon, 23 Dec 2024 09:43:32 +0100
Message-ID: <87ttaun617.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

Jean Louis <bugs@HIDDEN> writes:

> Is there no package that can accept structure and convert to
> iCalendar?

Not that I was able to find after looking several times, and rather
extensively. I was surprised too.

Anyway, as I said in my initial post, I think that a more robust
iCalendar implementation belongs in Emacs core. There are already
*three* partial implementations in Emacs, but they are all incomplete
and support different applications (Gnus, Org, and diary). The reason
for this, I believe, is that the current icalendar.el is not well
documented and somewhat difficult to extend. The idea here is to provide
a library that all three of these applications, as well as third party
packages, can more easily use.

Best,
Richard




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 23 Dec 2024 06:55:13 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Mon Dec 23 01:55:13 2024
Received: from localhost ([127.0.0.1]:53065 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tPcLR-0003Yg-DQ
	for submit <at> debbugs.gnu.org; Mon, 23 Dec 2024 01:55:13 -0500
Received: from stw1.rcdrun.com ([217.170.207.13]:49915)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <bugs@HIDDEN>) id 1tPcLN-0003WX-NK
 for 74994 <at> debbugs.gnu.org; Mon, 23 Dec 2024 01:55:11 -0500
Received: from localhost ([::ffff:41.75.183.124])
 (AUTH: PLAIN admin, TLS: TLS1.3,256bits,ECDHE_RSA_AES_256_GCM_SHA384)
 by stw1.rcdrun.com with ESMTPSA
 id 000000000007DC1B.0000000067690949.0010D257; Sun, 22 Dec 2024 23:55:05 -0700
Date: Mon, 23 Dec 2024 09:33:36 +0300
From: Jean Louis <bugs@HIDDEN>
To: Richard Lawrence <rwl@HIDDEN>
Subject: Re: bug#74994: Improve Emacs iCalendar support
Message-ID: <Z2kEQOIHC_NNPaBN@lco2>
References: <87bjx6mrjp.fsf@HIDDEN>
Mime-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Content-Disposition: inline
In-Reply-To: <87bjx6mrjp.fsf@HIDDEN>
User-Agent: Mutt/2.2.12 (2023-09-09)
X-Spam-Score: 0.0 (/)
X-Debbugs-Envelope-To: 74994
Cc: 74994 <at> debbugs.gnu.org
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.0 (-)

Is there no package that can accept structure and convert to
iCalendar?

-- 
Jean Louis




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 20 Dec 2024 19:54:05 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Fri Dec 20 14:54:05 2024
Received: from localhost ([127.0.0.1]:44123 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tOj4U-0002tN-V6
	for submit <at> debbugs.gnu.org; Fri, 20 Dec 2024 14:54:05 -0500
Received: from fhigh-a3-smtp.messagingengine.com ([103.168.172.154]:50755)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tOj4R-0002sf-3a
 for 74994 <at> debbugs.gnu.org; Fri, 20 Dec 2024 14:54:01 -0500
Received: from phl-compute-10.internal (phl-compute-10.phl.internal
 [10.202.2.50])
 by mailfhigh.phl.internal (Postfix) with ESMTP id 0903611401D2
 for <74994 <at> debbugs.gnu.org>; Fri, 20 Dec 2024 14:53:54 -0500 (EST)
Received: from phl-mailfrontend-02 ([10.202.2.163])
 by phl-compute-10.internal (MEProxy); Fri, 20 Dec 2024 14:53:54 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:content-type:content-type:date:date
 :from:from:in-reply-to:message-id:mime-version:reply-to:subject
 :subject:to:to; s=fm3; t=1734724434; x=1734810834; bh=iQDBjwWnZ0
 re6XqoHd19FR8IzLXSkm/KD8DMYJX9bVE=; b=dHf8I2O7bNdesMzSeMiGIi6r64
 P2Ljvre+B0J0SlQPQwk+i5ZTP9x32qppphRmsVZq5dr12ECFrcNiSqORQXSNZl/c
 9LXkwCK12XcRXo8MUbozF/mYkb9teBrH+EktN/DpW+szoHAL38q9pMlNhMJ6+hUf
 vTGyzAWalazYQzCIniqICPzHmeMHKMZ8K9tIHplbNQbh3YVyF5dMqrvjIdid6Q5k
 c6d5656rmP84MXYA5P9j9AOkTQ03QGtm69XWVVNVgk5OvKfr2syhCLf9gAdAhUMc
 GPfZv3EbNTjxkH/mhfz/AH4yV8Q1//dHRdAkmXUUYDHEVOnE1XCD1kub6rkQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:message-id
 :mime-version:reply-to:subject:subject:to:to:x-me-proxy
 :x-me-sender:x-me-sender:x-sasl-enc; s=fm1; t=1734724434; x=
 1734810834; bh=iQDBjwWnZ0re6XqoHd19FR8IzLXSkm/KD8DMYJX9bVE=; b=m
 CldqvWWqSvxtctVgn0fXVTQhJuMnKGxzyNGZO0PkYAj7pwkiNXxzJBitmruPkGYu
 crhZvQUMdHqdTK8HaVvskXSI/Ia+6IJeEfK3duS4bObnyd9lOQemliwqkmSUVaV8
 +3lBnHWMlxrMHMgJEek9k+UZ9lcJgn3ZFPpQy5GT1y/pp8d69rLrCwDw1xSBnsct
 v5IHBRn/t2ElIOF6LlzkAkGjd0Uym+sYYSBjNfbd/moVTvikiDmEex+SQwEhrJcv
 e2+7qh/B+mL8uUYrVz2ox6GQCPPMUBAqdBMsaqiYtXU58kaAUEt1A4WxDkN7Dmgf
 IycA8ocu2JY7HrAKtB4Cw==
X-ME-Sender: <xms:UctlZ0vhyStf8f_9cSAgprrUFNfPkceIsKWlBZ-m2M6tcGXMTRwwTg>
 <xme:UctlZxdreAhHovBl-HwtVkuMyg27JK3usZqPTCVcw08g9SgpLfQ_00tMR1egLsSeJ
 8ykGT1ASWlnBMGgFA>
X-ME-Received: <xmr:UctlZ_yaEG5XBFkMWxBXa6LSgR-8miu9Lh1Pv5cdAS2gLuCtntxsXy2CMDMBmTphkf4s5k6Ym2di3TL5pCCnZSfWbvQk3xHW29FT679MAx4TPn01uNMn>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddtvddguddvjecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvuf
 ffkfggtgesmhdtreertddttdenucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggv
 uceorhiflhesrhgvtghurhhsvgifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvg
 hrnhepieduleffheekhfeihfetgeekveelieetvefhteeggefgudevueejveeghfekleel
 necuvehluhhsthgvrhfuihiivgeptdenucfrrghrrghmpehmrghilhhfrhhomheprhiflh
 esrhgvtghurhhsvgifihhthhhlvghsshdrnhgvthdpnhgspghrtghpthhtohepuddpmhho
 uggvpehsmhhtphhouhhtpdhrtghpthhtohepjeegleelgeesuggvsggsuhhgshdrghhnuh
 drohhrgh
X-ME-Proxy: <xmx:UctlZ3PRg3UF76v9p4wA7EH3QhMnob9MavhAnKvEr9g7qlXOAetfKg>
 <xmx:UctlZ09ElYP8HyFXXX-cUgDIVb8hWMMgg1OAHLwvphb-DASKXoW-8Q>
 <xmx:UctlZ_Wrpx0Z80ykVSGOQ8rDe3fnQ8rSqMwPTxCPEIXQd6jARWArDw>
 <xmx:UctlZ9fmW7N2tpuoXhvOAcwHBkOpMkr2KUfcQZoZuaeit-IL_90uFA>
 <xmx:UctlZyl4CtOk72d1HOkkr2ZuLU4D-3y7kMFrekOM6kvErrSzNdLK3pFx>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA for
 <74994 <at> debbugs.gnu.org>; Fri, 20 Dec 2024 14:53:51 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: 74994 <at> debbugs.gnu.org
Subject: [PATCH 2/2] New major mode icalendar-mode
Date: Fri, 20 Dec 2024 20:53:49 +0100
Message-ID: <87y10aku5u.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="=-=-="
X-Spam-Score: -0.7 (/)
X-Debbugs-Envelope-To: 74994
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -1.7 (-)

--=-=-=
Content-Type: text/plain

Tags: patch

Here's a second patch which uses the parser provided by the last
patch to implement a new major mode, icalendar-mode, which for now just
provides syntax highlighting and some basic commands for folding and
unfolding lines.

Again, this is a draft; there's still plenty to be done. I'm looking
forward to your feedback.

Thanks,
Richard


--=-=-=
Content-Type: text/patch
Content-Disposition: attachment;
 filename=0002-New-major-mode-icalendar-mode.patch

From b0f3ee82d3e936c6e09d2fa44dc1f9ec933ac2b6 Mon Sep 17 00:00:00 2001
From: Richard Lawrence <rwl@HIDDEN>
Date: Fri, 20 Dec 2024 11:15:42 +0100
Subject: [PATCH 3/3] New major mode icalendar-mode

Import icalendar-mode.el from external repo
Move font lock setup from ical:define-* macros into constants in
  icalendar-mode.el
Check that face keywords are non-nil, and require icalendar-mode,
  when adding to icalendar-font-lock-keywords in ical:define-* macros
---
 lisp/calendar/icalendar-macs.el   |  10 +-
 lisp/calendar/icalendar-mode.el   | 609 ++++++++++++++++++++++++++++++
 lisp/calendar/icalendar-parser.el |  75 +---
 3 files changed, 614 insertions(+), 80 deletions(-)
 create mode 100644 lisp/calendar/icalendar-mode.el

diff --git a/lisp/calendar/icalendar-macs.el b/lisp/calendar/icalendar-macs.el
index 2030efc5e6d..fab78fce866 100644
--- a/lisp/calendar/icalendar-macs.el
+++ b/lisp/calendar/icalendar-macs.el
@@ -379,9 +379,7 @@ ical:define-param
        ;; Associate the print name with the type symbol for
        ;; `ical:parse-params' and `ical:print-param':
        (when ,param-name
-         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types))
-       ;; TODO: integrate param-name with eldoc in icalendar-mode
-       )))
+         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types)))))
 
 
 ;; Define properties:
@@ -797,10 +795,8 @@ ical:define-component
        ;; Associate the print name with the type symbol for
        ;; `icalendar-parse-component', `icalendar-print-component' etc.:
        (when ,component-name
-         (push (cons ,component-name (quote ,symbolic-name)) ical:component-types))
-
-       ;; TODO: integrate component-name with eldoc in icalendar-mode
-       )))
+         (push (cons ,component-name (quote ,symbolic-name))
+               ical:component-types)))))
 
 (provide 'icalendar-macs)
 ;; Local Variables:
diff --git a/lisp/calendar/icalendar-mode.el b/lisp/calendar/icalendar-mode.el
new file mode 100644
index 00000000000..0a0d339c89c
--- /dev/null
+++ b/lisp/calendar/icalendar-mode.el
@@ -0,0 +1,609 @@
+;;; icalendar-mode.el --- Major mode for iCalendar format  -*- lexical-binding: t; -*-
+;;;
+
+;; Copyright (C) 2024 Richard Lawrence
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Keywords: calendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines icalendar-mode, a major mode for editing
+;; iCalendar data. It defines a syntax table, faces, hooks, and
+;; commands for the mode and sets up syntax highlighting via
+;; font-lock-mode. Syntax highlighting uses the entries for
+;; font-lock-keywords already gathered in icalendar-parser.el, which
+;; see.
+
+;; When activated, icalendar-mode offers to unfold content lines if
+;; necessary, and switch to a new buffer containing the unfolded data;
+;; see `ical:maybe-switch-to-unfolded-buffer'. This is because the
+;; parsing facilities, and thus syntax highlighting, assume that
+;; content lines have already been unfolded. When a buffer is saved,
+;; icalendar-mode also offers to fold long content if necessary, as
+;; required by RFC5545; see `ical:before-save-checks'.
+
+;;; Code:
+
+(require 'icalendar-parser)
+
+;; Faces and font lock:
+(defgroup ical:faces
+  '((ical:property-name custom-face)
+    (ical:property-value custom-face)
+    (ical:parameter-name custom-face)
+    (ical:parameter-value custom-face)
+    (ical:component-name custom-face)
+    (ical:keyword custom-face)
+    (ical:binary-data custom-face)
+    (ical:date-time-types custom-face)
+    (ical:numeric-types custom-face)
+    (ical:recurrence-rule custom-face)
+    (ical:warning custom-face)
+    (ical:ignored custom-face))
+  "Faces for icalendar-mode.") ; TODO: :group
+
+(defface ical:property-name
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for iCalendar property names")
+
+(defface ical:property-value
+  '((default . (:inherit default)))
+  "Face for iCalendar property values")
+
+(defface ical:parameter-name
+  '((default . (:inherit font-lock-property-name-face)))
+  "Face for iCalendar parameter names")
+
+(defface ical:parameter-value
+  '((default . (:inherit font-lock-property-use-face)))
+  "Face for iCalendar parameter values")
+
+(defface ical:component-name
+  '((default . (:inherit font-lock-constant-face)))
+  "Face for iCalendar component names")
+
+(defface ical:keyword
+  '((default . (:inherit font-lock-keyword-face)))
+  "Face for other iCalendar keywords")
+
+(defface ical:binary-data
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar values that represent binary data")
+
+(defface ical:date-time-types
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar values that represent dates, date-times,
+durations, periods, and UTC offsets")
+
+(defface ical:numeric-types
+  '((default . (:inherit ical:property-value-face)))
+  "Face for iCalendar values that represent integers, floats, and geolocations")
+
+(defface ical:recurrence-rule
+  '((default . (:inherit font-lock-type-face)))
+  "Face for iCalendar recurrence rule values")
+
+(defface ical:uri
+  '((default . (:inherit ical:property-value-face :underline t)))
+  "Face for iCalendar values that are URIs (including URLs and mail addresses)")
+
+(defface ical:warning
+  '((default . (:inherit font-lock-warning-face)))
+  "Face for iCalendar syntax errors")
+
+(defface ical:ignored
+  '((default . (:inherit font-lock-comment-face)))
+  "Face for iCalendar syntax which is parsed but ignored")
+
+;;; Font lock:
+(defconst ical:params-font-lock-keywords
+  '((ical:match-other-param
+     (1 'font-lock-comment-face t t)
+     (2 'font-lock-comment-face t t)
+     (3 'ical:warning t t))
+    (ical:match-value-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sent-by-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-rsvp-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-role-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-reltype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-related-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-range-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-partstat-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-member-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-language-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-fbtype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-fmttype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-encoding-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-dir-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-to-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-delegated-from-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-cutype-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-cn-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:parameter-value t t)
+     (3 'ical:warning t t))
+    (ical:match-altrep-param
+     (1 'ical:parameter-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar property parameters in `font-lock-keywords'.")
+
+(defconst ical:properties-font-lock-keywords
+  '((ical:match-request-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-other-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-sequence-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-last-modified-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstamp-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-created-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-trigger-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-repeat-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-action-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-rrule-property
+     (1 'ical:property-name t t)
+     (2 'ical:recurrence-rule t t)
+     (3 'ical:warning t t))
+    (ical:match-rdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-exdate-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-uid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-url-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-related-to-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-recurrence-id-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-organizer-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-contact-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attendee-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzurl-property
+     (1 'ical:property-name t t)
+     (2 'ical:uri t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetto-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzoffsetfrom-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-tzname-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-tzid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-transp-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-freebusy-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-duration-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtstart-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-due-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-dtend-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-completed-property
+     (1 'ical:property-name t t)
+     (2 'ical:date-time-types t t)
+     (3 'ical:warning t t))
+    (ical:match-summary-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-status-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-resources-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-priority-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-percent-complete-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-location-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-geo-property
+     (1 'ical:property-name t t)
+     (2 'ical:numeric-types t t)
+     (3 'ical:warning t t))
+    (ical:match-description-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-comment-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-class-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t))
+    (ical:match-categories-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-attach-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t)
+     (13 'ical:uri t t)
+     (14 'ical:binary-data t t))
+    (ical:match-version-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-prodid-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-method-property
+     (1 'ical:property-name t t)
+     (2 'ical:property-value t t)
+     (3 'ical:warning t t))
+    (ical:match-calscale-property
+     (1 'ical:property-name t t)
+     (2 'ical:keyword t t)
+     (3 'ical:warning t t)))
+  "Entries for iCalendar properties in `font-lock-keywords'.")
+
+(defconst ical:ignored-properties-font-lock-keywords
+  `((,(rx ical:other-property) (1 'ical:ignored keep)
+                               (2 'ical:ignored keep)))
+  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
+
+(defconst ical:components-font-lock-keywords
+  '((ical:match-vcalendar-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-other-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-valarm-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-daylight-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-standard-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtimezone-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vfreebusy-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vjournal-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vtodo-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t))
+    (ical:match-vevent-component
+     (1 'ical:keyword t t)
+     (2 'ical:component-name t t)))
+  "Entries for iCalendar components in `font-lock-keywords'.")
+
+(defvar ical:font-lock-keywords
+  (append ical:params-font-lock-keywords
+          ical:properties-font-lock-keywords
+          ical:components-font-lock-keywords
+          ical:ignored-properties-font-lock-keywords)
+  "Value of `font-lock-keywords' for icalendar-mode.")
+
+
+;; The major mode:
+
+;;; Mode hook
+(defvar ical:mode-hook nil
+  "Hook run when activating `ical:mode'.")
+
+(add-to-list 'auto-mode-alist '("\\.ics\\'" . icalendar-mode))
+
+;;; Syntax table
+(defvar ical:mode-syntax-table
+    (let ((st (make-syntax-table)))
+      ;; Characters for which the standard syntax table suffices:
+      ;; ; (punctuation): separates some property values, and property parameters
+      ;; " (string): begins and ends string values
+      ;; : (punctuation): separates property name (and parameters) from property
+      ;;                  values
+      ;; , (punctuation): separates values in a list
+      ;; CR, LF (whitespace): content line endings
+      ;; space (whitespace): when at the beginning of a line, continues the
+      ;;                     previous line
+
+      ;; Characters which need to be adjusted from the standard syntax table:
+      ;; = is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?= ".   " st)
+      ;; / is punctuation, not a symbol constituent:
+      (modify-syntax-entry ?/ ".   " st)
+      st)
+    "Syntax table used in `ical:mode'.")
+
+
+;;; Commands
+
+;; TODO: is there a corresponding list by mimetype for buffers
+;; displaying message parts? Thought I saw this somewhere...
+
+(defun ical:switch-to-unfolded-buffer ()
+  "Switch to viewing the contents of the current buffer in a new
+buffer where content lines have been unfolded.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets; `unfolding'
+means removing the extra whitespace inserted by folding. The
+iCalendar standard (RFC5545) requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it. In icalendar-mode, folded lines may not have proper
+syntax highlighting; this command allows you to view iCalendar
+data with proper syntax highlighting, as the parser sees it.
+
+If the current buffer is visiting a file, this function will
+offer to save the buffer first, and then reload the contents from
+the file, performing unfolding with `icalendar-unfold-undecoded-region'
+before decoding it. This is the most reliable way to unfold lines.
+
+If it is not visiting a file, it will unfold the new buffer
+with `icalendar-unfold-region'. This can in some cases have
+undesirable effects (see its docstring), so the original contents
+are preserved unchanged in the current buffer.
+
+In both cases, after switching to the new buffer, this command
+offers to kill the original buffer.
+
+It is recommended to turn off `auto-fill-mode' when viewing an
+unfolded buffer, so that filling does not interfere with syntax
+highlighting. This function offers to disable `auto-fill-mode' if
+it is enabled in the new buffer; consider using
+`visual-line-mode' instead."
+  (interactive)
+  (when (and buffer-file-name (buffer-modified-p))
+    (when (y-or-n-p (format "Save before reloading from %s?"
+                            (file-name-nondirectory buffer-file-name)))
+      (save-buffer)))
+  (let ((old-buffer (current-buffer))
+        (mmode major-mode)
+        (uf-buffer (if buffer-file-name
+                       (ical:unfolded-buffer-from-file buffer-file-name)
+                     (ical:unfolded-buffer-from-buffer (current-buffer)))))
+    (switch-to-buffer uf-buffer)
+    ;; restart original major mode, in case the new buffer is
+    ;; still in fundamental-mode: TODO: is this necessary?
+    (funcall mmode)
+    (when (y-or-n-p (format "Unfolded buffer is shown. Kill %s?"
+                            (buffer-name old-buffer)))
+      (kill-buffer old-buffer))
+    (when (and auto-fill-function
+               (y-or-n-p "Disable auto-fill-mode?"))
+      (auto-fill-mode -1))))
+
+(defun ical:maybe-switch-to-unfolded-buffer ()
+  "Check for folded lines and ask for confirmation before calling
+`icalendar-switch-to-unfolded-buffer', which see.
+
+This function is intended to be run via `icalendar-mode-hook'
+when `icalendar-mode' is activated."
+  (interactive)
+  (if (ical:contains-folded-lines-p)
+      (when (y-or-n-p "Buffer contains folded lines; unfold in new buffer?")
+        (ical:switch-to-unfolded-buffer))
+    ;; No need for unfolding, just inform the user:
+    (message "Buffer does not contain any lines to unfold")))
+
+(add-hook 'ical:mode-hook 'ical:maybe-switch-to-unfolded-buffer)
+
+(defun ical:before-save-checks ()
+  "Offer to change coding system and fold content lines in the
+current buffer when saving a buffer in `icalendar-mode'.
+
+The iCalendar standard requires CR-LF line endings, so if
+`buffer-file-coding-system' does not use a coding system which
+specifies them, this command offers to switch to a corresponding
+coding system which does.
+
+`Folding' means inserting a line break and a single whitespace
+character to continue lines longer than 75 octets. The iCalendar
+standard requires folding lines when serializing data to
+iCalendar format, so if the buffer contains unfolded lines, this
+command asks you whether you want to fold them."
+  (interactive)
+  (when (eq major-mode 'ical:mode)
+    (let* ((cs buffer-file-coding-system)
+           (suggested-cs (if cs (coding-system-change-eol-conversion cs 'dos)
+                           'prefer-utf-8-dos)))
+      (when (and (not (coding-system-equal cs suggested-cs))
+                 (y-or-n-p
+                  (format "Current coding system %s does not use CR-LF line endings. Change to %s for save?" cs suggested-cs)))
+        (set-buffer-file-coding-system suggested-cs))
+      (when (and (ical:contains-unfolded-lines-p)
+                 (y-or-n-p "Fold content lines before saving?"))
+        (ical:fold-region (point-min) (point-max))))))
+
+(add-hook 'before-save-hook 'ical:before-save-checks)
+
+;;; Mode definition
+(define-derived-mode ical:mode text-mode "iCalendar"
+  "Major mode for viewing and editing iCalendar (RFC5545) data.
+
+This mode provides syntax highlighting for iCalendar components,
+properties, values, and property parameters, and commands to deal
+with folding and unfolding iCalendar content lines.
+
+`Folding' means inserting whitespace characters to continue long
+lines; `unfolding' means removing the extra whitespace inserted
+by folding. The iCalendar standard requires folding lines when
+serializing data to iCalendar format, and unfolding before
+parsing it.
+
+Thus icalendar-mode's syntax highlighting is designed to work with
+unfolded lines. When icalendar-mode is activated, it will offer to
+unfold lines; see `icalendar-switch-to-unfolded-buffer'. It will also
+offer to fold lines when saving a buffer to a file; see
+`icalendar-before-save-checks'. That function also offers to convert the
+line endings in the file to CR-LF, as the standard requires."
+  :group 'icalendar
+  :syntax-table ical:mode-syntax-table
+  ;; TODO: Keymap?
+  ;; TODO: buffer-local variables?
+  ;; TODO: indent-line-function and indentation variables
+  ;; TODO: mode-specific menu and context menus
+  ;; TODO: eldoc integration
+  ;; TODO: completion of keywords
+  ;; TODO: hook for folding in change-major-mode-hook?
+  (progn
+    (setq font-lock-defaults '(ical:font-lock-keywords nil t))))
+
+(provide 'icalendar-mode)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-mode.el ends here
diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-parser.el
index bc9524ff389..08cc4dbd0fb 100644
--- a/lisp/calendar/icalendar-parser.el
+++ b/lisp/calendar/icalendar-parser.el
@@ -41,12 +41,7 @@
 ;; standard as type symbols. These type symbols store all the metadata
 ;; about the relevant types, and are used for type-based dispatch in the
 ;; parser and printer functions. In the abstract syntax tree, each node
-;; contains a type symbol naming its type.
-;;
-;; The regular expressions defined by the `ical:define-*' macros are
-;; also used to create entries for `font-lock-keywords', which are
-;; gathered into several constants along the way, and used to provide
-;; syntax highlighting in icalendar-mode.el. A number of other regular
+;; contains a type symbol naming its type. A number of other regular
 ;; expressions which encode basic categories of the grammar are also
 ;; defined in this file.
 ;;
@@ -1619,9 +1614,6 @@ ical:utc-offset
 
 ;;; Section 3.2: Property Parameters
 
-(defconst ical:params-font-lock-keywords nil ;; populated by ical:define-param
-  "Entries for iCalendar property parameters in `font-lock-keywords'.")
-
 (defconst ical:param-types nil ;; populated by ical:define-param
   "Alist mapping printed parameter names to type symbols")
 
@@ -1756,7 +1748,6 @@ ical:altrepparam
   "Alternate text representation (URI)"
   ical:uri
   :quoted t
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.1")
 
 (ical:define-param ical:cnparam "CN"
@@ -1778,7 +1769,6 @@ ical:cutypeparam
   ;; don't recognize the same way as they would the UNKNOWN
   ;; value":
   :unrecognized "UNKNOWN"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.3")
 
 (ical:define-param ical:delfromparam "DELEGATED-FROM"
@@ -1791,7 +1781,6 @@ ical:delfromparam
   ical:cal-address
   :quoted t
   :list-sep ","
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.4")
 
 (ical:define-param ical:deltoparam "DELEGATED-TO"
@@ -1804,7 +1793,6 @@ ical:deltoparam
   ical:cal-address
   :quoted t
   :list-sep ","
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.5")
 
 (ical:define-param ical:dirparam "DIR"
@@ -1816,7 +1804,6 @@ ical:dirparam
 user which is the value of the property."
    ical:uri
    :quoted t
-   :value-face ical:uri
    :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.6")
 
 (ical:define-param ical:encodingparam "ENCODING"
@@ -1827,7 +1814,6 @@ ical:encodingparam
 is \"BINARY\"."
   (or "8BIT" "BASE64")
   :default "8BIT"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.7")
 
 (rx-define ical:mimetype
@@ -1867,7 +1853,6 @@ ical:fbtypeparam
       ical:x-name
       ical:iana-token)
   :default "BUSY"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.9")
 
 ;; TODO: see https://www.rfc-editor.org/rfc/rfc5646#section-2.1
@@ -1893,7 +1878,6 @@ ical:memberparam
   ical:cal-address
   :quoted t
   :list-sep ","
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.11")
 
 (ical:define-param ical:partstatparam "PARTSTAT"
@@ -1925,7 +1909,6 @@ ical:partstatparam
   ;; they don't recognize the same way as they would the
   ;; NEEDS-ACTION value."
   :default "NEEDS-ACTION"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.12")
 
 (ical:define-param ical:rangeparam "RANGE"
@@ -1936,7 +1919,6 @@ ical:rangeparam
 legacy applications might also produce \"THISANDPRIOR\"."
   "THISANDFUTURE"
   :default "THISANDFUTURE"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.13")
 
 (ical:define-param ical:trigrelparam "RELATED"
@@ -1948,7 +1930,6 @@ ical:trigrelparam
 the start of the component; similarly for \"END\"."
   (or "START" "END")
   :default "START"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.14")
 
 (ical:define-param ical:reltypeparam "RELTYPE"
@@ -1968,7 +1949,6 @@ ical:reltypeparam
   ;; "Applications MUST treat x-name and iana-token values they don't
   ;; recognize the same way as they would the PARENT value."
   :default "PARENT"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15")
 
 (ical:define-param ical:roleparam "ROLE"
@@ -1991,7 +1971,6 @@ ical:roleparam
   ;; they don't recognize the same way as they would the
   ;; REQ-PARTICIPANT value."
   :default "REQ-PARTICIPANT"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.16")
 
 (ical:define-param ical:rsvpparam "RSVP"
@@ -2002,7 +1981,6 @@ ical:rsvpparam
 the Organizer of a VEVENT or VTODO."
   ical:boolean
   :default "FALSE"
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.17")
 
 (ical:define-param ical:sentbyparam "SENT-BY"
@@ -2019,7 +1997,6 @@ ical:sentbyparam
   ;; have the same print name.
   ical:cal-address
   :quoted t
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.18")
 
 (ical:define-param ical:tzidparam "TZID"
@@ -2107,7 +2084,6 @@ ical:valuetypeparam
 containing property's value, if it is not of the default value
 type."
   ical:printed-value-type
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.20")
 
 (ical:define-param ical:otherparam nil ; don't add to ical:param-types
@@ -2117,9 +2093,7 @@ ical:otherparam
 parameters with an unknown name (matching rx `icalendar-param-name')
 whose values must be parsed and preserved but not further
 interpreted."
-  ical:param-value
-  :name-face font-lock-comment-face
-  :value-face font-lock-comment-face)
+  ical:param-value)
 
 (rx-define ical:other-param-safe
   ;; we use this rx to skip params when matching properties and
@@ -2134,10 +2108,6 @@ ical:other-param-safe
 
 ;;; Properties:
 
-(defconst ical:properties-font-lock-keywords
-  nil ;; populated by ical:define-property
-  "Entries for iCalendar properties in `font-lock-keywords'.")
-
 (defconst ical:property-types nil ;; populated by ical:define-property
   "Alist mapping printed property names to type symbols")
 
@@ -2373,7 +2343,6 @@ ical:calscale
   "GREGORIAN"
   :default "GREGORIAN"
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.1")
 
 (ical:define-property ical:method "METHOD"
@@ -2440,8 +2409,6 @@ ical:attach
                              ical:encodingparam)
                :zero-or-more (ical:otherparam))
   :other-validator ical:attach-validator
-  :extra-faces ((13 'ical:uri t t)
-                (14 'ical:binary-data t t))
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1")
 
 (defun ical:attach-validator (node)
@@ -2504,7 +2471,6 @@ ical:class
   :default "PUBLIC"
   :unrecognized "PRIVATE"
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3")
 
 (ical:define-property ical:comment "COMMENT"
@@ -2566,7 +2532,6 @@ ical:geo
 the equator if negative. The longitude value is east of the prime
 meridian if positive, and west of it if negative."
   ical:geo-coordinates
-  :value-face ical:numeric-types
   :child-spec (:zero-or-more (ical:otherparam))
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.6")
 
@@ -2595,7 +2560,6 @@ ical:percent-complete
 enforced here)."
   ical:integer
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:numeric-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.8")
 
 ;; TODO: type for priority values?
@@ -2609,7 +2573,6 @@ ical:priority
   ical:integer
   :default "0"
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:numeric-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9")
 
 (ical:define-property ical:resources "RESOURCES"
@@ -2650,7 +2613,6 @@ ical:status
 at most once on these components."
   ical:status-keyword
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11")
 
 (ical:define-property ical:summary "SUMMARY"
@@ -2675,7 +2637,6 @@ ical:completed
 an `icalendar-vtodo' was actually completed. The value must be an
 `icalendar-date-time' with a UTC time."
   ical:date-time
-  :value-face ical:date-time-types
   :child-spec (:zero-or-more (ical:otherparam))
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
 
@@ -2694,7 +2655,6 @@ ical:dtend
   :other-types (ical:date)
   :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2")
 
 (ical:define-property ical:due "DUE"
@@ -2712,7 +2672,6 @@ ical:due
   :other-types (ical:date)
   :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.3")
 
 (ical:define-property ical:dtstart "DTSTART"
@@ -2739,7 +2698,6 @@ ical:dtstart
   :other-types (ical:date)
   :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4")
 
 (ical:define-property ical:duration "DURATION"
@@ -2757,7 +2715,6 @@ ical:duration
 value, then the duration must be given as a number of weeks or days."
   ical:dur-value
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.5")
 
 (ical:define-property ical:freebusy "FREEBUSY"
@@ -2771,7 +2728,6 @@ ical:freebusy
   :list-sep ","
   :child-spec (:zero-or-one (ical:fbtypeparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.6")
 
 (ical:define-property ical:transp "TRANSP"
@@ -2786,7 +2742,6 @@ ical:transp
       "OPAQUE")
   :default "OPAQUE"
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7")
 
 ;;;;; Section 3.8.3: Time Zone Component Properties
@@ -2824,7 +2779,6 @@ ical:tzoffsetfrom
 UTC)."
   ical:utc-offset
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.3")
 
 (ical:define-property ical:tzoffsetto "TZOFFSETTO"
@@ -2839,7 +2793,6 @@ ical:tzoffsetto
 the prime meridian (behind UTC)."
   ical:utc-offset
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.4")
 
 (ical:define-property ical:tzurl "TZURL"
@@ -2849,7 +2802,6 @@ ical:tzurl
 `icalendar-vtimezone' component are published."
   ical:uri
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.5")
 
 ;;;;; Section 3.8.4: Relationship Component Properties
@@ -2885,7 +2837,6 @@ ical:attendee
                              ical:dirparam
                              ical:languageparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1")
 
 (ical:define-property ical:contact "CONTACT"
@@ -2915,7 +2866,6 @@ ical:organizer
                              ical:sentbyparam
                              ical:languageparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3")
 
 (ical:define-property ical:recurrence-id "RECURRENCE-ID"
@@ -2937,7 +2887,6 @@ ical:recurrence-id
                              ical:tzidparam
                              ical:rangeparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.4")
 
 (ical:define-property ical:related-to "RELATED-TO"
@@ -2961,7 +2910,6 @@ ical:url
 `icalendar-vfreebusy' component."
   ical:uri
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:uri
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6")
 
 ;; TODO: UID should probably be its own type
@@ -2998,7 +2946,6 @@ ical:exdate
   :list-sep ","
   :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1")
 
 (ical:define-property ical:rdate "RDATE"
@@ -3018,7 +2965,6 @@ ical:rdate
   :list-sep ","
   :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
                :zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.2")
 
 (ical:define-property ical:rrule "RRULE"
@@ -3033,7 +2979,6 @@ ical:rrule
   ical:recur
   ;; TODO: faces for subexpressions?
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:recurrence-rule
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3")
 
 ;;;;; Section 3.8.6: Alarm Component Properties
@@ -3052,7 +2997,6 @@ ical:action
             ical:x-name)))
   :default-type ical:text
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:keyword
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.1")
 
 (ical:define-property ical:repeat "REPEAT"
@@ -3065,7 +3009,6 @@ ical:repeat
   ical:integer
   :default 0
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:numeric-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.2")
 
 (ical:define-property ical:trigger "TRIGGER"
@@ -3087,7 +3030,6 @@ ical:trigger
   :child-spec (:zero-or-one (ical:valuetypeparam ical:trigrelparam)
                :zero-or-more (ical:otherparam))
   :other-validator ical:trigger-validator
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.3")
 
 (defun ical:trigger-validator (node)
@@ -3130,7 +3072,6 @@ ical:created
 in UTC time."
   ical:date-time
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
 
 (ical:define-property ical:dtstamp "DTSTAMP"
@@ -3154,7 +3095,6 @@ ical:dtstamp
 The value must be in UTC time."
   ical:date-time
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
 
 (ical:define-property ical:last-modified "LAST-MODIFIED"
@@ -3165,7 +3105,6 @@ ical:last-modified
 was last modified in the calendar database."
   ical:date-time
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:date-time-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3")
 
 (ical:define-property ical:sequence "SEQUENCE"
@@ -3180,7 +3119,6 @@ ical:sequence
   ical:integer
   :default 0
   :child-spec (:zero-or-more (ical:otherparam))
-  :value-face ical:numeric-types
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.4")
 
 ;;;;; Section 3.8.8: Miscellaneous Component Properties
@@ -3204,11 +3142,6 @@ ical:other-property
   :child-spec (:allow-others t)
   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8")
 
-(defconst ical:ignored-properties-font-lock-keywords
-  `((,(rx ical:other-property) (1 'ical:ignored keep)
-                               (2 'ical:ignored keep)))
-  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
-
 (defun ical:read-req-status-info (s)
   "Read a request status value from S.
 S should have been previously matched against `icalendar-request-status-info'."
@@ -3281,10 +3214,6 @@ ical:request-status
 
 ;;; Section 3.6: Calendar Components
 
-(defconst ical:components-font-lock-keywords
-  nil ;; populated by ical:define-component
-  "Entries for iCalendar components in `font-lock-keywords'.")
-
 (defconst ical:component-types nil ;; populated by ical:define-component
   "Alist mapping printed component names to type symbols")
 
-- 
2.39.5


--=-=-=--




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at 74994 <at> debbugs.gnu.org:


Received: (at 74994) by debbugs.gnu.org; 20 Dec 2024 19:48:03 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Fri Dec 20 14:48:03 2024
Received: from localhost ([127.0.0.1]:44107 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tOiyh-0002Z4-MG
	for submit <at> debbugs.gnu.org; Fri, 20 Dec 2024 14:48:03 -0500
Received: from fhigh-a3-smtp.messagingengine.com ([103.168.172.154]:49893)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tOiyf-0002Ya-Gg
 for 74994 <at> debbugs.gnu.org; Fri, 20 Dec 2024 14:48:01 -0500
Received: from phl-compute-06.internal (phl-compute-06.phl.internal
 [10.202.2.46])
 by mailfhigh.phl.internal (Postfix) with ESMTP id 313011140083
 for <74994 <at> debbugs.gnu.org>; Fri, 20 Dec 2024 14:47:56 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-06.internal (MEProxy); Fri, 20 Dec 2024 14:47:56 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:content-type:content-type:date:date
 :from:from:in-reply-to:message-id:mime-version:reply-to:subject
 :subject:to:to; s=fm3; t=1734724076; x=1734810476; bh=5qD/W2B32H
 crBBmNDWJ9KiGDz3Cv/ctRZr8PLjE521A=; b=F76hUF89/FMvkspqvn4UUZOHPZ
 4uYImd5gTZEtKAMkeBX39hBvDxuuQy9diueRB8Y3nP0GVAeSLWgfJ9CJk4cv39g9
 S81wYn1Yu2IfSSAr2M1IJbEj99QhcMpOhG1m4Ncjf9LRbrA+kItlGqiYsexl3fkH
 57nDCbkFPXLWSm4HfKfRxn/YUOkJPbdXJ/ntX5sHJUMd1mewDlMXnoSsWz6Laggo
 UqL0SnChcTJSD192v/iwiaRC2uEEP1v5XCHU5xwyLfrbqi5Vbcaiq5ywqkcCa0e/
 3HkmmnMaM61w+VbLgbjz9dYvMA606E8M57krsmOCyY3WLdl2oR6zrSU4Dgcw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:message-id
 :mime-version:reply-to:subject:subject:to:to:x-me-proxy
 :x-me-sender:x-me-sender:x-sasl-enc; s=fm1; t=1734724076; x=
 1734810476; bh=5qD/W2B32HcrBBmNDWJ9KiGDz3Cv/ctRZr8PLjE521A=; b=c
 DyZyDsh/DbZUi30Hv61cswP/3Z6quJn4VQgnPyZ+sYM5pkamVkJs/1ewcshlSlJv
 zrK9CozBOZD6G/r4vO+3tkf8CIcuf9Dftn4uysicHayd/pl2maP1VNaq9lNkuzU6
 32EQ0aMVcoTKjLJ9gGmnP8NL3ODRubeI1uyaisBrCpqhoy1E27VSRLpOhTu1wOE3
 zDJwAO0EoDtL+1nvG9UnC+6aVn/PEHCZAXiykdnIeLTYD28A+1fG0VBYVeHrvN4I
 BCiKnhlL8NwH70AH6TqBYaDtL6gmheM+jk8JQiDJwoKOIsbtMkk8dP5DtlVtNtfL
 Nz3rp5s88bE3Cjn9jhX3A==
X-ME-Sender: <xms:68llZ7ty_OkBo6tdrq5NGgURZ6dgSVnzoHNqxAdoKOt2UD3Y9fP35g>
 <xme:68llZ8f36qUs9gUQu9QnRvg4d_lAoM2b2aDbWJEDdif4pRJKG_hDiV1YfgzhCKozx
 Mob23y6vBoKXcdaSg>
X-ME-Received: <xmr:68llZ-yhfLQFlbs45FqbRvkDC9Om0m3a39avlOMrwfu727fL3JZvDgHE5H7YUecrr25mTxvRy-L92SfOwbgm0LLi3VgNWiGMK6eG7GkoLeC7YVNSAE6t>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddtvddguddviecutefuodetggdotefrod
 ftvfcurfhrohhfihhlvgemucfhrghsthforghilhdpggftfghnshhusghstghrihgsvgdp
 uffrtefokffrpgfnqfghnecuuegrihhlohhuthemuceftddtnecunecujfgurhephffvuf
 ffkfggtgesmhdtreertddttdenucfhrhhomheptfhitghhrghrugcunfgrfihrvghntggv
 uceorhiflhesrhgvtghurhhsvgifihhthhhlvghsshdrnhgvtheqnecuggftrfgrthhtvg
 hrnhepieduleffheekhfeihfetgeekveelieetvefhteeggefgudevueejveeghfekleel
 necuvehluhhsthgvrhfuihiivgeptdenucfrrghrrghmpehmrghilhhfrhhomheprhiflh
 esrhgvtghurhhsvgifihhthhhlvghsshdrnhgvthdpnhgspghrtghpthhtohepuddpmhho
 uggvpehsmhhtphhouhhtpdhrtghpthhtohepjeegleelgeesuggvsggsuhhgshdrghhnuh
 drohhrgh
X-ME-Proxy: <xmx:68llZ6Nl_gQK7oWlqCIzvhUFKDPNqflULAl4CsrNPObirsewLKiN7w>
 <xmx:68llZ798KfgVNuiXN9MRg6QkfvYt0vO6eE8cqwf7hwpDKFsM6sQ6pA>
 <xmx:68llZ6VTuBKZAjrK7xwdLBcQPsXM_Py5zCzh4rYRE0eRiYgHW0D7Yg>
 <xmx:68llZ8dsWzijIcAD3MSe7VrTaHHovSLwq7hybXk-1GnOuQli12lweA>
 <xmx:7MllZ1li0a5RQh72GJUB69oWrk3I2xWKRrdljggOJOzn79XtQPW3ZKFt>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA for
 <74994 <at> debbugs.gnu.org>; Fri, 20 Dec 2024 14:47:54 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: 74994 <at> debbugs.gnu.org
Subject: [PATCH 1/2] New parser for iCalendar (RFC5545)
Date: Fri, 20 Dec 2024 20:47:48 +0100
Message-ID: <871py2m90b.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="=-=-="
X-Debbugs-Envelope-To: 74994
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>

--=-=-=
Content-Type: text/plain

Tags: patch

Here is a draft patch implementing a new parser for iCalendar data. This
code implements the grammar of RFC5545, functions to parse this grammar
to an abstract syntax tree, functions to validate syntax trees,
functions to print syntax trees, and a test suite for the parser and
printer functions containing all the examples from RFC5545.  The code is
organized as follows:

lisp/calendar/icalendar-ast.el: defines the abstract syntax tree,
  including the validation functions
lisp/calendar/icalendar-macs.el: defines the icalendar-define-param,
  icalendar-define-property, and icalendar-define-component macros
lisp/calendar/icalendar-parser.el: defines the parsing and printing
  functions, and all of the individual parameters, properties, and
  components defined in the RFC.
test/lisp/calendar/icalendar-parser-tests.el: the test suite.
  All the tests pass on my machine with Emacs 29.1 and with Emacs master.

Looking forward to your feedback! This is a (very?) large patch, so
please let me know if it would be better to submit it another way.

Thanks,
Richard


--=-=-=
Content-Type: text/patch
Content-Disposition: attachment; filename=0001-New-parser-for-RFC5545.patch

From cba266129de7575fc2272348e87695bb5f9cf6df Mon Sep 17 00:00:00 2001
From: Richard Lawrence <rwl@HIDDEN>
Date: Thu, 19 Dec 2024 14:30:57 +0100
Subject: [PATCH 2/3] New parser for RFC5545

Import parser code and tests from external repository
Split imported code into icalendar-ast.el, icalendar-macs.el,
  icalendar-parser.el
Make type metadata available at compile time
Fix all compilation warnings

All parser tests pass, also when compiled and run by `make check'
---
 lisp/calendar/icalendar-ast.el               |  536 +++
 lisp/calendar/icalendar-macs.el              |  809 ++++
 lisp/calendar/icalendar-parser.el            | 4090 ++++++++++++++++++
 lisp/calendar/icalendar-uri-schemes.el       |  444 ++
 test/lisp/calendar/icalendar-parser-tests.el | 1796 ++++++++
 5 files changed, 7675 insertions(+)
 create mode 100644 lisp/calendar/icalendar-ast.el
 create mode 100644 lisp/calendar/icalendar-macs.el
 create mode 100644 lisp/calendar/icalendar-parser.el
 create mode 100644 lisp/calendar/icalendar-uri-schemes.el
 create mode 100644 test/lisp/calendar/icalendar-parser-tests.el

diff --git a/lisp/calendar/icalendar-ast.el b/lisp/calendar/icalendar-ast.el
new file mode 100644
index 00000000000..19411767fbc
--- /dev/null
+++ b/lisp/calendar/icalendar-ast.el
@@ -0,0 +1,536 @@
+;;; icalendar-ast.el --- Syntax trees for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the abstract syntax tree representation for
+;; iCalendar data.
+
+
+;;; Code:
+(require 'cl-lib)
+
+;;; Type symbols and metadata
+
+;; All nodes in the syntax treee have a type symbol as their first element.
+;; We use the following symbol properties (all prefixed with 'icalendar-')
+;; to associate type symbols with various important data about the type:
+;;
+;; is-type - t (marks this symbol as an icalendar type)
+;; is-value, is-param, is-property, or is-component - t
+;;   (specifies what sort of value this type represents)
+;; list-sep - for property and parameters types, a string (typically
+;;   "," or ";") which separates individual printed values, if the
+;;   type allows lists of values. If this is non-nil, syntax nodes of
+;;   this type should always have a list of values in their VALUE
+;;   field (even if there is only one value)
+;; matcher - a function to match this type. This function matches the
+;;   regular expression defined under the type's name; it is used to provide
+;;   syntax highlighting in `icalendar-mode'
+;; begin-rx, end-rx - for component-types, an `rx' regular expression which
+;;   matches the BEGIN and END lines that form its boundaries
+;; value-rx - an `rx' regular expression which matches individual values
+;;   of this type, with no consideration for quoting or lists of values.
+;;   (For value types, this is just a synonym for the rx definition
+;;   under the type's symbol)
+;; values-rx - for types that accept lists of values, an `rx' regular
+;;   expression which matches the whole list (including quotes, if required)
+;; full-value-rx - for property and parameter types, an `rx' regular
+;;   expression which matches a valid value expression in group 2, or
+;;   an invalid value in group 3
+;; value-reader - for value types, a function which creates syntax
+;;   nodes of this type given a string representing their value
+;; value-printer - for value types, a function to print individual
+;;   values of this type. It accepts a value and returns its string
+;;   representation.
+;; default-value - for property and parameter types, a string
+;;   representing a default value for nodes of this type. This is the
+;;   value assumed when no node of this type is present in the
+;;   relevant part of the syntax tree.
+;; substitute-value - for parameter types, a string representing a value
+;;   which will be substituted at parse times for unrecognized values.
+;;   (This is normally the same as default-value, but differs from it
+;;   in at least one case in RFC5545, thus it is stored separately.)
+;; default-type - for property types which can have values of multiple
+;;   types, this is the default type when no type for the value is
+;;   specified in the parameters. Any type of value other than this
+;;   one requires a VALUE=... parameter when the property is read or printed.
+;; other-types - for property types which can have values of multiple types,
+;;   this is a list of other types that the property can accept.
+;; child-spec - for property and component types, a plist describing the
+;;   required and optional child nodes. See `icalendar-define-property' and
+;;   `icalendar-define-component' for details.
+;; other-validator - a function to perform type-specific validation
+;;   for nodes of this type. If present, this function will be called
+;;   by `icalendar-ast-node-valid-p' during validation.
+;; type-documentation - a string documenting the type. This documentation is
+;;   printed in the help buffer when `describe-symbol' is called on TYPE.
+;; link - a hyperlink to the documentation of the type in the relevant standard
+
+(defun ical:type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is an iCalendar type symbol.
+
+This function only checks that SYMBOL has been marked as a type;
+it returns t for value types defined by `icalendar-define-type',
+but also e.g. for types defined by `icalendar-define-param' and
+`icalendar-define-property'. To check that SYMBOL names a value
+type for property or parameter values, see
+`icalendar-value-type-symbol-p' and
+`icalendar-printable-value-type-symbol-p'."
+  (and (symbolp symbol)
+       (get symbol 'ical:is-type)))
+
+(defun ical:value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a value
+type, i.e., a type for an iCalendar property or parameter value
+defined by `icalendar-define-type'.
+
+This means that SYMBOL must both satisfy
+`icalendar-type-symbol-p' and have the property
+`icalendar-is-value'. It does not require the type to be
+associated with a print name in `icalendar-value-types';
+for that see `icalendar-printable-value-type-symbol-p'."
+  (and (ical:type-symbol-p symbol)
+       (get symbol 'ical:is-value)))
+
+(defun ical:expects-list-of-values-p (type)
+  "Return non-nil if the syntax node type named by TYPE accepts a
+list of values. This is never t for value types or component
+types. For property and parameter types defined with
+`ical:define-param' and `ical:define-property', it is true if the
+:list-sep argument was specified in the definition."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:list-sep)))
+
+(defun ical:param-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+parameter."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-param)))
+
+(defun ical:property-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+property."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-property)))
+
+(defun ical:component-type-symbol-p (type)
+  "Return non-nil if TYPE is a type symbol for an iCalendar
+component."
+  (and (ical:type-symbol-p type)
+       (get type 'ical:is-component)))
+
+;; TODO: we could define other accessors here for the other metadata
+;; properties, but at the moment I see no advantage to this; they would
+;; all just be long-winded wrappers around `get'.
+
+
+;;; AST metadata from parser.
+
+;; This is intended to serve the same role as the
+;; `:standard-properties' array in `org-element-ast', though that name
+;; would be confusing in the context of RFC5545.
+(cl-defstruct (ical:meta (:constructor ical:-make-meta))
+  "Structure containing meta information in an iCalendar syntax
+node. Do not rely on this representation; it may change."
+  (buffer nil
+    :type (or null buffer)
+    :documentation "The buffer from which this node was parsed")
+  (parent nil
+    :type ical:ast-node-p
+    :documentation "The parent node to which this node belongs")
+  (begin nil
+    :type (or null integer-or-marker)
+    :documentation "The position at which the content of this node begins")
+  (end nil
+    :type (or null integer-or-marker)
+    :documentation "The position at which the content of this node ends")
+  (value-begin nil
+    :type (or null integer-or-marker)
+    :documentation "The position at which the value of this node begins")
+  (value-end nil
+    :type (or null integer-or-marker)
+    :documentation "The position at which the value of this node ends")
+  (original-value nil
+    :type (or null string)
+    :documentation "The original representation of the value as parsed.
+This can differ from the value stored in the node if e.g. the
+standard requires an unrecognized value to be treated the same as
+a certain default")
+  (original-name nil
+    :type (or null string)
+    :documentation
+    "The original representation of the parameter, property, or component
+name as parsed. This can differ from the name corresponding to the node's type
+if e.g. the standard requires parsing a node of an unrecognized type"))
+
+
+;;; AST representation
+
+;; Every syntax node has the format (TYPE META VALUE CHILDREN) where:
+;;
+;; TYPE is a type symbol (typically defined with ical:define-type,
+;; ical:define-param, ical:define-property, or ical:define-component;
+;; see Type Metadata, above)
+;;
+;; META is a struct containing parsing metadata about the node (see
+;; `ical:meta' above)
+;;
+;; VALUE is the node's value, if any.
+;; Depending on TYPE, VALUE can be:
+;; - nil (e.g. component nodes have no value)
+;; - an Elisp data structure representing one of the basic iCalendar
+;;   value types (e.g. a date, a period, or text)
+;; - a syntax node
+;; - a list of one of the above. This is the case if `ical:values-list-p'
+;;   returns t for TYPE.
+;;
+;; CHILDREN is a list of syntax nodes. For component nodes, a list of
+;; property nodes. For property nodes, a list containing parameter
+;; nodes. nil for all other nodes.
+;;
+;; We define general accessors and a constructor `ical:make-ast-node'
+;; for this representation here:
+(defsubst ical:ast-node-type (node)
+  "Return the symbol naming the type of iCalendar syntax node NODE."
+  (car node))
+
+(defsubst ical:ast-node-value (node)
+  "Return the value of iCalendar syntax node NODE.
+In component nodes, this is nil. Otherwise, it is a syntax node
+representing an iCalendar (property or parameter) value."
+  (nth 2 node))
+
+(defsubst ical:ast-node-children (node)
+  "Return the children of iCalendar syntax node NODE.
+In component nodes, this is a list of property nodes and/or
+subcomponent nodes. In property nodes, this is a list of
+parameter nodes. Otherwise the list is nil."
+  (nth 3 node))
+
+(defun ical:ast-node-p (val)
+  "Return non-nil if VAL is an iCalendar syntax node"
+  (and (listp val)
+       (length= val 4)
+       (ical:type-symbol-p (ical:ast-node-type val))))
+
+(defun ical:-keyword-to-slot-name (kw)
+  "Convert a keyword like :slotname to plain symbol \\='slotname"
+  (intern (string-trim (downcase (symbol-name kw)) ":")))
+
+(defun ical:ast-node-meta-get (node keyword)
+  "Get metadata key KEYWORD from NODE. The possible KEYWORDs are the
+slot names of `ical:meta'."
+  (let ((meta (cadr node))
+        (kw (ical:-keyword-to-slot-name keyword)))
+    (cl-struct-slot-value 'ical:meta kw meta)))
+
+(defun ical:ast-node-meta-set (node keyword value)
+  "Set metadata key KEYWORD in NODE to VALUE. The possible KEYWORDs
+are the slot names of `ical:meta'."
+  (let ((meta (cadr node))
+        (kw (ical:-keyword-to-slot-name keyword)))
+    (setf (cl-struct-slot-value 'ical:meta kw meta) value)))
+
+(defun ical:ast-node-first-child-of (type node)
+  "Return the first child of NODE of type TYPE, or nil if there is
+no such child."
+  (assq type (ical:ast-node-children node)))
+
+(defun ical:ast-node-children-of (type node)
+  "Return a list of all the children of NODE of type TYPE, or nil if
+there are none."
+  (seq-filter (lambda (c) (eq type (ical:ast-node-type c)))
+              (ical:ast-node-children node)))
+
+(defun ical:-ast-node-adopt (parent value children)
+  "Make syntax node PARENT the parent node of each syntax node in
+VALUE and CHILDREN. This sets `:parent' meta property in each
+node to PARENT, sets VALUE as PARENT's value, and appends
+CHILDREN to any existing children of PARENT's. Returns the
+modified PARENT. Both VALUE and CHILDREN may be lists. If VALUE
+is nil, PARENT's value is not modified."
+  (let* ((is-list-val (ical:expects-list-of-values-p
+                       (ical:ast-node-type parent)))
+         (to-adopt (cond
+                    ((and value is-list-val)
+                     (append value children))
+                    (value
+                     (cons value children))
+                    (t children))))
+    (dolist (child to-adopt)
+      (when (ical:ast-node-p child)
+        (ical:ast-node-meta-set child :parent parent))))
+  (when value
+    (setf (nth 2 parent) value))
+  (setf (nth 3 parent) (nconc (ical:ast-node-children parent)
+                              children))
+  parent)
+
+(cl-defun ical:make-ast-node (type
+                              &key value
+                                   children
+                                   buffer
+                                   begin
+                                   end
+                                   value-begin
+                                   value-end
+                                   parent
+                                   original-value
+                                   original-name)
+  "Construct an iCalendar syntax node of type TYPE.
+
+The following keyword arguments are accepted:
+
+:value - if given, should be a single syntax node. In value
+  nodes, this is the Elisp value parsed from a property or
+  parameter's value string. In parameter and property nodes, this
+  is a value node. In component nodes, it should be nil.
+
+:children - if given, should be a list of syntax nodes. In
+  property nodes, these should be the parameters of the property.
+  In component nodes, these should be the properties or
+  subcomponents of the component. It should otherwise be nil.
+
+The following keyword arguments, if given, represent syntactic
+metadata for the node; see the definition of `ical:meta' for
+more:
+
+:buffer - buffer from which VALUE was parsed
+:begin - position at which this node begins in BUFFER
+:end - position at which this node ends in BUFFER
+:value-begin - position at which VALUE begins in BUFFER
+:value-end - position at which VALUE ends in BUFFER
+:parent - the parent node of the node to be created
+:original-value - a string containing the original, uninterpreted value
+  of the node. This can differ from (a string represented by) VALUE
+  if e.g. a default VALUE was substituted for an unrecognized but
+  syntactically correct value.
+:original-name - a string containing the original, uninterpreted name
+  of the parameter, property or component this node represents.
+  This can differ from (a string representing) TYPE
+  if e.g. a default TYPE was substituted for an unrecognized but
+  syntactically correct one."
+  (let* ((meta (ical:-make-meta :buffer buffer
+                                :begin begin
+                                :value-begin value-begin
+                                :end end
+                                :value-end value-end
+                                :parent parent
+                                :original-value original-value
+                                :original-name original-name))
+         (node (list type meta nil nil)))
+    (ical:-ast-node-adopt node value children)))
+
+
+;;; Validation:
+
+;; Errors at the validation stage:
+;; e.g. property/param values did not match, or are of the wrong type,
+;; or required properties not present in a component
+(define-error 'ical:validation-error "Invalid iCalendar data")
+
+(defun ical:param-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a parameter type."
+  (and (ical:ast-node-p node)
+       (ical:param-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:property-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a property type."
+  (and (ical:ast-node-p node)
+       (ical:property-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:component-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a component type."
+  (and (ical:ast-node-p node)
+       (ical:component-type-symbol-p (ical:ast-node-type node))))
+
+(defun ical:ast-node-valid-meta-p (node)
+  "Validate that NODE's metadata is an appropriate struct. Signals
+an `icalendar-validation-error' if NODE's metadata is invalid, or
+returns NODE."
+  (unless (cl-typep (nth 1 node) 'ical:meta)
+    (signal 'ical:validation-error
+            (list "Invalid metadata struct in node"
+                  node))))
+
+(defun ical:ast-node-valid-value-p (node)
+  "Validate that NODE's value satisfies the requirements of its type.
+Signals an `icalendar-validation-error' if NODE's value is
+invalid, or returns NODE."
+  (let* ((type (ical:ast-node-type node))
+         (value (ical:ast-node-value node)))
+    (cond ((ical:value-type-symbol-p type)
+           (unless (cl-typep value type) ; see `ical:define-type'
+             (signal 'ical:validation-error
+                     (list (format "Invalid value for `%s' node: %s"
+                                   type value)
+                           node)))
+           node)
+          ((ical:component-node-p node)
+           ;; component types have no value, so no need to check anything
+           node)
+          ((and (or (ical:param-type-symbol-p type)
+                    (ical:property-type-symbol-p type))
+                (null (get type 'ical:value-type))
+                (stringp value))
+           ;; property and param nodes with no value type are assumed to contain
+           ;; strings which match a value regex:
+           (unless (string-match (rx-to-string (get type 'ical:value-rx)) value)
+             (signal 'ical:validation-error
+                     (list (format "Invalid string value for `%s' node: %s"
+                                   type value)
+                           node)))
+           node)
+          ;; otherwise this is a param or property node which itself
+          ;; should have one or more syntax nodes as a value, so
+          ;; recurse on value(s):
+          ((ical:expects-list-of-values-p type)
+           (unless (listp value) ;; TODO: check elements' types...?
+             (signal 'ical:validation-error
+                     (list (format "Expected list of values for `%s' node"
+                                   type)
+                           node)))
+           (mapc #'ical:ast-node-valid-value-p value)
+           node)
+          (t
+           (unless (ical:ast-node-p value)
+             (signal 'ical:validation-error
+                     (list (format "Invalid value for `%s' node: %s"
+                                   type value)
+                           node)))
+           (ical:ast-node-valid-value-p value)))))
+
+(defun ical:count-children-by-type (node)
+  "Return an alist mapping type symbols to the number of child nodes
+of that type in NODE."
+  (let ((children (ical:ast-node-children node))
+        (map nil))
+    (dolist (child children map)
+      (let* ((type (ical:ast-node-type child))
+             (n (alist-get type map)))
+        (setf (alist-get type map) (1+ (or n 0)))))))
+
+(defun ical:ast-node-valid-children-p (node)
+  "Validate that NODE's children satisfy the :child-spec associated
+with its type by `icalendar-define-component',
+`icalendar-define-property', `icalendar-define-param', or
+`icalendar-define-type'. Signals an `icalendar-validation-error'
+if NODE is invalid, or returns NODE.
+
+Note that this function does not check that the children of NODE
+are themselves valid; for that, see `ical:ast-node-valid-p'."
+  (let* ((type (ical:ast-node-type node))
+         (child-spec (get type 'ical:child-spec))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when child-spec
+
+      (dolist (child-type (plist-get child-spec :one))
+        (unless (= 1 (alist-get child-type child-counts 0))
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node must contain exactly one `%s'"
+                                type child-type)
+                        node))))
+
+      (dolist (child-type (plist-get child-spec :one-or-more))
+        (unless (<= 1 (alist-get child-type child-counts 0))
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node must contain one or more `%s'"
+                                type child-type)
+                        node))))
+
+      (dolist (child-type (plist-get child-spec :zero-or-one))
+        (unless (<= (alist-get child-type child-counts 0)
+                    1)
+          (signal 'ical:validation-error
+                  (list (format "iCalendar `%s' node may contain at most one `%s'"
+                                type child-type)
+                        node))))
+
+      ;; check that all child nodes are allowed:
+      (unless (plist-get child-spec :allow-others)
+        (let ((allowed-types (append (plist-get child-spec :one)
+                                     (plist-get child-spec :one-or-more)
+                                     (plist-get child-spec :zero-or-one)
+                                     (plist-get child-spec :zero-or-more)))
+              (appearing-types (mapcar #'car child-counts)))
+
+          (dolist (child-type appearing-types)
+            (unless (member child-type allowed-types)
+              (signal 'ical:validation-error
+                      (list (format "`%s' may not contain `%s'"
+                                    type child-type)
+                            node)))))))
+    ;; success:
+    node))
+
+(defun ical:ast-node-valid-p (node &optional recursively)
+  "Check that NODE is a valid iCalendar syntax node.
+By default, the check will only validate NODE itself, but if
+RECURSIVELY is non-nil, it will recursively check all its
+descendants as well. Signals an `icalendar-validation-error' if
+NODE is invalid, or returns NODE."
+  (unless (ical:ast-node-p node)
+    (signal 'ical:validation-error
+            (list "Not an iCalendar syntax node"
+                  node)))
+
+  (ical:ast-node-valid-meta-p node)
+  (ical:ast-node-valid-value-p node)
+  (ical:ast-node-valid-children-p node)
+
+  (let* ((type (ical:ast-node-type node))
+         (other-validator (get type 'ical:other-validator)))
+
+    (unless (ical:type-symbol-p type)
+      (signal 'ical:validation-error
+              (list (format "Node's type `%s' is not an iCalendar type symbol"
+                            type)
+                    node)))
+
+    (when (and other-validator (not (functionp other-validator)))
+      (signal 'ical:validation-error
+              (list (format "Bad validator function `%s' for type `%s'"
+                            other-validator type))))
+
+    (when other-validator
+      (funcall other-validator node)))
+
+  (let ((children (ical:ast-node-children node)))
+    (when (and recursively (not (null children)))
+      (dolist (c children)
+        (ical:ast-node-valid-p c recursively))))
+
+  ;; success:
+  node)
+
+(provide 'icalendar-ast)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-ast.el ends here
diff --git a/lisp/calendar/icalendar-macs.el b/lisp/calendar/icalendar-macs.el
new file mode 100644
index 00000000000..2030efc5e6d
--- /dev/null
+++ b/lisp/calendar/icalendar-macs.el
@@ -0,0 +1,809 @@
+;;; icalendar-macs.el --- Macros for iCalendar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines the macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', used in
+;; icalendar-parser.el to define the particular value types, parameters,
+;; properties and components in the standard as type symbols.
+
+
+(require 'cl-lib)
+
+(declare-function ical:value-type-symbol-p "icalendar-ast.el")
+
+;; Some utilities:
+(defun ical:protected-intern (sym-name)
+  "Call `intern' on SYM-NAME and return the result, but warn if the
+resulting symbol already has icalendar-relevant properties."
+  (let ((sym (intern sym-name)))
+    (when (or (fboundp sym)
+              (get sym 'rx-definition)
+              (get sym 'ical:is-type))
+      (warn "Symbol `%s' already has iCalendar properties" sym))
+    sym))
+
+(defun ical:format-child-spec (child-spec)
+  "Format CHILD-SPEC as a table for use in symbol documentation."
+  (concat
+   (format "%-30s%6s\n" "Type" "Number")
+   (make-string 36 ?-) "\n"
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1"))
+    (plist-get child-spec :one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "1+"))
+    (plist-get child-spec :one-or-more))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0-1"))
+    (plist-get child-spec :zero-or-one))
+   (mapconcat
+    (lambda (type) (format "%-30s%-6s\n" (format "`%s'" type) "0+"))
+    (plist-get child-spec :zero-or-more))))
+
+
+;; Define value types:
+(cl-defmacro ical:define-type (symbolic-name print-name doc specifier matcher
+                               &key link
+                                    (reader #'identity)
+                                    (printer #'identity))
+  "Define an iCalendar value type named SYMBOLIC-NAME.
+
+PRINT-NAME should be the string used to represent this type in
+the value of an `icalendar-valuetypeparam' property parameter, or
+nil if this is not a type that should be specified there. DOC
+should be a documentation string for the type. SPECIFIER should
+be a type specifier in the sense of `cl-deftype'. MATCHER should
+be an RX definition body (see `rx-define'; argument lists are not
+supported).
+
+Before the type is defined with `cl-deftype', a function will be
+defined named `icalendar-match-PRINT-NAME-value'
+(or `icalendar-match-OTHER-value', if PRINT-NAME is nil, where
+OTHER is derived from SYMBOLIC-NAME by removing any prefix
+\"icalendar-\" and suffix \"value\"). This function takes a
+string argument and matches it against MATCHER. This function may
+thus occur in SPECIFIER (e.g. in a (satisfies ...) clause).
+
+See the functions `icalendar-read-value-node',
+`icalendar-parse-value-node', and `icalendar-print-value-node' to
+convert values defined with this macro to and from their text
+representation in iCalendar format.
+
+The following keyword arguments are accepted:
+
+:reader - a function to read data of this type. It will be passed
+  a string matching MATCHER and should return an Elisp data structure.
+  Its name does not need to be quoted. (default: identity)
+
+:printer - a function to convert an Elisp data structure of this
+  type to a string. Its name does not need to be quoted.
+  (default: identity)
+
+:link - a string containing an URL for further documentation of this type"
+  (let* (;; Related functions:
+         (type-dname (if print-name
+                         (downcase print-name)
+                       (string-trim
+                        (symbol-name symbolic-name)
+                        "icalendar-" "value")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" type-dname "-value")))
+
+         ;; Documentation:
+         (header "It names a value type defined by `icalendar-define-type'.")
+         (matcher-doc (format
+"Strings representing values of this type can be matched with
+`%s'.\n" matcher-name))
+         (reader-doc (format "They can be read with `%s'\n" reader))
+         (printer-doc (format "and printed with `%s'." printer))
+         (full-doc (concat header "\n\n" doc "\n\n"
+                           matcher-doc reader-doc printer-doc "\n\n"
+"A syntax node of this type can be read with
+`icalendar-read-value-node' or parsed with `icalendar-parse-value-node',
+and printed with `icalendar-print-value-node'.")))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-value t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-rx (quote ,symbolic-name)
+                    'ical:value-reader (function ,reader)
+                    'ical:value-printer (function ,printer)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       (rx-define ,symbolic-name
+         ,matcher)
+
+       (defun ,matcher-name (s)
+         ,(format "Match string S against rx `%s'." symbolic-name)
+         (string-match (rx ,symbolic-name) s))
+
+       (cl-deftype ,symbolic-name () ,specifier)
+
+       ;; Store the association between the print name and the type
+       ;; symbol in ical:value-types. The check against print name
+       ;; here allows us to also define value types that aren't
+       ;; "really" types according to the standard, like
+       ;; `ical:geo-coordinates'. Only types that have a
+       ;; print-name can be specified in a VALUE parameter.
+       (when ,print-name
+         (push (cons ,print-name (quote ,symbolic-name)) ical:value-types)))))
+
+;; TODO: not sure this is needed. I've only used it once in the parser.
+(cl-defmacro ical:define-keyword-type (symbolic-name print-name doc matcher
+                                       &key link
+                                            (reader 'intern)
+                                            (printer 'symbol-name))
+  "Like `icalendar-define-type', except that string values matching MATCHER
+are assumed to be type-specific keywords that should be interned
+as symbols when read. (Thus no type specifier is necessary: it is
+always just \\='symbol.) Their printed representation is their
+symbol name."
+  `(ical:define-type ,symbolic-name ,print-name ,doc
+                     'symbol
+                     ,matcher
+                     :link ,link
+                     :reader ,reader
+                     :printer ,printer))
+
+
+;; Define parameters:
+(cl-defmacro ical:define-param (symbolic-name param-name doc value
+                                &key quoted
+                                     list-sep
+                                     default
+                                     (unrecognized default)
+                                     ((:name-face name-face)
+                                      'ical:parameter-name nondefault-name-face)
+                                     ((:value-face value-face)
+                                      'ical:parameter-value nondefault-value-face)
+                                     ((:warn-face warn-face)
+                                      'ical:warning nondefault-warn-face)
+                                     extra-faces
+                                     link)
+  "Define iCalendar parameter PARAM-NAME under the symbol SYMBOLIC-NAME.
+PARAM-NAME should be the parameter name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing values.
+If it is a regular expression, it is assumed that the values of
+this parameter are strings which match that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME which matches the
+parameter is defined:
+  Group 1 of this regex matches PARAM-NAME
+    (or any valid parameter name, if PARAM-NAME is nil).
+  Group 2 matches VALUE, which specifies a correct value
+    for this parameter according to RFC5545.
+  Group 3, if matched, contains any parameter value which does
+    *not* match VALUE, and is incorrect according to the standard.
+
+This regex matches the entire string representing this parameter,
+from \";\" to the end of its value. Another regular expression
+named `SYMBOLIC-NAME-value' is also defined to match just the
+value part, after \";PARAM-NAME=\", with groups 2 and 3 as above.
+
+A function to match the complete parameter expression called
+`icalendar-match-PARAM-NAME-param' is defined
+(or `icalendar-match-OTHER-param-value' if PARAM-NAME is nil,
+where OTHER is derived from SYMBOLIC-NAME by removing any prefix
+`icalendar-' and suffix `param'). This function is used
+to provide syntax highlighting in `icalendar-mode'.
+
+See the functions `icalendar-read-param-value',
+`icalendar-parse-param-value', `icalendar-parse-params' and
+`icalendar-print-param-node' to convert parameters defined with
+this macro to and from their text representation in iCalendar
+format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if the
+  parameter is not specified on a given property.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but syntactically
+  correct according to RFC5545. Unrecognized values must be in match
+  group 5 of the regex determined by VALUE. An unrecognized value will
+  be preserved in the syntax tree metadata and printed instead of this
+  value when the node is printed. Defaults to any value specified for
+  :default.
+
+:quoted - non-nil if values of this parameter must always be surrounded
+  by (double-)quotation marks when printed, according to RFC5545.
+
+:list-sep - if the parameter accepts a list of values, this should be a
+  string which separates the values (typically \",\"). If :list-sep is
+  non-nil, the value string will first be split on the separator, then
+  if :quoted is non-nil, the individual values will be unquoted, then
+  each value will be read according to VALUE and collected into a list
+  when parsing.  When printing, the inverse happens: values are quoted
+  if :quoted is non-nil, then joined with :list-sep. Passing this
+  argument marks SYMBOLIC-NAME as a type that accepts a list of values
+  for `icalendar-expects-list-of-values-p'.
+
+:name-face - a face symbol for highlighting the property name
+  (default: ical:parameter-name)
+
+:value-face - a face symbol for highlighting valid property values
+  (default: ical:parameter-value)
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: ical:warning)
+
+:extra-faces - a list of the form accepted for HIGHLIGHT in
+  `font-lock-keywords'.  In particular,
+    ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]]) ...)
+  can be used to apply different faces to different
+  match subgroups.
+
+:link - a string containing a URL for documentation of this parameter.
+  The URL will be provided in the documentation shown by
+  `describe-symbol' for SYMBOLIC-NAME."
+  (let* (;; Related function names:
+         (param-dname (if param-name
+                          (downcase param-name)
+                        (string-trim (symbol-name symbolic-name)
+                                     "icalendar-" "param")))
+         (matcher-name (ical:protected-intern
+                        (concat "icalendar-match-" param-dname "-param")))
+
+         (type-predicate-name
+          (ical:protected-intern (concat "icalendar-" param-dname "-param-p")))
+         ;; Value regexes:
+         (qvalue-rx (if quoted `(seq ?\" ,value ?\") value))
+         (values-rx (when list-sep
+                     `(seq ,qvalue-rx (zero-or-more ,list-sep ,qvalue-rx))))
+         (full-value-rx-name (ical:protected-intern
+                               (concat (symbol-name symbolic-name) "-value")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-value-face
+                        nondefault-warn-face extra-faces))
+         ;; Documentation:
+         (header "It names a parameter type defined by `icalendar-define-param'.")
+         (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "...")
+                     "VAL"))
+         (s (if list-sep "s" "")) ; to make plurals
+         (val-doc (concat "VAL" s " "
+                          "must be " (unless list-sep "a ") (when quoted "quoted ")
+                          (if (ical:value-type-symbol-p value)
+                              (format "`%s' value%s" (symbol-name value) s)
+                            (format "string%s matching rx `%s'" s value))))
+         (syntax-doc (format "Syntax: %s=%s\n%s"
+                             (or param-name "(NAME)") val-list val-doc))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-param t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:is-quoted ,quoted
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:matcher (function ,matcher-name)
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regex which matches just the value of the parameter:
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx qvalue-rx))
+             (group-n 3 ical:param-value)))
+
+       ;; Regex which matches the full parameter:
+       ;; Group 1: the parameter name,
+       ;; Group 2: correct values of the parameter, and
+       ;; Group 3: incorrect values up to the next parameter
+       (rx-define ,symbolic-name
+         (seq ";"
+              ;; if the parameter name has no printed form, the best we
+              ;; can do is match ical:param-name:
+              (group-n 1 ,(or param-name 'ical:param-name))
+              "="
+              ,full-value-rx-name))
+
+       ;; CL-type to represent syntax nodes for this parameter:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s parameter" param-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Matcher for the full param string, for syntax highlighting:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s parameter (defined by define-param)" param-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; Entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `ical:parse-params' and `ical:print-param':
+       (when ,param-name
+         (push (cons ,param-name (quote ,symbolic-name)) ical:param-types))
+       ;; TODO: integrate param-name with eldoc in icalendar-mode
+       )))
+
+
+;; Define properties:
+(cl-defmacro ical:define-property (symbolic-name property-name doc value
+                                   &key default
+                                        (unrecognized default)
+                                        (default-type
+                                         (if (ical:value-type-symbol-p value)
+                                             value
+                                           'ical:text))
+                                        other-types
+                                        list-sep
+                                        child-spec
+                                        other-validator
+                                        ((:name-face name-face)
+                                         'ical:property-name nondefault-name-face)
+                                        ((:value-face value-face)
+                                         'ical:property-value nondefault-value-face)
+                                        ((:warn-face warn-face)
+                                         'ical:warning nondefault-warn-face)
+                                        extra-faces
+                                        link)
+  "Define iCalendar property PROPERTY-NAME under SYMBOLIC-NAME.
+PROPERTY-NAME should be the property name as it should appear in
+iCalendar data.
+
+VALUE should either be a symbol for a value type defined with
+`icalendar-define-type', or an `rx' regular expression. If it is
+a type symbol, the regex, reader and printer functions associated
+with that type will be used when parsing and serializing the
+property's value. If it is a regular expression, it is assumed
+that the values are strings of type `icalendar-text' which match
+that regular expression.
+
+An `rx' regular expression named SYMBOLIC-NAME is defined to
+match the property:
+  Group 1 of this regex matches PROPERTY-NAME.
+  Group 2 matches VALUE.
+  Group 3, if matched, contains any property value which does
+   *not* match VALUE, and is incorrect according to the standard.
+  Group 4, if matched, contains the (unparsed) property parameters;
+   its boundaries can be used for parsing these.
+
+This regex matches the entire string representing this property,
+from the beginning of the content line to the end of its value.
+Another regular expression named `SYMBOLIC-NAME-value' is also
+defined to match just the value part, after the separating colon,
+with groups 2 and 3 as above.
+
+A function to match the complete property expression called
+`icalendar-match-PROPERTY-NAME-property' is defined. This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+See the functions `icalendar-read-property-value',
+`icalendar-parse-property-value', `icalendar-parse-property', and
+`icalendar-print-property-node' to convert properties defined
+with this macro to and from their text representation in
+iCalendar format.
+
+The following keyword arguments are accepted:
+
+:default - a (string representing the) default value, if
+  the property is not specified in a given component.
+
+:unrecognized - a (string representing the) value which must be
+  substituted for values that are not recognized but
+  syntactically correct according to RFC5545. Unrecognized values
+  must be in match group 5 of the regex determined by VALUE. An
+  unrecognized value will be preserved in the syntax tree
+  metadata and printed instead of this value when the node is
+  printed. Defaults to any value specified for :default.
+
+:default-type - a type symbol naming the default type of the
+  property's value. If the property's value differs from this
+  type, an `icalendar-valuetypeparam' parameter will be added to
+  the property's syntax node and printed when the node is
+  printed. Default is VALUE if VALUE is a value type symbol,
+  otherwise the type `icalendar-text'.
+
+:other-types - a list of type symbols naming value types other
+  than :default-type. These represent alternative types for the
+  property's value. If parsing the property's value under its
+  default type fails, these types will be tried in turn, and only
+  if the property's value matches none of them will an error be
+  signaled.
+
+:list-sep - if the property accepts a list of values, this should
+  be a string which separates the values (typically \",\"). If
+  :list-sep is non-nil, the value string will first be split on
+  the separator, then each value will be read according to VALUE
+  and collected into a list when parsing. When printing, the
+  inverse happens: values are printed individually and then
+  joined with :list-sep. Passing this argument marks
+  SYMBOLIC-NAME as a type that accepts a list of values for
+  `icalendar-expects-list-of-values-p'.
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - parameters that must appear exactly once
+  :one-or-more   - parameters that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - parameters that must appear at most once
+  :zero-or-more  - parameters that may appear more than once
+  :allow-others  - if non-nil, other parameters besides those listed in
+                   the above are allowed to appear. (In this case, a
+                   :zero-or-more clause is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:name-face - a face symbol for highlighting the property name
+  (default: `ical:property-name')
+
+:value-face - a face symbol for highlighting valid property values
+  (default: `ical:property-value')
+
+:warn-face - a face symbol for highlighting invalid property values
+  (default: `ical:warning')
+
+:extra-faces - a list of the form for HIGHLIGHT in `font-lock-keywords'.
+  In particular, ((GROUPNUM FACENAME [OVERRIDE [LAXMATCH]])...)
+  can be used to apply different faces to different match subgroups.
+
+:link - a string containing a URL for documentation of this property"
+  (let* (;; Value RX:
+        (full-value-rx-name
+         (ical:protected-intern
+          (concat (symbol-name symbolic-name) "-property-value")))
+        (values-rx (when list-sep
+                    `(seq ,value (zero-or-more ,list-sep ,value))))
+        ;; Related functions:
+        (property-dname (if property-name
+                            (downcase property-name)
+                          (string-trim (symbol-name symbolic-name)
+                                       "icalendar-" "-property")))
+        (matcher-name (ical:protected-intern
+                       (concat "icalendar-match-"
+                               property-dname
+                               "-property")))
+        (type-predicate-name
+         (ical:protected-intern (concat "icalendar-"
+                                        property-dname
+                                        "-property-p")))
+        ;; Faces:
+        (has-faces (or nondefault-name-face nondefault-value-face
+                       nondefault-warn-face extra-faces))
+        ;; Documentation:
+        (header "It names a property type defined by `icalendar-define-property'.")
+        (val-list (if list-sep (concat "VAL1" list-sep "VAL2" list-sep "...")
+                    "VAL"))
+        (default-doc (if default (format "The default value is: \"%s\"\n" default)
+                       ""))
+        (s (if list-sep "s" "")) ; to make plurals
+        (val-doc (concat "VAL" s " "
+                         "must be " (unless list-sep "a ")
+                         (format "value%s of one of the following types:\n" s)
+                         (string-join
+                          (cons
+                           (format "`%s' (default)" default-type)
+                           (mapcar (lambda (type) (format "`%s'" type))
+                                   other-types))
+                          "\n")
+                         default-doc))
+        (name-doc (if property-name "" "NAME must match rx `icalendar-name'"))
+        (syntax-doc (format "Syntax: %s[;PARAM...]:%s\n%s\n%s\n"
+                            (or property-name "NAME") val-list name-doc val-doc))
+        (child-doc
+         (concat
+          "The following parameters are required or allowed\n"
+          "as children in syntax nodes of this type:\n\n"
+          (ical:format-child-spec child-spec)
+          (when (plist-get child-spec :allow-others)
+            "\nOther parameters of any type are also allowed.\n")))
+        (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-property t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:default-value ,default
+                    'ical:default-type (quote ,default-type)
+                    'ical:other-types (quote ,other-types)
+                    'ical:list-sep ,list-sep
+                    'ical:substitute-value ,unrecognized
+                    'ical:value-type
+                    (when (ical:value-type-symbol-p (quote ,value))
+                      (quote ,value))
+                    'ical:value-rx (quote ,value)
+                    'ical:values-rx (quote ,values-rx)
+                    'ical:full-value-rx (quote ,full-value-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Value regex which matches:
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,full-value-rx-name
+         (or (group-n 2 ,(or values-rx value))
+             (group-n 3 (zero-or-more any))))
+
+       ;; Full property regex which matches:
+       ;; Group 1: the property name,
+       ;; Group 2: correct values of the property, and
+       ;; Group 3: incorrect values up to end-of-line (for syntax warnings)
+       (rx-define ,symbolic-name
+         (seq line-start
+              (group-n 1 ,(or property-name 'ical:name))
+              (group-n 4 (zero-or-more ical:other-param-safe))
+              ":"
+              ,full-value-rx-name
+              line-end))
+
+       ;; Matcher:
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for `%s' property (defined by define-property)"
+                  symbolic-name)
+         (re-search-forward (rx ,symbolic-name) limit t))
+
+       ;; CL-type to represent syntax nodes for this property:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s property" property-name)
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-property', `icalendar-print-property-node', etc.:
+       (when ,property-name
+         (push (cons ,property-name (quote ,symbolic-name)) ical:property-types))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,name-face) t t)
+                       (2 (quote ,value-face) t t)
+                       (3 (quote ,warn-face) t t)
+                       ,@extra-faces))
+               ical:font-lock-keywords)))))
+
+
+;; Define components:
+(cl-defmacro ical:define-component (symbolic-name component-name doc
+                                    &key
+                                    ((:keyword-face keyword-face)
+                                     'ical:keyword nondefault-keyword-face)
+                                    ((:name-face name-face)
+                                     'ical:component-name nondefault-name-face)
+                                    child-spec
+                                    other-validator
+                                    link)
+  "Define iCalendar component COMPONENT-NAME under SYMBOLIC-NAME.
+COMPONENT-NAME should be the name of the component as it should
+appear in iCalendar data.
+
+Regular expressions to match the component boundaries are defined
+named `COMPONENT-NAME-begin' and `COMPONENT-NAME-end' (or
+`OTHER-begin' and `OTHER-end', where `OTHER' is derived from
+SYMBOLIC-NAME by removing any prefix `icalendar-' and suffix
+`-component' if COMPONENT-NAME is nil).
+  Group 1 of these regexes matches the \"BEGIN\" or \"END\"
+    keyword that marks a component boundary.
+  Group 2 matches the component name.
+
+A function to match the component boundaries is defined called
+`icalendar-match-COMPONENT-NAME-component' (or
+`icalendar-match-OTHER-component', with OTHER as above). This
+function is used to provide syntax highlighting in
+`icalendar-mode'.
+
+The following keyword arguments are accepted:
+
+:child-spec - a plist mapping the following keywords to lists
+of type symbols:
+  :one           - properties or components that must appear exactly once
+  :one-or-more   - properties or components that must appear at least once and
+                   may appear more than once
+  :zero-or-one   - properties or components that must appear at most once
+  :zero-or-more  - properties or components that may appear more than once
+  :allow-others  - if non-nil, other children besides those listed in the above
+                   are allowed to appear. (In this case, a :zero-or-more clause
+                   is redundant.)
+
+:other-validator - a function to perform any additional validation of
+  the component, beyond what `icalendar-ast-node-valid-p' checks.
+  This function should accept one argument, a syntax node. It
+  should return non-nil if the node is valid, or signal an
+  `icalendar-validation-error' if it is not. Its name does not
+  need to be quoted.
+
+:keyword-face - a face symbol for highlighting the BEGIN/END keyword
+  (default: ical:keyword)
+
+:name-face - a face symbol for highlighting the component name
+  (default: ical:component-name)
+
+:link - a string containing a URL for documentation of this component"
+  (let* (;; Regexes:
+         (name-rx (or component-name 'ical:name))
+         (component-dname (if component-name
+                              (downcase component-name)
+                            (string-trim (symbol-name symbolic-name)
+                                         "icalendar-" "-component")))
+         (begin-rx-name (ical:protected-intern
+                         (concat "icalendar-" component-dname "-begin")))
+         (end-rx-name (ical:protected-intern
+                       (concat "icalendar-" component-dname "-end")))
+         ;; Related functions:
+         (matcher-name
+          (ical:protected-intern
+           (concat "icalendar-match-" component-dname "-component")))
+         (type-predicate-name
+          (ical:protected-intern
+           (concat "icalendar-" component-dname "-component-p")))
+         ;; Faces:
+         (has-faces (or nondefault-name-face nondefault-keyword-face))
+         ;; Documentation:
+         (header "It names a component type defined by
+`icalendar-define-component'.")
+         (name-doc (if (not component-name)
+                       "\nNAME must match rx `icalendar-name'"
+                     ""))
+         (syntax-doc (format "Syntax:\nBEGIN:%s\n[contentline ...]\nEND:%1$s%s"
+                             (or component-name "NAME")
+                             name-doc))
+         (child-doc
+          (concat
+           "The following properties and components are required or "
+           "allowed\nas children in syntax nodes of this type:\n\n"
+           (ical:format-child-spec child-spec)
+           (when (plist-get child-spec :allow-others)
+             "\nOther properties and components of any type are also allowed.\n")))
+         (full-doc (concat header "\n\n" doc "\n\n" syntax-doc "\n\n" child-doc)))
+
+    `(progn
+       ;; Type metadata needs to be available at both compile time and
+       ;; run time. In particular, `ical:value-type-symbol-p' needs to
+       ;; work at compile time.
+       (eval-and-compile
+         (setplist (quote ,symbolic-name)
+                   (list
+                    'ical:is-type t
+                    'ical:is-component t
+                    'ical:matcher (function ,matcher-name)
+                    'ical:begin-rx (quote ,begin-rx-name)
+                    'ical:end-rx (quote ,end-rx-name)
+                    'ical:child-spec (quote ,child-spec)
+                    'ical:other-validator (function ,other-validator)
+                    'ical:type-documentation ,full-doc
+                    'ical:link ,link)))
+
+       ;; Regexes which match:
+       ;; Group 1: BEGIN or END, and
+       ;; Group 2: the component name
+       (rx-define ,begin-rx-name
+         (seq line-start
+              (group-n 1 "BEGIN")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (rx-define ,end-rx-name
+         (seq line-start
+              (group-n 1  "END")
+              ":"
+              (group-n 2 ,name-rx)
+              line-end))
+
+       (defun ,matcher-name (limit)
+         ,(format "Matcher for %s component boundaries"
+                  (or component-name "unrecognized"))
+           (re-search-forward (rx (or ,begin-rx-name ,end-rx-name)) limit t))
+
+       ;; CL-type to represent syntax nodes for this component:
+       (defun ,type-predicate-name (node)
+         ,(format "Return non-nil if NODE represents a %s component"
+                  (or component-name "unrecognized"))
+         (and (ical:ast-node-p node)
+              (eq (ical:ast-node-type node) (quote ,symbolic-name))))
+
+       (cl-deftype ,symbolic-name () '(satisfies ,type-predicate-name))
+
+       ;; Generate an entry for font-lock-keywords in icalendar-mode:
+       (when ,has-faces
+         ;; Avoid circular load of icalendar-mode.el in
+         ;; icalendar-parser.el (which does not use the *-face
+         ;; keywords), while still allowing external code to add to
+         ;; font-lock-keywords dynamically:
+         (require 'icalendar-mode)
+         (push (quote (,matcher-name
+                       (1 (quote ,keyword-face) t t)
+                       (2 (quote ,name-face) t t)))
+               ical:font-lock-keywords))
+
+       ;; Associate the print name with the type symbol for
+       ;; `icalendar-parse-component', `icalendar-print-component' etc.:
+       (when ,component-name
+         (push (cons ,component-name (quote ,symbolic-name)) ical:component-types))
+
+       ;; TODO: integrate component-name with eldoc in icalendar-mode
+       )))
+
+(provide 'icalendar-macs)
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-macs.el ends here
diff --git a/lisp/calendar/icalendar-parser.el b/lisp/calendar/icalendar-parser.el
new file mode 100644
index 00000000000..bc9524ff389
--- /dev/null
+++ b/lisp/calendar/icalendar-parser.el
@@ -0,0 +1,4090 @@
+;;; icalendar-parser.el --- Parse iCalendar grammar  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines regular expressions, constants and functions that
+;; implement the iCalendar grammar according to RFC5545.
+;;
+;; iCalendar data is grouped into *components*, such as events or
+;; to-do items. Each component contains one or more *content lines*,
+;; which each contain a *property* name and its *value*, and possibly
+;; also property *parameters* with additional data that affects the
+;; interpretation of the property.
+;;
+;; The macros `ical:define-type', `ical:define-param',
+;; `ical:define-property' and `ical:define-component', defined in
+;; icalendar-macs.el, each create rx-style regular expressions for one
+;; of these categories in the grammar and are used here to define the
+;; particular value types, parameters, properties and components in the
+;; standard as type symbols. These type symbols store all the metadata
+;; about the relevant types, and are used for type-based dispatch in the
+;; parser and printer functions. In the abstract syntax tree, each node
+;; contains a type symbol naming its type.
+;;
+;; The regular expressions defined by the `ical:define-*' macros are
+;; also used to create entries for `font-lock-keywords', which are
+;; gathered into several constants along the way, and used to provide
+;; syntax highlighting in icalendar-mode.el. A number of other regular
+;; expressions which encode basic categories of the grammar are also
+;; defined in this file.
+;;
+;; The following functions provide the high-level interface to the parser:
+;;
+;;   `icalendar-parse-component'
+;;   `icalendar-parse-property'
+;;   `icalendar-parse-params'
+;;
+;; The format of the abstract syntax tree which these functions create
+;; is documented in icalendar-ast.el. Nodes in this tree can be
+;; serialized to iCalendar format with the corresponding printer
+;; functions:
+;;
+;;   `icalendar-print-component-node'
+;;   `icalendar-print-property-node'
+;;   `icalendar-print-params'
+
+;;; Code:
+
+(require 'icalendar-macs)
+(require 'icalendar-ast)
+(require 'cl-lib)
+(require 'subr-x)
+(require 'seq)
+(require 'rx)
+(require 'calendar)
+(require 'time-date)
+(require 'simple)
+(require 'help-mode)
+
+;;; Functions for folding and unfolding
+;;
+;; According to RFC5545, iCalendar content lines longer than 75 octets
+;; should be *folded* by inserting extra line breaks and leading
+;; whitespace to continue the line. Such lines must be *unfolded*
+;; before they can be parsed.  Unfolding can only reliably happen
+;; before Emacs decodes a region of text, because decoding potentially
+;; replaces the CR-LF line endings which terminate content lines.
+;; Programs that can control when decoding happens should use the
+;; stricter `ical:unfold-undecoded-region' to unfold text; programs
+;; that must work with decoded data should use the looser
+;; `ical:unfold-region'. `ical:fold-region' will fold content lines
+;; using line breaks appropriate to the buffer's coding system.
+;;
+;; All the parsing-related code belows assumes that lines have
+;; already been unfolded if necessary.
+
+(defun ical:unfold-undecoded-region (start end &optional buffer)
+  "Unfold an undecoded region in BUFFER between START and END.
+If omitted, BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation). RFC5545 specifies these whitespace
+characters to be a CR-LF sequence followed by a single space or
+tab character. Unfolding can only be done reliably before a
+region is decoded, since decoding potentially replaces CR-LF line
+endings. This function searches strictly for CR-LF sequences, and
+will fail if they have already been replaced, so it should only
+be called with a region that has not yet been decoded."
+  (with-current-buffer (or buffer (current-buffer))
+    (with-restriction start end
+      (goto-char (point-min))
+      (while (re-search-forward (rx (seq "\r\n" (or " " "\t")))
+                                nil t)
+        (replace-match "" nil nil)))))
+
+(defun ical:unfold-region (start end &optional buffer)
+  "Unfold a region in BUFFER between START and END. If omitted,
+BUFFER defaults to the current buffer.
+
+\"Unfolding\" means removing the whitespace characters inserted to
+continue lines longer than 75 octets (see `icalendar-fold-region'
+for the folding operation).
+
+WARNING: Unfolding can only be done reliably before text is
+decoded, since decoding potentially replaces CR-LF line endings.
+Unfolding an already-decoded region could lead to unexpected
+results, such as displaying multibyte characters incorrectly,
+depending on the contents and the coding system used.
+
+This function attempts to do the right thing even if the region
+is already decoded. If it is still undecoded, it is better to
+call `icalendar-unfold-undecoded-region' directly instead, and
+decode it afterward."
+  ;; TODO: also make this a command so it can be run manually?
+  (with-current-buffer (or buffer (current-buffer))
+    (let ((was-multibyte enable-multibyte-characters)
+          (start-char (position-bytes start))
+          (end-char (position-bytes end)))
+      ;; we put the buffer in unibyte mode and later restore its
+      ;; previous state, so that if the buffer was already multibyte,
+      ;; any multibyte characters where line folds broke up their
+      ;; bytes can be reinterpreted:
+      (set-buffer-multibyte nil)
+      (with-restriction start-char end-char
+        (goto-char (point-min))
+        ;; since we can't be sure that line folds have a leading CR
+        ;; in already-decoded regions, do the best we can:
+        (while (re-search-forward (rx (seq (zero-or-one "\r") "\n"
+                                           (or " " "\t")))
+                                  nil t)
+          (replace-match "" nil nil)))
+      ;; restore previous state, possibly reinterpreting characters:
+      (set-buffer-multibyte was-multibyte))))
+
+(defun ical:unfolded-buffer-from-region (start end &optional buffer)
+  "Create a new buffer with the same contents as the region between
+START and END (in BUFFER, if provided) and perform line unfolding
+in the new buffer with `icalendar-unfold-region'. That function
+can in some cases have undesirable effects; see its docstring. If
+BUFFER is visiting a file, it may be better to reload its
+contents from that file and perform line unfolding before
+decoding; see `icalendar-unfolded-buffer-from-file'. Returns the
+new buffer."
+  (let* ((old-buffer (or buffer (current-buffer)))
+         (contents (with-current-buffer old-buffer
+                     (buffer-substring start end)))
+         (uf-buffer (generate-new-buffer
+                     (concat (buffer-name old-buffer)
+                             "~UNFOLDED")))) ;; TODO: again, move to modeline?
+    (with-current-buffer uf-buffer
+      (insert contents)
+      (ical:unfold-region (point-min) (point-max))
+      ;; ensure we'll use CR-LF line endings on write, even if they weren't
+      ;; in the source data. The standard also says UTF-8 is the default
+      ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+      ;; is nil.
+      (setq buffer-file-coding-system
+            (if last-coding-system-used
+                (coding-system-change-eol-conversion last-coding-system-used
+                                                     'dos)
+              'prefer-utf-8-dos)))
+    uf-buffer))
+
+(defun ical:unfolded-buffer-from-buffer (buffer)
+  "Create a new buffer with the same contents as BUFFER and perform
+line unfolding with `icalendar-unfold-region'. That function can in
+some cases have undesirable effects; see its docstring. If BUFFER
+is visiting a file, it may be better to reload its contents from
+that file and perform line unfolding before decoding; see
+`icalendar-unfolded-buffer-from-file'. Returns the new buffer."
+  (with-current-buffer buffer
+    (ical:unfolded-buffer-from-region (point-min) (point-max) buffer)))
+
+(defun ical:unfolded-buffer-from-file (filename &optional visit beg end)
+    "Create a new buffer with the contents of FILENAME and perform
+line unfolding with `icalendar-unfold-undecoded-region', then
+decode the buffer, setting an appropriate value for
+`buffer-file-coding-system'. Optional arguments VISIT, BEG, END
+are as in `insert-file-contents'. Returns the new buffer."
+    (unless (and (file-exists-p filename)
+                 (file-readable-p filename))
+      (error "File cannot be read: %s" filename))
+    ;; TODO: instead of messing with the buffer name, it might be more
+    ;; useful to keep track of the folding state in a variable and
+    ;; display it somewhere else in the mode line
+    (let ((uf-buffer (generate-new-buffer (concat (file-name-nondirectory filename)
+                                                  "~UNFOLDED"))))
+      (with-current-buffer uf-buffer
+        (set-buffer-multibyte nil)
+        (insert-file-contents-literally filename visit beg end t)
+        (ical:unfold-undecoded-region (point-min) (point-max))
+        (set-buffer-multibyte t)
+        (decode-coding-inserted-region (point-min) (point-max) filename)
+        ;; ensure we'll use CR-LF line endings on write, even if they weren't
+        ;; in the source data. The standard also says UTF-8 is the default
+        ;; encoding, so use 'prefer-utf-8-dos when last-coding-system-used
+        ;; is nil. FIXME: for some reason, this doesn't seem to run at all!
+        (setq buffer-file-coding-system
+              (if last-coding-system-used
+                  (coding-system-change-eol-conversion last-coding-system-used
+                                                       'dos)
+                'prefer-utf-8-dos))
+        ;; restore buffer name after renaming by set-visited-file-name:
+        (let ((bname (buffer-name)))
+          (set-visited-file-name filename t)
+          (rename-buffer bname)))
+      uf-buffer))
+
+(defun ical:fold-region (begin end &optional use-tabs)
+  "Fold all content lines in the region longer than 75 octets.
+
+\"Folding\" means inserting a line break and a single space
+character at the beginning of the new line. If USE-TABS is
+non-nil, insert a tab character instead of a single space.
+
+RFC5545 specifies that lines longer than 75 *octets* (excluding
+the line-ending CR-LF sequence) must be folded, and allows that
+some implementations might fold lines in the middle of a
+multibyte character. This function takes care not to do that in a
+buffer where `enable-multibyte-characters' is non-nil, and only
+folds between character boundaries. If the buffer is in unibyte
+mode, however, and contains undecoded multibyte data, it may fold
+lines in the middle of a multibyte character."
+  ;; TODO: also make this a command so it can be run manually?
+  (save-excursion
+    (goto-char begin)
+    (when (not (bolp))
+      (let ((inhibit-field-text-motion t))
+        (beginning-of-line)))
+    (let ((bol (point))
+          (eol (make-marker))
+          (reg-end (make-marker))
+          (line-fold
+           (concat
+            ;; if \n will be translated to \r\n on save (EOL type 1,
+            ;; "DOS"), just insert \n, otherwise the full fold sequence:
+            ;; FIXME: is buffer-file-coding-system the only relevant one here?
+            ;; What if the buffer is not visiting a file, but has come from a
+            ;; process, represents a mime part in an email, etc.?
+            (if (eq 1 (coding-system-eol-type buffer-file-coding-system))
+                "\n"
+              "\r\n")
+            ;; leading whitespace after line break:
+            (if use-tabs "\t" " "))))
+      (set-marker reg-end end)
+      (while (< bol reg-end)
+        (let ((inhibit-field-text-motion t))
+          (end-of-line))
+        (set-marker eol (point))
+        (when (< 75 (- (position-bytes (marker-position eol))
+                       (position-bytes bol)))
+          (goto-char
+           ;; the max of 75 excludes the two CR-LF
+           ;; characters we're about to add:
+           (byte-to-position (+ 75 (position-bytes bol))))
+          (insert line-fold)
+          (set-marker eol (point)))
+        (setq bol (goto-char (1+ eol)))))))
+
+(defun ical:contains-folded-lines-p ()
+  "Determine whether the current buffer contains folded content
+lines that should be unfolded for parsing and display purposes.
+If it does, return the position at the end of the first fold."
+  (save-excursion
+    (goto-char (point-min))
+    (re-search-forward (rx (seq line-start (or " " "\t")))
+                       nil t)))
+
+(defun ical:contains-unfolded-lines-p ()
+  "Determine whether the current buffer contains long content lines
+that should be folded before saving or transmitting. If it does,
+return the position at the beginning of the first line that
+requires folding."
+  (save-excursion
+    (goto-char (point-min))
+    (let ((bol (point))
+          (eol (make-marker)))
+      (catch 'unfolded-line
+        (while (< bol (point-max))
+          (let ((inhibit-field-text-motion t))
+            (end-of-line))
+          (set-marker eol (point))
+          ;; the max of 75 excludes the two CR-LF characters
+          ;; after position eol:
+          (when (< 75 (- (position-bytes (marker-position eol))
+                         (position-bytes bol)))
+            (throw 'unfolded-line bol))
+          (setq bol (goto-char (1+ eol))))
+        nil))))
+
+
+;; Parsing-related code starts here. All the parsing code assumes that
+;; content lines have already been unfolded.
+
+;;;; Error handling:
+
+;; Errors at the parsing stage:
+;; e.g. value does not match expected regex
+(define-error 'ical:parse-error "Could not parse iCalendar data")
+
+;; Errors at the printing stage:
+;; e.g. default print function doesn't know how to print value
+(define-error 'ical:print-error "Unable to print iCalendar data")
+
+;;;; Some utilities:
+(defun ical:parse-one-of (types limit)
+  "Parse a value of one of the TYPES, which should be a list of type
+symbols, from point up to LIMIT. For each type in TYPES, the
+parser function associated with that type will be called at
+point. The return value of the first successful parser function
+is returned. If none of the parser functions are able to parse a
+value, an `icalendar-parse-error' is signaled."
+  (let* ((value nil)
+         (start (point))
+         (type (car types))
+         (parser (get type 'ical:value-parser))
+         (rest (cdr types)))
+    (while (and parser (not value))
+      (condition-case nil
+          (setq value (funcall parser limit))
+        (ical:parse-error
+         ;; value of this type not found, so try again:
+         (goto-char start)
+         (setq type (car rest)
+               rest (cdr rest)
+               parser (get type 'ical:value-parser)))))
+    (unless value
+      (signal 'ical:parse-error
+              (list (format "Unable to parse any of %s between %d and %d"
+                            types start limit))))
+    value))
+
+(defun ical:read-list-with (reader string
+                            &optional value-regex separators omit-nulls trim)
+  "Read a list of values from STRING with READER.
+
+READER should be a reader function that accepts a single string argument.
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string'.
+SEPARATORS defaults to \"[^\\][,;]\". TRIM defaults to matching a
+double quote character.
+
+VALUE-REGEX should be a regular expression if READER assumes that
+individual substrings in STRING have previously been matched
+against this regex. In this case, each value in S is placed in a
+temporary buffer and the match against VALUE-REGEX is performed
+before READER is called."
+  (let* ((wrapped-reader
+           (if (not value-regex)
+               ;; no need for temp buffer:
+               reader
+             ;; match the regex in a temp buffer before calling reader:
+             (lambda (s)
+               (with-temp-buffer
+                 (insert s)
+                 (goto-char (point-min))
+                 (unless (looking-at value-regex)
+                   (signal 'ical:parse-error
+                           (list (format "Expected list of values matching '%s'"
+                                         value-regex)
+                                 s)))
+                 (funcall reader (match-string 0))))))
+         (seps (or separators "[^\\][,;]"))
+         (trm (or trim "\""))
+         (raw-values (split-string string seps omit-nulls trm)))
+
+    (unless (functionp reader)
+      (signal 'ical:parser-error
+              (list (format "`%s' is not a reader function" reader))))
+
+    (mapcar wrapped-reader raw-values)))
+
+(defun ical:read-list-of (type string
+                          &optional separators omit-nulls trim)
+  "Read a list of values of type TYPE from STRING.
+
+TYPE should be a value type symbol. The reader function
+associated with that type will be called to read the successive
+values in STRING, and the values will be returned as a list of
+syntax nodes.
+
+SEPARATORS, OMIT-NULLS, and TRIM are as in `split-string' and
+will be passed on, if provided, to `icalendar-read-list-with'."
+  (let* ((reader (lambda (s) (ical:read-value-node type s)))
+         (val-regex (rx-to-string (get type 'ical:value-rx))))
+    (ical:read-list-with reader string val-regex
+                         separators omit-nulls trim)))
+
+(defun ical:list-of-p (list type)
+  "Returns non-nil if each value in LIST satisfies TYPE according to
+`cl-typep'"
+  (seq-every-p (lambda (val) (cl-typep val type)) list))
+
+(defun ical:default-value-printer (val)
+  "Default printer for a *single* property or parameter value.
+
+If VAL is a string, just return it unchanged.
+
+Otherwise, VAL should be a syntax node representing a value. In
+that case, return the original string value if another was
+substituted at parse time, or look up the printer function for
+the node's type and call it on the value inside the node.
+
+For properties and parameters that only allow a single value,
+this function should be a sufficient value printer. It is not
+sufficient for those that allow lists of values, or which have
+other special requirements like quoting or escaping."
+  (cond ((stringp val) val)
+        ((and (ical:ast-node-p val)
+              (get (ical:ast-node-type val) 'ical:value-printer))
+         (or (ical:ast-node-meta-get val :original-value)
+             (let* ((stored-value (ical:ast-node-value val))
+                    (type (ical:ast-node-type val))
+                    (printer (get type 'ical:value-printer)))
+               (funcall printer stored-value))))
+        ;; TODO: other cases to make things easy?
+        ;; e.g. symbols print as their names?
+        (t (signal 'ical:print-error
+                   (list (format "Don't know how to print value: %s" val)
+                         val)))))
+
+
+;;; Section 3.1: Content lines
+
+;; Regexp constants for parsing:
+
+;; In the following regexps and define-* declarations, because
+;; Emacs does not have named groups, we observe the following
+;; convention so that the regexps can be combined in sensible ways:
+;;
+;; - Groups 1 through 5 are reserved for the highest-level regexes
+;;   created by define-param, define-property and define-component and
+;;   used in the match-* functions. Group 1 always represents a 'key'
+;;   (e.g. param or property name), group 2 always represents a
+;;   correctly parsed value for that key, and group 3 (if matched) an
+;;   invalid or unknown value.
+;;
+;;   Groups 4 and 5 are reserved for other information in these
+;;   highest-level regexes, such as the parameter string between a
+;;   property name and its value, or unrecognized values allowed by
+;;   the standard and required to be treated like a default value.
+;;
+;; - Groups 6 through 10 are currently unused
+;; - Groups 11 through 20 are reserved for significant sub-expressions
+;;   of individual value expressions, e.g. the number of weeks in a
+;;   duration value. The various read-* functions rely on these groups
+;;   when converting iCalendar data to Elisp data structures.
+
+(rx-define ical:iana-token
+  (one-or-more (any alnum "-")))
+
+(rx-define ical:x-name
+  (seq "X-"
+      (zero-or-one (>= 3 (any alnum)) "-") ; Vendor ID
+      (one-or-more (any alnum "-")))) ; Name
+
+(rx-define ical:name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:crlf
+  (seq #x12 #xa))
+
+(rx-define ical:control
+  ;; All the controls except HTAB
+  (any (#x00 . #x08) (#x0A . #x1F) #x7F))
+
+;; TODO: double check that "nonascii" class actually corresponds to
+;; the range in the standard
+(rx-define ical:safe-char
+  ;; Any character except ical:control, ?\", ?\;, ?:, ?,
+  (any #x09 #x20 #x21  (#x23 . #x2B) (#x2D . #x39) (#x3C . #x7E) nonascii))
+
+(rx-define ical:qsafe-char
+  ;; Any character except ical:control and ?"
+  (any #x09 #x20 #x21 (#x23 . #x7E) nonascii))
+
+(rx-define ical:quoted-string
+  (seq ?\" (zero-or-more ical:qsafe-char) ?\"))
+
+(rx-define ical:paramtext
+  ;; RFC5545 allows *zero* characters here, but that would mean we could
+  ;; have parameters like ;FOO=;BAR="somethingelse", and what would then
+  ;; be the value of FOO? I see no reason to allow this and it breaks
+  ;; parameter parsing so I have required at least one char here
+  (one-or-more ical:safe-char))
+
+(rx-define ical:param-name
+  (or ical:iana-token ical:x-name))
+
+(rx-define ical:param-value
+  (or ical:paramtext ical:quoted-string))
+
+(rx-define ical:value-char
+  (any #x09 #x20 (#x21 . #x7E) nonascii))
+
+(rx-define ical:value
+  (zero-or-more ical:value-char))
+
+;; some helpers for brevity, not defined in the standard:
+(rx-define ical:comma-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?, item-rx))))
+
+(rx-define ical:semicolon-list (item-rx)
+  (seq item-rx
+       (zero-or-more (seq ?\; item-rx))))
+
+
+;;; Section 3.3: Property Value Data Types
+
+;; Note: These definitions are here (out of order with respect to the
+;; standard) because a few of them are already required for property
+;; parameter definitions (section 3.2) below.
+
+(defconst ical:value-types nil ;; populated by define-type
+  "Alist mapping value type strings in `icalendar-valuetypeparam'
+parameters to type symbols defined with `icalendar-define-type'")
+
+(defun ical:read-value-node (type s)
+  "Read an iCalendar value of type TYPE from string S to a syntax node.
+Returns a syntax node containing the value."
+  (let ((reader (get type 'ical:value-reader)))
+    (ical:make-ast-node type :value (funcall reader s))))
+
+(defun ical:parse-value-node (type limit)
+  "Parse an iCalendar value of type TYPE from point up to LIMIT.
+Returns a syntax node containing the value."
+  (let ((value-regex (rx-to-string (get type 'ical:value-rx))))
+
+    (unless (re-search-forward value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "No %s value between %d and %s"
+                            type (point) limit))))
+
+    (let ((begin (match-beginning 0))
+          (end (match-end 0))
+          (node (ical:read-value-node type (match-string 0))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      (ical:ast-node-meta-set node :begin begin)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-value-node (node)
+  "Serialize an iCalendar syntax node containing a value to a string."
+  (let* ((type (ical:ast-node-type node))
+         (value-printer (get type 'ical:value-printer)))
+    (funcall value-printer (ical:ast-node-value node))))
+
+(defun ical:printable-value-type-symbol-p (symbol)
+  "Return non-nil if SYMBOL is a type symbol representing a printable
+iCalendar value type, i.e., a type for a property or parameter
+value defined by `icalendar-define-type' which has a print
+name (mainly for use in `icalendar-valuetypeparam' parameters).
+
+This means that SYMBOL must both satisfy
+`icalendar-value-type-symbol-p' and be associated with a print
+name in `icalendar-value-types'."
+  (and (ical:value-type-symbol-p symbol)
+       (rassq symbol ical:value-types)))
+
+(defun ical:value-node-p (node)
+  "Return non-nil if NODE is an iCalendar syntax node whose type
+is a value type."
+  (and (ical:ast-node-p node)
+       (ical:value-type-symbol-p (ical:ast-node-type node))))
+
+;;;; 3.3.1 Binary
+;; from https://www.rfc-editor.org/rfc/rfc4648#section-4:
+(rx-define ical:base64char
+  (any (?A . ?Z) (?a . ?z) (?0 . ?9) ?+ ?/))
+
+(ical:define-type ical:binary "BINARY"
+   "Type for Binary values.
+
+The parsed and printed representations are the same: a string of characters
+representing base64-encoded data."
+   '(and string (satisfies ical:match-binary-value))
+   (seq (zero-or-more (= 4 ical:base64char))
+        (zero-or-one (or (seq (= 2 ical:base64char) "==")
+                         (seq (= 3 ical:base64char) "="))))
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.1")
+
+;;;; 3.3.2 Boolean
+(defun ical:read-boolean (s)
+  "Read an `icalendar-boolean' value from a string S.
+S should be a match against rx `icalendar-boolean'."
+  (let ((upcased (upcase s)))
+    (cond ((equal upcased "TRUE") t)
+          ((equal upcased "FALSE") nil)
+          (t (signal 'ical:parse-error
+                     (list "Expected 'TRUE' or 'FALSE'" s))))))
+
+(defun ical:print-boolean (b)
+  "Serialize an `icalendar-boolean' value B to a string."
+    (if b "TRUE" "FALSE"))
+
+(ical:define-type ical:boolean "BOOLEAN"
+   "Type for Boolean values.
+
+When printed, either the string 'TRUE' or 'FALSE'.
+When read, either t or nil."
+   'boolean
+   (or "TRUE" "FALSE")
+   :reader ical:read-boolean
+   :printer ical:print-boolean
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.2")
+
+;;;; 3.3.3 Calendar User Address
+;; Defined with URI, below
+
+;; Dates and Times:
+
+;;;; 3.3.4 Date
+(cl-deftype ical:numeric-year () '(integer 0 9999))
+(cl-deftype ical:numeric-month () '(integer 1 12))
+(cl-deftype ical:numeric-monthday () '(integer 1 31))
+
+(rx-define ical:year
+  (= 4 digit))
+
+(rx-define ical:month
+  (= 2 digit))
+
+(rx-define ical:mday
+  (= 2 digit))
+
+(defun ical:read-date (s)
+  "Read an `icalendar-date' from a string S.
+S should be a match against rx `icalendar-date'."
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8))))
+    (list month day year)))
+
+(defun ical:print-date (d)
+  "Serialize an `icalendar-date' to a string."
+  (format "%04d%02d%02d"
+          (calendar-extract-year d)
+          (calendar-extract-month d)
+          (calendar-extract-day d)))
+
+(ical:define-type ical:date "DATE"
+   "Type for Date values.
+
+When printed, a date is a string of digits in YYYYMMDD format.
+
+When read, a date is a list (MONTH DAY YEAR), with the three
+values being integers in the appropriate ranges; see `calendar.el'
+for functions that work with this representation."
+   '(and (satisfies calendar-date-is-valid-p))
+   (seq ical:year ical:month ical:mday)
+   :reader ical:read-date
+   :printer ical:print-date
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.4")
+
+;;;; 3.3.12 Time
+;; (Defined here so that ical:time RX can be used in ical:date-time)
+(cl-deftype ical:numeric-hour () '(integer 0 23))
+(cl-deftype ical:numeric-minute () '(integer 0 59))
+(cl-deftype ical:numeric-second () '(integer 0 60)) ; 60 represents a leap second
+
+(defun ical:read-time (s)
+  "Read an `icalendar-time' from a string S.
+S should be a match against rx `icalendar-time'."
+  (let ((hour (string-to-number (substring s 0 2)))
+        (minute (string-to-number (substring s 2 4)))
+        (second (string-to-number (substring s 4 6)))
+        (utcoffset (if (and (length= s 7)
+                            (equal "Z" (substring s 6 7)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (make-decoded-time :second second
+                       :minute minute
+                       :hour hour
+                       :zone utcoffset)))
+
+(defun ical:print-time (time)
+  "Serialize an `icalendar-time' to a string."
+  (format "%02d%02d%02d%s"
+          (decoded-time-hour time)
+          (decoded-time-minute time)
+          (decoded-time-second time)
+          (if (eql 0 (decoded-time-zone time))
+              "Z" "")))
+
+(defun ical:-decoded-time-p (val)
+  "Return non-nil if VAL is a valid decoded *time*.
+This predicate does not check date-related values in VAL;
+for that, see `icalendar--decoded-date-time-p'."
+  ;; FIXME: this should probably be defined alongside the
+  ;; other decoded-time-* functions!
+  (and (listp val)
+       (length= val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(ical:define-type ical:time "TIME"
+  "Type for Time values.
+
+When printed, a time is a string of six digits HHMMSS, followed
+by the letter 'Z' if it is in UTC.
+
+When read, a time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values. When
+read, the DAY, MONTH, YEAR, and DOW fields are nil, and these
+fields and DST are ignored when printed."
+  '(satisfies ical:-decoded-time-p)
+  (seq (= 6 digit) (zero-or-one ?Z))
+  :reader ical:read-time
+  :printer ical:print-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.12")
+
+;;;; 3.3.5 Date-Time
+(defun ical:-decoded-date-time-p (val)
+  ;; FIXME: this should probably be defined alongside the
+  ;; other decoded-time-* functions!
+  (and (listp val)
+       (length= val 9)
+       (cl-typep (decoded-time-second val) 'ical:numeric-second)
+       (cl-typep (decoded-time-minute val) 'ical:numeric-minute)
+       (cl-typep (decoded-time-hour val) 'ical:numeric-hour)
+       (cl-typep (decoded-time-day val) 'ical:numeric-monthday)
+       (cl-typep (decoded-time-month val) 'ical:numeric-month)
+       (cl-typep (decoded-time-year val) 'ical:numeric-year)
+       (calendar-date-is-valid-p (list (decoded-time-month val)
+                                       (decoded-time-day val)
+                                       (decoded-time-year val)))
+       ;; FIXME: the weekday slot value should be automatically
+       ;; calculated from month, day, and year, like:
+       ;;   (calendar-day-of-week (list month day year))
+       ;; Although `ical:read-date-time' does this correctly,
+       ;; `make-decoded-time' does not. Thus we can't use
+       ;; `make-decoded-time' to construct valid `ical:date-time'
+       ;; values unless this check is turned off,
+       ;; which means it's annoying to write tests and anything
+       ;; that uses cl-typecase to dispatch on values created by
+       ;; `make-decoded-time':
+       ;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
+       (cl-typep (decoded-time-dst val) '(member t nil -1))
+       (cl-typep (decoded-time-zone val) '(or integer null))))
+
+(defun ical:read-date-time (s)
+  "Read an `icalendar-date-time' from a string S.
+S should be a match against rx `icalendar-date-time'."
+  (let ((year (string-to-number (substring s 0 4)))
+        (month (string-to-number (substring s 4 6)))
+        (day (string-to-number (substring s 6 8)))
+        ;; "T" is index 8
+        (hour (string-to-number (substring s 9 11)))
+        (minute (string-to-number (substring s 11 13)))
+        (second (string-to-number (substring s 13 15)))
+        (utcoffset (if (and (length= s 16)
+                            (equal "Z" (substring s 15 16)))
+                       0
+                     ;; unknown/'floating' time zone:
+                     nil)))
+    (list second minute hour day month year
+          (calendar-day-of-week (list month day year))
+          -1 ; DST information not available
+          utcoffset)))
+
+(defun ical:print-date-time (datetime)
+  "Serialize an `icalendar-date-time' to a string."
+  (format "%04d%02d%02dT%02d%02d%02d%s"
+          (decoded-time-year datetime)
+          (decoded-time-month datetime)
+          (decoded-time-day datetime)
+          (decoded-time-hour datetime)
+          (decoded-time-minute datetime)
+          (decoded-time-second datetime)
+          (if (ical:date-time-is-utc-p datetime)
+              "Z" "")))
+
+(defun ical:date-time-is-utc-p (datetime)
+  "Return non-nil if DATETIME is in UTC time"
+  (let ((offset (decoded-time-zone datetime)))
+    (and offset (= 0 offset))))
+
+(ical:define-type ical:date-time "DATE-TIME"
+   "Type for Date-Time values.
+
+When printed, a date-time is a string of digits like:
+  YYYYMMDDTHHMMSS
+where the 'T' is literal, and separates the date string from the
+time string.
+
+When read, a date-time is a decoded time, i.e. a list in the format
+(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
+`decode-time' for the specifics of the individual values."
+   '(satisfies ical:-decoded-date-time-p)
+  (seq ical:date ?T ical:time)
+  :reader ical:read-date-time
+  :printer ical:print-date-time
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5")
+
+;;;; 3.3.6 Duration
+(rx-define ical:dur-second
+  (seq (group-n 19 (one-or-more digit)) ?S))
+
+(rx-define ical:dur-minute
+  (seq (group-n 18 (one-or-more digit)) ?M (zero-or-one ical:dur-second)))
+
+(rx-define ical:dur-hour
+  (seq (group-n 17 (one-or-more digit)) ?H (zero-or-one ical:dur-minute)))
+
+(rx-define ical:dur-day
+  (seq (group-n 16 (one-or-more digit)) ?D))
+
+(rx-define ical:dur-week
+  (seq (group-n 15 (one-or-more digit)) ?W))
+
+(rx-define ical:dur-time
+  (seq ?T (or ical:dur-hour ical:dur-minute ical:dur-second)))
+
+(rx-define ical:dur-date
+  (seq ical:dur-day (zero-or-one ical:dur-time)))
+
+(defun ical:read-dur-value (s)
+  "Read an `icalendar-dur-value' from a string S.
+S should be a match against rx `icalendar-dur-value'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((sign (if (equal (match-string 20) "-") -1 1)))
+    (if (match-string 15)
+        ;; dur-value specified in weeks, so just return an integer:
+        (* sign (string-to-number (match-string 15)))
+      ;; otherwise, make a time delta from the other units:
+      (let* ((days (match-string 16))
+             (ndays (* sign (if days (string-to-number days) 0)))
+             (hours (match-string 17))
+             (nhours (* sign (if hours (string-to-number hours) 0)))
+             (minutes (match-string 18))
+             (nminutes (* sign (if minutes (string-to-number minutes) 0)))
+             (seconds (match-string 19))
+             (nseconds (* sign (if seconds (string-to-number seconds) 0))))
+        (make-decoded-time :second nseconds :minute nminutes :hour nhours
+                           :day ndays)))))
+
+(defun ical:print-dur-value (dur)
+  "Serialize an `icalendar-dur-value' to a string"
+  (if (integerp dur)
+      ;; dur-value specified in weeks can only contain weeks:
+      (format "%sP%dW" (if (< dur 0) "-" "") (abs dur))
+    ;; otherwise, show all the time units present:
+    (let* ((days+- (or (decoded-time-day dur) 0))
+           (hours+- (or (decoded-time-hour dur) 0))
+           (minutes+- (or (decoded-time-minute dur) 0))
+           (seconds+- (or (decoded-time-second dur) 0))
+           ;; deal with the possibility of mixed positive and negative values
+           ;; in a time delta list:
+           (sum (+ seconds+-
+                   (* 60 minutes+-)
+                   (* 60 60 hours+-)
+                   (* 60 60 24 days+-)))
+           (abssum (abs sum))
+           (days (/ abssum (* 60 60 24)))
+           (sumnodays (mod abssum (* 60 60 24)))
+           (hours (/ sumnodays (* 60 60)))
+           (sumnohours (mod sumnodays (* 60 60)))
+           (minutes (/ sumnohours 60))
+           (seconds (mod sumnohours 60))
+           (sign (when (< sum 0) "-"))
+           (time-sep (unless (and (zerop hours) (zerop minutes) (zerop seconds))
+                       "T")))
+      (concat sign
+              "P"
+              (unless (zerop days) (format "%dD" days))
+              time-sep
+              (unless (zerop hours) (format "%dH" hours))
+              (unless (zerop minutes) (format "%dM" minutes))
+              (unless (zerop seconds) (format "%dS" seconds))))))
+
+(defun ical:-time-delta-p (val)
+  (and (listp val)
+       (length= val 9)
+       (let ((seconds (decoded-time-second val))
+             (minutes (decoded-time-minute val))
+             (hours (decoded-time-hour val))
+             (days (decoded-time-day val))) ; other values in list are ignored
+         (and
+          (cl-typep seconds 'integer)
+          (cl-typep minutes 'integer)
+          (cl-typep hours 'integer)
+          (cl-typep days 'integer)
+          (not (and (zerop seconds) (zerop minutes) (zerop hours)
+                    (zerop days)))))))
+
+(ical:define-type ical:dur-value "DUR-VALUE" ; avoid name clashes with DURATION
+  "Type for Duration values.
+
+When printed, a duration is a string containing:
+  - possibly a +/- sign
+  - the letter 'P'
+  - one or more sequences of digits followed by a letter representing a unit
+    of time: 'W' for weeks, 'D' for days, etc. Units smaller than a day are
+    separated from days by the letter 'T'. If a duration is specified in weeks,
+    other units of time are not allowed.
+
+For example, a duration of 15 days, 5 hours, and 20 seconds would be printed:
+   P15DT5H0M20S
+and a duration of 7 weeks would be printed:
+   P7W
+
+When read, a duration is either an integer, in which case it
+represents a number of weeks, or a decoded time, in which case it
+must represent a time delta in the sense of `decoded-time-add'.
+Note that, in the time delta representation, units of time longer
+than a day are not supported and will be ignored if present.
+
+This type is named `icalendar-dur-value' rather than
+`icalendar-duration' for consistency with the text of RFC5545 and
+so that its name does not collide with the symbol for the
+'DURATION' property."
+  '(or integer (satisfies ical:-time-delta-p))
+  ;; Group 15: weeks
+  ;; Group 16: days
+  ;; Group 17: hours
+  ;; Group 18: minutes
+  ;; Group 19: seconds
+  ;; Group 20: sign
+  (seq
+   (group-n 20 (zero-or-one (or ?+ ?-)))
+   ?P
+   (or ical:dur-date ical:dur-time ical:dur-week))
+  :reader ical:read-dur-value
+  :printer ical:print-dur-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.6")
+
+
+;;;; 3.3.7 Float
+(ical:define-type ical:float "FLOAT"
+   "Type for Float values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits,
+and possibly a decimal. When read, an Elisp float value."
+   '(float * *)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit)
+    (zero-or-one (seq ?. (one-or-more digit))))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.7")
+
+;;;; 3.3.8 Integer
+(ical:define-type ical:integer "INTEGER"
+   "Type for Integer values.
+
+When printed, possibly a sign + or -, followed by a sequence of digits.
+When read, an Elisp integer value between -2147483648 and 2147483647."
+   '(integer -2147483648 2147483647)
+   (seq
+    (zero-or-one (or ?+ ?-))
+    (one-or-more digit))
+   :reader string-to-number
+   :printer number-to-string
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8")
+
+;;;; 3.3.9 Period
+(defsubst ical:period-start (period)
+  "Return the `icalendar-date-time' which marks the start of PERIOD."
+  (car period))
+
+(defsubst ical:period-end (period)
+  "Return the `icalendar-date-time' which marks the end of PERIOD, or nil."
+  (cadr period))
+
+(defsubst ical:period-dur-value (period)
+  "Return the `icalendar-dur-value' which gives the length of PERIOD, or nil."
+  (caddr period))
+
+(defun ical:period-p (val)
+  (and (listp val)
+       (length= val 3)
+       (cl-typep (ical:period-start val) 'ical:date-time)
+       (cl-typep (ical:period-end val) '(or null ical:date-time))
+       (cl-typep (ical:period-dur-value val) '(or null ical:dur-value))))
+
+(defun ical:read-period (s)
+  "Read an `icalendar-period' from a string S.
+S should have been matched against rx `icalendar-period'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((start (ical:read-date-time (match-string 11)))
+        (end (when (match-string 12) (ical:read-date-time (match-string 12))))
+        (dur (when (match-string 13) (ical:read-dur-value (match-string 13)))))
+    (list start end dur)))
+
+(defun ical:print-period (per)
+  "Serialize an `icalendar-period' to a string"
+  (let ((start (ical:period-start per))
+        (end (ical:period-end per))
+        (dur (ical:period-dur-value per)))
+    (concat (ical:print-date-time start)
+            "/"
+            (if dur
+                (ical:print-dur-value dur)
+              (ical:print-date-time end)))))
+
+(ical:define-type ical:period "PERIOD"
+   "Type for Period values.
+
+A period of time is specified as a starting date-time together
+with either an explicit date-time as its end, or a duration which
+gives its length and implicitly marks its end.
+
+When printed, the starting date-time is separated from the end or
+duration by a / character.
+
+When read, a period is represented as a list (START END DUR),
+where START is an `icalendar-date-time', END is either an
+`icalendar-date-time' or nil, and DUR is either an
+`icalendar-dur-value' or nil. (This representation allows END to
+be computed from DUR and cached, and also distinguishes DUR and
+END, which might both be decoded times.)"
+  '(satisfies ical:period-p)
+  (seq (group-n 11 ical:date-time)
+       "/"
+       (or (group-n 12 ical:date-time)
+           (group-n 13 ical:dur-value)))
+  :reader ical:read-period
+  :printer ical:print-period
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.9")
+
+;;;; 3.3.10 Recurrence rules:
+(rx-define ical:freq
+   (or "SECONDLY" "MINUTELY" "HOURLY" "DAILY" "WEEKLY" "MONTHLY" "YEARLY"))
+
+(rx-define ical:weekday
+   (or "SU" "MO" "TU" "WE" "TH" "FR" "SA"))
+
+(rx-define ical:ordwk
+  (** 1 2 digit)) ; 1 to 53
+
+(rx-define ical:weekdaynum
+  ;; Group 19: Week num, if present
+  ;; Group 20: week day abbreviation
+   (seq (zero-or-one
+         (group-n 19 (seq (zero-or-one (or ?+ ?-))
+                          ical:ordwk)))
+        (group-n 20 ical:weekday)))
+
+(rx-define ical:weeknum
+  (seq (zero-or-one (or ?+ ?-))
+       ical:ordwk))
+
+(rx-define ical:monthdaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 31
+
+(rx-define ical:monthnum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 2 digit))) ; 1 to 12
+
+(rx-define ical:yeardaynum
+  (seq (zero-or-one (or ?+ ?-))
+       (** 1 3 digit))) ; 1 to 366
+
+(defconst ical:weekday-numbers
+  '(("SU" . 0)
+    ("MO" . 1)
+    ("TU" . 2)
+    ("WE" . 3)
+    ("TH" . 4)
+    ("FR" . 5)
+    ("SA" . 6))
+  "Alist mapping two-letter weekday abbreviations to numbers 0 to 6.
+Weekday abbreviations in recurrence rule parts are translated to
+and from numbers for compatibility with calendar-* and
+decoded-time-* functions.")
+
+(defun ical:read-weekdaynum (s)
+  "Read a weekday abbreviation to a number.
+If the abbreviation is preceded by an offset, read a dotted
+pair (WEEKDAY . OFFSET). Thus \"SU\" becomes 0, \"-1SU\"
+becomes (0 . -1), etc. S should have been matched against
+`icalendar-weekdaynum'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((dayno (cdr (assoc (match-string 20) ical:weekday-numbers)))
+        (weekno (match-string 19)))
+    (if weekno
+        (cons dayno (string-to-number weekno))
+      dayno)))
+
+(defun ical:print-weekdaynum (val)
+  "Serialize a number or dotted pair VAL to a string
+(as part of a BYDAY recur rule part). See `icalendar-read-weekdaynum'
+for the value format."
+  (if (consp val)
+      (let* ((dayno (car val))
+             (day (car (rassq dayno ical:weekday-numbers)))
+             (offset (cdr val)))
+        (concat (number-to-string offset) day))
+    ;; number alone just stands for a day:
+    (car (rassq val ical:weekday-numbers))))
+
+(defun ical:read-recur-rule-part (s)
+  "Read an `icalendar-recur-rule-part' from string S.
+S should have been matched against `icalendar-recur-rule-part'.
+The return value is a list (KEYWORD VALUE), where VALUE may
+itself be a list, depending on the values allowed by KEYWORD."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((keyword (intern (upcase (match-string 11))))
+        (values (match-string 12)))
+    (list keyword
+      (cl-case keyword
+        (FREQ (intern (upcase values)))
+        (UNTIL (if (length> values 8)
+                   (ical:read-date-time values)
+                 (ical:read-date values)))
+        ((COUNT INTERVAL)
+         (string-to-number values))
+        ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYSETPOS)
+         (ical:read-list-with #'string-to-number values nil ","))
+        (BYDAY
+         (ical:read-list-with #'ical:read-weekdaynum values
+                              (rx ical:weekdaynum) ","))
+        (WKST (cdr (assoc values ical:weekday-numbers)))))))
+
+(defun ical:print-recur-rule-part (part)
+  "Serialize recur rule part PART to a string."
+  (let ((keyword (car part))
+        (values (cadr part))
+        values-str)
+    (cl-case keyword
+      (FREQ (setq values-str (symbol-name values)))
+      (UNTIL (setq values-str (cl-typecase values
+                                (ical:date-time (ical:print-date-time values))
+                                (ical:date (ical:print-date values)))))
+      ((COUNT INTERVAL)
+       (setq values-str (number-to-string values)))
+      ((BYSECOND BYMINUTE BYHOUR BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYSETPOS)
+       (setq values-str (string-join (mapcar #'number-to-string values)
+                                     ",")))
+      (BYDAY
+       (setq values-str (string-join (mapcar #'ical:print-weekdaynum values)
+                                     ",")))
+      (WKST (setq values-str (car (rassq values ical:weekday-numbers)))))
+
+    (concat (symbol-name keyword) "=" values-str)))
+
+(rx-define ical:recur-rule-part
+  ;; Group 11: keyword
+  ;; Group 12: value(s)
+  (or (seq (group-n 11 "FREQ") "=" (group-n 12 ical:freq))
+      (seq (group-n 11 "UNTIL") "=" (group-n 12 (or ical:date-time ical:date)))
+      (seq (group-n 11 "COUNT") "=" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "INTERVAL") "=" (group-n 12 (one-or-more digit)))
+      (seq (group-n 11 "BYSECOND") "=" (group-n 12 ; 0 to 60
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYMINUTE") "=" (group-n 12 ; 0 to 59
+                                         (ical:comma-list (** 1 2 digit))))
+      (seq (group-n 11 "BYHOUR") "=" (group-n 12 ; 0 to 23
+                                       (ical:comma-list (** 1 2 digit)))) ; 0 to 23
+      (seq (group-n 11 "BYDAY") "=" (group-n 12 ; weeknum? daynum, e.g. SU or 34SU
+                                      (ical:comma-list ical:weekdaynum)))
+      (seq (group-n 11 "BYMONTHDAY") "=" (group-n 12
+                                           (ical:comma-list ical:monthdaynum)))
+      (seq (group-n 11 "BYYEARDAY") "=" (group-n 12
+                                          (ical:comma-list ical:yeardaynum)))
+      (seq (group-n 11 "BYWEEKNO") "=" (group-n 12 (ical:comma-list ical:weeknum)))
+      (seq (group-n 11 "BYMONTH") "=" (group-n 12 (ical:comma-list ical:monthnum)))
+      (seq (group-n 11 "BYSETPOS") "=" (group-n 12
+                                         (ical:comma-list ical:yeardaynum)))
+      (seq (group-n 11 "WKST") "=" (group-n 12 ical:weekday))))
+
+(defun ical:read-recur (s)
+  "Read a recurrence rule value from string S.
+S should be a match against rx `icalendar-recur'."
+  (ical:read-list-with #'ical:read-recur-rule-part s (rx ical:recur-rule-part) ";"))
+
+(defun ical:print-recur (val)
+  "Serialize a recurrence rule value VAL to a string."
+  ;; RFC5545 sec. 3.3.10: "to ensure backward compatibility with
+  ;; applications that pre-date this revision of iCalendar the
+  ;; FREQ rule part MUST be the first rule part specified in a
+  ;; RECUR value."
+  (string-join
+   (cons
+    (ical:print-recur-rule-part (assq 'FREQ val))
+    (mapcar #'ical:print-recur-rule-part
+            (seq-filter (lambda (part) (not (eq 'FREQ (car part))))
+                        val)))
+   ";"))
+
+(defconst ical:-recur-value-types
+  ;; Note: "list-of" is not a cl-type specifier, just a symbol here; it is
+  ;; handled specially when checking types in ical:recur-value-p:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+    UNTIL (or ical:date-time ical:date)
+    COUNT (integer 1 *)
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) (satisfies ical:dayno-offset-p)))
+    BYMONTHDAY (list-of (or (integer -31 -1) (integer 1 31)))
+    BYYEARDAY (list-of (or (integer -366 -1) (integer 1 366)))
+    BYWEEKNO (list-of (or (integer -53 -1) (integer 1 53)))
+    BYMONTH (list-of (integer 1 12)) ; unlike the others, months cannot be negative
+    BYSETPOS (list-of (or (integer -366 -1) (integer 1 366)))
+    WKST (integer 0 6))
+  "Plist mapping `icalendar-recur' keywords to type specifiers")
+
+(defun ical:dayno-offset-p (val)
+  "Return non-nil if VAL is a pair (DAYNO . OFFSET), part of a
+recurrence rule BYDAY value"
+  (and (consp val)
+       (cl-typep (car val) '(integer 0 6))
+       (cl-typep (cdr val) '(or (integer -53 -1) (integer 1 53)))))
+
+(defun ical:recur-value-p (vals)
+  "Return non-nil if VALS is an iCalendar recurrence rule value."
+  (and (listp vals)
+       ;; FREQ is always required:
+       (assq 'FREQ vals)
+       ;; COUNT and UNTIL are mutually exclusive if present:
+       (not (and (assq 'COUNT vals) (assq 'UNTIL vals)))
+       ;; If BYSETPOS is present, another BYXXX clause must be too:
+       (or (not (assq 'BYSETPOS vals))
+           (assq 'BYMONTH vals)
+           (assq 'BYWEEKNO vals)
+           (assq 'BYYEARDAY vals)
+           (assq 'BYMONTHDAY vals)
+           (assq 'BYDAY vals)
+           (assq 'BYHOUR vals)
+           (assq 'BYMINUTE vals)
+           (assq 'BYSECOND vals))
+       (let ((freq (ical:recur-freq vals))
+             (byday (ical:recur-by* 'BYDAY vals))
+             (byweekno (ical:recur-by* 'BYWEEKNO vals))
+             (bymonthday (ical:recur-by* 'BYMONTHDAY vals))
+             (byyearday (ical:recur-by* 'BYYEARDAY vals)))
+         (and
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value when the FREQ rule part is not set to MONTHLY or
+          ;; YEARLY."
+          (or (not (consp (car byday)))
+              (memq freq '(MONTHLY YEARLY)))
+          ;; "The BYDAY rule part MUST NOT be specified with a numeric
+          ;; value with the FREQ rule part set to YEARLY when the
+          ;; BYWEEKNO rule part is specified." This also covers:
+          ;; "[The BYWEEKNO] rule part MUST NOT be used when the FREQ
+          ;; rule part is set to anything other than YEARLY."
+          (or (not byweekno)
+              (and (eq freq 'YEARLY)
+                   (not (consp (car byday)))))
+          ;; "The BYMONTHDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to WEEKLY."
+          (not (and bymonthday (eq freq 'WEEKLY)))
+          ;; "The BYYEARDAY rule part MUST NOT be specified when the
+          ;; FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
+          (not (and byyearday (memq freq '(DAILY WEEKLY MONTHLY))))))
+       ;; check types of all rule parts:
+       (seq-every-p
+        (lambda (kv)
+          (when (consp kv)
+            (let* ((keyword (car kv))
+                   (val (cadr kv))
+                   (type (plist-get ical:-recur-value-types keyword)))
+              (and keyword val type
+                   (if (and (consp type)
+                            (eq (car type) 'list-of))
+                       (ical:list-of-p val (cadr type))
+                     (cl-typep val type))))))
+         vals)))
+
+(ical:define-type ical:recur "RECUR"
+  "Type for Recurrence Rule values.
+
+When printed, a recurrence rule value looks like
+  KEY1=VAL1;KEY2=VAL2;...
+where the VALs may themselves be lists or have other syntactic
+structure; see RFC5545 sec. 3.3.10 for all the gory details.
+
+The KEYs and their associated value types when read are as follows.
+The first is required:
+  '(FREQ (member YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY)
+These two are mutually exclusive; at most one may appear:
+    UNTIL (or icalendar-date-time icalendar-date)
+    COUNT (integer 1 *)
+All others are optional:
+    INTERVAL (integer 1 *)
+    BYSECOND (list-of (integer 0 60))
+    BYMINUTE (list-of (integer 0 59))
+    BYHOUR (list-of (integer 0 23))
+    BYDAY (list-of (or (integer 0 6) ; day of week
+                       (pair (integer 0 6)  ; (day of week . offset)
+                             (integer -53 53))) ; except 0
+    BYMONTHDAY (list-of (integer -31 31))  ; except 0
+    BYYEARDAY (list-of (integer -366 366)) ; except 0
+    BYWEEKNO (list-of (integer -53 53))    ; except 0
+    BYMONTH (list-of (integer 1 12))       ; months cannot be negative
+    BYSETPOS (list-of (integer -366 366))  ; except 0
+    WKST (integer 0 6))
+
+When read, these KEYs and their associated VALs are gathered into
+an alist.
+
+In general, the VALs consist of integers or lists of integers.
+Abbreviations for weekday names are translated into integers
+0 (=Sunday) through 6 (=Saturday), for compatibility with
+calendar.el and decoded-time-* functions.
+
+Some examples:
+
+1) Printed: FREQ=DAILY;COUNT=10;INTERVAL=2
+   Meaning: 10 occurrences that occur every other day
+   Read: ((FREQ DAILY)
+          (COUNT 10)
+          (INTERVAL 2))
+
+2) Printed: FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA
+   Meaning: Every day in January of every year until 2000/01/31 at 14:00 UTC
+   Read: ((FREQ YEARLY)
+          (UNTIL (0 0 14 31 1 2000 1 -1 0))
+          (BYMONTH (1))
+          (BYDAY (0 1 2 3 4 5 6)))
+
+3) Printed: FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2
+   Meaning: Every month on the second-to-last weekday of the month
+   Read: ((FREQ MONTHLY)
+          (BYDAY (1 2 3 4 5))
+          (BYSETPOS (-2)))
+
+Notice that singleton values are still wrapped in a list when the
+KEY accepts a list of values, but not when the KEY always has a
+single (e.g. integer) value."
+  '(satisfies ical:recur-value-p)
+  (ical:semicolon-list ical:recur-rule-part)
+  :reader ical:read-recur
+  :printer ical:print-recur
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10")
+
+(defun ical:recur-freq (recur-value)
+  "Return the frequency in RECUR-VALUE"
+  (car (alist-get 'FREQ recur-value)))
+
+(defun ical:recur-interval-size (recur-value)
+  "Return the interval size specified in RECUR-VALUE, or the default
+of 1."
+  (or (car (alist-get 'INTERVAL recur-value)) 1))
+
+(defun ical:recur-until (recur-value)
+  "Return the UNTIL date(-time) in RECUR-VALUE"
+  (car (alist-get 'UNTIL recur-value)))
+
+(defun ical:recur-count (recur-value)
+  "Return the COUNT in RECUR-VALUE"
+  (car (alist-get 'COUNT recur-value)))
+
+(defun ical:recur-weekstart (recur-value)
+  "Return the weekday which starts the work week specified in
+RECUR-VALUE, or the default (1 = Monday)"
+  (or (car (alist-get 'WKST recur-value)) 1))
+
+(defun ical:recur-by* (byunit recur-value)
+  "Return the values in the BYUNIT clause in RECUR-VALUE.
+BYUNIT should be a symbol: \\='BYMONTH, \\='BYDAY, etc.
+See `icalendar-recur' for all the possible BYUNIT values."
+  (car (alist-get byunit recur-value)))
+
+;;;; 3.3.11 Text
+(rx-define ical:escaped-char
+   (seq ?\\ (or ?\\ ?\; ?, ?N ?n)))
+
+(rx-define ical:text-safe-char
+  (not (or ?\" ?\; ?: ?\\ ?, ical:control))) ;; TODO: is this correct?
+
+(defun ical:text-region-p (val)
+  "Return t if VAL represents a region of text."
+  (and (listp val)
+       (markerp (car val))
+       (not (null (marker-buffer (car val))))
+       (markerp (cdr val))))
+
+(defun ical:make-text-region (&optional buffer begin end)
+  "Return an object that represents the region of text in BUFFER
+between BEGIN and END. BUFFER defaults to the current buffer, and
+BEGIN and END default to point and mark in BUFFER."
+  (let ((buf (or buffer (current-buffer)))
+        (b (make-marker))
+        (e (make-marker)))
+    (with-current-buffer buf
+      (set-marker b (or begin (min (point) (mark))) buf)
+      (set-marker e (or end (max (point) (mark))))
+      (cons b e))))
+
+(defsubst ical:text-region-begin (r)
+  "Return the marker at the beginning of the text region R"
+  (car r))
+
+(defsubst ical:text-region-end (r)
+  "Return the marker at the end of the text region R"
+  (cdr r))
+
+(defun ical:unescape-text-in-region (begin end)
+ "Unescape the text between BEGIN and END, replacing
+literal '\\n' and '\\N' with newline, and removing backslashes that escape
+commas, semicolons, and backslashes."
+ (with-restriction begin end
+   (save-excursion
+    (replace-string-in-region "\\N" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\n" "\n" (point-min) (point-max))
+    (replace-string-in-region "\\," "," (point-min) (point-max))
+    (replace-string-in-region "\\;" ";" (point-min) (point-max)))
+    (replace-string-in-region (concat "\\" "\\") "\\" (point-min) (point-max))))
+
+(defun ical:unescape-text-string (s)
+ "Unescape the text in string S, replacing literal '\\n' and '\\N'
+with newline, and removing backslashes that escape commas, semicolons
+and backslashes."
+  (with-temp-buffer
+    (insert s)
+    (ical:unescape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:escape-text-in-region (begin end)
+  "Escape the text between BEGIN and END, replacing newlines with
+literal '\\n', and escaping commas, semicolons and backslashes with a
+backslash."
+ (with-restriction begin end
+  (save-excursion
+    ;; replace backslashes first, so the ones introduced when
+    ;; escaping other characters don't end up double-escaped:
+    (replace-string-in-region "\\" (concat "\\" "\\") (point-min) (point-max))
+    (replace-string-in-region "\n" "\\n" (point-min) (point-max))
+    (replace-string-in-region "," "\\," (point-min) (point-max))
+    (replace-string-in-region ";" "\\;" (point-min) (point-max)))))
+
+(defun ical:escape-text-string (s)
+  "Escape the text in S, replacing newlines with '\\n', and escaping
+commas, semicolons, and backslashes with a backslash."
+  (with-temp-buffer
+    (insert s)
+    (ical:escape-text-in-region (point-min) (point-max))
+    (buffer-string)))
+
+(defun ical:read-text (s)
+  "Read an `icalendar-text' value from a string S.
+S should be a match against rx `icalendar-text'."
+  (ical:unescape-text-string s))
+
+(defun ical:print-text (val)
+  "Serialize an iCalendar text value. VAL may be a string or a text
+region (see `icalendar-make-text-region'). The text will be escaped before
+printing. If VAL is a region, the text it contains will not be
+modified; it is copied before escaping."
+  (if (stringp val)
+      (ical:escape-text-string val)
+    ;; val is a region, so copy and escape its contents:
+    (let* ((beg (ical:text-region-begin val))
+           (buf (marker-buffer beg))
+           (end (ical:text-region-end val)))
+      (with-temp-buffer
+        (insert-buffer-substring buf (marker-position beg) (marker-position end))
+        (ical:escape-text-in-region (point-min) (point-max))
+        (buffer-string)))))
+
+(defun ical:text-to-string (node)
+  "Return the value of an `icalendar-text' NODE as a string.
+The returned string is *not* escaped. For that, see `icalendar-print-text'."
+  (let ((val (ical:ast-node-value node)))
+    (if (stringp val)
+           val
+      (let* ((beg (ical:text-region-begin val))
+             (buf (marker-buffer beg))
+             (end (ical:text-region-end val)))
+        (with-current-buffer buf
+          (buffer-substring (marker-position beg) (marker-position end)))))))
+
+;; TODO: would it be useful to add a third representation, namely a
+;; function or thunk? So that e.g. Org can pre-process its own syntax
+;; and return a plain text string to use in the description?
+(ical:define-type ical:text "TEXT"
+   "Type for Text values.
+
+Text values can be represented in Elisp in two ways: as strings,
+or as buffer regions. For values which aren't expected to change,
+such as property values in a text/calendar email attachment, use
+strings. For values which are user-editable and might change
+between parsing and serializing to iCalendar format, use a
+region. In that case, a text value contains two markers BEGIN and
+END which mark the bounds of the region. See
+`icalendar-make-text-region' to create such values, and
+`icalendar-text-region-begin' and `icalendar-text-region-end' to
+access the markers.
+
+Certain characters in text values are required to be escaped by
+the iCalendar standard. These characters should NOT be
+pre-escaped when inserting them into the parse tree. Instead,
+`icalendar-print-text' takes care of escaping text values, and
+`icalendar-read-text' takes care of unescaping them, when parsing and
+printing iCalendar data."
+  '(or string (satisfies ical:text-region-p))
+  (zero-or-more (or ical:text-safe-char ?: ?\" ical:escaped-char))
+  :reader ical:read-text
+  :printer ical:print-text
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11")
+
+;; 3.3.12 Time - Defined above
+
+;;;; 3.3.13 URI
+;; see https://www.rfc-editor.org/rfc/rfc3986#section-3
+(require 'icalendar-uri-schemes)
+(rx-define ical:uri-with-scheme
+  ;; Group 11: URI scheme; see icalendar-uri-schemes.el
+  ;; Group 12: rest of URI after ":"
+  ;; This regex mostly just scans for all characters allowed by
+  ;; RFC3986. We make an effort to parse the scheme, even though this
+  ;; is an open-ended list, because otherwise the regex is either too
+  ;; permissive or too complicated to be useful. (ical:binary, in
+  ;; particular, matches a subset of the characters allowed in a URI).
+  ;; TODO: should we parse more structure here?
+  (seq (group-n 11 ical:uri-scheme)
+       ":"
+       (group-n 12
+         (one-or-more
+          (any alnum ?- ?. ?_ ?~                   ; unreserved chars
+               ?: ?/ ?? ?# ?\[ ?\] ?@              ; gen-delims
+               ?! ?$ ?& ?' ?\( ?\) ?* ?+ ?, ?\; ?= ; sub-delims
+               ?%)))))                             ; for %-encoding
+
+(ical:define-type ical:uri "URI"
+   "Type for URI values.
+
+The parsed and printed representations are the same: a URI string."
+   '(satisfies ical:match-uri-value)
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.13")
+
+;;;; 3.3.3 Calendar User Address
+(ical:define-type ical:cal-address "CAL-ADDRESS"
+   "Type for Calendar User Address values.
+
+The parsed and printed representations are the same: a URI string.
+Typically, this should be a mailto: URI.
+
+RFC5545 says: '*When used to address an Internet email transport
+  address* for a calendar user, the value MUST be a mailto URI,
+  as defined by [RFC2368]'
+
+Since it is unclear whether there are Calendar User Address values
+which are not used to address email, this type does not enforce the use
+of the mailto: scheme, but be prepared for problems if you create
+values of this type with any other scheme."
+   '(and string (satisfies ical:match-cal-address-value))
+   ical:uri-with-scheme
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.3")
+
+;;;; 3.3.14 UTC Offset
+(defun ical:read-utc-offset (s)
+  "Read a UTC offset from a string.
+S should be a match against rx `icalendar-utc-offset'"
+  (let ((sign (if (equal (substring s 0 1) "-") -1 1))
+        (nhours (string-to-number (substring s 1 3)))
+        (nminutes (string-to-number (substring s 3 5)))
+        (nseconds (if (length= s 7)
+                      (string-to-number (substring s 5 7))
+                    0)))
+    (* sign (+ nseconds (* 60 nminutes) (* 60 60 nhours)))))
+
+(defun ical:print-utc-offset (utcoff)
+  "Serialize a UTC offset to a string"
+  (let* ((sign (if (< utcoff 0) "-" "+"))
+         (absoff (abs utcoff))
+         (nhours (/ absoff (* 60 60)))
+         (no-hours (mod absoff (* 60 60)))
+         (nminutes (/ no-hours 60))
+         (nseconds (mod no-hours 60)))
+    (if (zerop nseconds)
+        (format "%s%02d%02d" sign nhours nminutes)
+      (format "%s%02d%02d%02d" sign nhours nminutes nseconds))))
+
+(ical:define-type ical:utc-offset "UTC-OFFSET"
+  "Type for UTC Offset values.
+
+When printed, a sign followed by a string of digits, like +HHMM
+or -HHMMSS. When read, an integer representing the number of
+seconds offset from UTC. This representation is for compatibility
+with `decode-time' and related functions."
+  '(integer -999999 999999)
+  (seq (or ?+ ?-) ; + is not optional for positive values!
+       (= 4 digit) ; HHMM
+       (zero-or-one (= 2 digit))) ; SS
+  :reader ical:read-utc-offset
+  :printer ical:print-utc-offset
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.3.14")
+
+
+;;; Section 3.2: Property Parameters
+
+(defconst ical:params-font-lock-keywords nil ;; populated by ical:define-param
+  "Entries for iCalendar property parameters in `font-lock-keywords'.")
+
+(defconst ical:param-types nil ;; populated by ical:define-param
+  "Alist mapping printed parameter names to type symbols")
+
+(defun ical:maybe-quote-param-value (s &optional always)
+  "Add quotes around param value string S if required. If ALWAYS is non-nil,
+add quotes to S regardless of its contents"
+  (if (or always
+          (not (string-match (rx ical:paramtext) s))
+          (< (match-end 0) (length s)))
+      (concat "\"" s "\"")
+    s))
+
+(defun ical:read-param-value (type s)
+  "Read a value for a parameter of type TYPE from a string S.
+S should have already been matched against the regex for TYPE and
+the match data should be available to this function. Returns a
+syntax node of type TYPE containing the read value.
+
+If TYPE accepts a list of values, S will be split on the list
+separator for TYPE and read individually."
+  (let* ((value-type (get type 'ical:value-type)) ; if nil, value is just a string
+         (value-regex (when (get type 'ical:value-rx)
+                         (rx-to-string (get type 'ical:value-rx))))
+         (list-sep (get type 'ical:list-sep))
+         (substitute-val (get type 'ical:substitute-value))
+         (unrecognized-val (match-string 5)) ; see :unrecognized in define-param
+         (raw-val (if unrecognized-val substitute-val s))
+         (one-val-reader (if (ical:value-type-symbol-p value-type)
+                             (lambda (s) (ical:read-value-node value-type s))
+                           #'identity)) ; value is just a string
+         ;; values may be quoted even if :quoted does not require it,
+         ;; so they need to be stripped of quotes. read-list-of does
+         ;; this by default; in the single value case, use string-trim
+         (read-val (if list-sep
+                       (ical:read-list-with one-val-reader raw-val
+                                            value-regex list-sep)
+                     (funcall one-val-reader
+                              (string-trim raw-val "\"" "\"")))))
+    (ical:make-ast-node type
+                        :value read-val
+                        :original-value unrecognized-val)))
+
+(defun ical:parse-param-value (type limit)
+  "Parse the value for a parameter of type TYPE from point up to LIMIT.
+TYPE should be a type symbol for an iCalendar parameter type.
+This function expects point to be at the start of the value
+string, after the parameter name and the equals sign. Returns a
+syntax node representing the parameter."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+    (unless (re-search-forward full-value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Unable to parse `%s' value between %d and %d"
+                            type (point) limit))))
+    (when (match-string 3)
+      (signal 'ical:parse-error
+              (list (format "Invalid value for `%s' parameter" type)
+                    (match-string 3))))
+
+    (let ((value-begin (match-beginning 2))
+          (value-end (match-end 2))
+          (node (ical:read-param-value type (match-string 2))))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; :begin must be set by parse-params
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end value-end)
+
+      node)))
+
+(defun ical:parse-params (limit)
+  "Parse the parameter string of the current property, up to LIMIT.
+Point should be at the \";\" at the start of the first parameter.
+Returns a list of parameters, which may be nil if none are present.
+After parsing, point is at the end of the parameter string and the
+start of the property value string."
+  (let ((params nil))
+    (rx-let ((ical:param-start (seq ";" (group-n 1 ical:param-name) "=")))
+      (while (re-search-forward (rx ical:param-start) limit t)
+        (when-let* ((begin (match-beginning 1))
+                    (param-name (match-string 1))
+                    (param-type (or (alist-get (upcase param-name)
+                                               ical:param-types
+                                               nil nil #'equal)
+                                    'ical:otherparam))
+                    (param-node (ical:parse-param-value param-type limit)))
+          (ical:ast-node-meta-set param-node :begin begin)
+          ;; store the original param name if we didn't recognize it:
+          (when (eq param-type 'ical:otherparam)
+            (ical:ast-node-meta-set param-node :original-name param-name))
+          (push param-node params))))
+    (nreverse params)))
+
+(defun ical:print-param-node (node)
+  "Serialize a parameter syntax node NODE to a string.
+NODE should be a syntax node whose type is an iCalendar
+parameter type."
+  (let* ((param-type (ical:ast-node-type node))
+         (list-sep (get param-type 'ical:list-sep))
+
+         (val/s (ical:ast-node-value node))
+         (printed (if (and list-sep (listp val/s))
+                      (mapcar #'ical:default-value-printer val/s)
+                    (ical:default-value-printer val/s)))
+         ;; add quotes to each value as needed, even if :quoted
+         ;; does not require it:
+         (must-quote (get param-type 'ical:is-quoted))
+         (quoted (if (listp printed)
+                     (mapcar
+                      (lambda (v) (ical:maybe-quote-param-value v must-quote))
+                      printed)
+                   (ical:maybe-quote-param-value printed must-quote)))
+         (val-str (or (ical:ast-node-meta-get node :original-value)
+                      (if (and list-sep (listp quoted))
+                          (string-join quoted list-sep)
+                        quoted)))
+         (param-name (car (rassq param-type ical:param-types)))
+         (name-str (or param-name
+                       ;; set by parse-params for unrecognized params:
+                       (ical:ast-node-meta-get node :original-name))))
+    (format ";%s=%s" name-str val-str)))
+
+(defun ical:print-params (param-nodes)
+  "Print the property parameter nodes in PARAM-NODES. Returns the
+printed parameter list as a string."
+  (apply #'concat
+    (mapcar #'ical:print-param-node
+            param-nodes)))
+
+;; Parameter definitions in RFC5545:
+
+(ical:define-param ical:altrepparam "ALTREP"
+  "Alternate text representation (URI)"
+  ical:uri
+  :quoted t
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.1")
+
+(ical:define-param ical:cnparam "CN"
+  "Common Name"
+  ical:param-value
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.2")
+
+(ical:define-param ical:cutypeparam "CUTYPE"
+  "Calendar User Type"
+  (or "INDIVIDUAL"
+      "GROUP"
+      "RESOURCE"
+      "ROOM"
+      "UNKNOWN"
+      (group-n 5
+        (or ical:x-name ical:iana-token)))
+  :default "INDIVIDUAL"
+  ;; "Applications MUST treat x-name and iana-token values they
+  ;; don't recognize the same way as they would the UNKNOWN
+  ;; value":
+  :unrecognized "UNKNOWN"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.3")
+
+(ical:define-param ical:delfromparam "DELEGATED-FROM"
+  "Delegators.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have delegated their participation to the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.4")
+
+(ical:define-param ical:deltoparam "DELEGATED-TO"
+  "Delegatees.
+
+This is a comma-separated list of quoted `icalendar-cal-address' URIs,
+typically specified on the `icalendar-attendee' property. The users in
+this list have been delegated to participate by the user which is
+the value of the property."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.5")
+
+(ical:define-param ical:dirparam "DIR"
+  "Directory Entry Reference.
+
+This parameter may be specified on properties with a
+`icalendar-cal-address' value type. It is a quoted URI which specifies
+a reference to a directory entry associated with the calendar
+user which is the value of the property."
+   ical:uri
+   :quoted t
+   :value-face ical:uri
+   :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.6")
+
+(ical:define-param ical:encodingparam "ENCODING"
+  "Inline Encoding, either \"8BIT\" (text, default) or \"BASE64\" (binary).
+
+If \"BASE64\", the property value is base64-encoded binary data.
+This parameter must be specified if the `icalendar-valuetypeparam'
+is \"BINARY\"."
+  (or "8BIT" "BASE64")
+  :default "8BIT"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.7")
+
+(rx-define ical:mimetype
+  (seq ical:mimetype-regname "/" ical:mimetype-regname))
+
+;; from https://www.rfc-editor.org/rfc/rfc4288#section-4.2:
+(rx-define ical:mimetype-regname
+  (** 1 127 (any alnum ?! ?# ?$ ?& ?. ?+ ?- ?^ ?_)))
+
+(ical:define-param ical:fmttypeparam "FMTTYPE"
+  "Format Type (Mimetype per RFC4288)
+
+Specifies the media type of the object referenced in the property value,
+for example \"text/plain\" or \"text/html\".
+Valid media types are defined in RFC4288; see
+URL `https://www.rfc-editor.org/rfc/rfc4288#section-4.2'"
+  ical:mimetype
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.8")
+
+(ical:define-param ical:fbtypeparam "FBTYPE"
+  "Free/Busy Time Type. Default is \"BUSY\".
+
+RFC5545 gives the following meanings to the values:
+
+FREE: the time interval is free for scheduling.
+BUSY: the time interval is busy because one or more events have
+  been scheduled for that interval.
+BUSY-UNAVAILABLE: the time interval is busy and that the interval
+  can not be scheduled.
+BUSY-TENTATIVE: the time interval is busy because one or more
+  events have been tentatively scheduled for that interval.
+Other values are treated like BUSY."
+  (or "FREE"
+      "BUSY-UNAVAILABLE"
+      "BUSY-TENTATIVE"
+      "BUSY"
+      ical:x-name
+      ical:iana-token)
+  :default "BUSY"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.9")
+
+;; TODO: see https://www.rfc-editor.org/rfc/rfc5646#section-2.1
+(rx-define ical:rfc5646-lang
+  (one-or-more (any alnum ?-)))
+
+(ical:define-param ical:languageparam "LANGUAGE"
+  "Language tag (per RFC5646)
+
+This parameter specifies the language of the property value as a
+language tag, for example \"en-US\" for US English or \"no\" for
+Norwegian. Valid language tags are defined in RFC5646; see
+URL `https://www.rfc-editor.org/rfc/rfc5646'"
+  ical:rfc5646-lang
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.10")
+
+(ical:define-param ical:memberparam "MEMBER"
+  "Group or List Membership.
+
+This is a comma-separated list of quoted `icalendar-cal-address'
+values. These are addresses of groups or lists of which the user
+in the property value is a member."
+  ical:cal-address
+  :quoted t
+  :list-sep ","
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.11")
+
+(ical:define-param ical:partstatparam "PARTSTAT"
+  "Participation status.
+
+The value specifies the participation status of the calendar user
+in the property value. They have different interpretations
+depending on whether they occur in a VEVENT, VTODO or VJOURNAL
+component. RFC5545 gives the values the following meanings:
+
+NEEDS-ACTION (all): needs action by the user
+ACCEPTED (all): accepted by the user
+DECLINED (all): declined by the user
+TENTATIVE (VEVENT, VTODO): tentatively accepted by the user
+DELEGATED (VEVENT, VTODO): delegated by the user
+COMPLETED (VTODO): completed at the `icalendar-date-time' in the
+  VTODO's `icalendar-completed' property
+IN-PROCESS (VTODO): in the process of being completed"
+  (or "NEEDS-ACTION"
+      "ACCEPTED"
+      "DECLINED"
+      "TENTATIVE"
+      "DELEGATED"
+      "COMPLETED"
+      "IN-PROCESS"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; NEEDS-ACTION value."
+  :default "NEEDS-ACTION"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.12")
+
+(ical:define-param ical:rangeparam "RANGE"
+  "Recurrence Identifier Range.
+
+Specifies the effective range of recurrence instances of the property's value.
+The value \"THISANDFUTURE\" is the only value compliant with RFC5545;
+legacy applications might also produce \"THISANDPRIOR\"."
+  "THISANDFUTURE"
+  :default "THISANDFUTURE"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.13")
+
+(ical:define-param ical:trigrelparam "RELATED"
+  "Alarm Trigger Relationship.
+
+This parameter may be specified on properties whose values give
+an alarm trigger as an `icalendar-duration'. If the parameter
+value is \"START\" (the default), the alarm triggers relative to
+the start of the component; similarly for \"END\"."
+  (or "START" "END")
+  :default "START"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.14")
+
+(ical:define-param ical:reltypeparam "RELTYPE"
+  "Relationship type.
+
+This parameter specifies a hierarchical relationship between the
+calendar component referenced in a `icalendar-related-to'
+property and the calendar component in which it occurs.
+\"PARENT\" means the referenced component is superior to this
+one, \"CHILD\" that the referenced component is subordinate to
+this one, and \"SIBLING\" means they are peers."
+  (or "PARENT"
+      "CHILD"
+      "SIBLING"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values they don't
+  ;; recognize the same way as they would the PARENT value."
+  :default "PARENT"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15")
+
+(ical:define-param ical:roleparam "ROLE"
+  "Participation role.
+
+This parameter specifies the participation role of the calendar
+user in the property value. RFC5545 gives the parameter values
+the following meanings:
+CHAIR: chair of the calendar entity
+REQ-PARTICIPANT (default): user's participation is required
+OPT-PARTICIPANT: user's participation is optional
+NON-PARTICIPANT: user is copied for information purposes only"
+  (or "CHAIR"
+      "REQ-PARTICIPANT"
+      "OPT-PARTICIPANT"
+      "NON-PARTICIPANT"
+      (group-n 5 (or ical:x-name
+                     ical:iana-token)))
+  ;; "Applications MUST treat x-name and iana-token values
+  ;; they don't recognize the same way as they would the
+  ;; REQ-PARTICIPANT value."
+  :default "REQ-PARTICIPANT"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.16")
+
+(ical:define-param ical:rsvpparam "RSVP"
+  "RSVP expectation.
+
+This parameter is an `icalendar-boolean' which specifies whether
+the calendar user in the property value is expected to reply to
+the Organizer of a VEVENT or VTODO."
+  ical:boolean
+  :default "FALSE"
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.17")
+
+(ical:define-param ical:sentbyparam "SENT-BY"
+  "Sent by.
+
+This parameter specifies a calendar user that is acting on behalf
+of the user in the property value."
+  ;; "The parameter value MUST be a mailto URI as defined in [RFC2368]"
+  ;; Weirdly, this is the only place in the standard I've seen "mailto:"
+  ;; be *required* for a cal-address. We ignore this requirement for
+  ;; now, because coding around the exception is not worth it: it
+  ;; requires some hackery to work around the fact that two different
+  ;; types, the looser and the more stringent cal-address, would need to
+  ;; have the same print name.
+  ical:cal-address
+  :quoted t
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.18")
+
+(ical:define-param ical:tzidparam "TZID"
+  "Time Zone identifier.
+
+This parameter identifies the VTIMEZONE component in the calendar
+which should be used to interpret the time value given in the
+property. The value of this parameter must be equal to the value
+of the TZID property in that VTIMEZONE component; there must be
+exactly one such component for every unique value of this
+parameter in the calendar."
+  ;; TODO: "This parameter MUST be specified on the "DTSTART","DTEND",
+  ;; "DUE", "EXDATE", and "RDATE" properties when either a DATE-TIME
+  ;; or TIME value type is specified and when the value is neither a
+  ;; UTC or a "floating" time."
+  ;; TODO: "The "TZID" property parameter MUST NOT be applied to DATE
+  ;; properties and DATE-TIME or TIME properties whose time values are
+  ;; specified in UTC."
+  (seq (zero-or-one "/") ical:paramtext)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.19")
+
+(defun ical:read-value-type (s)
+  "Read a value type from string S.
+S should contain the printed representation of a value type in a \"VALUE=...\"
+property parameter. If S represents a known type in `icalendar-value-types',
+it is read as the associated type symbol. Otherwise S is returned unchanged."
+  (let ((type-assoc (assoc s ical:value-types)))
+    (if type-assoc
+        (cdr type-assoc)
+      s)))
+
+(defun ical:print-value-type (type)
+  "Print a value type TYPE.
+TYPE should be an iCalendar type symbol naming a known value type
+defined with `icalendar-define-type', or a string naming an
+unknown type. If it is a symbol, return the associated printed
+representation for the type from `icalendar-value-types'.
+Otherwise return TYPE."
+  (if (symbolp type)
+      (car (rassq type ical:value-types))
+    type))
+
+(ical:define-type ical:printed-value-type nil
+  "Type to represent values of the `icalendar-valuetypeparam' parameter.
+
+When read, if the type named by the parameter is a known value
+type in `icalendar-value-types', it is represented as a type
+symbol for that value type. If it is an unknown value type, it is
+represented as a string. When printed, a string is returned
+unchanged; a type symbol is printed as the associated name in
+`icalendar-value-types'.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing of the `icalendar-valuetypeparam' parameter."
+  '(or string (satisfies ical:printable-value-type-symbol-p))
+  (or "BINARY"
+      "BOOLEAN"
+      "CAL-ADDRESS"
+      "DATE-TIME"
+      "DATE"
+      "DURATION"
+      "FLOAT"
+      "INTEGER"
+      "PERIOD"
+      "RECUR"
+      "TEXT"
+      "TIME"
+      "URI"
+      "UTC-OFFSET"
+      ;; Note: "Applications MUST preserve the value data for x-name
+      ;; and iana-token values that they don't recognize without
+      ;; attempting to interpret or parse the value data." So in this
+      ;; case we don't specify :default or :unrecognized in the
+      ;; parameter definition, and we don't put the value in group 5;
+      ;; the reader will just preserve whatever string matches here.
+      ical:x-name
+      ical:iana-token)
+  :reader ical:read-value-type
+  :printer ical:print-value-type)
+
+(ical:define-param ical:valuetypeparam "VALUE"
+  "Property value data type.
+
+This parameter is used to specify the value type of the
+containing property's value, if it is not of the default value
+type."
+  ical:printed-value-type
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.2.20")
+
+(ical:define-param ical:otherparam nil ; don't add to ical:param-types
+  "Parameter with an unknown name.
+
+This is not a parameter type defined by RFC5545; it represents
+parameters with an unknown name (matching rx `icalendar-param-name')
+whose values must be parsed and preserved but not further
+interpreted."
+  ical:param-value
+  :name-face font-lock-comment-face
+  :value-face font-lock-comment-face)
+
+(rx-define ical:other-param-safe
+  ;; we use this rx to skip params when matching properties and
+  ;; their values. Thus we *don't* capture the param names and param values
+  ;; in numbered groups here, which would clobber the groups of the enclosing
+  ;; expression.
+  (seq ";"
+       (or ical:iana-token ical:x-name)
+       "="
+       (ical:comma-list ical:param-value)))
+
+
+;;; Properties:
+
+(defconst ical:properties-font-lock-keywords
+  nil ;; populated by ical:define-property
+  "Entries for iCalendar properties in `font-lock-keywords'.")
+
+(defconst ical:property-types nil ;; populated by ical:define-property
+  "Alist mapping printed property names to type symbols")
+
+(defun ical:read-property-value (type s &optional params)
+    "Read a value for the property type TYPE from a string S.
+
+TYPE should be a type symbol for an iCalendar property type
+defined with `icalendar-define-property'. The property value is
+assumed to be of TYPE's default value type, unless an
+`icalendar-valuetypeparam' parameter appears in PARAMS, in which
+case a value of that type will be read. S should have already
+been matched against TYPE's value regex and the match data should
+be available to this function. Returns a property syntax node of
+type TYPE containing the read value and the list of PARAMS.
+
+If TYPE accepts lists of values, they will be split from S on the
+list separator and read separately."
+  (let* ((value-type (or (ical:value-type-from-params params)
+                         (get type 'ical:default-type)))
+         (list-sep (get type 'ical:list-sep))
+         (unrecognized-val (match-string 5))
+         (raw-val (if unrecognized-val
+                      (get type 'ical:substitute-value)
+                    s))
+         (value (if list-sep
+                    (ical:read-list-of value-type raw-val list-sep)
+                  (ical:read-value-node value-type raw-val))))
+    (ical:make-ast-node type
+                        :value value
+                        :original-value unrecognized-val
+                        :children params)))
+
+(defun ical:parse-property-value (type limit &optional params)
+  "Parse a value for the property type TYPE from point up to LIMIT.
+This function expects point to be at the start of the value
+expression, after \"PROPERTY-NAME[PARAM...]:\". Returns a syntax
+node of type TYPE containing the parsed value and the list of
+PARAMS."
+  (let ((full-value-regex (rx-to-string (get type 'ical:full-value-rx))))
+
+    (unless (re-search-forward full-value-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Unable to parse `%s' property value between %d and %d"
+                            type (point) limit))))
+
+    (when (match-string 3)
+      (signal 'ical:parse-error
+              (list (format "Invalid value for `%s' property" type)
+                    (match-string 3))))
+
+    (let* ((value-begin (match-beginning 2))
+           (value-end (match-end 2))
+           (end value-end)
+           (node (ical:read-property-value type (match-string 2) params)))
+      (ical:ast-node-meta-set node :buffer (current-buffer))
+      ;; 'begin must be set by parse-property
+      (ical:ast-node-meta-set node :value-begin value-begin)
+      (ical:ast-node-meta-set node :value-end value-end)
+      (ical:ast-node-meta-set node :end end)
+
+      node)))
+
+(defun ical:print-property-node (node)
+  "Serialize a property syntax node NODE to a string."
+  (ical:maybe-add-value-param node)
+  (let* ((type (ical:ast-node-type node))
+         (list-sep (get type 'ical:list-sep))
+         (property-name (car (rassq type ical:property-types)))
+         (params (ical:ast-node-children node))
+         (value (ical:ast-node-value node))
+         (value-str
+          (or (ical:ast-node-meta-get node :original-value)
+              (if list-sep
+                  (string-join (mapcar #'ical:default-value-printer value)
+                               list-sep)
+                (ical:default-value-printer value))))
+         (name-str (or property-name
+                       (ical:ast-node-meta-get node :original-name))))
+
+    (unless (and (stringp name-str)
+                 (length> name-str 0))
+      (signal 'ical:print-error
+              (list (format "Unknown property name for type `%s'" type)
+                    type node)))
+
+    (concat name-str
+            (ical:print-params params)
+            ":"
+            value-str
+            ;; TODO: make line ending sensitive to coding system?
+            "\r\n")))
+
+(defun ical:maybe-add-value-param (property-node)
+  "If the type of PROPERTY-NODE's value is not the same as its
+default-type, check that its parameter list contains an
+`icalendar-valuetypeparam' specifying that type as the type for
+the value. If not, add such a parameter to PROPERTY-NODE's list
+of parameters. Returns the possibly-modified PROPERTY-NODE.
+
+If the parameter list already contains a value type parameter for
+a type other than the property value's type, an
+`icalendar-validation-error' is signaled.
+
+If PROPERTY's value is a list, the type of the first element will
+be assumed to be the type for all the values in the list. If the
+list is empty, no change will be made to PROPERTY's parameters."
+  (catch 'no-value-type
+    (let* ((property-type (ical:ast-node-type property-node))
+           (value/s (ical:ast-node-value property-node))
+           (value (if (and (ical:expects-list-of-values-p property-type)
+                           (listp value/s))
+                      (car value/s)
+                    value/s))
+           (value-type (cond ((stringp value) 'ical:text)
+                             ((ical:ast-node-p value)
+                              (ical:ast-node-type value))
+                             ;; if we can't determine a type from the value, bail:
+                             (t (throw 'no-value-type property-node))))
+           (params (ical:ast-node-children property-node))
+           (expected-type (ical:value-type-from-params params)))
+
+      (when (not (eq value-type (get property-type 'ical:default-type)))
+        (if expected-type
+            (when (not (eq value-type expected-type))
+              (signal 'ical:validation-error
+                      (list (format (concat "Mismatching VALUE parameter. "
+                                            "VALUE specifies %s but "
+                                            "property value has type %s")
+                                    expected-type value-type))))
+          ;; the value isn't of the default type, but we didn't find a
+          ;; VALUE parameter, so add one now:
+          (let* ((valuetype-param
+                  (ical:make-ast-node 'ical:valuetypeparam
+                                      :value (ical:make-ast-node
+                                              'ical:printed-value-type
+                                              :value value-type)))
+                 (new-params (cons valuetype-param
+                                   (ical:ast-node-children property-node))))
+            (setf (nth 3 property-node) new-params))))
+
+      ;; Return the modified property node:
+      property-node)))
+
+(defun ical:value-type-from-params (params)
+  "If there is an `icalendar-valuetypeparam' in PARAMS, return the
+type symbol associated with the value type it specifies."
+  (catch 'found
+    (dolist (param params)
+      (when (ical:value-param-p param)
+        (let ((type (ical:ast-node-value
+                     (ical:ast-node-value param))))
+          (throw 'found type))))))
+
+(defun ical:parse-property (limit)
+  "Parse the current property, up to LIMIT. Point should be at the
+beginning of a property line; LIMIT should be the position at the
+end of the line.
+
+Returns a syntax node for the property. After parsing, point is
+at the beginning of the next content line."
+  (rx-let ((ical:property-start (seq line-start
+                                     (group-n 1 ical:name))))
+    (let ((line-begin nil)
+          (line-end nil)
+          (property-name nil)
+          (params nil))
+
+      ;; Property name
+      (unless (re-search-forward (rx ical:property-start) limit t)
+        (signal 'ical:parse-error
+                (list (format (concat "Malformed property at line %d, position %d:"
+                                      "could not match property name")
+                              (line-number-at-pos (point))
+                              (line-beginning-position)))))
+
+      (setq property-name (match-string 1))
+      (setq line-begin (line-beginning-position))
+      (setq line-end (line-end-position))
+
+      ;; Parameters
+      (when (looking-at ";")
+        (setq params (ical:parse-params line-end)))
+      ;; TODO: param validation?
+
+      (unless (looking-at ":")
+        (signal 'ical:parse-error
+                (list (format (concat "Malformed property at line %d, position %d:"
+                                      "missing colon before value")
+                              (line-number-at-pos (point))
+                              (point)))))
+      (forward-char)
+
+      ;; Value
+      (let* ((known-type (alist-get (upcase property-name)
+                                    ical:property-types
+                                    nil nil #'equal))
+             (property-type (or known-type 'ical:other-property))
+             (node (ical:parse-property-value property-type limit params)))
+
+        ;; sanity check, since e.g. invalid base64 data might not
+        ;; match all the way to the end of the line, as test
+        ;; rfc5545-sec3.1.3/2 initially revealed
+        (unless (eql (point) (line-end-position))
+          (signal 'ical:parse-error
+                  (list (format "Property value did not consume line %d: %s"
+                                (line-number-at-pos (point))
+                                (ical:default-value-printer
+                                 (ical:ast-node-value node))))))
+
+        ;; Set point up for the next property parser:
+        (while (not (bolp))
+          (forward-char))
+
+        ;; value, children are set in ical:read-property-value,
+        ;; value-begin, value-end, end in ical:parse-property-value.
+        ;; begin and original-name are only available here:
+        (ical:ast-node-meta-set node :begin line-begin)
+        (when (eq property-type 'ical:other-property)
+          (ical:ast-node-meta-set node :original-name property-name))
+
+        ;; Return the syntax node
+        node))))
+
+
+;;;; Section 3.7: Calendar Properties
+(ical:define-property ical:calscale "CALSCALE"
+  "Calendar scale.
+
+This property specifies the time scale of an
+`icalendar-vcalendar' object. The only scale defined by RFC5545
+is \"GREGORIAN\", which is the default."
+  ;; only allowed value:
+  "GREGORIAN"
+  :default "GREGORIAN"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.1")
+
+(ical:define-property ical:method "METHOD"
+  "Method for a scheduling request.
+
+When an `icalendar-vcalendar' is sent in a MIME message, this property
+specifies the semantics of the request in the message: e.g. it is
+a request to publish the calendar object, or a reply to an
+invitation. This property and the MIME message's \"method\"
+parameter value must be the same.
+
+RFC5545 does not define any methods, but RFC5546 does; see
+URL `https://www.rfc-editor.org/rfc/rfc5546.html#section-3.2'"
+  ;; TODO: implement methods in RFC5546?
+  ical:iana-token
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.2")
+
+(ical:define-property ical:prodid "PRODID"
+  "Product Identifier.
+
+This property identifies the program that created an
+`icalendar-vcalendar' object. It must be specified exactly once
+in a calendar object. Its value should be a globally unique
+identifier for the program, though RFC5545 does not specify any
+particular way of creating such an identifier."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.3")
+
+(ical:define-property ical:version "VERSION"
+  "Version (2.0 corresponds to RFC5545).
+
+This property specifies the version number of the iCalendar
+specification to which an `icalendar-vcalendar' object conforms,
+and must be specified exactly once in a calendar object. It is
+either the string \"2.0\" or a string like MIN;MAX specifying
+minimum and maximum versions of future revisions of the
+specification."
+  (or "2.0"
+      ;; minver ";" maxver
+      (seq ical:iana-token ?\; ical:iana-token))
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.7.4")
+
+
+;;;; Section 3.8:
+;;;;; Section 3.8.1: Descriptive Component Properties
+
+(ical:define-property ical:attach "ATTACH"
+  "Attachment.
+
+This property specifies a file attached to an iCalendar
+component, either via a URI, or as encoded binary data. In
+`icalendar-valarm' components, it is used to specify the
+notification sent by the alarm."
+  ;; Groups 11, 12 are used in ical:uri
+  (or (group-n 13 ical:uri)
+      (group-n 14 ical:binary))
+  :default-type ical:uri
+  :other-types (ical:binary)
+  :child-spec (:zero-or-one (ical:fmttypeparam
+                             ical:valuetypeparam
+                             ical:encodingparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:attach-validator
+  :extra-faces ((13 'ical:uri t t)
+                (14 'ical:binary-data t t))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1")
+
+(defun ical:attach-validator (node)
+  "Additional validator for an `icalendar-attach' NODE.
+Checks that NODE has a correct `icalendar-encodingparam' and
+`icalendar-valuetypeparam' if its value is an `icalendar-binary'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+ATTACH nodes; it is not normally necessary to call it directly."
+  (let* ((value-node (ical:ast-node-value node))
+         (value-type (ical:ast-node-type value-node))
+         (valtypeparam (ical:ast-node-first-child-of 'ical:valuetypeparam node))
+         (encodingparam (ical:ast-node-first-child-of 'ical:encodingparam node)))
+
+    (when (eq value-type 'ical:binary)
+      (unless (and (ical:ast-node-p valtypeparam)
+                   (eq 'ical:binary
+                       (ical:ast-node-value ; unwrap inner printed-value-type
+                        (ical:ast-node-value valtypeparam))))
+        (signal 'ical:validation-error
+                (list (concat "`icalendar-binary' attachment requires "
+                              "'VALUE=BINARY' parameter")
+                      node)))
+      (unless (and (ical:ast-node-p encodingparam)
+                   (equal "BASE64" (ical:ast-node-value encodingparam)))
+        (signal 'ical:validation-error
+                (list (concat "`icalendar-binary' attachment requires "
+                              "'ENCODING=BASE64' parameter")
+                      node))))
+    ;; success:
+    node))
+
+(ical:define-property ical:categories "CATEGORIES"
+  "Categories.
+
+This property lists categories or subtypes of an iCalendar
+component for e.g. searching or filtering. The categories can be
+any `icalendar-text' value."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.2")
+
+(ical:define-property ical:class "CLASS"
+  "(Access) Classification.
+
+This property specifies the scope of access that the calendar
+owner intends for a given component, e.g. public or private."
+  (or "PUBLIC"
+      "PRIVATE"
+      "CONFIDENTIAL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  ;; "If not specified in a component that allows this property, the
+  ;; default value is PUBLIC. Applications MUST treat x-name and
+  ;; iana-token values they don't recognize the same way as they would
+  ;; the PRIVATE value."
+  :default "PUBLIC"
+  :unrecognized "PRIVATE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3")
+
+(ical:define-property ical:comment "COMMENT"
+  "Comment to calendar user.
+
+This property can be specified multiple times in calendar components,
+and can contain any `icalendar-text' value."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.4")
+
+(ical:define-property ical:description "DESCRIPTION"
+  "Description.
+
+This property should be a longer, more complete description of
+the calendar component than is contained in the
+`icalendar-summary' property. In a `icalendar-vjournal'
+component, it is used to capture a journal entry, and may be
+specified multiple times. Otherwise it may only be specified
+once. In an `icalendar-valarm' component, it contains the
+notification text for a DISPLAY or EMAIL alarm."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5")
+
+(defun ical:read-geo-coordinates (s)
+  "Read an `icalendar-geo-coordinates' value from string S"
+  (let ((vals (mapcar #'string-to-number (string-split s ";"))))
+    (cons (car vals) (cadr vals))))
+
+(defun ical:print-geo-coordinates (val)
+  "Serialize an `icalendar-geo-coordinates' value to a string"
+  (concat (number-to-string (car val)) ";" (number-to-string (cdr val))))
+
+(defun ical:geo-coordinates-p (val)
+  "Return non-nil if VAL is an `icalendar-geo-coordinates' value"
+  (and (floatp (car val)) (floatp (cdr val))))
+
+(ical:define-type ical:geo-coordinates nil ; don't add to ical:value-types
+  "Type for global positions.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-geo' property. When printed, it
+is represented as a pair of `icalendar-float' values separated by
+a semicolon, like LATITUDE;LONGITUDE. When read, it is a dotted
+pair of Elisp floats (LATITUDE . LONGITUDE)."
+  '(satisfies ical:geo-coordinates-p)
+  (seq ical:float ";" ical:float)
+  :reader ical:read-geo-coordinates
+  :printer ical:print-geo-coordinates)
+
+(ical:define-property ical:geo "GEO"
+  "Global position of a component as a pair LATITUDE;LONGITUDE.
+
+Both values are floats representing a number of degrees. The
+latitude value is north of the equator if positive, and south of
+the equator if negative. The longitude value is east of the prime
+meridian if positive, and west of it if negative."
+  ical:geo-coordinates
+  :value-face ical:numeric-types
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.6")
+
+(ical:define-property ical:location "LOCATION"
+  "Location.
+
+This property describes the intended location or venue of a
+component, e.g. a particular room or building, with an
+`icalendar-text' value. RFC5545 suggests using the
+`icalendar-altrep' parameter on this property to provide more
+structured location information."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7")
+
+;; TODO: type for percentages?
+(ical:define-property ical:percent-complete "PERCENT-COMPLETE"
+  "Percent Complete.
+
+This property describes progress toward the completion of an
+`icalendar-vtodo' component. It can appear at most once in such a
+component. If this TODO is assigned to multiple people, the value
+represents the completion state for each person individually. The
+value should be between 0 and 100 (though this is not currently
+enforced here)."
+  ical:integer
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:numeric-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.8")
+
+;; TODO: type for priority values?
+(ical:define-property ical:priority "PRIORITY"
+  "Priority.
+
+This property describes the priority of a component. 0 means an
+undefined priority. Other values range from 1 (highest priority)
+to 9 (lowest priority). See RFC5545 for suggestions on how to
+represent other priority schemes with this property."
+  ical:integer
+  :default "0"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:numeric-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9")
+
+(ical:define-property ical:resources "RESOURCES"
+  "Resources for an activity.
+
+This property is a list of `icalendar-text' values that describe
+any resources required or foreseen for the activity represented
+by a component, e.g. a projector and screen for a meeting."
+  ical:text
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.10")
+
+(ical:define-keyword-type ical:status-keyword nil
+  "Keyword value of a STATUS property.
+
+This is not a real type defined by RFC5545; it is defined here to
+facilitate parsing that property."
+  ;; Note that this type does NOT allow arbitrary text:
+  (or "TENTATIVE"
+      "CONFIRMED"
+      "CANCELLED"
+      "NEEDS-ACTION"
+      "COMPLETED"
+      "IN-PROCESS"
+      "DRAFT"
+      "FINAL"))
+
+(ical:define-property ical:status "STATUS"
+  "Overall status or confirmation.
+
+This property is a keyword used by an Organizer to inform
+Attendees about the status of a component, e.g. whether an
+`icalendar-vevent' has been cancelled, whether an
+`icalendar-vtodo' has been completed, or whether an
+`icalendar-vjournal' is still in draft form. It can be specified
+at most once on these components."
+  ical:status-keyword
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11")
+
+(ical:define-property ical:summary "SUMMARY"
+  "Short summary.
+
+This property provides a short, one-line description of a
+component for display purposes. In an EMAIL `icalendar-valarm',
+it is used as the subject of the email. A longer description of
+the component can be provided in the `icalendar-description'
+property."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12")
+
+;;;;; Section 3.8.2: Date and Time Component Properties
+
+(ical:define-property ical:completed "COMPLETED"
+  "Time completed.
+
+This property is a timestamp that records the date and time when
+an `icalendar-vtodo' was actually completed. The value must be an
+`icalendar-date-time' with a UTC time."
+  ical:date-time
+  :value-face ical:date-time-types
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
+
+(ical:define-property ical:dtend "DTEND"
+  "End time of an event or free/busy block.
+
+This property's value specifies when an `icalendar-vevent' or
+`icalendar-freebusy' ends. Its value must be of the same type as
+the value of the component's corresponding `icalendar-dtstart'
+property. The value is a non-inclusive bound, i.e., the value of
+this property must be the first time or date *after* the end of
+the event or free/busy block."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2")
+
+(ical:define-property ical:due "DUE"
+  "Due date.
+
+This property specifies the date (and possibly time) by which an
+`icalendar-todo' item is expected to be completed, i.e., its
+deadline. If the component also has an `icalendar-dtstart'
+property, the two properties must have the same value type, and
+the value of the DTSTART property must be earlier than the value
+of this property."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.3")
+
+(ical:define-property ical:dtstart "DTSTART"
+  "Start time of a component.
+
+This property's value specifies when a component starts. In an
+`icalendar-vevent', it specifies the start of the event. In an
+`icalendar-vfreebusy', it specifies the start of the free/busy
+block. In `icalendar-standard' and `icalendar-daylight'
+sub-components, it defines the start time of a time zone
+specification.
+
+It is required in any component with an `icalendar-rrule'
+property, and in any `icalendar-vevent' component contained in a
+calendar that does not have a `icalendar-method' property.
+
+Its value must be of the same type as the value of the
+component's corresponding `icalendar-dtend' property. In an
+`icalendar-vtodo' component, it must also be of the same type as
+the value of an `icalendar-due' property (if present)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4")
+
+(ical:define-property ical:duration "DURATION"
+  "Duration.
+
+This property specifies a duration of time for a component.
+In an `icalendar-vevent', it can be used to implicitly specify
+the end of the event, instead of an explicit `icalendar-dtend'.
+In an `icalendar-vtodo', it can likewise be used to implicitly specify
+the due date, instead of an explicit `icalendar-due'.
+In an `icalendar-valarm', it used to specify the delay period
+before the alarm repeats.
+
+If a related `icalendar-dtstart' property has an `icalendar-date'
+value, then the duration must be given as a number of weeks or days."
+  ical:dur-value
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.5")
+
+(ical:define-property ical:freebusy "FREEBUSY"
+  "Free/Busy Times.
+
+This property specifies a list of periods of free or busy time in
+an `icalendar-vfreebusy' component. Whether it specifies free or
+busy times is determined by its `icalendar-fbtype' parameter. The
+times in each period must be in UTC format."
+  ical:period
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:fbtypeparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.6")
+
+(ical:define-property ical:transp "TRANSP"
+  "Time Transparency for free/busy searches.
+
+Note that this property only allows two values: \"TRANSPARENT\"
+or \"OPAQUE\". An OPAQUE value means that the component consumes
+time on a calendar. TRANSPARENT means it does not, and thus is
+invisible to free/busy time searches."
+  ;; Note that this does NOT allow arbitrary text:
+  (or "TRANSPARENT"
+      "OPAQUE")
+  :default "OPAQUE"
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7")
+
+;;;;; Section 3.8.3: Time Zone Component Properties
+
+(ical:define-property ical:tzid "TZID"
+  "Time Zone Identifier.
+
+This property specifies the unique identifier for a timezone in
+an `icalendar-vtimezone' component, and is a required property of
+that component. This is an identifier that `icalendar-tzidparam'
+parameters in other components may then refer to."
+  (seq (zero-or-one "/") ical:text)
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.1")
+
+(ical:define-property ical:tzname "TZNAME"
+  "Time Zone Name.
+
+This property specifies a customary name for a time zone in
+`icalendar-daylight' and `icalendar-standard' sub-components."
+  ical:text
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.2")
+
+(ical:define-property ical:tzoffsetfrom "TZOFFSETFROM"
+  "Time Zone Offset (prior to observance).
+
+This property specifies the time zone offset that is in use
+*prior to* this time zone observance. It is used to calculate the
+absolute time at which the observance takes place. It is a
+required property of an `icalendar-vtimezone' component. Positive
+numbers indicate time east of the prime meridian (ahead of UTC).
+Negative numbers indicate time west of the prime meridian (behind
+UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.3")
+
+(ical:define-property ical:tzoffsetto "TZOFFSETTO"
+  "Time Zone Offset (in this observance).
+
+This property specifies the time zone offset that is in use *in*
+this time zone observance. It is used to calculate the absolute
+time at which a new observance takes place. It is a required
+property of `icalendar-standard' and `icalendar-daylight'
+components. Positive numbers indicate time east of the prime
+meridian (ahead of UTC). Negative numbers indicate time west of
+the prime meridian (behind UTC)."
+  ical:utc-offset
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.4")
+
+(ical:define-property ical:tzurl "TZURL"
+  "Time Zone URL.
+
+This property specifies a URL where updated versions of an
+`icalendar-vtimezone' component are published."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.3.5")
+
+;;;;; Section 3.8.4: Relationship Component Properties
+
+(ical:define-property ical:attendee "ATTENDEE"
+  "Attendee.
+
+This property specfies a participant in a `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-valarm'. It is required when the
+containing component represents event, task, or notification for
+a *group* of people, but not for components that simply represent
+these items in a single user's calendar (in that case, it should
+not be specified). The property can be specified multiple times,
+once for each participant in the event or task. In an
+EMAIL-category VALARM component, this property specifies the
+address of the user(s) who should receive the notification email.
+
+The parameters `icalendar-roleparam', `icalendar-partstatparam',
+`icalendar-rsvpparam', `icalendar-delfromparam', and
+`icalendar-deltoparam' are especially relevant for further
+specifying the roles of each participant in the containing
+component."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cutypeparam
+                             ical:memberparam
+                             ical:roleparam
+                             ical:partstatparam
+                             ical:rsvpparam
+                             ical:deltoparam
+                             ical:delfromparam
+                             ical:sentbyparam
+                             ical:cnparam
+                             ical:dirparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1")
+
+(ical:define-property ical:contact "CONTACT"
+  "Contact.
+
+This property provides textual contact information relevant to an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy'."
+  ical:text
+  :child-spec (:zero-or-one (ical:altrepparam ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.2")
+
+(ical:define-property ical:organizer "ORGANIZER"
+  "Organizer.
+
+This property specifies the organizer of a group-scheduled
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'.
+It is required in those components if they represent a calendar
+entity with multiple participants. In an `icalendar-vfreebusy'
+component, it used to specify the user requesting free or busy
+time, or the user who published the calendar that the free/busy
+information comes from."
+  ical:cal-address
+  :child-spec (:zero-or-one (ical:cnparam
+                             ical:dirparam
+                             ical:sentbyparam
+                             ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3")
+
+(ical:define-property ical:recurrence-id "RECURRENCE-ID"
+  "Recurrence ID.
+
+This property is used together with the `icalendar-uid' and
+`icalendar-sequence' properties to identify a specific instance
+of a recurring `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' component. The property value is the
+original value of the `icalendar-dtstart' property of the
+recurrence instance. Its value must have the same type as that
+property's value, and both must specify times in the same way
+(either local or UTC)."
+  (or ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date)
+  :child-spec (:zero-or-one (ical:valuetypeparam
+                             ical:tzidparam
+                             ical:rangeparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.4")
+
+(ical:define-property ical:related-to "RELATED-TO"
+  "Related To (component UID).
+
+This property specifies the `icalendar-uid' value of a different,
+related calendar component. It can be specified on an
+`icalendar-vevent', `icalendar-vtodo', or `icalendar-vjournal'
+component. An `icalendar-reltypeparam' can be used to specify the
+relationship type."
+  ical:text
+  :child-spec (:zero-or-one (ical:reltypeparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.5")
+
+(ical:define-property ical:url "URL"
+  "Uniform Resource Locator.
+
+This property specifies the URL associated with an
+`icalendar-vevent', `icalendar-vtodo', `icalendar-vjournal', or
+`icalendar-vfreebusy' component."
+  ical:uri
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:uri
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6")
+
+;; TODO: UID should probably be its own type
+(ical:define-property ical:uid "UID"
+  "Unique Identifier.
+
+This property specifies a globally unique identifier for the
+containing component, and is required in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vfreebusy'
+component.
+
+RFC5545 requires that the program generating the UID guarantee
+that it be unique, and recommends generating it in a format which
+includes a timestamp on the left hand side of an '@' character,
+and the domain name or IP address of the host on the right-hand
+side."
+  ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.7")
+
+;;;;; Section 3.8.5: Recurrence Component Properties
+
+(ical:define-property ical:exdate "EXDATE"
+  "Exception Date-Times.
+
+This property defines a list of exceptions to a recurrence rule
+in an `icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component. Together
+with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-rdate' properties, it defines the recurrence set of
+the component."
+  (or ical:date-time
+      ical:date)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1")
+
+(ical:define-property ical:rdate "RDATE"
+  "Recurrence Date-Times.
+
+This property defines a list of date-times or dates on which an
+`icalendar-vevent', `icalendar-todo', `icalendar-vjournal',
+`icalendar-standard', or `icalendar-daylight' component recurs.
+Together with the `icalendar-dtstart', `icalendar-rrule', and
+`icalendar-exdate' properties, it defines the recurrence set of
+the component."
+  (or ical:period
+      ical:date-time
+      ical:date)
+  :default-type ical:date-time
+  :other-types (ical:date ical:period)
+  :list-sep ","
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:tzidparam)
+               :zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.2")
+
+(ical:define-property ical:rrule "RRULE"
+  "Recurrence Rule.
+
+This property defines a rule or repeating pattern for the dates
+and times on which an `icalendar-vevent', `icalendar-todo',
+`icalendar-vjournal', `icalendar-standard', or
+`icalendar-daylight' component recurs. Together with the
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate'
+properties, it defines the recurrence set of the component."
+  ical:recur
+  ;; TODO: faces for subexpressions?
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:recurrence-rule
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3")
+
+;;;;; Section 3.8.6: Alarm Component Properties
+
+(ical:define-property ical:action "ACTION"
+  "Action (when alarm triggered).
+
+This property defines the action to be taken when the containing
+`icalendar-valarm' component is triggered. It is a required
+property in an alarm component."
+  (or "AUDIO"
+      "DISPLAY"
+      "EMAIL"
+      (group-n 5
+        (or ical:iana-token
+            ical:x-name)))
+  :default-type ical:text
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:keyword
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.1")
+
+(ical:define-property ical:repeat "REPEAT"
+  "Repeat Count (after initial trigger).
+
+This property specifies the number of times an `icalendar-valarm'
+should repeat after it is initially triggered. This property,
+along with the `icalendar-duration' property, is required if the
+alarm triggers more than once."
+  ical:integer
+  :default 0
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:numeric-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.2")
+
+(ical:define-property ical:trigger "TRIGGER"
+  "Trigger.
+
+This property specifies when an `icalendar-valarm' should
+trigger. If the value is an `icalendar-dur-value', it represents
+a time of that duration relative to the start or end of a related
+`icalendar-vevent' or `icalendar-vtodo'. Whether the trigger
+applies to the start time or end time of the related component
+can be specified with the `icalendar-trigrelparam' parameter. A
+positive duration value triggers after the start or end of the
+related component; a negative duration value triggers before.
+
+If the value is an `icalendar-date-time', it must be in UTC
+format, and it triggers at the specified time."
+  (or ical:dur-value
+      ical:date-time)
+  :child-spec (:zero-or-one (ical:valuetypeparam ical:trigrelparam)
+               :zero-or-more (ical:otherparam))
+  :other-validator ical:trigger-validator
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.6.3")
+
+(defun ical:trigger-validator (node)
+  "Additional validator for an `icalendar-trigger' NODE.
+Checks that NODE has valid parameters depending on the type of its value.
+
+This function is called by `icalendar-ast-node-valid-p' for
+TRIGGER nodes; it is not normally necessary to call it directly."
+  (let* ((params (ical:ast-node-children node))
+         (value-node (ical:ast-node-value node))
+         (value-type (and value-node (ical:ast-node-type value-node))))
+    (when (eq value-type 'ical:date-time)
+      (let ((expl-type (ical:value-type-from-params params))
+            (dt-value (ical:ast-node-value value-node)))
+        (unless (eq expl-type 'ical:date-time)
+          (signal 'ical:validation-error
+                  (list (concat "Explicit `icalendar-valuetypeparam' required in "
+                                "`icalendar-trigger' with non-duration value")
+                        node)))
+        (when (ical:ast-node-first-child-of 'ical:trigrelparam node)
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-trigrelparam' not allowed in "
+                                "`icalendar-trigger' with non-duration value"))))
+        (unless (ical:date-time-is-utc-p dt-value)
+          (signal 'ical:validation-error
+                  (list (concat "`icalendar-date-time' value of "
+                                "`icalendar-trigger' must be in UTC time")
+                        node)))))
+    ;; success:
+    node))
+
+;;;;; Section 3.8.7: Change Management Component Properties
+
+(ical:define-property ical:created "CREATED"
+  "Date-Time Created.
+
+This property specifies the date and time when the calendar user
+initially created an `icalendar-vevent', `icalendar-vtodo', or
+`icalendar-vjournal' in the calendar database. The value must be
+in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
+
+(ical:define-property ical:dtstamp "DTSTAMP"
+  "Timestamp (of last revision or instance creation).
+
+In an `icalendar-vevent', `icalendar-vtodo',
+`icalendar-vjournal', or `icalendar-vfreebusy', this property
+specifies the date and time when the calendar user last revised
+the component's data in the calendar database. (In this case, it
+is equivalent to the `icalendar-last-modified' property.)
+
+If this property is specified on an `icalendar-vcalendar' object
+which contains an `icalendar-method' property, it specifies the
+date and time when that instance of the calendar object was
+created. In this case, it differs from the `icalendar-creation'
+and `icalendar-last-modified' properties: whereas those specify
+the time the underlying data was created and last modified in the
+calendar database, this property specifies when the calendar
+object *representing* that data was created.
+
+The value must be in UTC time."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
+
+(ical:define-property ical:last-modified "LAST-MODIFIED"
+  "Last Modified timestamp.
+
+This property specifies when the data in an `icalendar-vevent',
+`icalendar-vtodo', `icalendar-vjournal', or `icalendar-vtimezone'
+was last modified in the calendar database."
+  ical:date-time
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:date-time-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3")
+
+(ical:define-property ical:sequence "SEQUENCE"
+  "Revision Sequence Number.
+
+This property specifies the number of the current revision in a
+sequence of revisions in an `icalendar-vevent',
+`icalendar-vtodo', or `icalendar-vjournal' component. It starts
+at 0 and should be incremented monotonically every time the
+Organizer makes a significant revision to the calendar data that
+component represents."
+  ical:integer
+  :default 0
+  :child-spec (:zero-or-more (ical:otherparam))
+  :value-face ical:numeric-types
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.4")
+
+;;;;; Section 3.8.8: Miscellaneous Component Properties
+;; IANA and X- properties should be parsed and printed but can be ignored:
+(ical:define-property ical:other-property nil ; don't add to ical:property-types
+  "IANA or X-name property.
+
+This property type corresponds to the IANA Properties and
+Non-Standard Properties defined in RFC5545; it represents
+properties with an unknown name (matching rx
+`icalendar-iana-token' or `icalendar-x-name') whose values must
+be parsed and preserved but not further interpreted. Its value
+may be set to any type with the `icalendar-valuetypeparam'
+parameter."
+  ical:value
+  :default-type ical:text
+  ;; "The default value type is TEXT. The value type can be set to any
+  ;; value type." TODO: should we specify :other-types? Without it, a
+  ;; VALUE param will be required to parse anything other than text,
+  ;; but that seems reasonable.
+  :child-spec (:allow-others t)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8")
+
+(defconst ical:ignored-properties-font-lock-keywords
+  `((,(rx ical:other-property) (1 'ical:ignored keep)
+                               (2 'ical:ignored keep)))
+  "Entries for iCalendar ignored properties in `font-lock-keywords'.")
+
+(defun ical:read-req-status-info (s)
+  "Read a request status value from S.
+S should have been previously matched against `icalendar-request-status-info'."
+  ;; TODO: this smells like a design flaw. Silence the byte compiler for now.
+  (ignore s)
+  (let ((code (match-string 11))
+        (desc (match-string 12))
+        (exdata (match-string 13)))
+    (list code (ical:read-text desc) (when exdata (ical:read-text exdata)))))
+
+(defun ical:print-req-status-info (rsi)
+  "Serialize request status info value RSI to a string."
+  (let ((code (car rsi))
+        (desc (cadr rsi))
+        (exdata (caddr rsi)))
+    (if exdata
+        (format "%s;%s;%s" code (ical:print-text desc) (ical:print-text exdata))
+      (format "%s;%s" code (ical:print-text desc)))))
+
+(defun ical:req-status-info-p (val)
+  "Return non-nil if VAL is an `icalendar-request-status-info' value."
+  (and (listp val)
+       (length= val 3)
+       (stringp (car val))
+       (stringp (cadr val))
+       (cl-typep (caddr val) '(or string null))))
+
+(ical:define-type ical:req-status-info nil
+  "Type for REQUEST-STATUS property values.
+
+When read, a list (CODE DESCRIPTION EXCEPTION). CODE is a hierarchical
+numerical code, represented as a string, with the following meanings:
+  1.xx Preliminary success
+  2.xx Successful
+  3.xx Client Error
+  4.xx Scheduling Error
+DESCRIPTION is a longer description of the request status, also a string.
+EXCEPTION (which may be nil) is textual data describing an error.
+
+When printed, the three elements are separated by semicolons, like
+  CODE;DESCRIPTION;EXCEPTION
+or
+  CODE;DESCRIPTION
+if EXCEPTION is nil.
+
+This is not a type defined by RFC5545; it is defined here to
+facilitate parsing the `icalendar-request-status' property."
+  '(satisfies ical:req-status-info-p)
+  (seq
+   ;; statcode: hierarchical status code
+   (group-n 11
+     (seq (one-or-more digit)
+          (** 1 2 (seq ?. (one-or-more digit)))))
+   ?\;
+   ;; statdesc: status description
+   (group-n 12 ical:text)
+   ;; exdata: exception data
+   (zero-or-one (seq ?\; (group-n 13 ical:text))))
+  :reader ical:read-req-status-info
+  :printer ical:print-req-status-info
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+(ical:define-property ical:request-status "REQUEST-STATUS"
+  "Request status"
+  ical:req-status-info
+  :child-spec (:zero-or-one (ical:languageparam)
+               :zero-or-more (ical:otherparam))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3")
+
+
+;;; Section 3.6: Calendar Components
+
+(defconst ical:components-font-lock-keywords
+  nil ;; populated by ical:define-component
+  "Entries for iCalendar components in `font-lock-keywords'.")
+
+(defconst ical:component-types nil ;; populated by ical:define-component
+  "Alist mapping printed component names to type symbols")
+
+(defun ical:parse-component (limit)
+  "Parse an iCalendar component from point up to LIMIT.
+Point should be at the start of the component, i.e., at the start
+of a line that looks like \"BEGIN:[COMPONENT-NAME]\". After parsing,
+point is at the beginning of the next line following the component
+(or end of the buffer). Returns a syntax node representing the component."
+  (let ((begin-pos nil)
+        (body-begin-pos nil)
+        (end-pos nil)
+        (body-end-pos nil)
+        (begin-regex (rx line-start "BEGIN:" (group-n 2 ical:name) line-end)))
+
+    (unless (re-search-forward begin-regex limit t)
+      (signal 'ical:parse-error
+              (list (format "Not at start of a component at line %d, position %d"
+                            (line-number-at-pos (point))
+                            (point)))))
+
+    (setq begin-pos (match-beginning 0)
+          body-begin-pos (1+ (match-end 0))) ; start of next line
+
+    (let* ((component-name (match-string 2))
+           (known-type (alist-get (upcase component-name)
+                                  ical:component-types
+                                  nil nil #'equal))
+           (component-type (or known-type 'ical:other-component))
+           (children nil))
+
+      ;; Find end of component:
+      (save-excursion
+        (if (re-search-forward
+             (rx-to-string `(seq line-start "END:" ,component-name line-end))
+             limit t)
+            (setq end-pos (match-end 0)
+                  body-end-pos (1- (match-beginning 0))) ; end of prev. line
+          (signal 'ical:parse-error
+                  (list (format (concat "Matching END: of component %s not found "
+                                        "between %d and %d")
+                                component-name begin-pos limit)))))
+
+      (while (not (bolp)) (forward-char))
+
+      ;; Parse the properties and subcomponents of this component:
+      (while (<= (point) body-end-pos)
+        (push (ical:parse-property-or-component end-pos)
+              children))
+
+      ;; Set point up for the next parser:
+      (goto-char end-pos)
+      (while (and (< (point) (point-max)) (not (bolp)))
+        (forward-char))
+
+      ;; Return the syntax node for the component:
+      (ical:make-ast-node component-type
+                          :children (nreverse children)
+                          :original-name
+                            (when (eq component-type 'ical:other-component)
+                              component-name)
+                          :buffer (current-buffer)
+                          :begin begin-pos
+                          :end end-pos
+                          :value-begin body-begin-pos
+                          :value-end body-end-pos))))
+
+(defun ical:parse-property-or-component (limit)
+  "Parse a component or a property at point.
+Point should be at the beginning of a line which begins a
+component or contains a property."
+  (cond ((looking-at (rx line-start "BEGIN:" ical:name line-end))
+         (icalendar-parse-component limit))
+        ((looking-at (rx line-start ical:name))
+         (icalendar-parse-property (line-end-position)))
+        (t (signal 'ical:parse-error
+                   (list (format (concat "Not at start of property or component "
+                                         "at line %d, position %d")
+                                 (line-number-at-pos (point))
+                                 (point)))))))
+
+(defun ical:print-component-node (node)
+  "Serialize a component syntax node NODE to a string."
+  (let* ((type (ical:ast-node-type node))
+         (name (or (ical:ast-node-meta-get node :original-name)
+                   (car (rassq type ical:component-types))))
+         (children (ical:ast-node-children node)))
+
+    (unless name
+      (signal 'ical:print-error
+              (list (format "Unknown component name for type `%s'" type)
+                    type node)))
+
+    (concat
+     ;; TODO: should line ending be sensitive to buffer coding system?
+     (format "BEGIN:%s\r\n" name)
+     (apply #'concat
+            (mapcar #'ical:print-property-or-component children))
+     (format "END:%s\r\n" name))))
+
+(defun ical:print-property-or-component (node)
+  "Serialize a property or component node NODE to a string."
+  (let ((type (ical:ast-node-type node)))
+    (cond ((get type 'ical:is-property)
+           (ical:print-property-node node))
+          ((get type 'ical:is-component)
+           (ical:print-component-node node))
+          (t (signal 'ical:print-error
+                     (list (format "Not a component or property node")
+                           node))))))
+
+(ical:define-component ical:vevent "VEVENT"
+  "Represents an event.
+
+This component contains properties which describe an event, such
+as its start and end time (`icalendar-dtstart' and
+`icalendar-dtend') and a summary (`icalendar-summary') and
+description (`icalendar-description'). It may also contain
+`icalendar-valarm' components as subcomponents which describe
+reminder notifications related to the event. Event components can
+only be direct children of an `icalendar-vcalendar'; they cannot
+be subcomponents of any other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:dtstart
+                             ;; TODO: dtstart required if METHOD not present
+                             ;; in parent calendar
+                             ical:class
+                             ical:created
+                             ical:description
+                             ical:dtend
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:priority
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:transp
+                             ical:url
+                             ical:recurid
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:rstatus
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vevent-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1")
+
+(defun ical:rrule-validator (node)
+  "When component NODE has an `icalendar-rrule', validate that its
+`icalendar-dtstart', `icalendar-rdate', and `icalendar-exdate' properties
+satisfy the requirements imposed by this rule."
+  (let* ((rrule (ical:ast-node-first-child-of 'ical:rrule node))
+         (recval (when rrule (ical:ast-node-value rrule)))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node))
+         (start (when dtstart (ical:ast-node-value dtstart)))
+         (rdates (ical:ast-node-children-of 'ical:rdate node))
+         (included (when rdates (mapcan #'ical:ast-node-value rdates)))
+         (exdates (ical:ast-node-children-of 'ical:exdate node))
+         (excluded (when exdates (mapcan #'ical:ast-node-value exdates))))
+    (when rrule
+      (unless dtstart
+        (signal 'ical:validation-error
+                (list (concat "An `icalendar-rrule' requires an "
+                              "`icalendar-dtstart' property")
+                      node)))
+      (when included
+        (unless (ical:list-of-p (ical:ast-node-type start) included)
+          (signal 'ical:validation-error
+                 (list (concat "`icalendar-rdate' values must agree with type "
+                              "of `icalendar-dtstart' property")
+                       node))))
+      (when excluded
+        (unless (ical:list-of-p (ical:ast-node-type start) excluded)
+          (signal 'ical:validation-error
+                 (list (concat "`icalendar-exdate' values must agree with type "
+                              "of `icalendar-dtstart' property")
+                       node))))
+      (let* ((freq (car (alist-get 'FREQ recval)))
+             (until (car (alist-get 'UNTIL recval))))
+        (when (eq 'ical:date (ical:ast-node-type start))
+          (when (or (memq freq '(HOURLY MINUTELY SECONDLY))
+                    (assq 'BYSECOND recval)
+                    (assq 'BYMINUTE recval)
+                    (assq 'BYHOUR recval))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-rrule' must not contain time-based "
+                                  "rules when `icalendar-dtstart' is a plain date")
+                          node))))
+        (when until
+          (unless (eq (ical:ast-node-type start)
+                      (ical:ast-node-type until))
+            (signal 'ical:validation-error
+                    (list (concat "`icalendar-rrule' UNTIL clause must agree with "
+                                  "type of `icalendar-dtstart' property")
+                          node)))
+          (when (eq 'ical:date-time (ical:ast-node-type until))
+            (let ((until-zone
+                   (decoded-time-zone (ical:ast-node-value until)))
+                  (start-zone
+                   (decoded-time-zone (ical:ast-node-value start))))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with local time, then the UNTIL rule part MUST also
+              ;; be specified as a date with local time":
+              (when (and (null start-zone) (not (null until-zone)))
+                (signal 'ical:validation-error
+                        (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "local time if `icalendar-dtstart' is")
+                              node)))
+              ;; "If the "DTSTART" property is specified as a date
+              ;; with UTC time or a date with local time and time zone
+              ;; reference, then the UNTIL rule part MUST be specified
+              ;; as a date with UTC time":
+              (when (and (integerp start-zone)
+                         (not (ical:date-time-is-utc-p until)))
+                (signal 'ical:validation-error
+                        (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "UTC time if `icalendar-dtstart' has a "
+                                      "defined time zone")
+                              node)))))
+          ;; "In the case of the "STANDARD" and "DAYLIGHT"
+          ;; sub-components the UNTIL rule part MUST always be
+          ;; specified as a date with UTC time":
+          (when (memq (ical:ast-node-type node) '(ical:standard ical:daylight))
+            (unless (ical:date-time-is-utc-p until)
+              (signal 'ical:validation-error
+                      (list (concat "`icalendar-rrule' UNTIL clause must be in "
+                                      "UTC time in `icalendar-standard' and "
+                                      "`icalendar-daylight' components")
+                            node)))))))
+    ;; Success:
+    node))
+
+(defun ical:vevent-validator (node)
+  "Additional validator for an `icalendar-vevent' NODE.
+Checks that NODE has conformant `icalendar-due',
+`icalendar-duration', and `icalendar-dtstart' properties, and
+calls `icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VEVENT nodes; it is not normally necessary to call it directly."
+  (let* ((duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dtend (ical:ast-node-first-child-of 'ical:dtend node)))
+    (when (and dtend duration)
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-dtend' and `icalendar-duration' "
+                            "properties must not appear in the same "
+                            "`icalendar-vevent'")
+                    node))))
+  (ical:rrule-validator node)
+  ;; success:
+  node)
+
+(ical:define-component ical:vtodo "VTODO"
+  "Represents a To-Do item or task.
+
+This component contains properties which describe a to-do item or
+task, such as its due date (`icalendar-due') and a summary
+(`icalendar-summary') and description (`icalendar-description').
+It may also contain `icalendar-valarm' components as
+subcomponents which describe reminder notifications related to
+the task. To-do components can only be direct children of an
+`icalendar-vcalendar'; they cannot be subcomponents of any other
+component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:completed
+                             ical:created
+                             ical:description
+                             ical:dtstart
+                             ical:due
+                             ical:duration
+                             ical:geo
+                             ical:last-modified
+                             ical:location
+                             ical:organizer
+                             ical:percent-complete
+                             ical:priority
+                             ical:recurrence-id
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:exdate
+                              ical:request-status
+                              ical:related-to
+                              ical:resources
+                              ical:rdate
+                              ical:other-property
+                              ical:valarm))
+  :other-validator ical:vtodo-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.2")
+
+(defun ical:vtodo-validator (node)
+  "Additional validator for an `icalendar-vtodo' NODE.
+Checks that NODE has conformant `icalendar-due',
+`icalendar-duration', and `icalendar-dtstart' properties, and calls
+`icalendar-rrule-validator'.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTODO nodes; it is not normally necessary to call it directly."
+  (let* ((due (ical:ast-node-first-child-of 'ical:due node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (dtstart (ical:ast-node-first-child-of 'ical:dtstart node)))
+    (when (and due duration)
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-due' and `icalendar-duration' properties "
+                            "must not appear in the same `icalendar-vtodo'")
+                    node)))
+    (when (and duration (not dtstart))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-duration' requires `icalendar-dtstart' "
+                            "property in the same `icalendar-vtodo'")
+                    node))))
+  (ical:rrule-validator node)
+  ;; success:
+  node)
+
+(ical:define-component ical:vjournal "VJOURNAL"
+  "Represents a journal entry.
+
+This component contains properties which describe a journal
+entry, which might be any longer-form data (e.g., meeting notes,
+a diary entry, or information needed to complete a task). It can
+be associated with an `icalendar-vevent' or `icalendar-vtodo' via
+the `icalendar-related-to' property. A journal entry does not
+take up time in a calendar, and plays no role in searches for
+free or busy time. Journal components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:class
+                             ical:created
+                             ical:dtstart
+                             ical:last-modified
+                             ical:organizer
+                             ical:recurid
+                             ical:sequence
+                             ical:status
+                             ical:summary
+                             ical:url
+                             ical:rrule)
+               :zero-or-more (ical:attach
+                              ical:attendee
+                              ical:categories
+                              ical:comment
+                              ical:contact
+                              ical:description
+                              ical:exdate
+                              ical:related-to
+                              ical:rdate
+                              ical:rstatus
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.3")
+
+(ical:define-component ical:vfreebusy "VFREEBUSY"
+  "Represents a published set of free/busy time blocks, or a request
+or response for such blocks.
+
+The free/busy information is represented by the
+`icalendar-freebusy' property (which may be given more than once)
+and the related `icalendar-fbtype' parameter. Note that
+recurrence properties (`icalendar-rrule', `icalendar-rdate', and
+`icalendar-exdate') are NOT permitted in this component.
+
+When used to publish blocks of free/busy time in a user's
+schedule, the `icalendar-organizer' property specifies the user.
+
+When used to request free/busy time in a user's schedule, or to
+respond to such a request, the `icalendar-attendee' property
+specifies the user whose time is being requested, and the
+`icalendar-organizer' property specifies the user making the
+request.
+
+Free/busy components can only be direct children
+of `icalendar-vcalendar'; they cannot be subcomponents of any
+other component, and cannot contain subcomponents."
+  :child-spec (:one (ical:dtstamp ical:uid)
+               :zero-or-one (ical:contact
+                             ical:dtstart
+                             ical:dtend
+                             ical:organizer
+                             ical:url)
+               :zero-or-more (ical:attendee
+                              ical:comment
+                              ical:freebusy
+                              ical:rstatus
+                              ical:other-property))
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.4")
+
+(ical:define-component ical:vtimezone "VTIMEZONE"
+  "Represents a time zone.
+
+A time zone is identified by an `icalendar-tzid' property, which
+is required in this component. Times in other calendar components
+can be specified in local time in this time zone with the
+`icalendar-tzidparam' parameter. An `icalendar-vcalendar' object
+must contain exactly one `icalendar-vtimezone' component for each
+unique timezone identifier used in the calendar.
+
+Besides the time zone identifier, a time zone component must
+contain at least one `icalendar-standard' or `icalendar-daylight'
+subcomponent, which describe the observance of standard or
+daylight time in the time zone, including the dates of the
+observance and the relevant offsets from UTC time."
+  :child-spec (:one (ical:tzid)
+               :zero-or-one (ical:last-modified
+                             ical:tzurl)
+               :zero-or-more (ical:standard
+                              ical:daylight
+                              ical:other-property))
+  :other-validator ical:vtimezone-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(defun ical:vtimezone-validator (node)
+  "Additional validator for an `icalendar-vtimezone' NODE.
+Checks that NODE has at least one `icalendar-standard' or
+`icalendar-daylight' child.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VTIMEZONE nodes; it is not normally necessary to call it directly."
+  (let ((child-counts (ical:count-children-by-type node)))
+    (when (and (= 0 (alist-get 'ical:standard child-counts 0))
+               (= 0 (alist-get 'ical:daylight child-counts 0)))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-timezone' must have at least one "
+                            "`icalendar-standard' or `icalendar-daylight' child")
+                    node))))
+  ;; success:
+  node)
+
+(ical:define-component ical:standard "STANDARD"
+  "Represents a Standard Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other explanatory
+comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:daylight "DAYLIGHT"
+  "Represents a Daylight Savings Time observance in a time zone.
+
+The observance has a start time, specified by an
+`icalendar-dtstart' property, which is required in this component
+and must be in *local* time format. The observance may have a
+recurring onset (e.g. each year on a particular day or date)
+described by the `icalendar-rrule' and `icalendar-rdate'
+properties. An end date for the observance, if there is one, must
+be specified in the UNTIL clause of the `icalendar-rrule' in UTC
+time.
+
+The offset from UTC time when the observance begins is specified
+in the `icalendar-tzoffsetfrom' property, which is required. The
+offset from UTC time while the observance is in effect is
+specified by the `icalendar-tzoffsetto' property, which is also
+required. A common identifier for the time zone observance can be
+specified in the `icalendar-tzname' property. Other
+explanatory comments can be provided in `icalendar-comment'.
+
+This component must be a direct child of an `icalendar-vtimezone'
+component and cannot contain other subcomponents."
+  :child-spec (:one (ical:dtstart
+                     ical:tzoffsetto
+                     ical:tzoffsetfrom)
+               :zero-or-one (ical:rrule)
+               :zero-or-more (ical:comment
+                              ical:rdate
+                              ical:tzname
+                              ical:other-property)
+               :other-validator ical:rrule-validator)
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5")
+
+(ical:define-component ical:valarm "VALARM"
+  "Represents an alarm.
+
+An alarm is a notification or reminder for an event or task. The
+type of notification is determined by this component's
+`icalendar-action' property: it may be an AUDIO, DISPLAY, or
+EMAIL notification.
+If it is an audio alarm, it can include an
+`icalendar-attach' property specifying the audio to be rendered.
+If it is a DISPLAY alarm, it must include an `icalendar-description'
+property containing the text to be displayed.
+If it is an EMAIL alarm, it must include both an
+`icalendar-summary' and an `icalendar-description', which specify
+the subject and body of the email, and one or more
+`icalendar-attendee' properties, which specify the recipients.
+
+The required `icalendar-trigger' property specifies when the
+alarm triggers. If the alarm repeats, then `icalendar-duration'
+and `icalendar-repeat' properties are also both required.
+
+This component must occur as a direct child of an
+`icalendar-vevent' or `icalendar-vtodo' component, and cannot
+contain any subcomponents."
+  :child-spec (:one (ical:action ical:trigger)
+               :zero-or-one (ical:duration ical:repeat)
+               :zero-or-more (ical:summary
+                              ical:description
+                              ical:attendee
+                              ical:attach
+                              ical:other-property))
+  :other-validator ical:valarm-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6.6")
+
+(defun ical:valarm-validator (node)
+  "Additional validator function for `icalendar-valarm' components.
+Checks that NODE has the right properties corresponding to its
+`icalendar-action' type, e.g., that an EMAIL alarm has a
+subject (`icalendar-summary') and recipients (`icalendar-attendee').
+
+This function is called by `icalendar-ast-node-valid-p' for
+VALARM nodes; it is not normally necessary to call it directly."
+  (let* ((action (ical:ast-node-first-child-of 'ical:action node))
+         (duration (ical:ast-node-first-child-of 'ical:duration node))
+         (repeat (ical:ast-node-first-child-of 'ical:repeat node))
+         (child-counts (ical:count-children-by-type node)))
+
+    (when (and duration (not repeat))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-valarm' node with `icalendar-duration' "
+                            "must also have `icalendar-repeat' property")
+                    node)))
+
+    (when (and repeat (not duration))
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-valarm' node with `icalendar-repeat' "
+                            "must also have `icalendar-duration' property")
+                    node)))
+
+    (let ((action-str (upcase (ical:text-to-string
+                               (ical:ast-node-value action)))))
+      (cond ((equal "AUDIO" action-str)
+             (unless (<= (alist-get 'ical:attach child-counts 0) 1)
+               (signal 'ical:validation-error
+                       (list (concat "AUDIO `icalendar-valarm' may not have "
+                                     "more than one `icalendar-attach'")
+                             node)))
+             node)
+
+            ((equal "DISPLAY" action-str)
+             (unless (= 1 (alist-get 'ical:description child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "DISPLAY `icalendar-valarm' must have "
+                                     "exactly one `icalendar-description'")
+                             node)))
+             node)
+
+            ((equal "EMAIL" action-str)
+             (unless (= 1 (alist-get 'ical:summary child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "exactly one `icalendar-summary'")
+                             node)))
+             (unless (= 1 (alist-get 'ical:description child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "exactly one `icalendar-description'")
+                             node)))
+             (unless (<= 1 (alist-get 'ical:attendee child-counts 0))
+               (signal 'ical:validation-error
+                       (list (concat "EMAIL `icalendar-valarm' must have "
+                                     "at least one `icalendar-attendee'")
+                             node)))
+             node)
+
+            (t
+             ;; "Applications MUST ignore alarms with x-name and iana-token
+             ;; values they don't recognize." So this is not a validation-error:
+             (warn (format "Unknown ACTION value in VALARM: %s" action-str))
+             node)))))
+
+(ical:define-component ical:other-component nil
+  "Component type for unrecognized component names.
+
+This component type corresponds to the IANA and X-name components
+allowed by RFC5545 sec. 3.6; it represents components with an
+unknown name (matching rx `icalendar-iana-token' or
+`icalendar-x-name') which must be parsed and preserved but not
+further interpreted."
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.6")
+
+;; Technically VCALENDAR is not a "component", but for the
+;; purposes of parsing and syntax highlighting, it looks just like
+;; one, so we define it as such here.
+;; TODO: if this becomes a problem, modify `ical:component-node-p'
+;; to return nil for VCALENDAR components
+(ical:define-component ical:vcalendar "VCALENDAR"
+  "Calendar Object.
+
+This is the top-level data structure defined by RFC5545. A
+VCALENDAR must contain the calendar properties `icalendar-prodid'
+and `icalendar-version', and may contain the calendar properties
+`icalendar-method' and `icalendar-calscale'.
+
+It must also contain at least one VEVENT, VTODO, VJOURNAL,
+VFREEBUSY, or other component, and for every unique
+`icalendar-tzidparam' value appearing in a property within these
+components, the calendar object must contain an
+`icalendar-vtimezone' defining a timezone with that TZID."
+
+  :child-spec (:one (ical:prodid ical:version)
+               :zero-or-one (ical:calscale ical:method)
+               :zero-or-more (ical:other-property
+                              ical:vevent
+                              ical:vtodo
+                              ical:vjournal
+                              ical:vfreebusy
+                              ical:vtimezone
+                              ical:other-component))
+  :other-validator ical:vcalendar-validator
+  :link "https://www.rfc-editor.org/rfc/rfc5545#section-3.4")
+
+(defun ical:all-tzidparams-in (node)
+  "Recursively search NODE for `icalendar-tzidparam' nodes and
+return a list of their values"
+  (cond ((ical:tzid-param-p node)
+         (list (ical:ast-node-value node)))
+        ((ical:param-node-p node)
+         nil)
+        (t ;; TODO: could prune search here when properties don't allow tzidparam
+         (seq-uniq (mapcan #'ical:all-tzidparams-in
+                           (ical:ast-node-children node))))))
+
+(defun ical:vcalendar-validator (node)
+  "Additional validator for `icalendar-vcalendar' NODE. Checks that
+NODE has at least one component child and that all of the
+`ical-tzidparam' values appearing in subcomponents have a
+corresponding `icalendar-vtimezone' definition.
+
+This function is called by `icalendar-ast-node-valid-p' for
+VCALENDAR nodes; it is not normally necessary to call it directly."
+  (let* ((children (ical:ast-node-children node))
+         (comp-children (seq-filter #'ical:component-node-p children))
+         (tz-children (seq-filter #'ical:vtimezone-component-p children))
+         (defined-tzs (mapcar
+                       (lambda (tz)
+                         ;; ensure timezone component has a TZID property and
+                         ;; extract its string value:
+                         (when (ical:ast-node-valid-p tz)
+                           (ical:text-to-string
+                            (ical:ast-node-value
+                             (ical:ast-node-first-child-of 'ical:tzid tz)))))
+                       tz-children))
+         (appearing-tzids (ical:all-tzidparams-in node)))
+    (unless comp-children
+      (signal 'ical:validation-error
+              (list (concat "`icalendar-vcalendar' must contain "
+                            "at least one component")
+                    node)))
+
+    (let ((seen nil))
+      (dolist (tzid appearing-tzids)
+        (unless (member tzid seen)
+          (unless (member tzid defined-tzs)
+            (signal 'ical:validation-error
+                    (list (format "No VTIMEZONE with TZID '%s' in calendar" tzid)
+                          node))))
+        (push tzid seen)))
+
+    ;; success:
+    node))
+
+;; TODO: parse-calendar and print-calendar functions.  parse-component
+;; is sufficient to parse all the syntax in a calendar, but a
+;; calendar-level parsing function is needed to add support for
+;; timezones. This function should ensure that every
+;; `icalendar-tzidparam' in the calendar has a corresponding
+;; `icalendar-vtimezone' component, and modify the zone information of
+;; the parsed date-time according to the offset in that timezone (and
+;; the print function should do the inverse). Calculating the offsets,
+;; however, is dependent on an implementation of recurrence rules which
+;; is still in the works.
+
+
+
+
+;;; Documentation for all of the above via `describe-symbol':
+(defun icalendar-documented-symbol-p (sym)
+  "iCalendar symbol predicate for `describe-symbol-backends'"
+  (or (get sym 'icalendar-type-documentation)
+      ;; grammatical categories defined with rx-define, but with no
+      ;; other special icalendar docs:
+      (and (get sym 'rx-definition)
+           (length> (symbol-name sym) 10)
+           (equal "icalendar-" (substring (symbol-name sym) 0 10)))))
+
+(defun icalendar-documentation (sym buf frame)
+  "iCalendar documentation backend for `describe-symbol-backends'"
+  (ignore buf frame) ; Silence the byte compiler
+  (with-help-window (help-buffer)
+    (with-current-buffer standard-output
+      (let* ((type-doc (get sym 'icalendar-type-documentation))
+             (link (get sym 'icalendar-link))
+             (rx-def (get sym 'rx-definition))
+             (rx-doc (when rx-def
+                       (with-output-to-string
+                         (pp rx-def))))
+             (value-rx-def (get sym 'ical:value-rx))
+             (value-rx-doc (when value-rx-def
+                             (with-output-to-string
+                               (pp value-rx-def))))
+             (values-rx-def (get sym 'ical:values-rx))
+             (values-rx-doc (when values-rx-def
+                             (with-output-to-string
+                               (pp values-rx-def))))
+
+             (full-doc
+              (concat
+               (when type-doc
+                 (format "`%s' is an iCalendar type:\n\n%s\n\n"
+                         sym type-doc))
+               (when link
+                 (format "For further information see\nURL `%s'\n\n" link))
+               ;; FIXME: this is probably better done in rx.el!
+               ;; TODO: could also generalize this to recursively
+               ;; search rx-def for any symbol that starts with "icalendar-"...
+               (when rx-def
+                 (format "`%s' is an iCalendar grammar category.
+Its `rx' definition is:\n\n%s%s%s"
+                         sym
+                         rx-doc
+                         (if value-rx-def
+                             (format "\nIndividual values must match:\n%s"
+                                      value-rx-doc)
+                           "")
+                         (if values-rx-def
+                             (format "\nLists of values must match:\n%s"
+                                      values-rx-doc)
+                           "")))
+               "\n")))
+
+        (insert full-doc)
+        full-doc))))
+
+
+(defconst ical:describe-symbol-backend
+  '(nil icalendar-documented-symbol-p icalendar-documentation)
+  "Entry for icalendar documentation in `describe-symbol-backends'")
+
+(push ical:describe-symbol-backend describe-symbol-backends)
+
+;; Unloading:
+(defun ical:parser-unload-function ()
+  "Unload function for `icalendar-parser'."
+  (mapatoms
+   (lambda (sym)
+     (when (string-match "^icalendar-" (symbol-name sym))
+       (unintern sym obarray))))
+
+  (setq describe-symbol-backends
+        (remq ical:describe-symbol-backend describe-symbol-backends))
+  ;; Proceed with normal unloading:
+  nil)
+
+(provide 'icalendar-parser)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-parser.el ends here
diff --git a/lisp/calendar/icalendar-uri-schemes.el b/lisp/calendar/icalendar-uri-schemes.el
new file mode 100644
index 00000000000..c94a36c13d8
--- /dev/null
+++ b/lisp/calendar/icalendar-uri-schemes.el
@@ -0,0 +1,444 @@
+;;; icalendar-uri-schemes.el --- URI schemes in iCalendar -*- lexical-binding:t -*-
+
+;; Copyright (C) 2024 Free Software Foundation, Inc.
+
+;; Author: Richard Lawrence <rwl@HIDDEN>
+;; Maintainer: emacs-devel@HIDDEN
+;; Created: October 2024
+;; Keywords: calendar
+;; Human-Keywords: calendar, iCalendar
+
+;; This file is part of GNU Emacs.
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This file is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this file.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file defines one (large) regular expression, ical:uri-scheme,
+;; to match URI schemes registered with IANA.
+;;
+;; The schemes are listed at:
+;;   https://www.iana.org/assignments/uri-schemes/uri-schemes.txt
+;; Note the licensing terms for this list, available at:
+;;   https://www.iana.org/help/licensing-terms
+;; which as of 2024-10-24 says:
+;;
+;;   IANA and IETF desire to (a) dedicate any applicable copyright
+;;   rights that they may own in the Protocol Registries to the public
+;;   domain, and (b) license any copyright or related rights for which
+;;   they are a licensee (with a right to sublicense) to the broadest
+;;   extent that they are permitted to do so. Accordingly, both IANA
+;;   and IETF affirm that any applicable rights that they may have in
+;;   the Protocol Registries are subject to the Creative Commons CC0
+;;   1.0 dedication found at
+;;   https://creativecommons.org/publicdomain/zero/1.0/legalcode
+;;
+;; This file is current as of 2024-10-24.
+
+;;; Code:
+(require 'rx)
+
+(rx-define ical:uri-scheme (or
+"aaa"
+"aaas"
+"about"
+"acap"
+"acct"
+"acd"
+"acr"
+"adiumxtra"
+"adt"
+"afp"
+"afs"
+"aim"
+"amss"
+"android"
+"appdata"
+"apt"
+"ar"
+"ark"
+"at"
+"attachment"
+"aw"
+"barion"
+"bb"
+"beshare"
+"bitcoin"
+"bitcoincash"
+"blob"
+"bluetooth"
+"bolo"
+"brid"
+"browserext"
+"cabal"
+"calculator"
+"callto"
+"cap"
+"cast"
+"casts"
+"chrome"
+"chrome-extension"
+"cid"
+"coap"
+"coap+tcp"
+"coap+ws"
+"coaps"
+"coaps+tcp"
+"coaps+ws"
+"com-eventbrite-attendee"
+"content"
+"content-type"
+"crid"
+"cstr"
+"cvs"
+"dab"
+"dat"
+"data"
+"dav"
+"dhttp"
+"diaspora"
+"dict"
+"did"
+"dis"
+"dlna-playcontainer"
+"dlna-playsingle"
+"dns"
+"dntp"
+"doi"
+"dpp"
+"drm"
+"drop"
+"dtmi"
+"dtn"
+"dvb"
+"dvx"
+"dweb"
+"ed2k"
+"eid"
+"elsi"
+"embedded"
+"ens"
+"ethereum"
+"example"
+"facetime"
+"fax"
+"feed"
+"feedready"
+"fido"
+"file"
+"filesystem"
+"finger"
+"first-run-pen-experience"
+"fish"
+"fm"
+"ftp"
+"fuchsia-pkg"
+"geo"
+"gg"
+"git"
+"gitoid"
+"gizmoproject"
+"go"
+"gopher"
+"graph"
+"grd"
+"gtalk"
+"h323"
+"ham"
+"hcap"
+"hcp"
+"hs20"
+"http"
+"https"
+"hxxp"
+"hxxps"
+"hydrazone"
+"hyper"
+"iax"
+"icap"
+"icon"
+"im"
+"imap"
+"info"
+"iotdisco"
+"ipfs"
+"ipn"
+"ipns"
+"ipp"
+"ipps"
+"irc"
+"irc6"
+"ircs"
+"iris"
+"iris.beep"
+"iris.lwz"
+"iris.xpc"
+"iris.xpcs"
+"isostore"
+"itms"
+"jabber"
+"jar"
+"jms"
+"keyparc"
+"lastfm"
+"lbry"
+"ldap"
+"ldaps"
+"leaptofrogans"
+"lid"
+"lorawan"
+"lpa"
+"lvlt"
+"machineProvisioningProgressReporter"
+"magnet"
+"mailserver"
+"mailto"
+"maps"
+"market"
+"matrix"
+"message"
+"microsoft.windows.camera"
+"microsoft.windows.camera.multipicker"
+"microsoft.windows.camera.picker"
+"mid"
+"mms"
+"modem"
+"mongodb"
+"moz"
+"ms-access"
+"ms-appinstaller"
+"ms-browser-extension"
+"ms-calculator"
+"ms-drive-to"
+"ms-enrollment"
+"ms-excel"
+"ms-eyecontrolspeech"
+"ms-gamebarservices"
+"ms-gamingoverlay"
+"ms-getoffice"
+"ms-help"
+"ms-infopath"
+"ms-inputapp"
+"ms-launchremotedesktop"
+"ms-lockscreencomponent-config"
+"ms-media-stream-id"
+"ms-meetnow"
+"ms-mixedrealitycapture"
+"ms-mobileplans"
+"ms-newsandinterests"
+"ms-officeapp"
+"ms-people"
+"ms-personacard"
+"ms-project"
+"ms-powerpoint"
+"ms-publisher"
+"ms-recall"
+"ms-remotedesktop"
+"ms-remotedesktop-launch"
+"ms-restoretabcompanion"
+"ms-screenclip"
+"ms-screensketch"
+"ms-search"
+"ms-search-repair"
+"ms-secondary-screen-controller"
+"ms-secondary-screen-setup"
+"ms-settings"
+"ms-settings-airplanemode"
+"ms-settings-bluetooth"
+"ms-settings-camera"
+"ms-settings-cellular"
+"ms-settings-cloudstorage"
+"ms-settings-connectabledevices"
+"ms-settings-displays-topology"
+"ms-settings-emailandaccounts"
+"ms-settings-language"
+"ms-settings-location"
+"ms-settings-lock"
+"ms-settings-nfctransactions"
+"ms-settings-notifications"
+"ms-settings-power"
+"ms-settings-privacy"
+"ms-settings-proximity"
+"ms-settings-screenrotation"
+"ms-settings-wifi"
+"ms-settings-workplace"
+"ms-spd"
+"ms-stickers"
+"ms-sttoverlay"
+"ms-transit-to"
+"ms-useractivityset"
+"ms-virtualtouchpad"
+"ms-visio"
+"ms-walk-to"
+"ms-whiteboard"
+"ms-whiteboard-cmd"
+"ms-word"
+"msnim"
+"msrp"
+"msrps"
+"mss"
+"mt"
+"mtqp"
+"mumble"
+"mupdate"
+"mvn"
+"mvrp"
+"mvrps"
+"news"
+"nfs"
+"ni"
+"nih"
+"nntp"
+"notes"
+"num"
+"ocf"
+"oid"
+"onenote"
+"onenote-cmd"
+"opaquelocktoken"
+"openid"
+"openpgp4fpr"
+"otpauth"
+"p1"
+"pack"
+"palm"
+"paparazzi"
+"payment"
+"payto"
+"pkcs11"
+"platform"
+"pop"
+"pres"
+"prospero"
+"proxy"
+"pwid"
+"psyc"
+"pttp"
+"qb"
+"query"
+"quic-transport"
+"redis"
+"rediss"
+"reload"
+"res"
+"resource"
+"rmi"
+"rsync"
+"rtmfp"
+"rtmp"
+"rtsp"
+"rtsps"
+"rtspu"
+"sarif"
+"secondlife"
+"secret-token"
+"service"
+"session"
+"sftp"
+"sgn"
+"shc"
+"shttp"
+"sieve"
+"simpleledger"
+"simplex"
+"sip"
+"sips"
+"skype"
+"smb"
+"smp"
+"sms"
+"smtp"
+"snews"
+"snmp"
+"soap.beep"
+"soap.beeps"
+"soldat"
+"spiffe"
+"spotify"
+"ssb"
+"ssh"
+"starknet"
+"steam"
+"stun"
+"stuns"
+"submit"
+"svn"
+"swh"
+"swid"
+"swidpath"
+"tag"
+"taler"
+"teamspeak"
+"tel"
+"teliaeid"
+"telnet"
+"tftp"
+"things"
+"thismessage"
+"thzp"
+"tip"
+"tn3270"
+"tool"
+"turn"
+"turns"
+"tv"
+"udp"
+"unreal"
+"upt"
+"urn"
+"ut2004"
+"uuid-in-package"
+"v-event"
+"vemmi"
+"ventrilo"
+"ves"
+"videotex"
+"vnc"
+"view-source"
+"vscode"
+"vscode-insiders"
+"vsls"
+"w3"
+"wais"
+"web3"
+"wcr"
+"webcal"
+"web+ap"
+"wifi"
+"wpid"
+"ws"
+"wss"
+"wtai"
+"wyciwyg"
+"xcon"
+"xcon-userid"
+"xfire"
+"xmlrpc.beep"
+"xmlrpc.beeps"
+"xmpp"
+"xftp"
+"xrcp"
+"xri"
+"ymsgr"
+"z39.50"
+"z39.50r"
+"z39.50s"
+))
+
+(provide 'icalendar-uri-schemes)
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ical:" . "icalendar-"))
+;; End:
+;;; icalendar-uri-schemes.el ends here
diff --git a/test/lisp/calendar/icalendar-parser-tests.el b/test/lisp/calendar/icalendar-parser-tests.el
new file mode 100644
index 00000000000..a087fc93751
--- /dev/null
+++ b/test/lisp/calendar/icalendar-parser-tests.el
@@ -0,0 +1,1796 @@
+;;; tests/icalendar-parser.el --- Tests for icalendar-parser  -*- lexical-binding: t; -*-
+
+(require 'cl-lib)
+(require 'ert)
+(require 'icalendar-parser)
+
+(cl-defmacro ict:parse/print-test (string &key expected parser type printer source)
+  "Create a test which parses STRING, prints the resulting parse
+tree, and compares the printed version with STRING (or with
+EXPECTED, if given). If they are the same, the test passes.
+PARSER and PRINTER should be the parser and printer functions
+appropriate to STRING. TYPE, if given, should be the type of
+object PARSER is expected to parse; it will be passed as PARSER's
+first argument. SOURCE should be a symbol; it is used to name the
+test."
+  (let ((parser-form
+         (if type
+             `(funcall (function ,parser) (quote ,type) (point-max))
+           `(funcall (function ,parser) (point-max)))))
+    `(ert-deftest ,(intern (concat "ict:parse/print-" (symbol-name source))) ()
+       ,(format "Parse and reprint example from `%s'; pass if they match" source)
+       (let* ((parse-buf (get-buffer-create "*iCalendar Parse Test*"))
+              (print-buf (get-buffer-create "*iCalendar Print Test*"))
+              (unparsed ,string)
+              (expected (or ,expected unparsed))
+              (printed nil))
+         (set-buffer parse-buf)
+         (erase-buffer)
+         (insert unparsed)
+         (goto-char (point-min))
+         (let ((parsed ,parser-form))
+           (should (icalendar-ast-node-valid-p parsed))
+           (set-buffer print-buf)
+           (erase-buffer)
+           (insert (funcall (function ,printer) parsed))
+           ;; TODO: this may need adjusting if printers become coding-system aware
+           (decode-coding-region (point-min) (point-max) 'utf-8-dos)
+           (setq printed (buffer-substring-no-properties (point-min) (point-max)))
+           (should (equal expected printed)))))))
+
+(ict:parse/print-test
+"ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/1)
+
+(ict:parse/print-test
+"RDATE;VALUE=DATE:19970304,19970504,19970704,19970904\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.1/2)
+
+(ict:parse/print-test
+"ATTACH:http://example.com/public/quarterly-report.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/1)
+
+(ict:parse/print-test
+;; Corrected. The original contains invalid base64 data; it was
+;; missing the final "=", as noted in errata ID 5602.
+;; The decoded string should read:
+;; The quick brown fox jumps over the lazy dog.
+"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4=\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.1.3/2)
+
+(ict:parse/print-test
+"DESCRIPTION;ALTREP=\"cid:part1.0001@HIDDEN\":The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2/1)
+
+(ict:parse/print-test
+"DESCRIPTION;ALTREP=\"CID:part3.msg.970415T083000@HIDDEN\": Project XYZ Review Meeting will include the following agenda items: (a) Market Overview\\, (b) Finances\\, (c) Project Management\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.1/1)
+
+(ict:parse/print-test
+"ORGANIZER;CN=\"John Smith\":mailto:jsmith@HIDDEN\n"
+;; CN param value does not require quotes, so they're missing when
+;; re-printed:
+:expected "ORGANIZER;CN=John Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.2/1)
+
+(ict:parse/print-test
+"ATTENDEE;CUTYPE=GROUP:mailto:ietf-calsch@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.3/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-FROM=\"mailto:jsmith@HIDDEN\":mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.4/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-TO=\"mailto:jdoe@HIDDEN\",\"mailto:jqpublic@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.5/1)
+
+(ict:parse/print-test
+"ORGANIZER;DIR=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.6/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2ljaW5nIGVsaXQsIHNlZCBkbyBlaXVzbW9kIHRlbXBvciBpbmNpZGlkdW50IHV0IGxhYm9yZSBldCBkb2xvcmUgbWFnbmEgYWxpcXVhLiBVdCBlbmltIGFkIG1pbmltIHZlbmlhbSwgcXVpcyBub3N0cnVkIGV4ZXJjaXRhdGlvbiB1bGxhbWNvIGxhYm9yaXMgbmlzaSB1dCBhbGlxdWlwIGV4IGVhIGNvbW1vZG8gY29uc2VxdWF0LiBEdWlzIGF1dGUgaXJ1cmUgZG9sb3IgaW4gcmVwcmVoZW5kZXJpdCBpbiB2b2x1cHRhdGUgdmVsaXQgZXNzZSBjaWxsdW0gZG9sb3JlIGV1IGZ1Z2lhdCBudWxsYSBwYXJpYXR1ci4gRXhjZXB0ZXVyIHNpbnQgb2NjYWVjYXQgY3VwaWRhdGF0IG5vbiBwcm9pZGVudCwgc3VudCBpbiBjdWxwYSBxdWkgb2ZmaWNpYSBkZXNlcnVudCBtb2xsaXQgYW5pbSBpZCBlc3QgbGFib3J1bS4=\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.7/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=application/msword:ftp://example.com/pub/docs/agenda.doc\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.8/1)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=BUSY:19980415T133000Z/19980415T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.9/1)
+
+(ict:parse/print-test
+"SUMMARY;LANGUAGE=en-US:Company Holiday Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/1)
+
+(ict:parse/print-test
+"LOCATION;LANGUAGE=en:Germany\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/2)
+
+(ict:parse/print-test
+"LOCATION;LANGUAGE=no:Tyskland\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.10/3)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:ietf-calsch@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/1)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:projectA@HIDDEN\",\"mailto:projectB@HIDDEN\":mailto:janedoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.11/2)
+
+(ict:parse/print-test
+"ATTENDEE;PARTSTAT=DECLINED:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.12/1)
+
+(ict:parse/print-test
+"RECURRENCE-ID;RANGE=THISANDFUTURE:19980401T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.13/1)
+
+(ict:parse/print-test
+"TRIGGER;RELATED=END:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.14/1)
+
+(ict:parse/print-test
+"RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.15/1)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=CHAIR:mailto:mrbig@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.16/1)
+
+(ict:parse/print-test
+"ATTENDEE;RSVP=TRUE:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.17/1)
+
+(ict:parse/print-test
+"ORGANIZER;SENT-BY=\"mailto:sray@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.18/1)
+
+(ict:parse/print-test
+"DTSTART;TZID=America/New_York:19980119T020000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/1)
+
+(ict:parse/print-test
+"DTEND;TZID=America/New_York:19980119T030000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.2.19/2)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=image/vnd.microsoft.icon;ENCODING=BASE64;VALUE=BINARY:AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgIAAAICAgADAwMAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAAAAABNEMQAAAAAAAkQgAAAAAAJEREQgAAACECQ0QgEgAAQxQzM0E0AABERCRCREQAADRDJEJEQwAAAhA0QwEQAAAAAEREAAAAAAAAREQAAAAAAAAkQgAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.1/1)
+
+(ict:parse/print-test
+"TRUE"
+:type icalendar-boolean
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.2/1)
+
+(ict:parse/print-test
+"mailto:jane_doe@HIDDEN"
+:type icalendar-cal-address
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.3/1)
+
+(ict:parse/print-test
+"19970714"
+:type icalendar-date
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.4/1)
+
+(ict:parse/print-test
+;; 'Floating' time:
+"19980118T230000"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/1)
+
+(ict:parse/print-test
+;; UTC time:
+"19980119T070000Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/2)
+
+(ict:parse/print-test
+;; Leap second (seconds = 60)
+"19970630T235960Z"
+:type icalendar-date-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.5/3)
+
+(ict:parse/print-test
+;; Local time:
+"DTSTART:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/4)
+
+(ict:parse/print-test
+;; UTC time:
+"DTSTART:19970714T173000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/5)
+
+(ict:parse/print-test
+;; Local time with TZ identifier:
+"DTSTART;TZID=America/New_York:19970714T133000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.5/6)
+
+(ict:parse/print-test
+"P15DT5H0M20S"
+:expected "P15DT5H20S"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/1)
+
+(ict:parse/print-test
+"P7W"
+:type icalendar-dur-value
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.6/2)
+
+(ict:parse/print-test
+"1000000.0000001"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/1)
+
+(ict:parse/print-test
+"1.333"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/2)
+
+(ict:parse/print-test
+"-3.14"
+:type icalendar-float
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.7/3)
+
+(ict:parse/print-test
+"1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/1)
+
+(ict:parse/print-test
+"-1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/2)
+
+(ict:parse/print-test
+"+1234567890"
+;; "+" sign isn't required, so it's not re-printed:
+:expected "1234567890"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/3)
+
+(ict:parse/print-test
+"432109876"
+:type icalendar-integer
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.8/4)
+
+(ict:parse/print-test
+"19970101T180000Z/19970102T070000Z"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/1)
+
+(ict:parse/print-test
+"19970101T180000Z/PT5H30M"
+:type icalendar-period
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.9/2)
+
+(ict:parse/print-test
+"FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/1)
+
+(ict:parse/print-test
+"FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/2)
+
+(ict:parse/print-test
+"FREQ=DAILY;COUNT=10;INTERVAL=2"
+:type icalendar-recur
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.10/3)
+
+(ict:parse/print-test
+"Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared."
+:type icalendar-text
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.11/1)
+
+(ict:parse/print-test
+;; Local time:
+"230000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/1)
+
+(ict:parse/print-test
+;; UTC time:
+"070000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/2)
+
+(ict:parse/print-test
+;; Local time:
+"083000"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/3)
+
+(ict:parse/print-test
+;; UTC time:
+"133000Z"
+:type icalendar-time
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.12/4)
+
+(ict:parse/print-test
+;; Local time with TZ identifier:
+"SOMETIMEPROP;TZID=America/New_York;VALUE=TIME:083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.3.12/5)
+
+(ict:parse/print-test
+"http://example.com/my-report.txt"
+:type icalendar-uri
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.13/1)
+
+(ict:parse/print-test
+"-0500"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc5545-sec3.3.14/1)
+
+(ict:parse/print-test
+"+0100"
+:type icalendar-utc-offset
+:parser icalendar-parse-value-node
+:printer icalendar-print-value-node
+:source rfc55453.3.14/1)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@HIDDEN
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.4/1)
+
+(ict:parse/print-test
+"DTSTART:19960415T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.5/1)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123401@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970903T163000Z
+DTEND:19970903T190000Z
+SUMMARY:Annual Employee Review
+CLASS:PRIVATE
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/1)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123402@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART:19970401T163000Z
+DTEND:19970402T010000Z
+SUMMARY:Laurel is in sensitivity awareness class.
+CLASS:PUBLIC
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/2)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:19970901T130000Z-123403@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=DATE:19971102
+SUMMARY:Our Blissful Anniversary
+TRANSP:TRANSPARENT
+CLASS:CONFIDENTIAL
+CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION
+RRULE:FREQ=YEARLY
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/3)
+
+(ict:parse/print-test
+"BEGIN:VEVENT
+UID:20070423T123432Z-541111@HIDDEN
+DTSTAMP:20070423T123432Z
+DTSTART;VALUE=DATE:20070628
+DTEND;VALUE=DATE:20070709
+SUMMARY:Festival International de Jazz de Montreal
+TRANSP:TRANSPARENT
+END:VEVENT
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.1/4)
+
+(ict:parse/print-test
+"BEGIN:VTODO
+UID:20070313T123432Z-456553@HIDDEN
+DTSTAMP:20070313T123432Z
+DUE;VALUE=DATE:20070501
+SUMMARY:Submit Quebec Income Tax Return for 2006
+CLASS:CONFIDENTIAL
+CATEGORIES:FAMILY,FINANCE
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/1)
+
+(ict:parse/print-test
+"BEGIN:VTODO
+UID:20070514T103211Z-123404@HIDDEN
+DTSTAMP:20070514T103211Z
+DTSTART:20070514T110000Z
+DUE:20070709T130000Z
+COMPLETED:20070707T100000Z
+SUMMARY:Submit Revised Internet-Draft
+PRIORITY:1
+STATUS:NEEDS-ACTION
+END:VTODO
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.2/2)
+
+(ict:parse/print-test
+"BEGIN:VJOURNAL
+UID:19970901T130000Z-123405@HIDDEN
+DTSTAMP:19970901T130000Z
+DTSTART;VALUE=DATE:19970317
+SUMMARY:Staff meeting minutes
+DESCRIPTION:1. Staff meeting: Participants include Joe\\,Lisa\\, and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\\n 2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday.\\n3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 555-2323 (tel).
+END:VJOURNAL
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.3/1)
+
+(ict:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T082949Z-FA43EF@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTART:19971015T050000Z
+DTEND:19971016T050000Z
+DTSTAMP:19970901T083000Z
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/1)
+
+(ict:parse/print-test
+"BEGIN:VFREEBUSY
+UID:19970901T095957Z-76A912@HIDDEN
+ORGANIZER:mailto:jane_doe@HIDDEN
+ATTENDEE:mailto:john_public@HIDDEN
+DTSTAMP:19970901T100000Z
+FREEBUSY:19971015T050000Z/PT8H30M,19971015T160000Z/PT5H30M,19971015T223000Z/PT6H30M
+URL:http://example.com/pub/busy/jpublic-01.ifb
+COMMENT:This iCalendar file contains busy time information for the next three months.
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/2)
+
+(ict:parse/print-test
+;; Corrected. Original has invalid value in ORGANIZER
+"BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@HIDDEN
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.4/3)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:DAYLIGHT
+DTSTART:19670430T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19740106T020000
+RDATE:19750223T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19760425T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/1)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+BEGIN:STANDARD
+DTSTART:20071104T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/2)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:America/New_York
+LAST-MODIFIED:20050809T050000Z
+TZURL:http://zones.example.com/tz/America-New_York.ics
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/3)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/4)
+
+(ict:parse/print-test
+"BEGIN:VTIMEZONE
+TZID:Fictitious
+LAST-MODIFIED:19870101T000000Z
+BEGIN:STANDARD
+DTSTART:19671029T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19870405T020000
+RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:19990424T020000
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=4
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.5/5)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER;VALUE=DATE-TIME:19970317T133000Z
+REPEAT:4
+DURATION:PT15M
+ACTION:AUDIO
+ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/1)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER:-PT30M
+REPEAT:2
+DURATION:PT15M
+ACTION:DISPLAY
+DESCRIPTION:Breakfast meeting with executive\\nteam at 8:30 AM EST.
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/2)
+
+(ict:parse/print-test
+"BEGIN:VALARM
+TRIGGER;RELATED=END:-P2D
+ACTION:EMAIL
+ATTENDEE:mailto:john_doe@HIDDEN
+SUMMARY:*** REMINDER: SEND AGENDA FOR WEEKLY STAFF MEETING ***
+DESCRIPTION:A draft agenda needs to be sent out to the attendees to the weekly managers meeting (MGR-LIST). Attached is a pointer the document template for the agenda file.
+ATTACH;FMTTYPE=application/msword:http://example.com/templates/agenda.doc
+END:VALARM
+"
+:parser icalendar-parse-component
+:printer icalendar-print-component-node
+:source rfc5545-sec3.6.6/3)
+
+(ict:parse/print-test
+"CALSCALE:GREGORIAN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.1/1)
+
+(ict:parse/print-test
+"METHOD:REQUEST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.2/1)
+
+(ict:parse/print-test
+"PRODID:-//ABC Corporation//NONSGML My Product//EN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7.3/1)
+
+(ict:parse/print-test
+"VERSION:2.0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.7./1)
+
+(ict:parse/print-test
+"ATTACH:CID:jsmith.part3.960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/1)
+
+(ict:parse/print-test
+"ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/reports/r-960812.ps\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.1/2)
+
+(ict:parse/print-test
+"CATEGORIES:APPOINTMENT,EDUCATION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/1)
+
+(ict:parse/print-test
+"CATEGORIES:MEETING\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.2/2)
+
+(ict:parse/print-test
+"CLASS:PUBLIC\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.3/1)
+
+(ict:parse/print-test
+"COMMENT:The meeting really needs to include both ourselves and the customer. We can't hold this meeting without them. As a matter of fact\\, the venue for the meeting ought to be at their site. - - John\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.4/1)
+
+(ict:parse/print-test
+"DESCRIPTION:Meeting to provide technical review for \"Phoenix\" design.\\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\\nRSVP to team leader.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.5/1)
+
+(ict:parse/print-test
+"GEO:37.386013;-122.082932\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.6/1)
+
+(ict:parse/print-test
+"LOCATION:Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/1)
+
+(ict:parse/print-test
+"LOCATION;ALTREP=\"http://xyzcorp.com/conf-rooms/f123.vcf\":Conference Room - F123\\, Bldg. 002\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.7/2)
+
+(ict:parse/print-test
+"PERCENT-COMPLETE:39\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.8/1)
+
+(ict:parse/print-test
+"PRIORITY:1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/1)
+
+(ict:parse/print-test
+"PRIORITY:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/2)
+
+(ict:parse/print-test
+"PRIORITY:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.9/3)
+
+(ict:parse/print-test
+"RESOURCES:EASEL,PROJECTOR,VCR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/1)
+
+(ict:parse/print-test
+"RESOURCES;LANGUAGE=fr:Nettoyeur haute pression\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.10/2)
+
+(ict:parse/print-test
+"STATUS:TENTATIVE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/1)
+
+(ict:parse/print-test
+"STATUS:NEEDS-ACTION\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/2)
+
+(ict:parse/print-test
+"STATUS:DRAFT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.11/3)
+
+(ict:parse/print-test
+"SUMMARY:Department Party\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.1.12/1)
+
+(ict:parse/print-test
+"COMPLETED:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.1/1)
+
+(ict:parse/print-test
+"DTEND:19960401T150000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/1)
+
+(ict:parse/print-test
+"DTEND;VALUE=DATE:19980704\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.2/2)
+
+(ict:parse/print-test
+"DUE:19980430T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.3/1)
+
+(ict:parse/print-test
+"DTSTART:19980118T073000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.4/1)
+
+(ict:parse/print-test
+"DURATION:PT1H0M0S\n"
+;; 0M and 0S are not re-printed because they don't contribute to the duration:
+:expected "DURATION:PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/1)
+
+(ict:parse/print-test
+"DURATION:PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.5/2)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/1)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/2)
+
+(ict:parse/print-test
+"FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.6/3)
+
+(ict:parse/print-test
+"TRANSP:TRANSPARENT\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/1)
+
+(ict:parse/print-test
+"TRANSP:OPAQUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.2.7/2)
+
+(ict:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/1)
+
+(ict:parse/print-test
+"TZID:America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/2)
+
+(ict:parse/print-test
+"TZID:/example.org/America/New_York\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.1/3)
+
+(ict:parse/print-test
+"TZNAME:EST\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/1)
+
+(ict:parse/print-test
+"TZNAME;LANGUAGE=fr-CA:HNE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.2/2)
+
+(ict:parse/print-test
+"TZOFFSETFROM:-0500\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/1)
+
+(ict:parse/print-test
+"TZOFFSETFROM:+1345\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.3/2)
+
+(ict:parse/print-test
+"TZOFFSETTO:-0400\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/1)
+
+(ict:parse/print-test
+"TZOFFSETTO:+1245\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.4/2)
+
+(ict:parse/print-test
+"TZURL:http://timezones.example.org/tz/America-Los_Angeles.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.3.5/1)
+
+(ict:parse/print-test
+"ATTENDEE;MEMBER=\"mailto:DEV-GROUP@HIDDEN\":mailto:joecool@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/1)
+
+(ict:parse/print-test
+"ATTENDEE;DELEGATED-FROM=\"mailto:immud@HIDDEN\":mailto:ildoit@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/2)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Henry Cabot:mailto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/3)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM=\"mailto:bob@HIDDEN\";PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/4)
+
+(ict:parse/print-test
+"ATTENDEE;CN=John Smith;DIR=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":mailto:jimdo@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/5)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;DELEGATED-FROM=\"mailto:iamboss@HIDDEN\";CN=Henry Cabot:mailto:hcabot@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/6)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=NON-PARTICIPANT;PARTSTAT=DELEGATED;DELEGATED-TO=\"mailto:hcabot@HIDDEN\";CN=The Big Cheese:mailto:iamboss@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/7)
+
+(ict:parse/print-test
+"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/8)
+
+(ict:parse/print-test
+;; Corrected. Original lacks quotes around SENT-BY address.
+"ATTENDEE;SENT-BY=\"mailto:jan_doe@HIDDEN\";CN=John Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.1/9)
+
+(ict:parse/print-test
+"CONTACT:Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/1)
+
+(ict:parse/print-test
+;; Corrected. Original contained unallowed backslash in ldap: URI
+"CONTACT;ALTREP=\"ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)\":Jim Dolittle\\, ABC Industries\\,+1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/2)
+
+(ict:parse/print-test
+"CONTACT;ALTREP=\"CID:part3.msg970930T083000SILVER@HIDDEN\":Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/3)
+
+(ict:parse/print-test
+"CONTACT;ALTREP=\"http://example.com/pdi/jdoe.vcf\":Jim Dolittle\\, ABC Industries\\, +1-919-555-1234\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.2/4)
+
+(ict:parse/print-test
+"ORGANIZER;CN=John Smith:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/1)
+
+(ict:parse/print-test
+"ORGANIZER;CN=JohnSmith;DIR=\"ldap://example.com:6666/o=DC%20Associates,c=US???(cn=John%20Smith)\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/2)
+
+(ict:parse/print-test
+"ORGANIZER;SENT-BY=\"mailto:jane_doe@HIDDEN\":mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.3/3)
+
+(ict:parse/print-test
+"RECURRENCE-ID;VALUE=DATE:19960401\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/1)
+
+(ict:parse/print-test
+"RECURRENCE-ID;RANGE=THISANDFUTURE:19960120T120000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.4/2)
+
+(ict:parse/print-test
+"RELATED-TO:jsmith.part7.19960817T083000.xyzMail@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/1)
+
+(ict:parse/print-test
+"RELATED-TO:19960401-080045-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.5/2)
+
+(ict:parse/print-test
+"URL:http://example.com/pub/calendars/jsmith/mytime.ics\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.6/1)
+
+(ict:parse/print-test
+"UID:19960401T080045Z-4000F192713-0052@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.4.7/1)
+
+(ict:parse/print-test
+"EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.1/1)
+
+(ict:parse/print-test
+"RDATE:19970714T123000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/1)
+
+(ict:parse/print-test
+"RDATE;TZID=America/New_York:19970714T083000\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/2)
+
+(ict:parse/print-test
+"RDATE;VALUE=PERIOD:19960403T020000Z/19960403T040000Z,19960404T010000Z/PT3H\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/3)
+
+(ict:parse/print-test
+"RDATE;VALUE=DATE:19970101,19970120,19970217,19970421,19970526,19970704,19970901,19971014,19971128,19971129,19971225\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.2/4)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;COUNT=10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/1)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;UNTIL=19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/2)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;INTERVAL=2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/3)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/4)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/5)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/6)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;COUNT=10\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/7)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/8)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/9)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/10)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/11)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/12)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/13)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/14)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/15)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/16)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/17)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYMONTHDAY=-3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/18)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/19)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/20)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/21)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/22)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/23)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/24)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/25)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYDAY=20MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/26)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/27)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/28)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/29)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/30)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/31)
+
+(ict:parse/print-test
+"RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/32)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/33)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/34)
+
+(ict:parse/print-test
+"RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/35)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/36)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/37)
+
+(ict:parse/print-test
+"RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/38)
+
+(ict:parse/print-test
+"RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/39)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/40)
+
+(ict:parse/print-test
+"RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/41)
+
+(ict:parse/print-test
+"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.5.3/42)
+
+(ict:parse/print-test
+"ACTION:AUDIO\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/1)
+
+(ict:parse/print-test
+"ACTION:DISPLAY\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.1/2)
+
+(ict:parse/print-test
+"REPEAT:4\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.2/1)
+
+(ict:parse/print-test
+"TRIGGER:-PT15M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/1)
+
+(ict:parse/print-test
+"TRIGGER;RELATED=END:PT5M\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/2)
+
+(ict:parse/print-test
+"TRIGGER;VALUE=DATE-TIME:19980101T050000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.6.3/3)
+
+(ict:parse/print-test
+"CREATED:19960329T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.1/1)
+
+(ict:parse/print-test
+"DTSTAMP:19971210T080000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.2/1)
+
+(ict:parse/print-test
+"LAST-MODIFIED:19960817T133000Z\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.3/1)
+
+(ict:parse/print-test
+"SEQUENCE:0\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/1)
+
+(ict:parse/print-test
+"SEQUENCE:2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.7.4/2)
+
+(ict:parse/print-test
+"DRESSCODE:CASUAL\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/1)
+
+(ict:parse/print-test
+"NON-SMOKING;VALUE=BOOLEAN:TRUE\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.1/2)
+
+(ict:parse/print-test
+"X-ABC-MMSUBJ;VALUE=URI;FMTTYPE=audio/basic:http://www.example.org/mysubj.au\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.2/1)
+
+(ict:parse/print-test
+"REQUEST-STATUS:2.0;Success\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/1)
+
+(ict:parse/print-test
+"REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/2)
+
+(ict:parse/print-test
+"REQUEST-STATUS:2.8; Success\\, repeating event ignored. Scheduled as a single event.;RRULE:FREQ=WEEKLY\\;INTERVAL=2\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/3)
+
+(ict:parse/print-test
+"REQUEST-STATUS:4.1;Event conflict.  Date-time is busy.\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/4)
+
+(ict:parse/print-test
+"REQUEST-STATUS:3.7;Invalid calendar user;ATTENDEE:mailto:jsmith@HIDDEN\n"
+:parser icalendar-parse-property
+:printer icalendar-print-property-node
+:source rfc5545-sec3.8.8.3/5)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congress Center\\nAtlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-component ;; TODO: use calendar parser once one exists
+:printer icalendar-print-component-node
+:source rfc5545-sec4/1)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+PRODID:-//RDU Software//NONSGML HandCal//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:19981025T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19990404T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:19980309T231000Z
+UID:guid-1.example.com
+ORGANIZER:mailto:mrbig@HIDDEN
+ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:employee-A@HIDDEN
+DESCRIPTION:Project XYZ Review Meeting
+CATEGORIES:MEETING
+CLASS:PUBLIC
+CREATED:19980309T130000Z
+SUMMARY:XYZ Project Review
+DTSTART;TZID=America/New_York:19980312T083000
+DTEND;TZID=America/New_York:19980312T093000
+LOCATION:1CP Conference Room 4350
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-component ;; TODO: use calendar parser once one exists
+:printer icalendar-print-component-node
+:source rfc5545-sec4/2)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@HIDDEN
+ORGANIZER:mailto:jdoe@HIDDEN
+ATTENDEE;RSVP=TRUE:mailto:jsmith@HIDDEN
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/conf/bkgrnd.ps
+END:VEVENT
+END:VCALENDAR
+"
+:parser icalendar-parse-component ;; TODO: use calendar parser once one exists
+:printer icalendar-print-component-node
+:source rfc5545-sec4/3)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VTODO
+DTSTAMP:19980130T134500Z
+SEQUENCE:2
+UID:uid4@HIDDEN
+ORGANIZER:mailto:unclesam@HIDDEN
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@HIDDEN
+DUE:19980415T000000
+STATUS:NEEDS-ACTION
+SUMMARY:Submit Income Taxes
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER:19980403T120000Z
+ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-files/ssbanner.aud
+REPEAT:4
+DURATION:PT1H
+END:VALARM
+END:VTODO
+END:VCALENDAR
+"
+:parser icalendar-parse-component ;; TODO: use calendar parser once one exists
+:printer icalendar-print-component-node
+:source rfc5545-sec4/4)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VJOURNAL
+DTSTAMP:19970324T120000Z
+UID:uid5@HIDDEN
+ORGANIZER:mailto:jsmith@HIDDEN
+STATUS:DRAFT
+CLASS:PUBLIC
+CATEGORIES:Project Report,XYZ,Weekly Meeting
+DESCRIPTION:Project xyz Review Meeting Minutes\\nAgenda\\n1. Review of project version 1.0 requirements.\\n2.Definitionof project processes.\\n3. Review of project schedule.\\nParticipants: John Smith\\, Jane Doe\\, Jim Dandy\\n-It was decided that the requirements need to be signed off byproduct marketing.\\n-P roject processes were accepted.\\n-Project schedule needs to account for scheduled holidaysand employee vacation time. Check with HR for specificdates.\\n-New schedule will be distributed by Friday.\\n-Next weeks meeting is cancelled. No meeting until 3/23.
+END:VJOURNAL
+END:VCALENDAR
+"
+:parser icalendar-parse-component ;; TODO: use calendar parser once one exists
+:printer icalendar-print-component-node
+:source rfc5545-sec4/5)
+
+(ict:parse/print-test
+"BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//RDU Software//NONSGML HandCal//EN
+BEGIN:VFREEBUSY
+ORGANIZER:mailto:jsmith@HIDDEN
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+END:VCALENDAR
+"
+:parser icalendar-parse-component ;; TODO: use calendar parser once one exists
+:printer icalendar-print-component-node
+:source rfc5545-sec4/6)
+
+
+
+;; Local Variables:
+;; read-symbol-shorthands: (("ict:" . "icalendar-test-"))
+;; End:
+;;; tests/icalendar-parser.el ends here
-- 
2.39.5


--=-=-=--




Information forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.

Message received at submit <at> debbugs.gnu.org:


Received: (at submit) by debbugs.gnu.org; 20 Dec 2024 13:07:38 +0000
From debbugs-submit-bounces <at> debbugs.gnu.org Fri Dec 20 08:07:37 2024
Received: from localhost ([127.0.0.1]:42026 helo=debbugs.gnu.org)
	by debbugs.gnu.org with esmtp (Exim 4.84_2)
	(envelope-from <debbugs-submit-bounces <at> debbugs.gnu.org>)
	id 1tOcjB-00088d-LR
	for submit <at> debbugs.gnu.org; Fri, 20 Dec 2024 08:07:37 -0500
Received: from lists.gnu.org ([209.51.188.17]:48194)
 by debbugs.gnu.org with esmtp (Exim 4.84_2)
 (envelope-from <rwl@HIDDEN>) id 1tOcj9-00088W-Rf
 for submit <at> debbugs.gnu.org; Fri, 20 Dec 2024 08:07:36 -0500
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 <rwl@HIDDEN>)
 id 1tOcj9-0004JD-76
 for bug-gnu-emacs@HIDDEN; Fri, 20 Dec 2024 08:07:35 -0500
Received: from fhigh-b8-smtp.messagingengine.com ([202.12.124.159])
 by eggs.gnu.org with esmtps (TLS1.2:ECDHE_RSA_AES_256_GCM_SHA384:256)
 (Exim 4.90_1) (envelope-from <rwl@HIDDEN>)
 id 1tOcj7-0004bD-3c
 for bug-gnu-emacs@HIDDEN; Fri, 20 Dec 2024 08:07:34 -0500
Received: from phl-compute-03.internal (phl-compute-03.phl.internal
 [10.202.2.43])
 by mailfhigh.stl.internal (Postfix) with ESMTP id 763682540126
 for <bug-gnu-emacs@HIDDEN>; Fri, 20 Dec 2024 08:07:30 -0500 (EST)
Received: from phl-mailfrontend-01 ([10.202.2.162])
 by phl-compute-03.internal (MEProxy); Fri, 20 Dec 2024 08:07:30 -0500
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 recursewithless.net; h=cc:content-type:content-type:date:date
 :from:from:in-reply-to:message-id:mime-version:reply-to:subject
 :subject:to:to; s=fm3; t=1734700050; x=1734786450; bh=DyHTNEJNbf
 cRddgT3eCcxiMYzkmjkZOXdsdcxGYBrMg=; b=Xb25QlYlfJx+7QhEEPjKMwngU0
 2GRSSry+AggmjB/oRT8JMmZ7YZuPeZqtFmOeBhIm30jG1Mit0ZFzU94oMdhbt++H
 gM6bvKIZLXloJiD4uW9lzkd8RMx92wM7ry71GUNwqfPgzmFowGbynzSStfpl2MQT
 HPL1+BfLKJNv/ibX71FfYKy6UVYEgS3IMiSwZ6Rx865rgCNZ1LNchtEewjdxg3/l
 FvD75OZ7Ta4wTYMihW0HlRtomjHX/6yxvJKXcMctBSwyhijZnda9kZVyWXEma9i0
 A3bXlT7HIC69ZeA2RKKUVAIY8OvYgXKqTXNCSyyexocNbFL4caC5kZO4sYRQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=
 messagingengine.com; h=cc:content-type:content-type:date:date
 :feedback-id:feedback-id:from:from:in-reply-to:message-id
 :mime-version:reply-to:subject:subject:to:to:x-me-proxy
 :x-me-sender:x-me-sender:x-sasl-enc; s=fm1; t=1734700050; x=
 1734786450; bh=DyHTNEJNbfcRddgT3eCcxiMYzkmjkZOXdsdcxGYBrMg=; b=G
 GeRf17sA9Cy+Bt4Vwqrqp3hlYlhg4sOsWlMgufCJ5fbdW07M+Xs+1p/c/689BHaP
 emwl/CBlK21boWZnlMLdT7VQThyAzaOumnE7n56djBjo8CfcJmxCcFszJJOgLVsF
 AStg+cML06EzF861kprrtheDRbSeDT5ZpeuTnAGhBmW6iJUf1Uxxoi93qJG8e+Uo
 FP0QItA9mzimOJrOFje/DIYxPHmeygW+BcZWo0FEK6iNQ0ms0VFX4vni6DVxOdyh
 Dg09ZWzB+7SmOp1dRQquHGn5m+gJ1xeyB69em7fVYli0Glsee8DO+/DwTsmp7tJ+
 4OEW9Mt4N5ws2BzLnUYGA==
X-ME-Sender: <xms:EmxlZ3FGLGJLclKf06m2RxeMkm1s1M9U6TNo360sp2NCSUsszyyA0w>
 <xme:EmxlZ0WgRcr7JYHKaJxf-NXleJSWAIXgZjVztb-Rh-Au67X6NXjAlR1moGWfZL76Z
 920prd-mX1POPU0hw>
X-ME-Received: <xmr:EmxlZ5KTnzr319U0bXDN9R1c2Yfg5XqQSC5RvnMCdnhfN_TQMzoszr45h43RBGrh6dfQyTqdLQvFYCUaUyuQgUGJYOJ_s-SveaOBl0HucQtdHA0ZweYM>
X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefuddruddtvddggeeiucetufdoteggodetrfdotf
 fvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdggtfgfnhhsuhgsshgtrhhisggvpdfu
 rfetoffkrfgpnffqhgenuceurghilhhouhhtmecufedttdenucenucfjughrpefhvffuff
 fkgggtsehttdertddttddtnecuhfhrohhmpeftihgthhgrrhguucfnrgifrhgvnhgtvgcu
 oehrfihlsehrvggtuhhrshgvfihithhhlhgvshhsrdhnvghtqeenucggtffrrghtthgvrh
 hnpefgvedvkeevteeugefgudegieejudfhieehteevgfeuhedvffefgeehueegueejhfen
 ucffohhmrghinhepghhnuhdrohhrghdprhgvtghurhhsvgifihhthhhlvghsshdrnhgvth
 dpihgtrghlvghnuggrrhdqphgrrhhsvghrqdgrnhguqdhmohguvgdrohhrghenucevlhhu
 shhtvghrufhiiigvpedtnecurfgrrhgrmhepmhgrihhlfhhrohhmpehrfihlsehrvggtuh
 hrshgvfihithhhlhgvshhsrdhnvghtpdhnsggprhgtphhtthhopedupdhmohguvgepshhm
 thhpohhuthdprhgtphhtthhopegsuhhgqdhgnhhuqdgvmhgrtghssehgnhhurdhorhhg
X-ME-Proxy: <xmx:EmxlZ1FtAkDp5nnIXFBmbvIepLMoOTQmcKcBREjZWnofvdc-ln1cfA>
 <xmx:EmxlZ9UVL7Dd_drN2taUL9tutdPz3b5VbLqJziUMhQe6ldbe5O-Plw>
 <xmx:EmxlZwNG7KhzxegWobZJN57pnDVZJex2HMCaPm1ADni4wFup-GPOwg>
 <xmx:EmxlZ80S4sqFCVT0cW_39Wa6y6CPmIFDwcvotGGgwRyNMCBJRYTrPA>
 <xmx:EmxlZ8dxQfkHyuKqJ0qZ-uEGdSVpPBgnApn2OFWNwJpTUARTu1okDKhZ>
Feedback-ID: if7394488:Fastmail
Received: by mail.messagingengine.com (Postfix) with ESMTPA for
 <bug-gnu-emacs@HIDDEN>; Fri, 20 Dec 2024 08:07:29 -0500 (EST)
From: Richard Lawrence <rwl@HIDDEN>
To: bug-gnu-emacs@HIDDEN
Subject: Improve Emacs iCalendar support
Date: Fri, 20 Dec 2024 14:07:22 +0100
Message-ID: <87bjx6mrjp.fsf@HIDDEN>
MIME-Version: 1.0
Content-Type: text/plain
Received-SPF: pass client-ip=202.12.124.159;
 envelope-from=rwl@HIDDEN; helo=fhigh-b8-smtp.messagingengine.com
X-Spam_score_int: -27
X-Spam_score: -2.8
X-Spam_bar: --
X-Spam_report: (-2.8 / 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,
 RCVD_IN_DNSWL_LOW=-0.7, RCVD_IN_VALIDITY_RPBL_BLOCKED=0.001,
 RCVD_IN_VALIDITY_SAFE_BLOCKED=0.001, SPF_HELO_PASS=-0.001,
 SPF_PASS=-0.001 autolearn=ham autolearn_force=no
X-Spam_action: no action
X-Spam-Score: -1.6 (-)
X-Debbugs-Envelope-To: submit
X-BeenThere: debbugs-submit <at> debbugs.gnu.org
X-Mailman-Version: 2.1.18
Precedence: list
List-Id: <debbugs-submit.debbugs.gnu.org>
List-Unsubscribe: <https://debbugs.gnu.org/cgi-bin/mailman/options/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=unsubscribe>
List-Archive: <https://debbugs.gnu.org/cgi-bin/mailman/private/debbugs-submit/>
List-Post: <mailto:debbugs-submit <at> debbugs.gnu.org>
List-Help: <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=help>
List-Subscribe: <https://debbugs.gnu.org/cgi-bin/mailman/listinfo/debbugs-submit>, 
 <mailto:debbugs-submit-request <at> debbugs.gnu.org?subject=subscribe>
Errors-To: debbugs-submit-bounces <at> debbugs.gnu.org
Sender: "Debbugs-submit" <debbugs-submit-bounces <at> debbugs.gnu.org>
X-Spam-Score: -2.6 (--)

Severity: wishlist

As discussed already a bit on emacs-devel, here:

https://lists.gnu.org/archive/html/emacs-devel/2024-10/msg00425.html

and in a write-up I posted here:

https://recursewithless.net/emacs/icalendar-parser-and-mode.org

I would like to see Emacs gain an updated, full-fledged implementation
of RFC5545, the current version of the iCalendar standard.

I have been working on this for a couple of months, and have some code
that's ready to be reviewed and discussed. I'm creating this bug to
track that discussion.




Acknowledgement sent to Richard Lawrence <rwl@HIDDEN>:
New bug report received and forwarded. Copy sent to bug-gnu-emacs@HIDDEN. Full text available.
Report forwarded to bug-gnu-emacs@HIDDEN:
bug#74994; Package emacs. Full text available.
Please note: This is a static page, with minimal formatting, updated once a day.
Click here to see this page with the latest information and nicer formatting.
Last modified: Tue, 25 Nov 2025 20:45:01 UTC

GNU bug tracking system
Copyright (C) 1999 Darren O. Benham, 1997 nCipher Corporation Ltd, 1994-97 Ian Jackson.