From c0361686df443e6a9d30390cc3f4ff3bee80589c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 19 Aug 2026 20:38:41 +0200 Subject: [PATCH] fix(import): don't lose Fossify's contact birthdays and anniversaries (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found the actual cause. Fossify mirrors Contacts birthdays and anniversaries with startTS == endTS (MainActivity), so its exporter's dayCode(endTS + 12h) rounds back to the starting day and writes DTEND == DTSTART. We read that literally: a yearly series with DURATION:P0D, which the provider expands into no instances at all. The import reported success and the events were nowhere — matching the report exactly ("birthdays, memorials, dates"). An all-day event may no longer end at or before it starts, and an all-day DURATION is floored at P1D. Verified against the released parser, which returns days=0.0 for all three fixture events. Do not generalise this into sniffing PRODID: the same exporter is correct for UI-created events (endTS anchors at noon of the last day) and for CalDAV rows, and Fossify's bundled holiday files are conformant while naming Fossify in their PRODID. Both are kept as fixtures. Also: existingUids counted DELETED rows, so re-importing after deleting events skipped everything as duplicates. Closes #225 --- CHANGELOG.md | 8 ++- .../data/calendar/CalendarDataSource.kt | 6 +- .../domain/ics/IcsFossifyImportTest.kt | 35 ++++++++-- .../ics/fossify-contact-birthdays.ics | 64 +++++++++++++++++++ docs/ARCHITECTURE.md | 34 ++++++---- 5 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 app/src/test/resources/ics/fossify-contact-birthdays.ics diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a32f9..1513cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Imported reminders fire at the hour you chose**, not at midnight UTC — the same rule the app already applied to all-day events you create yourself ([#225]). -- **An all-day event with no end date, or one ending the day it starts, imports - as a one-day event** instead of a zero-length one that never appears ([#225]). +- **Birthdays and anniversaries imported from Fossify Calendar now show up.** + Fossify writes the ones it mirrors from your contacts as events that start and + end on the same day, which the calendar read as lasting no time at all: they + imported without complaint and then appeared nowhere. Any all-day event that + ends where it starts, or carries no end at all, is now a one-day event + ([#225]). ### Added - **More of an imported `.ics` survives the trip**: tasks come across as events diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index bf7e7f4..3e9c05b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -795,8 +795,12 @@ class AndroidCalendarDataSource @Inject constructor( override fun existingUids(calendarId: Long): Set = resolver.query( CalendarContract.Events.CONTENT_URI, arrayOf(CalendarContract.Events.UID_2445), + // DELETED rows linger until a sync adapter purges them; counting those + // as present would make a re-import skip everything the user has since + // deleted, reporting "all duplicates" and importing nothing. "${CalendarContract.Events.CALENDAR_ID} = ? AND " + - "${CalendarContract.Events.UID_2445} IS NOT NULL", + "${CalendarContract.Events.UID_2445} IS NOT NULL AND " + + "${CalendarContract.Events.DELETED} = 0", arrayOf(calendarId.toString()), null, )?.use { c -> diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt index ce33cec..881ebbe 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -10,12 +10,15 @@ import org.junit.jupiter.api.Test * Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order, * the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included. * - * Note what is *not* asserted here: their all-day `DTEND` is RFC-correct. - * `Event.endTS` anchors an all-day event at noon of its last day + * Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected": + * `Event.endTS` anchors a UI-created all-day event at noon of its last day * (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a * CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the - * following midnight. Treating it as inclusive would push every imported all-day - * event out by a day. + * following midnight. Shifting those by a day would corrupt them. + * + * The exception — and the whole of #225 — is events mirrored from Contacts. + * `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h` + * lands back on the starting day and they export zero length. */ class IcsFossifyImportTest { @@ -53,6 +56,30 @@ class IcsFossifyImportTest { assertThat(days(event)).isEqualTo(1) } + @Test + fun `contact-mirrored birthdays export zero length and must not vanish`() { + // The reported failure (#225). Fossify mirrors Contacts birthdays and + // anniversaries with startTS == endTS, so the exporter's +12h rounds + // back to the starting day: DTSTART == DTEND. Read literally these are + // yearly series of zero-length occurrences, which the provider expands + // into nothing at all — the birthdays import "successfully" and are + // then nowhere to be seen. + val text = checkNotNull( + javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"), + ).use { it.readBytes().toString(Charsets.UTF_8) } + + val result = parser.parse(text) + + assertThat(result.events).hasSize(3) + result.events.forEach { + assertThat(it.isAllDay).isTrue() + assertThat(days(it)).isEqualTo(1) + assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY") + // Their all-day reminder encoding: "on the day", not midnight UTC. + assertThat(it.semanticReminderMinutes()).containsExactly(0) + } + } + @Test fun `a real Fossify holiday file imports unchanged`() { // Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its diff --git a/app/src/test/resources/ics/fossify-contact-birthdays.ics b/app/src/test/resources/ics/fossify-contact-birthdays.ics new file mode 100644 index 0000000..6c9a7b8 --- /dev/null +++ b/app/src/test/resources/ics/fossify-contact-birthdays.ics @@ -0,0 +1,64 @@ +BEGIN:VCALENDAR +PRODID:-//Fossify//NONSGML Event Calendar//EN +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Anna Schmidt +UID:101 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Birthdays +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:19900412 +DTEND;VALUE=DATE:19900412 +X-FOSSIFY-MISSING-YEAR:0 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Opa +UID:102 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Birthdays +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:19700603 +DTEND;VALUE=DATE:19700603 +X-FOSSIFY-MISSING-YEAR:1 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Hochzeitstag +UID:103 +X-FOSSIFY-CATEGORY-COLOR:-1155931 +CATEGORIES:Anniversaries +LAST-MODIFIED:20250615T150640Z +TRANSP:TRANSPARENT +DTSTART;VALUE=DATE:20050917 +DTEND;VALUE=DATE:20050917 +X-FOSSIFY-MISSING-YEAR:0 +DTSTAMP:20260819T120000Z +CLASS:PUBLIC +STATUS:CONFIRMED +RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9 +BEGIN:VALARM +DESCRIPTION:Reminder +ACTION:DISPLAY +TRIGGER:P0DT9H0M0S +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f7b2c0..36d87be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -352,22 +352,28 @@ alert row behind it. by other people's calendars. What that costs us, learned from reading the Simple Calendar / Fossify exporter (#225): -**An all-day event may never reach the provider zero days long.** RFC 5545 -§3.6.1 gives a `DATE`-valued `DTSTART` with no `DTEND` a one-day duration, and a -`DTEND` equal to `DTSTART` is meaningless — but the failure is silent rather than -loud: the provider expands a zero-length series into no instances at all, so the -event is simply not there, with nothing logged. `resolveEnd` floors the span at a -day and `buildImportedEventValues` floors an all-day `DURATION` at `P1D`. +**An all-day event may never reach the provider zero days long.** This is what +#225 was: Fossify mirrors Contacts birthdays and anniversaries with +`startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)` +rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally +that is a yearly series of zero-length occurrences, which the provider expands +into no instances at all — the import reports success and the birthdays are +nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an +all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with +no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day +`DURATION` at `P1D`. -Resist the urge to correct a producer's all-day `DTEND` by sniffing its `PRODID`. -Fossify's looks like it needs it and does not: `Event.endTS` anchors an all-day -event at *noon* of its last day (`EventActivity.getStartEndTimes`), or at the -exclusive midnight when the row came from CalDAV, and the exporter's -`+ TWELVE_HOURS` rounds either to the following midnight — correct on both paths. -Shifting it would push every imported all-day event out by a day. The holiday -files bundled inside Fossify are a second trap: their `PRODID` reads +Do **not** generalise that into correcting the producer's `DTEND` by sniffing its +`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a +UI-created all-day event at *noon* of its last day +(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row +came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight. +Shifting those would push every one of them out by a day. The holiday files +bundled inside Fossify are a second trap: their `PRODID` reads `Fossify Calendar Holiday Generator` and their content is plain conformant -iCalendar. One is kept as a fixture in `app/src/test/resources/ics`. +iCalendar. Both that file and a contact-birthday export are kept as fixtures in +`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the +conformant one. **One bad event must not cost the file.** The provider validates `RRULE` through `EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later