feat(ics): export — share single event + back up local calendars as .ics
Branch 1 of 2 for v2.7 (the .ics topic). Adds the write side of a hand-rolled RFC 5545 engine (zero deps, stays on kotlinx-datetime): - domain/ics: IcsText (escape + 75-octet folding), IcsEvent model, IcsWriter.writeCalendar. Timezone rule: all-day VALUE=DATE, one-off timed UTC Z, recurring timed TZID-labelled from EVENT_TIMEZONE (no VTIMEZONE — import resolves TZID against the OS tz db). - Single-event share from the detail screen (FileProvider + ACTION_SEND). - Whole-calendar backup of the writable local calendars to a SAF file (Settings -> Calendars -> Export as .ics), one combined VCALENDAR. - insertEvent now writes Events.UID_2445; legacy rows fall back to a stable synthesised UID at export time so a later restore won't dupe. - EXDATE / RECURRENCE-ID overrides are deliberately skipped this pass (documented v1 limit; import will skip them too). Engine + mapper unit-tested. Import (Branch 2, feat/ics-import) ships in the same v2.7 release; no tag until both land + on-device review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
150
docs/superpowers/plans/2026-06-18-05-ics-export.md
Normal file
150
docs/superpowers/plans/2026-06-18-05-ics-export.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user