Files
calendula/docs/superpowers/plans/2026-06-18-06-ics-import.md
Jean-Luc Makiola e1c2e9f2e5 feat(ics): import core — parser, dedup-aware bulk import, form prefill
v2.7 Branch 2 (core, no UI yet). The read side of the .ics engine:

- domain/ics: IcsParser (inverse of IcsWriter) — unfold/unescape/param
  parsing, VALUE=DATE / UTC-Z / TZID date handling resolved against the OS
  tz db, VEVENT walk → ParsedIcsEvent + typed warnings. Liberal-in/
  strict-out: a malformed VEVENT is skipped, RECURRENCE-ID overrides /
  attendees / unresolved TZIDs are reported, not silently dropped.
- Promoted parseRfc2445DurationMillis into domain/ics (shared by writer-
  side mapper and parser); IcsDuration + test.
- Datasource existingUids()/insertImportedEvent(); repository
  importEvents() with UID dedup (skip known UIDs → idempotent restore) →
  IcsImportSummary. IcsImporter reads a Uri's text.
- ParsedIcsEvent.toEventForm() for the single-event "open into the create
  form" path.

Parser round-trips against IcsWriter; dedup + form-adapter unit-tested.
Intent filter, routing and import UI land in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:59:32 +02:00

123 lines
7.1 KiB
Markdown

# Calendula - Plan 06: ICS Import (v2.7, Branch 2 von 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
Kalender. Beide Branches landen in **einem** Release v2.7.0.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter`
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
Repository-Methode `importEvents(targetCalendarId, events)``ImportSummary`
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
Event-Anzahl.
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
den Rest der Datei durch.
**Leitentscheidungen:**
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
- `…T…Z` → UTC-Instant.
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID`
Gerätezone als Fallback (+ Warnung).
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
seine `TZID` nicht in der OS-DB ist).
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null`
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
Datei → freundlicher „nichts gefunden"-Hinweis.
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
verlustfrei. Events ohne UID bekommen beim Insert eine frische
(`UUID@calendula`, wie `insertEvent`).
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
Notification-Deep-Link).
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP``EventStatus`/`Availability`.
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
vorhanden).
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
---
## Tasks
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [ ] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
- [ ] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
gequotete Params)
- [ ] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
- [ ] `IcsParser.parse(text)``IcsParseResult(events, warnings)`: VCALENDAR/
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
**Datenschicht (`data/calendar/` + `data/ics/`):**
- [ ] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
- [ ] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
- [ ] Repository `importEvents(targetCalendarId, events)``ImportSummary`
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
Fake-Datasource
- [ ] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
**Intent + Routing:**
- [ ] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
- [ ] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
>1 → Bulk-Import-Screen; 0 → Hinweis
**UI:**
- [ ] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
- [ ] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
- [ ] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
leere-Datei-Hinweis
**Abschluss:**
- [ ] `./gradlew lint test assembleDebug` grün
- [ ] CHANGELOG (`[Unreleased]`), ROADMAP/STATE; v2.7 cut **erst** wenn beide
Branches gemerged sind und On-Device-Review durch ist