Compare commits
4 Commits
bc49730ea5
...
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
|
||||
- **"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
|
||||
|
||||
### 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
|
||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||
|
||||
@@ -232,15 +232,10 @@ interface CalendarDataSource {
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||
* `Events._ID` of the row now holding them.
|
||||
*
|
||||
* 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].
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun updateOccurrence(
|
||||
eventId: Long,
|
||||
@@ -1199,16 +1194,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
val row = querySeriesRow(eventId)
|
||||
// Deliberately stricter than deleteOccurrence's bare _sync_id check: the
|
||||
// detach path drops the occurrence with EXDATE, which only means anything
|
||||
// on a row that actually recurs. Without an RRULE there is nothing to
|
||||
// exclude from, so such a row keeps the existing path rather than getting
|
||||
// a recurrence set written onto a one-off event. The UI only offers the
|
||||
// scope choice for a recurring event, so neither case is reachable today.
|
||||
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.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
@@ -1227,116 +1212,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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: the insert either fails outright or lands
|
||||
* an orphan row, and the parent's expansion collapses — which is why editing
|
||||
* one occurrence of such a series did nothing at all, and occasionally left a
|
||||
* stray copy behind (Codeberg #234). The reporter's calendar was a Google one
|
||||
* that "shows as on-device", i.e. rows the sync adapter had not stamped yet;
|
||||
* Calendula's own local and contact special-date calendars are permanently in
|
||||
* this shape.
|
||||
*
|
||||
* EXDATE + a standalone row needs no parent link, and is what a detached
|
||||
* instance degrades to when there is no `RECURRENCE-ID` to carry it: the user
|
||||
* sees one edited event where the occurrence was, and the rest of the series
|
||||
* untouched.
|
||||
*
|
||||
* What that costs, plainly, because none of it is recoverable later — the
|
||||
* detached row has *no* stored link back to its series, which is the whole
|
||||
* reason this path exists:
|
||||
* - It stops travelling with the series. Moving the series to another
|
||||
* calendar leaves it behind ([moveEvent] copies the master and its
|
||||
* `ORIGINAL_ID` children; this is neither), and deleting the whole series
|
||||
* leaves it standing where an exception row would have gone with the parent.
|
||||
* - Its EXDATE hole is an absolute instant. Editing the series' *time* for all
|
||||
* events moves the generated instances but not the hole, so the occurrence
|
||||
* comes back alongside the detached copy. That staleness predates this path
|
||||
* — a #47 delete resurrects the same way — but a duplicate is a louder
|
||||
* symptom than a resurrection, and it wants fixing at the series-update end.
|
||||
* - It is built from the form, not cloned from the parent, so columns the form
|
||||
* doesn't model are dropped rather than inherited: `ORGANIZER`, `STATUS`,
|
||||
* and the organizer/resource attendee rows [reconcileAttendees] otherwise
|
||||
* preserves. Same limitation as [moveEvent]. Rare on the calendars that
|
||||
* reach this path, which are locally authored, but not impossible.
|
||||
*
|
||||
* Order is deliberate. The insert goes first, so a failure there leaves the
|
||||
* series completely untouched (the same discipline as
|
||||
* [updateEventFromOccurrence]). If the EXDATE update then fails, the new row
|
||||
* is a visible duplicate of an occurrence that is still in the series, so it
|
||||
* is rolled back before the failure surfaces — better a save the user can
|
||||
* retry than a silent duplicate. The reverse order would risk the opposite:
|
||||
* an occurrence excluded from the series 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): the series no longer contains this
|
||||
// occurrence, so there is nothing here to edit. Reached from a stale
|
||||
// screen still pointing at the parent — without this the EXDATE merge
|
||||
// would fold the repeat away, the update would still report a changed
|
||||
// row, and the save would quietly leave a *second* standalone copy.
|
||||
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
|
||||
throw NoSuchEventException(eventId)
|
||||
}
|
||||
// Carries the form's reminders, guests and colour like any new event —
|
||||
// and a fresh UID, because the detached row really is a separate event
|
||||
// now: 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: this is 1 whenever the series
|
||||
// row still exists. It catches the row disappearing under us, not an
|
||||
// EXDATE the provider's expansion fails to match — that would report
|
||||
// success and leave the duplicate. Same rollback idiom as moveEvent.
|
||||
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 a rollback
|
||||
* that itself fails must not replace the real failure with a confusing one.
|
||||
* The worst case is the duplicate we were trying to avoid, which the user can
|
||||
* see and delete — 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(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -243,26 +243,6 @@ internal fun buildOccurrenceExceptionValues(
|
||||
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.
|
||||
*
|
||||
* This is the "edit only this event" shape for a series with **no `_sync_id`**,
|
||||
* where an exception row can't be used at all — see [buildOccurrenceExdateValues]
|
||||
* for why the parent link never forms. The occurrence is dropped from the parent
|
||||
* with EXDATE and re-created as its own event, which is what a detached instance
|
||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
||||
*
|
||||
* Dropping the rule mirrors what the exception path gets for free: the provider
|
||||
* clears the RRULE it cloned from the parent when an exception carries
|
||||
* DTSTART + DURATION ([buildOccurrenceExceptionValues]). Here nothing is cloned,
|
||||
* so the rule has to be stripped by hand — leaving it on would insert a second
|
||||
* *series* overlapping the first, which is the duplication this path exists to
|
||||
* avoid.
|
||||
*/
|
||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
||||
|
||||
/**
|
||||
* 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`
|
||||
@@ -455,26 +435,6 @@ internal fun buildOccurrenceExdateValues(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||
* — i.e. this occurrence has already been dropped from the series (deleted, or
|
||||
* detached into its own event).
|
||||
*
|
||||
* Guards the detach path against running twice for the same occurrence, which a
|
||||
* stale detail screen still pointing at the parent can otherwise reach. The
|
||||
* EXDATE merge folds the repeat away silently and the parent update still
|
||||
* reports one row changed, so without this check the second save would leave a
|
||||
* *second* standalone copy and call it a success.
|
||||
*/
|
||||
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
|
||||
* 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.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -265,17 +264,13 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
onSaved()
|
||||
}
|
||||
// A failed save leaves the user on a form that looks exactly as it
|
||||
// did, so the snackbar is the only sign anything happened — it gets
|
||||
// the long duration rather than the default flash (Codeberg #234:
|
||||
// the failure read as "nothing happens at all").
|
||||
SaveUiState.Failed -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||
}
|
||||
SaveUiState.NeedsPermission -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||
}
|
||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||
else -> Unit
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -57,8 +56,6 @@ import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "EventEdit"
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||
* want different reminder handling (#49), and differ in whether they own the
|
||||
@@ -754,16 +751,7 @@ class EventEditViewModel @Inject constructor(
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
SaveUiState.NeedsPermission
|
||||
} catch (e: NoSuchEventException) {
|
||||
// The write found the event (or the occurrence) already gone —
|
||||
// the same answer the pre-check gives, and a far better one than
|
||||
// a bare "couldn't save" for something that no longer exists.
|
||||
SaveUiState.Gone
|
||||
} catch (e: Exception) {
|
||||
// The user only gets a generic snackbar, so without this a
|
||||
// failed write leaves no trace at all 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -558,183 +558,6 @@ class EventWriteMapperTest {
|
||||
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")
|
||||
// The rule must not survive: without a _sync_id nothing clears an
|
||||
// inherited RRULE the way the exception path's DTSTART + DURATION does,
|
||||
// so keeping it 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 — the invariant buildEventInsertValues
|
||||
// holds for every non-recurring event.
|
||||
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`() {
|
||||
// The detached row is built from the form rather than cloned from the
|
||||
// parent, so anything the user edited has to survive the trip — 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 too — a detached occurrence must not be
|
||||
// silently 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 and guests aren't columns — the insert path seeds them from
|
||||
// the form, so they only have to survive on the form itself.
|
||||
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 of the no-_sync_id edit have to agree, or the user sees
|
||||
// the occurrence twice or not at all. With the time left untouched, the
|
||||
// EXDATE stamp and the detached row's DTSTART describe the same instant.
|
||||
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 fold the repeated EXDATE away and still report a
|
||||
// changed row, leaving a second standalone copy of one occurrence.
|
||||
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 and must not
|
||||
// hide an exclusion — that would let a duplicate through.
|
||||
assertThat(
|
||||
exdateContains(
|
||||
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
||||
1_781_164_800_000L,
|
||||
isAllDay = false,
|
||||
),
|
||||
).isTrue()
|
||||
}
|
||||
|
||||
// --- per-event colour ---
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user