Compare commits
4 Commits
fix/stale-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1df4800d | ||
|
|
7351b2d35c | ||
|
|
1a66a31563 | ||
|
|
fe3c659804 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -7,18 +7,6 @@ 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]).
|
||||
|
||||
## [2.19.3] — 2026-08-22
|
||||
|
||||
### Added
|
||||
@@ -1483,4 +1471,3 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||
|
||||
@@ -976,12 +976,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
) {
|
||||
val row = querySeriesRow(eventId)
|
||||
val values = buildEventUpdateValues(
|
||||
original = original,
|
||||
updated = updated,
|
||||
seriesDtStartMillis = row.dtStartMillis,
|
||||
seriesExdate = row.exdate,
|
||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (values.isNotEmpty()) {
|
||||
@@ -1230,66 +1228,10 @@ 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
|
||||
|
||||
@@ -10,9 +10,6 @@ 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. */
|
||||
@@ -137,21 +134,18 @@ 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, RRULE and EXDATE cleared;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE 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, 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.
|
||||
* 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.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
seriesDtStartMillis: Long,
|
||||
seriesExdate: String?,
|
||||
zone: ZoneId,
|
||||
): Map<String, Any?> = buildMap {
|
||||
if (updated.title.trim() != original.title.trim()) {
|
||||
@@ -191,10 +185,6 @@ 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)
|
||||
@@ -216,14 +206,6 @@ 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +420,11 @@ internal fun buildOccurrenceExdateValues(
|
||||
allDay: Int,
|
||||
): Map<String, Any?> {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||
val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
|
||||
val existing = existingExdate?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
.orEmpty()
|
||||
val merged = (existing + stamp).distinct().joinToString(",")
|
||||
return mapOf(
|
||||
CalendarContract.Events.EXDATE to merged,
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
@@ -449,122 +435,22 @@ internal fun buildOccurrenceExdateValues(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [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<String> = 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 =
|
||||
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)
|
||||
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)
|
||||
} else {
|
||||
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [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
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
|
||||
<string name="state_failure_provider">Nie udało się kalendarza.</string>
|
||||
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
|
||||
<string name="permission_rationale_body">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.</string>
|
||||
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać.</string>
|
||||
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
|
||||
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
|
||||
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
|
||||
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
|
||||
<string name="permission_retry_button">Spróbuj ponownie</string>
|
||||
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
|
||||
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu.</string>
|
||||
<string name="permission_benefit_private_title">Prywatność z założenia</string>
|
||||
<string name="permission_benefit_private_body">Brak uprawnień do internetu, brak telemetrii — nic nigdy nie opuszcza Twojego telefonu.</string>
|
||||
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
|
||||
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
|
||||
@@ -333,7 +333,7 @@
|
||||
<string name="settings_default_reminder">Domyślne przypomnienie</string>
|
||||
<string name="settings_default_reminder_allday">Wydarzenia całodniowe</string>
|
||||
<string name="settings_allday_reminder_time">Godzina przypomnienia o wydarzeniach całodniowych</string>
|
||||
<string name="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych zostaną pojawiają się o %1$s</string>
|
||||
<string name="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych pojawiają się o %1$s</string>
|
||||
<string name="reminder_none">Brak</string>
|
||||
<string name="reminder_use_default">Użyj domyślnego przypomnienia</string>
|
||||
<string name="reminder_custom_amount">jednostek</string>
|
||||
@@ -353,10 +353,10 @@
|
||||
<string name="settings_language_auto">Domyślny systemu</string>
|
||||
<string name="settings_translate">Pomóż w tłumaczeniu</string>
|
||||
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
|
||||
<string name="settings_views_subtitle">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu</string>
|
||||
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
||||
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
||||
<string name="settings_appearance_subtitle">Motyw, kolory, czcionki</string>
|
||||
<string name="settings_views_subtitle">Domyślny widok, układ, kolejność</string>
|
||||
<string name="settings_event_form_subtitle">Domyślne pola i zachowanie</string>
|
||||
<string name="settings_notifications_subtitle">Przypomnienia i dostarczanie</string>
|
||||
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
|
||||
<string name="settings_section_special_dates">Szczególne daty kontaktów</string>
|
||||
<string name="settings_special_dates_enable">Pokaż daty kontaktów</string>
|
||||
@@ -509,8 +509,8 @@
|
||||
<string name="reminder_day_tomorrow">Jutro</string>
|
||||
<string name="reminder_day_yesterday">Wczoraj</string>
|
||||
<string name="agenda_no_more_today">Brak zaplanowanych wydarzeń</string>
|
||||
<string name="settings_soften_colors">Zredukuj intensywność kolorów kalendarza</string>
|
||||
<string name="settings_soften_colors_summary">Dopasuj intensywność kolorów kalendarza i wydarzeń do motywu. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza.</string>
|
||||
<string name="settings_soften_colors">Harmonizuj kolory kalendarza</string>
|
||||
<string name="settings_soften_colors_summary">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.</string>
|
||||
<string name="settings_agenda_show_today">Zawsze pokazuj dzisiejszy dzień</string>
|
||||
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
|
||||
<string name="special_dates_calendar_birthday">Urodziny</string>
|
||||
@@ -543,4 +543,128 @@
|
||||
<string name="month_split_no_events">Brak planów</string>
|
||||
<string name="month_split_expand">Pokaż cały miesiąc</string>
|
||||
<string name="month_split_collapse">Pokaż wydarzenia dnia</string>
|
||||
<string name="event_move_gone">To wydarzenie już nie istnieje</string>
|
||||
<string name="event_move_action">Przenieś…</string>
|
||||
<string name="event_move_recurring_title">Przenieś wydarzenie cykliczne</string>
|
||||
<string name="event_move_occurrence_only">Przeniesienie całej serii zmieniłoby dni, w których wypadają wydarzenia, więc można przenieść tylko to wystąpienie.</string>
|
||||
<string name="event_move_done">Przeniesiono na %1$s</string>
|
||||
<string name="event_move_undo">Cofnij</string>
|
||||
<string name="event_move_undone">Cofnięto przeniesienie</string>
|
||||
<string name="event_move_failed">Nie udało się przenieść wydarzenia</string>
|
||||
<string name="event_move_write_denied">Calendula wymaga uprawnień do zapisu, aby móc przenosić wydarzenia</string>
|
||||
<string name="event_move_blocked_series_end">Nie można przenieść wydarzenia poza datę zakończenia jego serii</string>
|
||||
<string name="event_edit_recurrence_next">Następne: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Ta reguła nie ma powtórzeń</string>
|
||||
<string name="event_access_default_summary">Standardowe ustawienie tego kalendarza</string>
|
||||
<string name="event_access_public_summary">Każdy, kto ma dostęp, widzi pełne szczegóły</string>
|
||||
<string name="event_access_private_summary">Inni widzą tylko to, że jesteś zajęty</string>
|
||||
<string name="event_access_confidential_summary">Oznaczone jako poufne; co to oznacza, zależy od konta kalendarza</string>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="duration_custom_max">Maksymalnie %1$s</string>
|
||||
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
|
||||
<string name="onboarding_backup_title">Twoje wydarzenia istnieją tylko tutaj</string>
|
||||
<string name="onboarding_backup_body">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ą.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Wybrany przez Ciebie folder</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Kopie zapasowe to zwykłe pliki .ics — umieść je w miejscu, które się synchronizuje, albo na karcie SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Raz dziennie, automatycznie</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula eksportuje Twoje lokalne kalendarze w tle. Możesz zmienić częstotliwość w Ustawieniach.</string>
|
||||
<string name="onboarding_backup_enable_button">Wybierz folder i utwórz kopię zapasową</string>
|
||||
<string name="onboarding_view_title">Co powinno się otworzyć jako pierwsze?</string>
|
||||
<string name="onboarding_month_style_title">Jak powinien wyglądać miesiąc?</string>
|
||||
<string name="onboarding_view_continue_button">Kontynuuj</string>
|
||||
<string name="onboarding_back">Wstecz</string>
|
||||
<string name="onboarding_visibility_button">Rozumiem</string>
|
||||
<string name="onboarding_done_title">Wszystko gotowe</string>
|
||||
<string name="onboarding_done_body">Twoje kalendarze są gotowe. Wszystko co właśnie wybrałeś możesz później zmienić w Ustawieniach.</string>
|
||||
<string name="onboarding_done_button">Otwórz mój kalendarz</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d wybrane</item>
|
||||
<item quantity="few">%d wybrane</item>
|
||||
<item quantity="many">%d wybranych</item>
|
||||
<item quantity="other">%d wybranych</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Znaleziono w opisach</string>
|
||||
<string name="search_selection_close">Anuluj wybór</string>
|
||||
<string name="search_select_all">Wybierz wszystkie</string>
|
||||
<string name="search_delete_selected">Usuń wybrane</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Usunąć %d wydarzenie?</item>
|
||||
<item quantity="few">Usunąć %d wydarzenia?</item>
|
||||
<item quantity="many">Usunąć %d wydarzeń?</item>
|
||||
<item quantity="other">Usunąć %d wydarzeń?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">Usunięto %d wydarzenie</item>
|
||||
<item quantity="few">Usunięto %d wydarzenia</item>
|
||||
<item quantity="many">Usunięto %d wydarzeń</item>
|
||||
<item quantity="other">Usunięto %d wydarzeń</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d usunięto, %2$d nie udało się</string>
|
||||
<string name="search_delete_denied_partial">%1$d usunięto, następnie utracono uprawnienia do zapisu</string>
|
||||
<string name="settings_theme_hint">Czy aplikacja ma być jasna, czy ciemna. Wybór zostanie zastosowany natychmiast.</string>
|
||||
<string name="settings_theme_system_summary">Obecnie %1$s</string>
|
||||
<string name="settings_font_specimen_heading">czwartek, 14 maja</string>
|
||||
<string name="settings_font_specimen_body">Przegląd zespołu o 10:00, a potem lunch z Robinem w kawiarni na rynku.</string>
|
||||
<string name="settings_week_start_auto_summary">Obecnie %1$s</string>
|
||||
<string name="settings_time_format_auto_summary">Zgodnie z systemem: %1$s</string>
|
||||
<string name="settings_timeline_scale">Wysokość godzin</string>
|
||||
<string name="settings_timeline_scale_hint">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.</string>
|
||||
<string name="timeline_scale_fit_day">Cały dzień na ekranie</string>
|
||||
<string name="timeline_scale_fit_day_summary">Cała doba na jednym ekranie, bez przewijania</string>
|
||||
<string name="timeline_scale_compact">Kompaktowa</string>
|
||||
<string name="timeline_scale_compact_summary">Więcej godzin na ekranie, mniejsze bloki</string>
|
||||
<string name="timeline_scale_regular">Standardowa</string>
|
||||
<string name="timeline_scale_regular_summary">Standardowy rozmiar</string>
|
||||
<string name="timeline_scale_comfortable">Wygodna</string>
|
||||
<string name="timeline_scale_comfortable_summary">Większe bloki, więcej przewijania</string>
|
||||
<string name="timeline_scale_custom">Własna</string>
|
||||
<string name="timeline_scale_custom_summary">Wysokość, którą wybrałeś gestem „uszczypnięcia” osi czasu</string>
|
||||
<string name="settings_drag_to_reschedule">Przeciągnij, aby zmienić termin</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Przenieś wydarzenie, przeciągając je na inny dzień lub godzinę</string>
|
||||
<string name="settings_past_events_sample_morning">Przegląd zespołu</string>
|
||||
<string name="settings_past_events_sample_midday">Lunch z Robin</string>
|
||||
<string name="settings_past_events_sample_evening">Próba chóru</string>
|
||||
<string name="settings_widget_size">Rozmiar widżetu agendy</string>
|
||||
<string name="settings_widget_size_hint">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.</string>
|
||||
<string name="settings_widget_size_small">Mały</string>
|
||||
<string name="settings_widget_size_medium">Średni</string>
|
||||
<string name="settings_widget_size_large">Duży</string>
|
||||
<string name="settings_widget_size_extra_large">Bardzo duży</string>
|
||||
<string name="settings_widget_size_summary">Tekst wydarzenia %1$d%%</string>
|
||||
<string name="settings_event_duration">Domyślny czas trwania</string>
|
||||
<string name="settings_event_duration_hint">Czas trwania nowego wydarzenia do momentu zmiany godziny jego zakończenia. Nie dotyczy to wydarzeń całodniowych.</string>
|
||||
<string name="settings_calendar_durations_title">Czas trwania dla poszczególnych kalendarzy</string>
|
||||
<string name="settings_calendar_durations_hint">Przypisz kalendarzowi własną domyślną długość — np. 8 godzin dla zmian w pracy.</string>
|
||||
<string name="settings_calendar_duration_inherits">Domyślny (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">Używaj domyślnego czasu trwania (%1$s)</string>
|
||||
<string name="settings_allday_reminder_fires_at">Powiadamia o %1$s</string>
|
||||
<string name="settings_group_look">Wygląd i zachowanie</string>
|
||||
<string name="settings_group_data">Dane</string>
|
||||
<string name="settings_group_app">Aplikacja</string>
|
||||
<string name="settings_group_about">O aplikacji</string>
|
||||
<string name="settings_widgets_subtitle">Widżet agendy, kafelek Szybkich ustawień</string>
|
||||
<string name="settings_backup_subtitle">Eksport, import, automatyczna kopia zapasowa</string>
|
||||
<string name="settings_section_widgets">Widżety i kafelki</string>
|
||||
<string name="settings_widgets_hint">Ustawienia widżetów ekranu głównego i kafelka Szybkich ustawień. Aby dodać widżet, naciśnij i przytrzymaj na ekranie domowym.</string>
|
||||
<string name="settings_section_backup">Kopia zapasowa i przywracanie</string>
|
||||
<string name="settings_views_all_header">Wszystkie widoki</string>
|
||||
<string name="settings_week_day_header">Tydzień i dzień</string>
|
||||
<string name="settings_default_view_hint">Widok, który pojawia się po uruchomieniu aplikacji Calendula.</string>
|
||||
<string name="settings_week_start_hint">Dzień, od którego zaczyna się każdy tydzień we wszystkich widokach i widżetach.</string>
|
||||
<string name="settings_time_format_hint">Format godziny w aplikacji. Opcja automatyczna dostosowuje się do ustawień systemowych.</string>
|
||||
<string name="settings_past_events_hint">Co agenda robi z wydarzeniami, które już się zakończyły.</string>
|
||||
<string name="settings_dynamic_color_summary">Dopasuj kolory aplikacji do swojej tapety.</string>
|
||||
<string name="settings_about_privacy">Polityka prywatności</string>
|
||||
<string name="calendars_visibility_hint">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.</string>
|
||||
<string name="calendars_visibility_a11y">Pokaż \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Niektóre kalendarze są wyłączone</string>
|
||||
<string name="calendars_visibility_notice_message">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.</string>
|
||||
<string name="calendar_picker_missing_title">Brakuje jakiegoś kalendarza?</string>
|
||||
<string name="calendar_picker_missing_summary">Może być wyłączony, tylko do odczytu lub wypełniony na podstawie Twoich kontaktów — zarządzaj swoimi kalendarzami tutaj.</string>
|
||||
<string name="calendars_state_read_only">Tylko do odczytu</string>
|
||||
<string name="calendars_state_not_synced">Niezsynchronizowany z tym urządzeniem</string>
|
||||
<string name="calendars_state_managed">Wypełniony na podstawie Twoich kontaktów</string>
|
||||
<string name="calendars_managed_delete_locked">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.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="onboarding_backup_skip_button">Nie teraz</string>
|
||||
</resources>
|
||||
|
||||
@@ -128,8 +128,7 @@ class EventWriteMapperTest {
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
series: Long = seriesStart,
|
||||
exdate: String? = null,
|
||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
|
||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
||||
|
||||
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||
private fun instantAt(local: String, zoneId: String): Long =
|
||||
@@ -559,239 +558,6 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||
}
|
||||
|
||||
// --- 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 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 `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 `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, 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(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||
.isEqualTo("20260724")
|
||||
}
|
||||
|
||||
@Test
|
||||
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 `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, 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)),
|
||||
)
|
||||
// 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 `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 `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(
|
||||
exdateAfter(
|
||||
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
|
||||
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
|
||||
isAllDay = false,
|
||||
),
|
||||
).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 ---
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user