# Calendula - Plan 05: ICS Export (v2.7, Branch 1 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 Schreib-Hälfte des `.ics`-Themas. Calendula serialisiert eigene
Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als
Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender
(`ACCOUNT_TYPE_LOCAL`) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes
Gerät sonst Totalverlust. Diese Branch baut **nur Export**; Import
(Parser, Restore, Open-into-form) folgt in Branch 2 (`feat/ics-import`),
beide landen zusammen in **einem** Release v2.7.0 — es gibt also keine
Zwischenversion, die UIDs schreibt, ohne sie je zu lesen.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review (gemeinsam mit Branch 2).

**Architecture:** Neue reine-Kotlin-Engine `domain/ics/` — kein
`CalendarContract`, keine Android-Deps, voll JVM-testbar. Kern ist
`IcsWriter` (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten,
gibt einen `VCALENDAR`-String zurück). Keine ICS-Library: wir bleiben auf
`kotlinx-datetime` (kein `java.time`-Desugaring) und hand-rollen wie schon
bei RRULE in `Recurrence.kt`. Das Schreiben in eine SAF-Datei / das
Share-Intent liegt in einer dünnen Android-Schicht
(`data/ics/IcsExporter` o. ä.), die Provider-Reads → Domain-Modelle →
`IcsWriter` → `OutputStream` verdrahtet.

**Recherche-Befunde (Codebase, 2026-06-18):**

1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist
   `kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in
   `domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen
   `UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und
   nutzt `SimpleRecurrence.toRRule()` direkt.
2. **Kein UID-Handling.** `Events.UID_2445` wird heute nirgends gelesen oder
   geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID
   matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt
   **diese** Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch
   unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release).
3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige:
   `EVENT_TIMEZONE` landet in `EventDetail.eventTimezone`, das Detail zeigt ein
   Fremdzonen-Label nur bei Abweichung vom Gerät (`foreignTimeZoneLabel`).
   Schreiben (`EventWriteMapper.toWriteTimes`): all-day → UTC-Mitternachten,
   `EVENT_TIMEZONE="UTC"`; getimt → `EVENT_TIMEZONE = zone.id`, und alle Caller
   übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst
   erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte
   Events behalten ihre Originalzone.

**Leitentscheidungen:**

1. **Zeitzonen-Regel beim Schreiben (fallbasiert):**
   - **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv
     (Tag-danach). Keine Zone — trivial korrekt.
   - **Getimt, nicht wiederkehrend** → UTC-Instant `…T…Z`. Ein Instant ist ein
     Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei.
   - **Getimt, wiederkehrend** → `DTSTART;TZID=<EVENT_TIMEZONE>:<lokale Wandzeit>`.
     Eine Serie muss an der **Wandzeit** verankert sein, sonst driftet ein
     „wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone
     liegt bereits in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine
     `kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone).
   - **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen
     Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf
     (`kotlinx-datetime`/`java.time` kennen jede IANA-Id). Technisch nicht
     RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als
     bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports),
     kein Blocker. Voll-`VTIMEZONE` ist „später, falls nötig".
2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445`
   (z. B. `<random-uuid>@calendula`). Bestehende Events ohne UID exportieren
   wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
   `event-id + DTSTART` (`<id>-<dtstart>@calendula`), damit derselbe Bestand
   über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht
   verdoppelt. Bestehende Rows werden **nicht** rückwirkend gestempelt
   (kein Migrations-Sweep über fremde Kalender).
3. **Manueller Export, kein Background.** Backup via
   `ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name
   `calendula-backup-<datum>.ics`); Einzel-Event-Share via `ACTION_SEND` mit
   einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein
   geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos;
   Auto-Backup bleibt explizit Roadmap-`later`).
4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle
   gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die
   Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. mit,
   damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren
   kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien.
   *Offen, vor dem Backup-Task zu fixieren:* exaktes Property fürs
   Kalender-Mapping (`X-WR-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen;
   für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property
   wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`).
5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau
   die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1),
   `LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus den
   Remindern (DISPLAY, `TRIGGER` = `-PT<min>M`), `STATUS`
   (CONFIRMED/TENTATIVE/CANCELLED), `TRANSP` (Free→TRANSPARENT/Busy→OPAQUE),
   `UID`, `DTSTAMP`. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID-
   Ausnahmen) bleiben **vorerst weg** — Export erzeugt nichts, was Import in
   Branch 2 nicht auch wieder lesen kann.
6. **Korrekte RFC-5545-Mechanik:** Zeilen-Folding bei >75 Oktett (CRLF +
   Space-Fortsetzung), Text-Escaping (`\` `;` `,` `\n`), CRLF-Zeilenenden,
   `PRODID`/`VERSION:2.0`-Header. Eine reine, einzeln getestete Hilfsschicht
   (`IcsLine`/`fold`/`escapeText`), nicht ad hoc im Writer verstreut.

---

## Tasks

**Domain-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test
      (`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`,
      LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`.
- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant
      + isAllDay + zoneId, recurrenceRule?, location, description,
      reminderMinutes, status, availability, uid, calendarName) — entkoppelt
      vom Provider-Modell
- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event
      `VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test
      `IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone,
      Reminder, Escaping)
- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "<eventId>-<dtstartMillis>@calendula"`)
      + Stabilitätstest

**Provider → Domain (`data/calendar/IcsExportMapper.kt`):**
- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl.
      DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`),
      `EventExportProjection`; Datasource-Methode `exportableEvents()` +
      Repository `exportEvents()`; Test `IcsExportMapperTest`
- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem
      Create

**Android-Export-Schicht:**
- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile`
      (FileProvider-Cache) als UTF-8
- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein
      Event (one-off) → Cache-File über `FileProvider` → `ACTION_SEND`
- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
      `ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar
      (Plural „Exported N events")
- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares)
- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/
      Fehler + Plural, dateierter Default-Name

**Abschluss:**
- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)**
- [x] CHANGELOG (`[Unreleased]`) ergänzt
- [ ] On-Device-Review; **kein** Tag/Release vor Review und vor Merge von
      Branch 2 (`feat/ics-import`)

**Offene Detail-Calls (vor Review klären, nicht-blockierend):**
- Kalender→Event-Mapping nutzt das per-`VEVENT`-Property `X-CALENDULA-CALENDAR`
  (statt `X-WR-CALNAME`), damit eine kombinierte Datei mehrere Kalender trägt.
- Backup = **eine** kombinierte `VCALENDAR`-Datei über alle lokalen Kalender.
- EXDATE / `RECURRENCE-ID`-Ausnahmen werden beim Export ausgelassen
  (`ORIGINAL_ID IS NULL`) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.
