Compare commits

..

4 Commits

Author SHA1 Message Date
Jean-Luc Makiola
0f1df4800d Translations update from Weblate (#247)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/247
2026-08-27 20:38:38 +02:00
Weblate
7351b2d35c Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Bazyli Cyran
1a66a31563 Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Weblate
fe3c659804 Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
7 changed files with 140 additions and 388 deletions

View File

@@ -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

View File

@@ -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,16 +1194,6 @@ class AndroidCalendarDataSource @Inject constructor(
form: EventForm, form: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long { ): 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. // The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues( val values = buildOccurrenceExceptionValues(
form = form, form = form,
@@ -1227,116 +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: 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( override fun updateEventFromOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,

View File

@@ -243,26 +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.
*
* 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 * 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`
@@ -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 * 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

View File

@@ -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,17 +264,13 @@ fun EventEditScreen(
viewModel.reset() viewModel.reset()
onSaved() 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 -> { 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

View File

@@ -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,16 +751,7 @@ class EventEditViewModel @Inject constructor(
throw e throw e
} catch (e: SecurityException) { } catch (e: SecurityException) {
SaveUiState.NeedsPermission 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) { } 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 SaveUiState.Failed
} }
} }

View File

@@ -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 opuszcza 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>

View File

@@ -558,183 +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")
// 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 --- // --- per-event colour ---
@Test @Test