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>
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:
- Parse-Mechanik (Umkehr von
IcsText): zuerst Unfolding (CRLF + Space/Tab → wegfalten), dann pro ZeileNAME[;params]:valuezerlegen, TEXT-Werte unescapen (\\\;\,\n). Eine reine, einzeln getestete Schicht (IcsLineParser), nicht ad hoc im Walker. - Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):
VALUE=DATE(YYYYMMDD) → all-day, Instant auf UTC-Mitternacht (wie der Provider all-day speichert), exklusivesDTENDbleibt exklusiv.…T…Z→ UTC-Instant.…T…mitTZID=<zone>→ lokale Wandzeit in der Zone, aufgelöst gegen die OS-tz-Datenbank (TimeZone.of); unbekannte/fehlendeTZID→ Gerätezone als Fallback (+ Warnung).- Kein
VTIMEZONE-Parsing —TZIDwird gegen die OS-DB aufgelöst (s. Branch-1 Entscheidung); einVTIMEZONE-Block wird übersprungen (Warnung nur, wenn seineTZIDnicht in der OS-DB ist).
- 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. - UID-Dedup beim Bulk-Import: vor dem Insert die vorhandenen
Events.UID_2445des 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, wieinsertEvent). - Empfang via Intent: Manifest-
ACTION_VIEW/SENDmit MIMEtext/calendar(+.ics-Pfadmuster fürfile/content-Schemes).MainActivity(singleTop, wie beim Reminder-Tap) liest denUri, parst, und reicht das Ergebnis als Compose-State anCalendarHost(gleiches Key-Muster wie der Notification-Deep-Link). - Reminder/Status/Transp zurück:
VALARMTRIGGER(negatives-PT…,PT0…) → Lead-Minuten;STATUS/TRANSP→EventStatus/Availability.DURATION-statt-DTENDüberparseRfc2445DurationMillis(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+IcsWriterexistieren; Parser spiegelt sie.parseRfc2445DurationMillis(inIcsExportMapper.kt) parst die Provider-DURATION-Formen inkl. des nicht-standardkonformenP<n>S.EventForm(Domain): Zeiten alsLocalDateTimein Gerätezone,calendarIdnullable; das Formular wählt beinullden zuletzt genutzten Kalender vor.insertEventschreibt bereitsEvents.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(wieIcsEvent, aberuid: String?) + Datum/Zeit-Parser (VALUE=DATE/…Z/TZID→ Instant + isAllDay + zoneId)IcsParser.parse(text)→IcsParseResult(events, warnings): VCALENDAR/ VEVENT-Walk, skip-and-report fürRECURRENCE-ID/unbekannteVTIMEZONE/ Attendees; ein defektes VEVENT killt nicht den Rest. Round-trip-Test gegenIcsWriter-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)(QueryEvents.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, MIMEtext/calendar,.ics- Pfadmuster (file/content);MainActivityparst eingehendenUri - 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 →
ImportSummaryals Ergebnis - Einzel-Öffnen:
EventEditScreenmit vorausgefülltem Formular (neuer Prefill-Pfad imEventEditViewModel, ohneeventId) - Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals (importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt), leere-Datei-Hinweis
Abschluss:
./gradlew lint test assembleDebuggrün- CHANGELOG (
[Unreleased]), ROADMAP/STATE; v2.7 cut erst wenn beide Branches gemerged sind und On-Device-Review durch ist