Completes v2.7 Branch 2. Wires the import core into the app: - Manifest ACTION_VIEW/SEND for text/calendar; MainActivity parses the incoming Uri (content/file only, so calendula:// deep-links don't match) and routes it through RootScreen → CalendarHost like the other one-shot intents. - ImportViewModel reads + parses the file and routes by count: one event → the prefilled create form for review (EventEditViewModel.openImported, which freezes the reminder default so the file's reminders win); many → ImportScreen with a writable-calendar picker, then a bulk import (UID dedup) and a result summary. - ImportScreen also surfaces parser warnings (skipped recurrence overrides, ignored attendees, unknown-timezone fallback). Strings EN+DE. Package is ui.imports (not ui.import — Java keyword). lint + test + assembleDebug green. No v2.7 tag until on-device review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
7.1 KiB
Markdown
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):**
|
|
- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
|
|
- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
|
|
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
|
|
gequotete Params)
|
|
- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
|
|
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
|
|
- [x] `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/`):**
|
|
- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
|
|
- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
|
|
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
|
|
- [x] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
|
|
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
|
|
Fake-Datasource
|
|
- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
|
|
|
|
**Intent + Routing:**
|
|
- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
|
|
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
|
|
- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
|
|
>1 → Bulk-Import-Screen; 0 → Hinweis
|
|
|
|
**UI:**
|
|
- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
|
|
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
|
|
- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
|
|
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
|
|
- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
|
|
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
|
|
leere-Datei-Hinweis
|
|
|
|
**Abschluss:**
|
|
- [x] `./gradlew lint test assembleDebug` grün
|
|
- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
|
|
Branches gemerged sind und On-Device-Review durch ist
|