Compare commits
4 Commits
c0b7ad29ef
...
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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **"Only this event" now actually saves your edit.** On some calendars —
|
|
||||||
including Google ones that still show as on-device, and any local calendar —
|
|
||||||
editing a single occurrence of a repeating event did nothing at all: the scope
|
|
||||||
dialog closed, the edit screen stayed put, and saving again just repeated it.
|
|
||||||
Android can only attach a single-occurrence change to its series once the
|
|
||||||
calendar has been synced at least once, so on those calendars the change had
|
|
||||||
nowhere to go. Calendula now removes that one occurrence from the series and
|
|
||||||
saves the edit as its own event instead, which is what you see either way. A
|
|
||||||
save that does fail also says so for longer, rather than flashing past
|
|
||||||
([#234]).
|
|
||||||
|
|
||||||
## [2.19.3] — 2026-08-22
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1483,4 +1471,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
|
||||||
|
|||||||
@@ -232,15 +232,10 @@ interface CalendarDataSource {
|
|||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Events._ID` of the row now holding them.
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
*
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
* A series with a `_sync_id` gets a modified-occurrence exception. One
|
|
||||||
* without gets the occurrence excluded from the parent via EXDATE plus a
|
|
||||||
* standalone event carrying the edits — an exception cannot link to its
|
|
||||||
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
|
||||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(
|
fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
@@ -1199,13 +1194,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
|
||||||
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
|
|
||||||
// something on a row that recurs, so a non-recurring one keeps the
|
|
||||||
// exception path rather than getting a recurrence set written onto it.
|
|
||||||
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
|
||||||
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
|
||||||
}
|
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -1224,89 +1212,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Edit only this event" on a series with **no `_sync_id`**: drop the
|
|
||||||
* occurrence from the parent with EXDATE and insert the edited values as a
|
|
||||||
* standalone event on the same calendar.
|
|
||||||
*
|
|
||||||
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
|
||||||
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
|
||||||
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
|
||||||
* EXDATE plus a standalone row needs no link — what a detached instance
|
|
||||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
|
||||||
*
|
|
||||||
* The detached row keeps no stored link back to its series, so: it no longer
|
|
||||||
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
|
||||||
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
|
||||||
* re-timing the whole series brings the occurrence back beside the copy (a
|
|
||||||
* #47 delete resurrects the same way); and it is built from the form, not
|
|
||||||
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
|
|
||||||
* preserves are dropped — the same limitation as [moveEvent].
|
|
||||||
*
|
|
||||||
* Insert first, so a failure leaves the series untouched
|
|
||||||
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
|
||||||
* EXDATE update then fails, since it would be a visible duplicate. The
|
|
||||||
* reverse order risks the worse outcome — an excluded occurrence with no
|
|
||||||
* replacement, i.e. an edit that quietly deletes.
|
|
||||||
*/
|
|
||||||
private fun detachOccurrence(
|
|
||||||
eventId: Long,
|
|
||||||
beginMillis: Long,
|
|
||||||
row: SeriesRow,
|
|
||||||
form: EventForm,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): Long {
|
|
||||||
// Already detached (or deleted) from a stale screen still pointing at the
|
|
||||||
// parent: the EXDATE merge would fold the repeat away and still report a
|
|
||||||
// changed row, quietly leaving a *second* standalone copy.
|
|
||||||
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
|
|
||||||
throw NoSuchEventException(eventId)
|
|
||||||
}
|
|
||||||
// Reminders, guests and colour come along like any new event, and so does
|
|
||||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
|
||||||
// parent's would collide with it in .ics restore dedup.
|
|
||||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = row.exdate,
|
|
||||||
occurrenceMillis = beginMillis,
|
|
||||||
dtStartMillis = row.dtStartMillis,
|
|
||||||
rrule = row.rrule,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
|
||||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
|
||||||
// provider's expansion fails to match.
|
|
||||||
val updatedRows = try {
|
|
||||||
resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(), null, null,
|
|
||||||
)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw t
|
|
||||||
}
|
|
||||||
if (updatedRows == 0) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw WriteFailedException(
|
|
||||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return detachedId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
|
||||||
* update failed. Best effort: the caller is already throwing, and the worst
|
|
||||||
* case is the duplicate we were avoiding — never a lost occurrence.
|
|
||||||
*/
|
|
||||||
private fun rollBackDetached(detachedId: Long) {
|
|
||||||
runCatching { deleteEvent(detachedId) }.onFailure {
|
|
||||||
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateEventFromOccurrence(
|
override fun updateEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -243,21 +243,6 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The form as a **detached occurrence**: the same edited values, with the
|
|
||||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
|
||||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
|
||||||
*
|
|
||||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
|
||||||
* exception row can't attach to its parent at all (Codeberg #234).
|
|
||||||
*
|
|
||||||
* The exception path gets the rule dropped for free — the provider clears the
|
|
||||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
|
||||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
|
||||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
|
||||||
*/
|
|
||||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
@@ -450,23 +435,6 @@ internal fun buildOccurrenceExdateValues(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
|
||||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
|
||||||
*
|
|
||||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
|
||||||
* merge folds the repeat away silently and the update still reports one row
|
|
||||||
* changed, so a second save would leave a second standalone copy.
|
|
||||||
*/
|
|
||||||
internal fun exdateContains(
|
|
||||||
existingExdate: String?,
|
|
||||||
occurrenceMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
): Boolean {
|
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
|
||||||
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
* 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
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -265,16 +264,13 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
|
||||||
// the only sign anything happened — long rather than the default
|
|
||||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
|
||||||
SaveUiState.Failed -> {
|
SaveUiState.Failed -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||||
}
|
}
|
||||||
SaveUiState.NeedsPermission -> {
|
SaveUiState.NeedsPermission -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
}
|
}
|
||||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -57,8 +56,6 @@ import kotlin.time.Duration.Companion.minutes
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val TAG = "EventEdit"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
@@ -754,15 +751,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
SaveUiState.NeedsPermission
|
SaveUiState.NeedsPermission
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
// The event or occurrence is already gone: the same answer the
|
|
||||||
// pre-check gives, and better than a bare "couldn't save".
|
|
||||||
SaveUiState.Gone
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// The user only gets a generic snackbar, so without this a failed
|
|
||||||
// write leaves nothing to report (Codeberg #234). Scope and event
|
|
||||||
// id only — never the form's content.
|
|
||||||
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
|
||||||
SaveUiState.Failed
|
SaveUiState.Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
|
<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="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_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_request_button">Przyznaj dostęp do kalendarza</string>
|
||||||
<string name="permission_denied_title">Brak dostępu 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_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_open_settings_button">Otwórz ustawienia systemowe</string>
|
||||||
<string name="permission_retry_button">Spróbuj ponownie</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_title">Prywatność z założenia</string>
|
||||||
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu.</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_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_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>
|
<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">Domyślne przypomnienie</string>
|
||||||
<string name="settings_default_reminder_allday">Wydarzenia całodniowe</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">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_none">Brak</string>
|
||||||
<string name="reminder_use_default">Użyj domyślnego przypomnienia</string>
|
<string name="reminder_use_default">Użyj domyślnego przypomnienia</string>
|
||||||
<string name="reminder_custom_amount">jednostek</string>
|
<string name="reminder_custom_amount">jednostek</string>
|
||||||
@@ -353,10 +353,10 @@
|
|||||||
<string name="settings_language_auto">Domyślny systemu</string>
|
<string name="settings_language_auto">Domyślny systemu</string>
|
||||||
<string name="settings_translate">Pomóż w tłumaczeniu</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_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_appearance_subtitle">Motyw, kolory, czcionki</string>
|
||||||
<string name="settings_views_subtitle">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu</string>
|
<string name="settings_views_subtitle">Domyślny widok, układ, kolejność</string>
|
||||||
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
<string name="settings_event_form_subtitle">Domyślne pola i zachowanie</string>
|
||||||
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</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_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
|
||||||
<string name="settings_section_special_dates">Szczególne daty 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>
|
<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_tomorrow">Jutro</string>
|
||||||
<string name="reminder_day_yesterday">Wczoraj</string>
|
<string name="reminder_day_yesterday">Wczoraj</string>
|
||||||
<string name="agenda_no_more_today">Brak zaplanowanych wydarzeń</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">Harmonizuj kolory 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_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">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="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>
|
<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_no_events">Brak planów</string>
|
||||||
<string name="month_split_expand">Pokaż cały miesiąc</string>
|
<string name="month_split_expand">Pokaż cały miesiąc</string>
|
||||||
<string name="month_split_collapse">Pokaż wydarzenia dnia</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>
|
</resources>
|
||||||
|
|||||||
@@ -558,174 +558,6 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- toDetachedOccurrence ("edit only this event", no _sync_id) ---
|
|
||||||
|
|
||||||
@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 `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 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 `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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 `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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user