Files
calendula/docs/superpowers/plans/2026-06-18-05-ics-export.md
Jean-Luc Makiola 0b683d374f 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>
2026-06-18 14:27:53 +02:00

9.1 KiB

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 → IcsWriterOutputStream 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-dayDTSTART;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, wiederkehrendDTSTART;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):

  • 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.
  • IcsEvent-Eingabemodell (reine Kotlin: summary, start/end als Instant + isAllDay + zoneId, recurrenceRule?, location, description, reminderMinutes, status, availability, uid, calendarName) — entkoppelt vom Provider-Modell
  • 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)
  • UID-Ableitung deriveIcsUid (uid ?: "<eventId>-<dtstartMillis>@calendula") + Stabilitätstest

Provider → Domain (data/calendar/IcsExportMapper.kt):

  • Mapper Provider-Row → IcsEvent (ColumnReader.toIcsEvent) inkl. DURATION→DTEND-Rekonstruktion (parseRfc2445DurationMillis), EventExportProjection; Datasource-Methode exportableEvents() + Repository exportEvents(); Test IcsExportMapperTest
  • insertEvent schreibt Events.UID_2445 (UUID@calendula) bei jedem Create

Android-Export-Schicht:

  • data/ics/IcsExporter: writeDocument(uri) (SAF) + stageShareFile (FileProvider-Cache) als UTF-8
  • Einzel-Event-Share: Share-Action im Event-Detail → IcsWriter für ein Event (one-off) → Cache-File über FileProviderACTION_SEND
  • Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars → ACTION_CREATE_DOCUMENT → in den URI streamen; Ergebnis-Snackbar (Plural „Exported N events")
  • FileProvider + file_paths.xml im Manifest (Cache-Dir für Shares)
  • 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)
  • 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.