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

7.1 KiB

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/TRANSPEventStatus/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