diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6eb2a..ae31639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **A deleted occurrence no longer comes back when the series is re-timed.** + Delete a single occurrence of a repeating event, then change the time of the + whole series, and the occurrence you had removed reappeared. Doing it the + other way round — "This and all following events" — lost every removal in the + part of the series being changed. Removals now travel with the event: they + keep their place in the series across a time change, a move to a different + day, a timezone change and a switch to or from an all-day event, and they + carry over when a series is split. Repeating events on your device's own + calendars are affected, including the birthday and anniversary calendars + Calendula creates from your contacts ([#248]). - **Home-screen widgets now turn the page at midnight.** Both the month and the agenda widget kept highlighting yesterday as "today" — and the agenda kept greying out the wrong events as already past — until you paged the month back @@ -1522,3 +1532,4 @@ automatically, with zero telemetry and no internet permission. [#225]: https://codeberg.org/jlmakiola/calendula/issues/225 [#228]: https://codeberg.org/jlmakiola/calendula/issues/228 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234 +[#248]: https://codeberg.org/jlmakiola/calendula/issues/248 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 422cdc5..eab8af6 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 @@ -1005,10 +1005,12 @@ class AndroidCalendarDataSource @Inject constructor( updated: EventForm, allDayReminderTimeMinutes: Int, ) { + val row = querySeriesRow(eventId) val values = buildEventUpdateValues( original = original, updated = updated, - seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis, + seriesDtStartMillis = row.dtStartMillis, + seriesExdate = row.exdate, zone = ZoneId.systemDefault(), ) if (values.isNotEmpty()) { @@ -1347,10 +1349,66 @@ class AndroidCalendarDataSource @Inject constructor( } // Insert the new series first: if it fails, the original is untouched. val newEventId = insertEvent(updated, allDayReminderTimeMinutes) + carrySplitExdate(newEventId, row, beginMillis, original, updated) truncateSeries(eventId, row, beginMillis) return newEventId } + /** + * Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the + * series [newEventId] that now owns them, re-timed by the shift the split + * applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row + * from the form, which knows nothing about them, so without this every + * occurrence the user had deleted from the tail of the series comes back. + * + * The whole time/recurrence set rides along with EXDATE for the reason + * [buildOccurrenceExdateValues] documents: on its own the provider does not + * read an EXDATE write as a recurrence change, and leaves the instances it + * expanded on insert standing. + * + * A failure rolls the new series back and throws, so the split fails whole + * rather than landing with the exclusions quietly dropped — the parent is + * still untruncated at this point, so the event is left exactly as it was. + */ + private fun carrySplitExdate( + newEventId: Long, + parent: SeriesRow, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ) { + // Dropping the recurrence in the same save leaves a one-off tail, and an + // exclusion means nothing on a row that doesn't recur. + if (updated.rrule.isNullOrBlank()) return + val carried = shiftedExdate( + existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay), + original = original, + updated = updated, + zone = ZoneId.systemDefault(), + ) ?: return + val row = querySeriesRow(newEventId) + val values = ContentValues().apply { + put(CalendarContract.Events.EXDATE, carried) + put(CalendarContract.Events.DTSTART, row.dtStartMillis) + put(CalendarContract.Events.RRULE, row.rrule) + put(CalendarContract.Events.DURATION, row.duration) + put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone) + put(CalendarContract.Events.ALL_DAY, row.allDay) + } + try { + val rows = resolver.update( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId), + values, null, null, + ) + if (rows == 0) { + throw WriteFailedException("carry exdate onto split series id=$newEventId") + } + } catch (t: Throwable) { + runCatching { deleteEvent(newEventId) } + throw t + } + } + override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) { val row = querySeriesRow(eventId) // From the first occurrence on = the whole series; also the fallback diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 3696b5c..12f01c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -14,6 +14,9 @@ import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.ResolverStyle +import java.time.LocalDate as JavaLocalDate import java.time.LocalDateTime as JavaLocalDateTime /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ @@ -138,18 +141,21 @@ internal fun buildEventInsertValues( * * Time fields travel together (the provider validates them as a unit): * - unchanged times, all-day flag and rrule → no time columns at all; - * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; + * - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared; * - recurring result → the *series* DTSTART moves by the same **wall-clock** * shift the user applied to the displayed occurrence and is re-resolved in the * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION - * replaces DTEND, RRULE is written. This keeps past occurrences intact when - * someone edits a later occurrence's time, and keeps the anchor's time-of-day - * stable across a DST boundary or a zone change between the two. + * replaces DTEND, RRULE is written, and the row's exclusions + * ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past + * occurrences intact when someone edits a later occurrence's time, and keeps + * the anchor's time-of-day stable across a DST boundary or a zone change + * between the two. */ internal fun buildEventUpdateValues( original: EventForm, updated: EventForm, seriesDtStartMillis: Long, + seriesExdate: String?, zone: ZoneId, ): Map = buildMap { if (updated.title.trim() != original.title.trim()) { @@ -189,6 +195,10 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.DTEND, newTimes.dtEndMillis) put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.DURATION, null) + // The exclusions named occurrences of a series that no longer exists. + // Left behind they are dormant rather than harmless: adding a recurrence + // back later would punch the old holes into the new one. + put(CalendarContract.Events.EXDATE, null) } else { // Move the series anchor by the *wall-clock* shift the user applied to the // displayed occurrence, then re-resolve it in the event's (possibly new) @@ -210,6 +220,14 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) + // An EXDATE stamp is an absolute instant, so it excludes an occurrence + // only while the series keeps generating one at exactly that instant. The + // anchor has just moved, so every occurrence regenerates elsewhere and a + // stamp left behind matches none of them — the occurrence the user deleted + // comes back. Move the stamps the same way the anchor moved. + shiftedExdate(seriesExdate, original, updated, zone) + ?.takeIf { it != seriesExdate } + ?.let { put(CalendarContract.Events.EXDATE, it) } } } @@ -439,11 +457,7 @@ internal fun buildOccurrenceExdateValues( allDay: Int, ): Map { val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0) - val existing = existingExdate?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() - val merged = (existing + stamp).distinct().joinToString(",") + val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",") return mapOf( CalendarContract.Events.EXDATE to merged, CalendarContract.Events.DTSTART to dtStartMillis, @@ -471,22 +485,122 @@ internal fun exdateContains( return existingExdate?.split(',')?.any { it.trim() == stamp } == true } +/** + * [existingExdate] with every stamp re-timed by the same **wall-clock** shift the + * series anchor takes from [original] to [updated] — the exclusions' half of the + * anchor move [buildEventUpdateValues] performs, and what carries them onto the + * new series when "this and following" splits one. + * + * A stamp is an absolute instant, so it keeps naming its occurrence only if it + * moves exactly as the occurrence does: shifted in wall clock and re-resolved in + * the event's (possibly new) zone. A millisecond delta would instead bake in the + * offset that happened to apply at the edited occurrence — an hour off for any + * exclusion on the far side of a DST boundary. All-day-ness is read from + * [original] and written from [updated], so the `VALUE=DATE` and date-time forms + * convert into each other when the event switches. + * + * Null when there is nothing to carry: no stamps, or a stamp in a form Calendula + * never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those + * are left exactly as they are rather than guessed at — a stale stamp excludes + * nothing, but a mangled one could exclude the wrong occurrence. + */ +internal fun shiftedExdate( + existingExdate: String?, + original: EventForm, + updated: EventForm, + zone: ZoneId, +): String? { + val stamps = exdateStamps(existingExdate) + if (stamps.isEmpty()) return null + val fromZone = original.writeZone(zone) + val toZone = updated.writeZone(zone) + val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal()) + return stamps + .map { stamp -> + val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: return null + formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone) + } + .distinct() + .joinToString(",") +} + +/** + * The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones + * that belong to the *new* series once "this and following" splits a recurring + * event there. The parent keeps the full list; its stamps past the split point are + * simply inert once it stops generating those occurrences. + * + * The occurrence at [beginMillis] itself is never carried. It is the one the user + * is editing, so it exists by definition, and honouring a stale exclusion for it + * would swallow the edit whole. + * + * Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]). + */ +internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? { + val stamps = exdateStamps(existingExdate) + if (stamps.isEmpty()) return null + return stamps + .filter { stamp -> + val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null + utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis + } + .takeIf { it.isNotEmpty() } + ?.joinToString(",") +} + +/** The individual stamps of an EXDATE column value; it is a comma-separated list. */ +private fun exdateStamps(exdate: String?): List = exdate + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + /** * One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date * reads off the UTC calendar day. */ -private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String { - val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC) - return if (isAllDay) { - "%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth) +private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String = + formatExdateStamp( + local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(), + isAllDay = isAllDay, + zone = ZoneOffset.UTC, + ) + +/** + * One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An + * all-day entry keeps only the date (its time-of-day is the anchor's, not the + * occurrence's); a timed one is resolved in [zone] and written as a UTC instant. + */ +private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String = + if (isAllDay) { + local.toLocalDate().format(ALL_DAY_EXDATE) } else { - "%04d%02d%02dT%02d%02d%02dZ".format( - utc.year, utc.monthValue, utc.dayOfMonth, - utc.hour, utc.minute, utc.second, - ) + local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE) } -} + +/** + * [stamp] as the wall clock it names in [zone] — the inverse of + * [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a + * caller can tell "not ours, leave it alone" from a value it may safely re-time. + */ +private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? = + runCatching { + if (isAllDay) { + JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay() + } else { + JavaLocalDateTime.parse(stamp, TIMED_EXDATE) + .atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime() + } + }.getOrNull() + +/** `VALUE=DATE` EXDATE form, for an all-day series. */ +private val ALL_DAY_EXDATE: DateTimeFormatter = + DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT) + +/** UTC date-time EXDATE form, for a timed series. */ +private val TIMED_EXDATE: DateTimeFormatter = + DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT) /** * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index afdc5ea..34abdc2 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -43,25 +43,25 @@ Dnes Zpět Upravit - Vymazat + Odstranit Sdílet Kopírovat Sdílet událost Tuto událost nelze sdílet. Odstranit událost? Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení. - Smazat opakující se událost + Odstranit opakující se událost Pouze tuto událost Tuto a všechny následující události Všechny události v sérii Upravit opakující se událost Tuto událost nelze odstranit - Calendula potřebuje oprávnění k zápisu, aby mohla mazat události + Calendula potřebuje oprávnění k zápisu, aby mohla odstraňovat události Přesunout… Přesunout opakující se událost Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost. Přesunuto do %1$s - Zpět + Vrátit Vráceno zpět Událost nelze přesunout Calendula k přesouvání události vyžaduje oprávnění k zápisu @@ -111,7 +111,7 @@ Barva Barva kalendáře Vlastní barva - Obnovit + Znovu nastavit Pro tento kalendář není k dispozici Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení. Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána. @@ -121,8 +121,8 @@ Pouze u upravených polí dojde k přepsání vnějšího zdroje Zrušit změny Událost zůstává beze změny - Událost byla smazána - Během úpravy došlo ke smazání události, například pomocí jiného zařízení. Vaše změny již nelze uložit. + Událost byla odstraněna + Během úpravy došlo k odstranění události, například pomocí jiného zařízení. Vaše změny již nelze uložit. Chcete použít výchozí upomínku? Tato událost byla importována bez nastavené upomínky. @@ -250,4 +250,86 @@ Složka, kterou si vyberete Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu. Jednou denně, automaticky + Calendula exportuje vaše místní kalendáře na pozadí. Frekvenci lze změnit v nastavení. + Vyberte složku a proveďte zálohu + Teď ne + Co by se mělo otevřít nejdříve? + Jak by mělo vypadat měsíční rozvržení? + Pokračovat + Zpět + Jasně + Vše je připraveno + Vaše kalendáře jsou připraveny. Veškeré volby lze později změnit v nastavení. + Otevřít můj kalendář + Upomínky událostí + Oznámení v časech upomínek vašich událostí + Ať vám neunikne žádná událost + Android sám o sobě nezobrazuje upomínky událostí — to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule. + Upomínky, doručené + Upomínky k vašim událostem vám přijdou v danou dobu jako oznámení. + Používáte ještě nějakou jinou aplikaci s kalendářem? + Pokud i jiná aplikace odesílá upomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout. + Lze kdykoliv změnit + Tento přepínač naleznete v Nastavení, v části Oznámení. + Zapnout upomínky + Později + Odložit + Zrušit + Zítra + Včera + Měsíc + Týden + Den + Program + Zobrazení + Přejít na datum + Dnes + Dnes + Zítra + Teď už jsi v obraze + Dnes bez dalších událostí + Začátek %1$s + Konec %1$s + Přejít na dnešek + Hledat + Vyhledat události + Zpět + Vymazat + Prohledávejte své události podle názvu, místa konání nebo poznámek. + Žádná událost neodpovídá “%1$s”. + + %d označený + %d označené + %d označených + %d označených + + Nalezeno v popisech + Zrušit výběr + Vybrat vše + Odstranit vybrané + + Odstranit %d událost? + Odstranit %d události? + Odstranit %d událostí? + Odstranit %d událostí? + + + %d událost odstraněna + %d události odstraněny + %d událostí odstraněno + %d událostí odstraněno + + %1$d odstraněno, u %2$d se nepodařilo + %1$d odstraněno, poté byl odepřen přístup pro zápis + Tímto se smažou kalendáře spojené s určitým kontaktem a všechny události v nich obsažené. Veškerá upomínky a poznámky, které jste do nich přidali, budou ztraceny. + Nadcházející + Události Calendula + Měsíc Calendula + Obnovit + Nová událost + Otevřete Calendulu a povolte přístup ke kalendáři + Předchozí měsíc + Následující měsíc + Dnes + Kalendáře diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 629871c..3c869f9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -16,14 +16,14 @@ Otwórz systemowe ustawienia kalendarza Nie udało się kalendarza. Zobacz wszystkie swoje wydarzenia w pięknej oprawie - Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia. + Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. Przyznaj dostęp do kalendarza Brak dostępu do kalendarza Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych. Otwórz ustawienia systemowe Spróbuj ponownie - Pozostaje na Twoim urządzeniu - Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu. + Prywatność z założenia + Brak uprawnień do internetu, brak telemetrii — nic nigdy nie opuszcza Twojego telefonu. Wszystkie Twoje kalendarze w jednym miejscu Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia. Nigdy żadnego śledzenia @@ -333,7 +333,7 @@ Domyślne przypomnienie Wydarzenia całodniowe Godzina przypomnienia o wydarzeniach całodniowych - Przypomnienia o wydarzeniach całodniowych zostaną pojawiają się o %1$s + Przypomnienia o wydarzeniach całodniowych pojawiają się o %1$s Brak Użyj domyślnego przypomnienia jednostek @@ -353,10 +353,10 @@ Domyślny systemu Pomóż w tłumaczeniu Dodaj lub ulepsz tłumaczenie w Weblate - Motyw, domyślny widok, pierwszy dzień tygodnia - Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu - Domyślne pola dla nowych wydarzeń - Przypomnienia o wydarzeniach + Motyw, kolory, czcionki + Domyślny widok, układ, kolejność + Domyślne pola i zachowanie + Przypomnienia i dostarczanie Urodziny i rocznice kontaktów Szczególne daty kontaktów Pokaż daty kontaktów @@ -509,8 +509,8 @@ Jutro Wczoraj Brak zaplanowanych wydarzeń - Zredukuj intensywność kolorów kalendarza - Dopasuj intensywność kolorów kalendarza i wydarzeń do motywu. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza. + Harmonizuj kolory kalendarza + Zachowaj odcień każdego kalendarza, ale wyrównaj jego jasność, tak aby każde wydarzenie pozostało czytelne, a kolory współgrały ze sobą. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza. Zawsze pokazuj dzisiejszy dzień Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań. Urodziny @@ -543,4 +543,128 @@ Brak planów Pokaż cały miesiąc Pokaż wydarzenia dnia + To wydarzenie już nie istnieje + Przenieś… + Przenieś wydarzenie cykliczne + Przeniesienie całej serii zmieniłoby dni, w których wypadają wydarzenia, więc można przenieść tylko to wystąpienie. + Przeniesiono na %1$s + Cofnij + Cofnięto przeniesienie + Nie udało się przenieść wydarzenia + Calendula wymaga uprawnień do zapisu, aby móc przenosić wydarzenia + Nie można przenieść wydarzenia poza datę zakończenia jego serii + Następne: %1$s + Ta reguła nie ma powtórzeń + Standardowe ustawienie tego kalendarza + Każdy, kto ma dostęp, widzi pełne szczegóły + Inni widzą tylko to, że jesteś zajęty + Oznaczone jako poufne; co to oznacza, zależy od konta kalendarza + %1$s %2$s + Maksymalnie %1$s + Krok %1$d z %2$d + Twoje wydarzenia istnieją tylko tutaj + Nic z tego telefonu nie synchronizuje się z kontem, więc jego utrata oznaczałaby utratę kalendarza. Calendula może utworzyć dla Ciebie kopię zapasową. + Wybrany przez Ciebie folder + Kopie zapasowe to zwykłe pliki .ics — umieść je w miejscu, które się synchronizuje, albo na karcie SD. + Raz dziennie, automatycznie + Calendula eksportuje Twoje lokalne kalendarze w tle. Możesz zmienić częstotliwość w Ustawieniach. + Wybierz folder i utwórz kopię zapasową + Co powinno się otworzyć jako pierwsze? + Jak powinien wyglądać miesiąc? + Kontynuuj + Wstecz + Rozumiem + Wszystko gotowe + Twoje kalendarze są gotowe. Wszystko co właśnie wybrałeś możesz później zmienić w Ustawieniach. + Otwórz mój kalendarz + + %d wybrane + %d wybrane + %d wybranych + %d wybranych + + Znaleziono w opisach + Anuluj wybór + Wybierz wszystkie + Usuń wybrane + + Usunąć %d wydarzenie? + Usunąć %d wydarzenia? + Usunąć %d wydarzeń? + Usunąć %d wydarzeń? + + + Usunięto %d wydarzenie + Usunięto %d wydarzenia + Usunięto %d wydarzeń + Usunięto %d wydarzeń + + %1$d usunięto, %2$d nie udało się + %1$d usunięto, następnie utracono uprawnienia do zapisu + Czy aplikacja ma być jasna, czy ciemna. Wybór zostanie zastosowany natychmiast. + Obecnie %1$s + czwartek, 14 maja + Przegląd zespołu o 10:00, a potem lunch z Robinem w kawiarni na rynku. + Obecnie %1$s + Zgodnie z systemem: %1$s + Wysokość godzin + Ile miejsca w pionie zajmuje jedna godzina w widoku tygodnia i dnia. Oba widoki korzystają z tego ustawienia. Możesz również rozciągnąć lub ścisnąć oś czasu dwoma palcami, aby ustawić dowolną wysokość pomiędzy. + Cały dzień na ekranie + Cała doba na jednym ekranie, bez przewijania + Kompaktowa + Więcej godzin na ekranie, mniejsze bloki + Standardowa + Standardowy rozmiar + Wygodna + Większe bloki, więcej przewijania + Własna + Wysokość, którą wybrałeś gestem „uszczypnięcia” osi czasu + Przeciągnij, aby zmienić termin + Przenieś wydarzenie, przeciągając je na inny dzień lub godzinę + Przegląd zespołu + Lunch z Robin + Próba chóru + Rozmiar widżetu agendy + Rozmiar tekstu wyświetlanego przez widżet agendy na ekranie domowym. Widżet miesiąca nie posiada tego ustawienia — jego siatka zawsze dopasowuje się do przydzielonego miejsca. + Mały + Średni + Duży + Bardzo duży + Tekst wydarzenia %1$d%% + Domyślny czas trwania + Czas trwania nowego wydarzenia do momentu zmiany godziny jego zakończenia. Nie dotyczy to wydarzeń całodniowych. + Czas trwania dla poszczególnych kalendarzy + Przypisz kalendarzowi własną domyślną długość — np. 8 godzin dla zmian w pracy. + Domyślny (%1$s) + Używaj domyślnego czasu trwania (%1$s) + Powiadamia o %1$s + Wygląd i zachowanie + Dane + Aplikacja + O aplikacji + Widżet agendy, kafelek Szybkich ustawień + Eksport, import, automatyczna kopia zapasowa + Widżety i kafelki + Ustawienia widżetów ekranu głównego i kafelka Szybkich ustawień. Aby dodać widżet, naciśnij i przytrzymaj na ekranie domowym. + Kopia zapasowa i przywracanie + Wszystkie widoki + Tydzień i dzień + Widok, który pojawia się po uruchomieniu aplikacji Calendula. + Dzień, od którego zaczyna się każdy tydzień we wszystkich widokach i widżetach. + Format godziny w aplikacji. Opcja automatyczna dostosowuje się do ustawień systemowych. + Co agenda robi z wydarzeniami, które już się zakończyły. + Dopasuj kolory aplikacji do swojej tapety. + Polityka prywatności + Wyłącz kalendarz, aby ukryć go na tym urządzeniu — jego wydarzenia znikną z aplikacji i przestaniesz otrzymywać powiadomienia. To ten sam przełącznik, którego używają inne aplikacje kalendarza, więc one również go ukryją. Nic nie zostanie usunięte, nie wpłynie to na inne urządzenia, a kalendarz możesz tu włączyć ponownie w dowolnym momencie. + Pokaż \"%1$s\" + Niektóre kalendarze są wyłączone + Calendula wyświetla kalendarze włączone na tym urządzeniu, a niektóre z Twoich są wyłączone. Aby zobaczyć ich wydarzenia, włącz je w sekcji Ustawienia → Kalendarze. + Brakuje jakiegoś kalendarza? + Może być wyłączony, tylko do odczytu lub wypełniony na podstawie Twoich kontaktów — zarządzaj swoimi kalendarzami tutaj. + Tylko do odczytu + Niezsynchronizowany z tym urządzeniem + Wypełniony na podstawie Twoich kontaktów + Ten kalendarz jest uzupełniany na podstawie Twoich kontaktów, więc Calendula utworzy go ponownie przy następnej synchronizacji. Aby go usunąć, wyłącz daty szczególne w menu Ustawienia → Szczególne daty kontaktów. + %1$s (%2$s) + Nie teraz diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8a4c6a8..06a9fbc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -6,34 +6,33 @@ Days Weeks - Calendula - 一个现代化的日历应用 + 现代化的日历应用 加载中… - 推迟 - 错误! - 需要日历权限 + 重试 + 出错了。 + 需要日历访问权限。 授予访问权限 - 无已设置的日历 + 未配置任何日历。 打开系统日历设置 - 无法读取日历 - 优雅地查看你的所有活动 - Calendula需要获取权限来访问您的日历来展示和管理您的活动。所有权限均会在使用前申请——不必担心隐私泄露。 - 授予日历权限 - 日历权限不可用 - Calendula 无法显示没有日历访问权限的事件。你可以在系统设置里再次授予它。 + 无法读取日历。 + 优雅地查看您所有的事件 + Calendula 需要访问您的日历,以展示和管理您的事件。 + 授予日历访问权限 + 日历访问被拒绝 + Calendula 无法在没有日历访问权限的情况下显示事件。您可以在系统设置中重新授予权限。 打开系统设置 重试 - 保留在设备 - 您的日历数据将会在本地读取,而不会被上传。 - 所有日历 - Google、CalDAV、本地设备——任何同步到设备的东西都会直接显示出来。 - 无追踪器 - 零遥测,零分析,没有广告。 - 数据仅保留在本地设备 ·无网络权限 - 上个月 - 下个月 + 从设计上保护隐私 + 没有网络权限,没有遥测——任何数据都不会离开您的手机。 + 所有日历汇聚一处 + Google、CalDAV、本地日历——只要同步到设备就会自动显示。 + 永无追踪 + 零遥测、零分析、无广告。 + 数据保留在您的设备 · 无网络权限 + 上月 + 下月 今天 - 更多选项 + 更多操作 打开菜单 设置 今天 @@ -43,80 +42,572 @@ 编辑 删除 分享 - 分享活动 - 无法分享该活动。 - 删除活动? - 该活动将从你的日历以及所有同步设备中移除。 - 删除重复活动 - 仅该活动 - 当前及接下来的所有活动 - 系列中的所有活动 - 编辑重复活动 - 无法删除该活动 - Calendula需要权限读取或删除活动 + 分享事件 + 无法分享此事件。 + 删除事件? + 该事件将从您的日历以及所有同步设备中移除。 + 删除重复事件 + 仅此事件 + 此事件及其后的所有事件 + 整个系列的所有事件 + 编辑重复事件 + 无法删除事件 + Calendula 需要写入权限才能删除事件 取消 - 确认 - 新活动 + 确定 + 新事件 关闭 保存 - 增加标题 - 由“%1$s”管理——标题、日期和重复内容会与你的联系人保持同步。提醒、位置和笔记由你自行编辑。 + 添加标题 + 由“%1$s”管理——标题、日期和重复会从您的联系人同步。提醒、位置和备注由您编辑。 开始 结束 - 报错:结束时间早于起始时间 - 无可写入的日历 - 无法保存该活动 - Calendula需要写入权限来创建活动 - 其他领域 - 新增 - 新建提醒 + 结束时间早于开始时间 + 没有可写入的日历 + 无法保存事件 + Calendula 需要写入权限才能创建事件 + 更多字段 + 添加 + 添加提醒 移除提醒 - 访问者 - 新增访问者 - 通过 Email 新增一个访问者… - 从联系人中添加访问者 - 从联系人中添加地址 - 移除访问者 - 必填 - 可选项 - Calendula 不发送邀请函。你的日历账户同步时可能会给访客发邮件。 - 保存在本设备,其他访客不会被提醒。 + 参与者 + 添加参与者 + 通过电子邮件添加参与者… + 从联系人添加 + 从联系人选取地址 + 移除参与者 + 必需 + 可选 + Calendula 不发送邀请。您的日历帐户同步时可能会通过邮件通知参与者。 + 存储于本设备,不会通知任何人。 自定义 分钟 小时 - 空闲 - 隐私设置 - 色彩 + 忙闲状态 + 可见性 + 颜色 日历颜色 自定义颜色 重置 - 该日历不可用 - 此日历未设置任何颜色集。您可以在“设置”中为此类日历启用自定义颜色。 - 该日历在下一次同步时可能会删除或覆盖该颜色。 - 其他设备的活动已更改 - 您在编辑时,该事件已被同步或其他应用修改。您的修改要怎么处理? + 此日历不支持 + 此日历未提供颜色集。您可以在“设置”中为此类日历允许自定义颜色。 + 此日历下次同步时可能会丢弃或覆盖该颜色。 + 事件已在别处更改 + 您编辑时,此事件已通过同步或其他应用被修改。您的更改应如何处理? 保存我的更改 - 仅您编辑的字段会覆盖外部更改 + 仅覆盖您编辑过的字段 丢弃我的更改 - 该活动保持现状 - 活动被删除 - 这个事件在您编辑期间已被删除了(比如在另一台设备上)。您的修改现在无法保存。 - 确认无需重复 + 事件保持当前状态 + 事件已被删除 + 该事件在您编辑期间已被删除(例如在另一设备上)。您的更改无法再保存。 + 不重复 自定义 - 结束时间 - 从不 + 结束 + 永不 于指定日期 - 重复几次后 + 在指定次数后 - 报错:结束日期早于开始日期 - 占用中 + 重复结束日期早于事件开始时间 + 忙碌 默认 公开 + 复制 + 移动… + 删除重复活动 + 移动整个系列会改变其发生日期,因此只能移动此事件。 + 已移动到 %1$s + 撤销 + 已撤销移动 + 无法保存该活动 + Calendula需要权限读取或删除活动 + 该事件已不存在 + 无法将事件移至其系列结束日期之后 + 设备时区 + 跟随您所在位置 + 搜索时区 + 最近使用 + 所有时区 + 没有与“%1$s”匹配的时区 + %1$s 您的本地时间 + 应用默认提醒? + 此事件导入时没有提醒。 + + 此事件导入时包含 %1$d 个提醒。 + + Calendula + + 应用默认 + 保留原样 + 请输入 1 到 999 之间的数字 + 下一次:%1$s + 此规则永不重复 + 全天 + Calendula + 未知日历 + 地点 + 描述 + 参与者 + 重复 + 重复事件 + 每天 + 每周 + 每月 + 每年 + 每 %1$d 天 + 每 %1$d 周 + 每 %1$d 月 + 每 %1$d 年 + %1$s(%2$s) + %1$s,直到 %2$s + %1$s,共 %2$d 次 + 此事件已不存在。 + 已接受 + 已拒绝 + 待定 + 未回复 + + 提醒 + 时区 + 待定 + 取消 + 空闲 + 私密 + 机密 + 按日历默认设置 + 有权限者可见全部详情 + 他人仅看到您忙碌 + 标记为机密,具体含义由日历帐户决定 + 组织者 + 可选项 + 资源 + 您的回复:%1$s + 事件开始时 + 默认提醒 + + 提前 %d 分钟 + + + 提前 %d 小时 + + + 提前 %d 天 + + + 提前 %d 周 + + + %d分钟 + + + %d 小时 + + + %d 天 + + %1$s %2$s + 最多 %1$s + + %d 周 + + (无标题) + 第 %1$d 步,共 %2$d 步 + 您的事件仅保存在这里 + 本手机上的事件不会同步到任何帐户,丢失手机将丢失日历。Calendula 可以为您创建备份。 + 您选择的文件夹 + 备份为纯 .ics 文件——可存放在同步文件夹或 SD 卡中。 + 每天自动备份 + Calendula 会在后台导出您的本地日历。您可以在“设置”中更改频率。 + 选择文件夹并备份 + 暂不 + 默认打开哪个视图? + 月视图样式? + 继续 + 返回 + 知道了 + 一切就绪 + 您的日历已就绪。所有选择均可在“设置”中更改。 + 打开我的日历 + 事件提醒 + 按事件提醒时间推送的通知 + 不错过任何事件 + Android 本身不会显示事件提醒——需要日历应用来承担。让 Calendula 接管这项工作吧。 + 准时送达提醒 + 您事件中的每个提醒都会按时以通知形式送达。 + 使用第二个日历应用? + 如果另一个应用也发送提醒,您会看到两次——请在其中一个中关闭。 + 随时更改 + 开关位于“设置”中的“通知”下。 + 开启提醒 + 暂不 + 稍后提醒 + 关闭 + 明天 + 昨天 + + + + 日程 + 视图 + 跳转到日期 + 今天 + 今天 + 明天 + 暂无待办 + 今天没有更多事件 + 开始 %1$s + 结束 %1$s + 去今天 + 搜索 + 搜索事件 + 返回 + 清除 + 按标题、地点或备注搜索您的事件。 + 没有与“%1$s”匹配的事件。 + + 已选 %d 个 + + 在描述中找到 + 取消选择 + 全选 + 删除所选 + + 删除 %d个活动? + + + 删除了%d个活动 + + 已删除 %1$d 个,%2$d 个无法删除 + 已删除 %1$d 个,之后写入权限被撤 + 即将开始 + Calendula 日程 + Calendula 月视图 + 刷新 + 新活动 + 打开 Calendula 授予日历访问权限 + 上个月 + 下个月 + 今天 + 日历 + 设置 + 返回 + 返回 + 外观 + 主题 + 跟随系统 + 浅色 + 深色 + 应用使用浅色或深色主题。选择后立即生效。 + 当前为 %1$s + 默认视图 + 动态颜色 + 需要 Android 12 或更高版本 + 协调日历颜色 + 保留每个日历的色调但均衡亮度,使所有事件清晰可读且色彩协调。关闭则显示日历源的原始颜色。 + 标题字体 + 正文字体 + 系统默认 + Atkinson Hyperlegible + Lora + JetBrains Mono + 选择文件… + 自定义字体 + 无法将文件识别为字体 + 2026年8月28日 星期四 + 团队评审 10:00,然后与 Robin 在集市广场咖啡馆共进午餐。 + 每周起始日 + 自动 + 当前为 %1$s + 周数 + 在月视图中显示日历周数 + 工具栏中的“今天”按钮 + 在工具栏中显示“跳至今天”按钮,而非浮动按钮 + 应用名称 + 在启动器中显示为“日历”。仅启动器名称改变;切换后图标可能移动到新位置。 + 时间格式 + 自动 + 12 小时制(下午 2:00) + 24 小时制(14:00) + 跟随系统:%1$s + 整点分隔线 + 在周视图和日视图中显示每个整点的分隔线 + 小时高度 + 周视图和日视图中每小时占用的垂直空间。两者共享此设置。您也可以用双指捏合时间线设置任意高度。 + 适应全天 + 24 小时一屏显示,无需滚动 + 紧凑 + 每屏显示更多小时,区块更小 + 常规 + 标准间距 + 舒适 + 区块更宽敞,需更多滚动 + 自定义 + 您捏合时间线所设定的高度 + 变暗已完成事件 + 在月视图和周视图中淡化已结束的事件 + 拖拽重新安排 + 通过拖拽事件到另一天或时间来移动它 + 过去事件 + 显示 + 变暗 + 隐藏 + 团队评审 + 与 Robin 午餐 + 合唱团练习 + 日程 + 日程范围 + “日程”视图列出未来事件的时间跨度。 + 日程小部件范围 + 主屏幕日程小部件列出未来事件的时间跨度。 + 日程小部件大小 + 主屏幕日程小部件绘制文字的大小。月视图小部件无此设置——它的网格始终适应您给定的空间。 + + + + 特大 + 事件文字 %1$d%% + 始终显示今天 + 即使今天没有剩余事件,也将今天保持在日程及其小部件顶部。 + 范围栏 + 在日程顶部显示一个条,标明显示的日期范围,并提供一个按钮用于临时切换范围 + 今天 + 本周 + 本月 + 未来 7 天 + 未来 30 天 + 自定义… + 天数 + 仅本次生效——重新打开 Calendula 时恢复为已保存的范围。 + 显示所有未来事件 + + %d 天 + + 视图 + 月视图 + 月视图样式 + 分页 + 一屏显示一个月,左右滑动切换月份。 + 滚动月份 + 每个月在其标题下,与下个月之间略有间距。 + 无缝周 + 各周连续排列,月份之间无间隙。 + 拆分 + 紧凑网格标记有事件的日子,点击的日期在下方列出。 + 无安排 + 显示整月 + 显示当天事件 + 快速切换按钮 + 选择右上角按钮循环切换的视图,并拖动排序。关闭的视图仍可从导航菜单访问。 + 导航菜单 + 拖动排序导航菜单中的视图。 + 拖动以排序 + 新建事件表单 + 默认显示的字段——其他字段位于“更多字段”下 + 新建事件时聚焦标题 + 创建新事件时,光标自动置于标题字段并弹出键盘。 + 默认时长 + 新事件在您更改结束时间之前的默认时长。全天事件不受影响。 + 按日历设置时长 + 为某个日历单独设置默认时长——例如工作班次设为 8 小时。 + 默认(%1$s) + 使用默认时长(%1$s) + 允许在不支持的日历上使用颜色 + 某些日历(例如某些 CalDAV)未发布颜色集;自定义事件颜色可能会在下一次同步时被丢弃或覆盖。这是那些日历的限制,并非 Calendula 可修复的问题。 + 通知 + 事件提醒 + 看到重复提醒?另一个日历应用也在发送——请关闭其中一个。 + 默认提醒 + 全天事件 + 全天事件提醒时间 + 全天事件的提醒在 %1$s 触发 + 在 %1$s 通知 + + 使用默认提醒 + 数量 + 设置 + 按日历设置提醒 + 为每个日历覆盖默认提醒——分别针对定时事件和全天事件。日历可保持默认、不使用提醒,或设置自己的提醒。 + 默认(%1$s) + 可靠送达 + Android 可能为省电延迟提醒。豁免 Calendula 以使其准时送达。 + 豁免电池优化——提醒准时送达。 + 贪睡时长 + 日历 + 管理日历 + 创建本地日历;管理同步日历 + 语言 + 应用语言 + 系统默认 + 帮助翻译 + 在 Weblate 上添加或改进语言 + 外观与行为 + 数据 + 应用 + 关于 + 主题、颜色、字体 + 默认视图、布局、顺序 + 默认字段和行为 + 提醒与送达 + 联系人生日和纪念日 + 日程小部件、快速设置磁贴 + 导出、导入、自动备份 + 小部件与磁贴 + 主屏幕小部件和快速设置磁贴的设置。长按主屏幕即可添加小部件。 + 备份与恢复 + 所有视图 + 周与日 + 启动 Calendula 时默认打开的视图。 + 所有视图和小部件中每周开始的日子。 + 整个应用的时间显示格式。自动跟随系统设置。 + 日程对已结束事件的处理方式。 + 从壁纸中提取应用颜色。 + 联系人特殊日期 + 显示联系人日期 + 将联系人生日和其他日期镜像到本地日历。仅读取本设备上的联系人——不会上传任何内容,也不会修改您的联系人。 + 生日 + 纪念日 + 其他日期 + 标题格式 + 使用 {name} 表示联系人,{year} 表示年份(出生年份或纪念日开始年份;未知时隐藏)。 + 提醒 + 显示年份 + 在标题中包含 {year}(当已知时) + 立即同步 + 尚未同步 + 上次同步:%1$s + 在“日历”设置中分别设置每个日历的颜色和可见性。 + 在“联系人特殊日期”中设置 + 已暂停 + Calendula 无法读取您的联系人,因此这些日历未更新。 + 授予访问权限 + 关闭联系人日期? + 这将删除联系人日期日历及其事件。您添加的任何提醒或备注将丢失。 + 这将删除“%1$s”日历及其事件。您添加的任何提醒或备注将丢失。 + 关闭 + 保存 + 关于 + 许可协议 + MIT + 作者 Jean-Luc Makiola + 源代码 + 隐私政策 + 支持开发 + 版本 %1$s + Calendula 应用图标 + 报告问题 + 发送崩溃报告或打开问题追踪器 + 日历 + 您的日历 + 尚未创建本地日历。创建一个以仅在本设备保存事件。 + 添加日历 + 关闭日历可隐藏其在设备上的显示——事件会从应用中消失,同时停止提醒。此开关与其他日历应用共用,因此它们也会隐藏。不会删除任何内容,不会影响其他设备,您随时可在此重新开启。 + 显示“%1$s” + 部分日历已关闭 + Calendula 显示本设备已开启的日历,但您有一些是关闭的。要查看事件,请在“设置”→“日历”中将其开启。 + 找不到日历? + 可能已关闭、只读,或由联系人填充——在此管理您的日历。 + 只读 + 未同步到本设备 + 由联系人填充 + 此日历由联系人填充,Calendula 下次同步时会重新创建。要删除它,请在“设置”→“特殊日期”中关闭相关功能。 + 同步日历 + 来自设备上的帐户。请在对应的应用中创建和编辑。 + 在应用中管理 + %1$s(%2$s) + %1$s 的更多选项 + 启用全部 + 禁用全部 + 添加帐户 + 新建日历 + 编辑日历 + 名称 + 色彩 + 添加描述 + 删除日历? + “%1$s”及其所有事件将从本设备永久删除。 + 无法保存更改。 + 返回 + 本地日历不会同步到任何地方,请导出为 .ics 文件以保留副本。 + 导出为 .ics 文件 + 导出日历 + 选择要包含在 .ics 文件中的日历。 + 导出 + 恢复 + 从 .ics 文件恢复 + 从备份或其他日历应用导入事件。 + 自动备份 + 定期将本地日历导出为 .ics 文件保存到文件夹。 + 备份文件夹 + 点击选择文件夹 + 间隔 + 每 %1$s + 最短 30 分钟。 + 尚未自动备份 + 上次备份:%1$s + 上次备份失败:%1$s + 备份 + 自动备份反复失败时发出警告。 + 自动备份失败 + Calendula 无法写入备份文件。请在“设置”中检查备份文件夹。 + 无法导出备份。 + + 已导出 %d 个事件。 + + 导入事件 + 添加至日历 + 此文件中未找到事件。 + 无法读取此文件。 + 没有可写入的日历用于导入。请先创建本地日历。 + 导入完成 + 已跳过日历中已存在的事件。 + 已添加 + 重复项 + 关闭 + 已跳过重复事件中部分已修改的发生项。 + 已跳过无开始时间的事件。 + 未导入参与者列表。 + 未知时区已回退为设备时区。 + 导入 + + 正在导入 %d 个事件 + + + 此文件中包含 %d 个事件。 + + + 导入 %d 个事件 + + + 已导入 %d 个事件。 + + + 已跳过 %d 个(已存在于该日历)。 + + 新事件 + 创建新事件 + 新事件 + 添加快速设置磁贴 + 在快速设置面板中添加“新事件”磁贴。 + %1$s 已崩溃 + %1$s 上次意外关闭。您可以将此报告作为问题发送以帮助修复。报告在您选择共享之前仅保留在设备上,不含任何个人数据或日历内容——仅包含以下技术细节。 + 报告 + 暂不 + 崩溃报告 + %1$s 崩溃报告 + 报告已复制到剪贴板 + 无法打开问题追踪器。报告已在剪贴板中。 + 感谢您报告 %1$s 的崩溃。请补充您记得的操作内容,然后提交。\n\n### 发生了什么\n\n\n### 崩溃报告\n%2$s\n + _(报告太长,无法通过此链接附带——请从剪贴板粘贴。)_ + 生日 + 纪念日 + 特殊日期 + {name} 的生日({year}) + {name} 的纪念日({year}) + {name} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 1722a51..96a54c4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -128,7 +128,8 @@ class EventWriteMapperTest { original: EventForm, updated: EventForm, series: Long = seriesStart, - ): Map = buildEventUpdateValues(original, updated, series, berlin) + exdate: String? = null, + ): Map = buildEventUpdateValues(original, updated, series, exdate, berlin) /** The instant [local] names in [zoneId], as the provider would store it. */ private fun instantAt(local: String, zoneId: String): Long = @@ -558,172 +559,237 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) } - // --- toDetachedOccurrence ("edit only this event", no _sync_id) --- + // --- EXDATE follows the series when its times change (Codeberg #248) --- + + /** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */ + private fun julySeries(): EventForm = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + /** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */ + private fun EventForm.atHour(hour: Int): EventForm = copy( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)), + ) @Test - fun `a detached occurrence drops the series rule and becomes a one-off row`() { - val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH") - val detached = edited.toDetachedOccurrence() - - val values = buildEventInsertValues( - form = detached, - uid = "uid@calendula", - times = detached.toWriteTimes(berlin), - ) - assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") - // A surviving rule would insert a second *series* overlapping the first - // (Codeberg #234's stray duplicate). - assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) - assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) - // A one-off row carries DTEND rather than a duration. - assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) - assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L) - assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + fun `a series time edit moves its exclusions with the anchor`() { + // The bug: the stamp stayed at the old instant, which the moved series no + // longer generates, so the occurrence the user deleted came back. + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + // 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z. + val values = update(original, original.atHour(10), series, "20260708T070000Z") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z") } @Test - fun `a detached occurrence carries every edited field onto the new row`() { - // Built from the form, not cloned from the parent: a field dropped here - // is an edit silently lost. - val edited = form(timezone = "America/New_York").copy( - title = " Standup ", - location = "Room 2", - description = "notes", - reminders = listOf(10), - availability = Availability.Free, - accessLevel = AccessLevel.Private, - rrule = "FREQ=DAILY", - ) - val detached = edited.toDetachedOccurrence() - - val values = buildEventInsertValues( - form = detached, - uid = "uid@calendula", - times = detached.toWriteTimes(berlin), - ) - assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup") - assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2") - assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes") - assertThat(values[CalendarContract.Events.AVAILABILITY]) - .isEqualTo(CalendarContract.Events.AVAILABILITY_FREE) - assertThat(values[CalendarContract.Events.ACCESS_LEVEL]) - .isEqualTo(CalendarContract.Events.ACCESS_PRIVATE) - // The pinned zone survives — never re-anchored to the device. - assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") - assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula") - // Reminders aren't columns; the insert path seeds them from the form. - assertThat(detached.reminders).containsExactly(10) + fun `an exclusion keeps its wall clock across a DST boundary`() { + // Anchor and edited occurrence are in July (CEST, +2); the excluded + // occurrence sits in January (CET, +1). Shifting by the millisecond delta + // measured at the edited occurrence would leave it an hour off. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = julySeries() + // 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z. + val values = update(original, original.atHour(10), series, "20260114T080000Z") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z") } @Test - fun `a detached all-day occurrence stays on UTC midnights`() { - val edited = form( + fun `pinning a series to another zone re-resolves its exclusions there`() { + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + val values = update( + original, + original.copy(timezone = "Asia/Tokyo"), + series, + "20260716T070000Z", + ) + // The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z. + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z") + } + + @Test + fun `an all-day series move shifts its exclusions by whole days`() { + val series = instantAt("2026-07-01T00:00", "UTC") + val original = form( isAllDay = true, - start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), - end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), - ).copy(title = "Birthday", rrule = "FREQ=YEARLY") - val detached = edited.toDetachedOccurrence() - - val values = buildEventInsertValues( - form = detached, - uid = "uid@calendula", - times = detached.toWriteTimes(berlin), + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)), ) - assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) - assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") - assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L) - // Exclusive DTEND — the next UTC midnight. - assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L) - assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) + + assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE]) + .isEqualTo("20260724") } @Test - fun `the detached row lands exactly where the parent's exdate removes it`() { - // The two halves must agree on the instant, or the user sees the - // occurrence twice or not at all. - val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY") - val occurrenceMillis = 1_781_164_800_000L - - val parent = buildOccurrenceExdateValues( - existingExdate = null, - occurrenceMillis = occurrenceMillis, - dtStartMillis = 1_780_560_000_000L, - rrule = "FREQ=WEEKLY", - duration = "P5400S", - timezone = "Europe/Berlin", - allDay = 0, - ) - val detached = edited.toDetachedOccurrence() - val inserted = buildEventInsertValues( - form = detached, - uid = "uid@calendula", - times = detached.toWriteTimes(berlin), - ) - assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z") - assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis) - // The parent keeps its own anchor and rule — only this occurrence leaves. - assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L) - assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") + fun `switching a series to all-day rewrites its exclusions as dates`() { + // The two forms aren't interchangeable: a date-time stamp on an all-day + // series matches no occurrence, so the exclusion would be lost. + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722") } @Test - fun `a detached all-day occurrence matches its date-only exdate stamp`() { - val parent = buildOccurrenceExdateValues( - existingExdate = null, - occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z - dtStartMillis = 1_749_600_000_000L, - rrule = "FREQ=YEARLY", - duration = "P1D", - timezone = "UTC", - allDay = 1, - ) - val detached = form( + fun `switching a series back to timed rewrites its exclusions as instants`() { + val series = instantAt("2026-07-01T00:00", "UTC") + val original = form( isAllDay = true, - start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), - end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), - ).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence() - val inserted = buildEventInsertValues( - form = detached, - uid = "uid@calendula", - times = detached.toWriteTimes(berlin), + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val timed = original.copy( + isAllDay = false, + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), ) - assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611") - assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L) - } - - // --- exdateContains (guards a second detach of the same occurrence) --- - - @Test - fun `an occurrence already excluded is recognised, timed and all-day`() { - // Detaching twice would leave a second standalone copy. - assertThat( - exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false), - ).isTrue() - assertThat( - exdateContains("20260611", 1_781_136_000_000L, isAllDay = true), - ).isTrue() + // The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z. + assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE]) + .isEqualTo("20260722T070000Z") } @Test - fun `an occurrence not in the exdate list is not mistaken for an excluded one`() { - assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false)).isFalse() - assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse() - // A neighbouring occurrence must not match — the guard is per-instant. - assertThat( - exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false), - ).isFalse() + fun `a text-only edit leaves the exclusions alone`() { + val original = julySeries() + val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z") + assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE) } @Test - fun `an exclusion is found anywhere in a multi-entry exdate list`() { - // Whitespace after a comma is legal in the stored column. + fun `changing only the rule rewrites no exclusions`() { + // The times are untouched, so every surviving occurrence keeps its instant + // and the stamps still name the right ones. + val original = julySeries() + val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z") + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY") + assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE) + } + + @Test + fun `an exdate form we do not write is left untouched`() { + // A sync adapter may store a TZID-parameterised or floating stamp. A stale + // stamp excludes nothing; a mangled one could exclude the wrong occurrence. + val original = julySeries() + val values = update( + original, + original.atHour(10), + instantAt("2026-07-01T09:00", "Europe/Berlin"), + "TZID=Europe/Berlin;20260722T090000", + ) + assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE) + } + + @Test + fun `removing the recurrence clears the exclusions with it`() { + // Dormant, not harmless: adding a recurrence back later would punch the old + // holes into the new one. + val original = julySeries() + val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z") + assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null) + } + + @Test + fun `undoing a series move lands the exclusions back where they started`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = julySeries() + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)), + ) + val exdate = "20260722T070000Z" + + val forward = update(original, moved, series, exdate) + val movedExdate = forward[CalendarContract.Events.EXDATE] as String + assertThat(movedExdate).isNotEqualTo(exdate) + + val back = update( + moved, + original, + forward[CalendarContract.Events.DTSTART] as Long, + movedExdate, + ) + assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate) + } + + @Test + fun `a series with no exclusions writes no exdate column`() { + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + assertThat(update(original, original.atHour(10), series, exdate = null)) + .doesNotContainKey(CalendarContract.Events.EXDATE) + } + + // --- exdateAfter (the exclusions a "this and following" split inherits) --- + + @Test + fun `a split carries only the exclusions past the split point`() { + // The occurrence at the split point is the one being edited, so it exists + // by definition — a stale exclusion for it would swallow the edit whole. assertThat( - exdateContains( - "20260604T080000Z, 20260611T080000Z,20260618T080000Z", - 1_781_164_800_000L, + exdateAfter( + existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z", + beginMillis = instantAt("2026-07-15T08:00", "UTC"), isAllDay = false, ), - ).isTrue() + ).isEqualTo("20260722T080000Z") + } + + @Test + fun `a split carries nothing when every exclusion is behind it`() { + assertThat( + exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false), + ).isNull() + assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull() + } + + @Test + fun `all-day exclusions split on their UTC date`() { + assertThat( + exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true), + ).isEqualTo("20260722") + } + + @Test + fun `an unreadable exclusion carries nothing across a split`() { + assertThat( + exdateAfter( + "20260722T080000Z,TZID=Europe/Berlin;20260729T100000", + instantAt("2026-07-15T08:00", "UTC"), + isAllDay = false, + ), + ).isNull() + } + + @Test + fun `split exclusions move by the same shift as the new series start`() { + // What the split path composes: filter to the tail, then re-time it by the + // shift the user applied to the occurrence they split at. + val original = julySeries() + val carried = shiftedExdate( + existingExdate = exdateAfter( + "20260716T070000Z", + instantAt("2026-07-15T07:00", "UTC"), + isAllDay = false, + ), + original = original, + updated = original.atHour(11), + zone = berlin, + ) + // 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z. + assertThat(carried).isEqualTo("20260716T090000Z") + } + + @Test + fun `nothing to shift yields no exdate`() { + assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull() + assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull() } // --- per-event colour --- @@ -914,4 +980,168 @@ class EventWriteMapperTest { val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null)) assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S") } + + @Test + fun `a detached all-day occurrence matches its date-only exdate stamp`() { + val parent = buildOccurrenceExdateValues( + existingExdate = null, + occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z + dtStartMillis = 1_749_600_000_000L, + rrule = "FREQ=YEARLY", + duration = "P1D", + timezone = "UTC", + allDay = 1, + ) + val detached = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + ).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence() + val inserted = buildEventInsertValues( + form = detached, + uid = "uid@calendula", + times = detached.toWriteTimes(berlin), + ) + assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611") + assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L) + } + + @Test + fun `a detached all-day occurrence stays on UTC midnights`() { + val edited = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + ).copy(title = "Birthday", rrule = "FREQ=YEARLY") + val detached = edited.toDetachedOccurrence() + + val values = buildEventInsertValues( + form = detached, + uid = "uid@calendula", + times = detached.toWriteTimes(berlin), + ) + assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L) + // Exclusive DTEND — the next UTC midnight. + assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L) + assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) + } + + @Test + fun `a detached occurrence carries every edited field onto the new row`() { + // Built from the form, not cloned from the parent: a field dropped here + // is an edit silently lost. + val edited = form(timezone = "America/New_York").copy( + title = " Standup ", + location = "Room 2", + description = "notes", + reminders = listOf(10), + availability = Availability.Free, + accessLevel = AccessLevel.Private, + rrule = "FREQ=DAILY", + ) + val detached = edited.toDetachedOccurrence() + + val values = buildEventInsertValues( + form = detached, + uid = "uid@calendula", + times = detached.toWriteTimes(berlin), + ) + assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup") + assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2") + assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes") + assertThat(values[CalendarContract.Events.AVAILABILITY]) + .isEqualTo(CalendarContract.Events.AVAILABILITY_FREE) + assertThat(values[CalendarContract.Events.ACCESS_LEVEL]) + .isEqualTo(CalendarContract.Events.ACCESS_PRIVATE) + // The pinned zone survives — never re-anchored to the device. + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") + assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula") + // Reminders aren't columns; the insert path seeds them from the form. + assertThat(detached.reminders).containsExactly(10) + } + + @Test + fun `a detached occurrence drops the series rule and becomes a one-off row`() { + val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH") + val detached = edited.toDetachedOccurrence() + + val values = buildEventInsertValues( + form = detached, + uid = "uid@calendula", + times = detached.toWriteTimes(berlin), + ) + assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") + // A surviving rule would insert a second *series* overlapping the first + // (Codeberg #234's stray duplicate). + assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) + assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) + // A one-off row carries DTEND rather than a duration. + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + } + + @Test + fun `an exclusion is found anywhere in a multi-entry exdate list`() { + // Whitespace after a comma is legal in the stored column. + assertThat( + exdateContains( + "20260604T080000Z, 20260611T080000Z,20260618T080000Z", + 1_781_164_800_000L, + isAllDay = false, + ), + ).isTrue() + } + + @Test + fun `an occurrence already excluded is recognised, timed and all-day`() { + // Detaching twice would leave a second standalone copy. + assertThat( + exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false), + ).isTrue() + assertThat( + exdateContains("20260611", 1_781_136_000_000L, isAllDay = true), + ).isTrue() + } + + @Test + fun `an occurrence not in the exdate list is not mistaken for an excluded one`() { + assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false)).isFalse() + assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse() + // A neighbouring occurrence must not match — the guard is per-instant. + assertThat( + exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false), + ).isFalse() + } + + @Test + fun `the detached row lands exactly where the parent's exdate removes it`() { + // The two halves must agree on the instant, or the user sees the + // occurrence twice or not at all. + val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY") + val occurrenceMillis = 1_781_164_800_000L + + val parent = buildOccurrenceExdateValues( + existingExdate = null, + occurrenceMillis = occurrenceMillis, + dtStartMillis = 1_780_560_000_000L, + rrule = "FREQ=WEEKLY", + duration = "P5400S", + timezone = "Europe/Berlin", + allDay = 0, + ) + val detached = edited.toDetachedOccurrence() + val inserted = buildEventInsertValues( + form = detached, + uid = "uid@calendula", + times = detached.toWriteTimes(berlin), + ) + assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z") + assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis) + // The parent keeps its own anchor and rule — only this occurrence leaves. + assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L) + assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13ed688..4b1388c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ lifecycleCompose = "2.10.0" androidxTestRules = "1.7.0" # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). glance = "1.1.1" -work = "2.10.1" +work = "2.11.2" documentfile = "1.1.0" [libraries]