Skip to content

Conversation

@axunonb
Copy link
Collaborator

@axunonb axunonb commented Sep 29, 2025

VTimeZoneInfo and RecurringComponent:

  • Marked CalDateTime? RecurrenceId as obsolete in favor of RecurrenceId? RecurrenceInstance, but both properties can still be used in parallel, when the RANGE parameter is not required
  • Added new data type RecurrenceIdentifier
  • Added new RecurrenceIdentifierSerializer

Supports #455 - implementation for recurrence not included

@codecov
Copy link

codecov bot commented Sep 29, 2025

Codecov Report

❌ Patch coverage is 81.05263% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Ical.Net/Serialization/SerializerFactory.cs 71.9% 0 Missing and 9 partials ⚠️
Ical.Net/Utility/ParameterProviderHelper.cs 66.7% 2 Missing and 3 partials ⚠️
...zation/DataTypes/RecurrenceIdentifierSerializer.cs 86.2% 0 Missing and 4 partials ⚠️

Impacted file tree graph

@@           Coverage Diff           @@
##            main    #870     +/-   ##
=======================================
+ Coverage   67.9%   68.1%   +0.2%     
=======================================
  Files        112     115      +3     
  Lines       4297    4353     +56     
  Branches     973    1003     +30     
=======================================
+ Hits        2919    2966     +47     
+ Misses      1035    1028      -7     
- Partials     343     359     +16     
Files with missing lines Coverage Δ
Ical.Net/CalendarComponents/RecurringComponent.cs 77.0% <100.0%> (+0.8%) ⬆️
Ical.Net/DataTypes/RecurrenceIdentifier.cs 100.0% <100.0%> (ø)
Ical.Net/Serialization/DataTypeMapper.cs 75.6% <100.0%> (ø)
....Net/Serialization/DataTypes/DateTimeSerializer.cs 87.2% <100.0%> (+2.4%) ⬆️
Ical.Net/Serialization/PropertySerializer.cs 86.4% <100.0%> (ø)
Ical.Net/VTimeZoneInfo.cs 55.3% <100.0%> (+6.4%) ⬆️
...zation/DataTypes/RecurrenceIdentifierSerializer.cs 86.2% <86.2%> (ø)
Ical.Net/Utility/ParameterProviderHelper.cs 66.7% <66.7%> (ø)
Ical.Net/Serialization/SerializerFactory.cs 72.2% <71.9%> (-8.4%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@axunonb axunonb force-pushed the wip/axunonb/pr/recurrence-id-range branch from 0d48777 to 3758134 Compare September 29, 2025 12:19
@axunonb axunonb marked this pull request as draft September 29, 2025 12:21
@axunonb
Copy link
Collaborator Author

axunonb commented Sep 29, 2025

@minichma How about going the first step in terms of RECURRENCE-ID;RANGE=THISANDFUTURE this way?

@axunonb axunonb force-pushed the wip/axunonb/pr/recurrence-id-range branch 2 times, most recently from 1c3d890 to 8e3d263 Compare September 30, 2025 08:47
@ical-org ical-org deleted a comment from sonarqubecloud bot Sep 30, 2025
…NGE` parameter

* Marked `CalDateTime? RecurrenceId` as obsolete in favor of `RecurrenceId? RecurrenceInstance`, but both properties can still be used in parallel, when the `RANGE` parameter is not required
* Added new data type `RecurrenceId`
* Added new  `RecurrenceIdSerializer`
@axunonb axunonb force-pushed the wip/axunonb/pr/recurrence-id-range branch from 8e3d263 to 2c41cfe Compare September 30, 2025 09:22
Copy link
Collaborator

@minichma minichma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Based on that change the actual filtering should be quite straight forward. Let me know if you'd like me to assist there.

/// This class is used to uniquely identify a particular occurrence within a recurring series. The
/// identifier consists of a date and a recurrence range, which together specify the instance.
/// </remarks>
public class RecurrenceId : ICalendarParameterCollectionContainer
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably have Equals/GetHashCode

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now implements IComparable<RecurrenceIdentifier>

var dtSerializer = factory.Build(typeof(CalDateTime), SerializationContext) as DateTimeSerializer;

recurrenceId.Parameters.AddRange(dtSerializer!.GetParameters(recurrenceId.StartTime));
if (recurrenceId.Range == RecurrenceRange.ThisAndFuture)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fail on unknown ones?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log warning

s = new CalendarSerializer(ctx);
return new CalendarSerializer(ctx);
}
else if (typeof(ICalendarComponent).IsAssignableFrom(objectType))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a perfect fit for pattern matching nowadays.

/// With <see cref="RecurrenceRange.ThisAndFuture"/>, the instance applies to the specified
/// <see cref="RecurrenceId.StartTime"/> and all future occurrences.
/// </summary>
public virtual RecurrenceId? RecurrenceInstance
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider naming?

@axunonb
Copy link
Collaborator Author

axunonb commented Sep 30, 2025

Refactoring from review

Renamed the RecurrenceId class to RecurrenceIdentifier

  • Removed ICalendarParameterCollectionContainer because custom parameters should not be allowed here
  • Added IComparable<RecurrenceIdentifier>

Renamed the RecurrenceIdSerializer class to RecurrenceIdentifierSerializer

  • Added IParameterProvider

Added class ParameterProviderHelper that contains methods

  • GetCalDateTimeParameters
  • GetRecurrenceIdentifierParameters

Miscellaneous

  • Update DateTimeSerializer.GetParameters to call ParameterProviderHelper.GetCalDateTimeParameters
  • Update IParameterProvider.GetParameters to accept a nullable object
  • Update SerializerFactory.Build to use pattern matching

Note: Did not investigate possible code duplication in RecurringComponent / VTimeZoneInfo as they existed before, too.

Based on that change the actual filtering should be quite straight forward. Let me know if you'd like me to assist there.

Oh yes, that would be more than welcome.

Renamed the `RecurrenceId` class to `RecurrenceIdentifier`
* Removed `ICalendarParameterCollectionContainer` because custom parameters should not be allowed here
* Added `IComparable<RecurrenceIdentifier>`

Renamed the `RecurrenceIdSerializer` class to `RecurrenceIdentifierSerializer`
* Added `IParameterProvider`

Added class `ParameterProviderHelper` that contains methods
* `GetCalDateTimeParameters`
* `GetRecurrenceIdentifierParameters`

Miscellaneous
* Update `DateTimeSerializer.GetParameters` to call `ParameterProviderHelper.GetCalDateTimeParameters`
* Update `IParameterProvider.GetParameters` to accept a nullable object
* Update `SerializerFactory.Build` to use pattern matching
@axunonb axunonb force-pushed the wip/axunonb/pr/recurrence-id-range branch from 12515c1 to e76de2f Compare September 30, 2025 20:59
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
8.1% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@axunonb axunonb marked this pull request as ready for review September 30, 2025 21:02
@axunonb axunonb requested a review from minichma September 30, 2025 21:02
@minichma
Copy link
Collaborator

minichma commented Oct 1, 2025

Based on that change the actual filtering should be quite straight forward.

Having a closer look at the RFC, my assumption might be mistaken. 😆 Anyhow, will give it a try.

@minichma
Copy link
Collaborator

minichma commented Oct 1, 2025

Hm, my naive assumption was as follows, but seems to be wrong:

  1. Generate recurrences of original and new VEVENT independently
  2. If
    • the range is THISANDFUTURE do something like 'oldSequence.TakeWhile(item => item != recurrenceId).OrderedMerge(newSequence)`
    • else do something like 'oldSequence.Where(item => item != recurrenceId).OrderedMerge(newSequence)`

My assumption was that the new component may or may not have an RRULE or RDATE. If it hasn't, it produces just a single occurrence. But looking at the RFC

When the given recurrence instance is
rescheduled, all subsequent instances are also rescheduled by the
same time difference. For instance, if the given recurrence
instance is rescheduled to start 2 hours later, then all
subsequent instances are also rescheduled 2 hours later.

this sounds more like the new VEVENT (the one with the RECURRENCE-ID) doesn't have its own RRULE/RDATE/EXDATE properties but inherits those of the original one, only the offset is considered, right? If so, several questions arise:

  • What if the new RRULE does have an RRULE? Does it replace the original one?
  • What if the new component's DTSTART doesn't match the original RRULE? Probably undefined.
  • What about RDATEs of the original component? Are they transposed too? (while doing this for RRULEs should be doable, for RDATEs its somewhat more involved. What if the TZ changes, etc.)
  • What about EXDATEs/EDRULEs?
  • What if the original component has RDATEs but no RRULEs and the new one has RRULEs (or the other way round)?
  • What if the new DTSTART is in a different TZ with different DST changes. How to transpose RDATEs from the original sequence?
  • ...

Had a look what Google Calendar and Outlook would export in case of modified time series.

  • Google Calendar: Didn't get it to export RECURRENCE-ID neither for single days being shifted nor for 'this and after'
  • Outlook: Used 'RECURRENCE-ID' when modifying single dates. When modifying 'this and after', the original sequence had a 'UNTIL' added to the RRULE and an additional, independent VEVENT (with a different UID but the same title) was created

Any comments are welcome.
This is what ChatGPT says:

Short, precise take based on RFC 5545 and vendor docs:

Mental model (what to implement)

  1. Master set: Build the recurrence set S from the master VEVENT (same UID, no RECURRENCE-ID): expand DTSTART + all RRULE + RDATE, then remove EXDATE. Order by effective local time (respect VTIMEZONE). ([icalendar.org]1)

  2. Overrides: For each VEVENT with RECURRENCE-ID (same UID):

    • No RANGE param: override only the single instance whose original start equals RECURRENCE-ID. Replace properties given on the override; others inherit from the master. Remove the original instance from S. ([datatracker.ietf.org]2)
    • RANGE=THISANDFUTURE: compute Δ = (override.DTSTARTRECURRENCE-ID, in the RECURRENCE-ID’s timezone/value type), then shift every subsequent instance in S by exactly Δ. (Also shift DTEND/DURATION by Δ unless the override sets them explicitly.) ([datatracker.ietf.org]2)
  3. Conflict resolution: If multiple overrides target the same instance, pick the one with highest SEQUENCE (or latest LAST-MODIFIED as tie-break). ([datatracker.ietf.org]2)

Answers to your questions

  • Does the override (the component with RECURRENCE-ID) carry its own RRULE/RDATE/EXDATE?
    No, not in the RFC’s model. The recurrence set comes from the master; overrides change properties of one instance (or the tail with RANGE=THISANDFUTURE). Treat recurrence-defining props on an override as non-standard; safest is to ignore them. (Outlook exports exception instances with RECURRENCE-ID but not their own RRULE.) ([icalendar.org]1)
  • If an override does include an RRULE, does it replace the original?
    Undefined/non-standard. Some clients may treat this as a “split series,” but RFC 5545 doesn’t define that behavior. Prefer: ignore and log. ([datatracker.ietf.org]2)
  • What if the override’s DTSTART doesn’t match any generated instance from the master?
    Then it’s invalid per spec (the RECURRENCE-ID must equal the original instance DTSTART). Robust parsers often ignore it. ([datatracker.ietf.org]2)
  • Do RDATE instances get transposed under THISANDFUTURE?
    Yes. “Subsequent instances” means subsequent members of the recurrence set—i.e., including future RDATE occurrences—shift by the same Δ. Apply EXDATE filtering after shifting. (Mind time zones/DST using the RECURRENCE-ID’s value type/TZ.) ([datatracker.ietf.org]2)
  • EXDATEs/EXRULEs?
    EXDATE continues to exclude instances (post-shift). EXRULE is deprecated in RFC 5545; don’t generate it, and if present, treat cautiously. ([iana.org]3)
  • What if the master has only RDATEs (no RRULE), and you do THISANDFUTURE?
    Still apply Δ to all later members of S (which, in this case, are just the later RDATE instances). ([datatracker.ietf.org]2)

Implementation sketch (deterministic)

  1. Expand S from master (bounded window).
  2. Index overrides by (RECURRENCE-ID, RANGE).
  3. Apply per-instance overrides (no RANGE).
  4. Sort remaining overrides with RANGE=THISANDFUTURE by RECURRENCE-ID ascending, and for each, compute Δ and shift every t >= RECURRENCE-ID.
  5. Re-apply EXDATE after all shifts; then property-merge (inherit from master; override wins where set).
  6. Handle ties with SEQUENCE/LAST-MODIFIED.

References: RFC 5545 §3.8.4.4 (RECURRENCE-ID incl. RANGE=THISANDFUTURE), §3.8.5.* (recurrence set construction), §A.3 (deprecated EXRULE); Microsoft iCalendar notes confirming exception semantics. ([datatracker.ietf.org]2)

If you want, I can turn this into a small testable expansion/override function (e.g., Python with dateutil.rrule) with a few edge-case fixtures.

@minichma
Copy link
Collaborator

minichma commented Oct 1, 2025

As the changes in this PR are an improvement also without having the full evaluation implemented, it might be a good idea to complete this one and create a new one for the rest. This would avoid delaying this one and would remove time pressure. What do you think?

@axunonb
Copy link
Collaborator Author

axunonb commented Oct 1, 2025

First of all thanks for the reviews, which are always inspiring!

it might be a good idea to complete this one and create a new one for the rest.

Yes, that's what I had in mind as well when mentioning "implementation for recurrence not included".

Having a closer look at the RFC, my assumption might be mistaken.

Kind of 😉, I'm afraid. Google doesn't support it, and I also understood that we should follow the Microsoft way:
"the original sequence had a 'UNTIL' added to the RRULE and an additional, independent VEVENT (with a different UID but the same title) was created". The UID for the new internal VEVENT is random, so we don't have to care for references to this UID.
(I've been asking Gemini).

Never created a sub issue before, but this topic could a good trial for creating one to #455. I'll create it, and there we could discuss further details.

@axunonb axunonb merged commit 8e7533d into main Oct 1, 2025
10 of 11 checks passed
@axunonb axunonb deleted the wip/axunonb/pr/recurrence-id-range branch October 1, 2025 21:26
@axunonb
Copy link
Collaborator Author

axunonb commented Oct 1, 2025

@minichma Would #864 and #870 deserve a new release? 5.1.1 or 5.2.0?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants