cb480a8a

Reconcile the components of a UID when importing a calendar

A component with a `RECURRENCE-ID` replaces the instance of its series that it names rather than adding an occurrence beside it, and Google and Outlook write one for every "this event only" edit. Recurring one component at a time cannot know that, so the edited instance was imported **twice**: once where it used to be and once where it now is.

## `deduplicateBasedOnId` is deleted

It was the hand-rolled version of this feature: sort groups by their descending first occurrence on the theory that the earliest-starting group is the original, then drop any occurrence already seen. Because the key was the *whole* occurrence — start **and** end — it only worked when the override changed neither, which is the uncommon case. Move the override's `DTEND` by a minute and the duplicate came back. It also deduplicated across UIDs, which nothing justifies.

[ical-recurrence 0.3.0.0](https://github.com/NorfairKing/ical/pull/43) reconciles a UID's components, so the library does this now, and correctly.

## What moved

`RecurringEvents` has to carry what reconciliation needs from each component, so `RecurringEvent` gains its `SEQUENCE` and its `RECURRENCE-ID`. There is still no `uid` field, because the map is keyed by it; `recurringOf` puts the two back together for `ICal.recur`. **Both new fields are omitted at their defaults, so an old picked file still parses and means what it used to** — existing caches keep working.

The pipeline keeps its shape of one `Static` plus a set of start/end pairs. What changes is the grouping key: reconciliation can put occurrences from two different components in one UID's recurrence set, and each occurrence carries the component it came from, so `Recur` groups by that component rather than by UID. `ResolveZones` and everything below it are unchanged apart from renamed types.

`Pick` can no longer drop the components it does not want, so `Static` gains an `Inclusion` and `Recur` drops the excluded occurrences after reconciliation instead. Both filters need this:

- `STATUS:CANCELLED` on a component with a `RECURRENCE-ID` is how "delete this one instance" is written. Dropping that component before the recurrence set is reconciled makes the series' instance **reappear** at that time instead of disappearing — a live bug.
- `SMOS_NO_CALENDAR_IMPORT` is a per-component opt-out, and an overriding component has a description of its own.

## Goldens

All checked by hand against a separately derived expectation.

| File | Why |
| --- | --- |
| `recurrence-id.*` | **New.** The case that matters: a conforming `RECURRENCE-ID` moving one instance of a weekly series. Its `.unresolved` shows the series keeping the instances it still has, and no longer the overridden one. |
| `uid.*` | Keeps its malformed `RECURRENCE-ID` — zoned where the series' `DTSTART` is floating (§3.8.4.4 forbids that) and naming a Friday where the series generates Mondays. It matches no instance, so it is now an orphan: the override is kept, the series still produces the instance it failed to name, and `RecurrenceIdentifierUnmatched` says the input needs fixing. One more occurrence than before — and the old output was luck, not correctness: `deduplicateBasedOnId` happened to drop the series' instance because the override's start and end were byte-identical to it. |
| `ignored.recurring` | No longer empty: the component is picked with `include: false` rather than dropped. Everything downstream of it is still empty. |
| `museum.recurring`, `exception.recurring` | Gain a `sequence` field; those two components have a `SEQUENCE` other than 0. |

Three more move for the `ical` bump alone rather than anything here: `museum.parsed` and `visit.parsed` gain the `X-WR-TIMEZONE` those calendars always had and the library now round-trips, and `zoned-recurring.unresolved` loses a duplicate instance — the already-fixed bug where `DTSTART` and the recurrence rule both produced the first instance with two different ends.

`stack test smos-calendar-import --pedantic`: 183 passed, 0 failed.

Total

56m23s

Created Started Finished

Time to Start Worker time Duration Time to finish Idle
Config 3s 1s 1s 5s 3s
Eval 5s 40s 40s 46s 0s
Build 41s 2m14s 55m46s 56m27s 53m44s
Suite 3s 2m57s 56m23s 56m27s 53m48s

Timeline

Config Eval Build Compressed gap
0s20s40s54m40s55m55m20s55m40s56m56m20s