Compare commits

..

4 Commits

Author SHA1 Message Date
db4611d284 fix(ics): round all-day reminders up and stop dropping them on export (#225)
Review follow-ups on the import work:

- All-day reminder offsets are `days * 1440 - timeOfDay`, so rounding to the
  nearest day lost a day for any producer whose all-day notifications fire after
  noon ("1 day before at 18:00" arrives as -PT6H). Round up instead.
- Export decodes an all-day row's raw provider offset back to its whole-day lead
  time. It is normally negative, and the writer discards a trigger that fires
  after the event, so our own backups came back with no all-day reminder at all.
- A floating EXDATE DATE-TIME is read in the series' zone, not the device's;
  otherwise the exclusion lands on an instant no occurrence has.
- Wire the parsed calendar name up: it had no reader, so the CATEGORIES handling
  was inert. It now preselects a target calendar of that name.
2026-08-27 19:36:45 +02:00
c0361686df fix(import): don't lose Fossify's contact birthdays and anniversaries (#225)
Found the actual cause. Fossify mirrors Contacts birthdays and
anniversaries with startTS == endTS (MainActivity), so its exporter's
dayCode(endTS + 12h) rounds back to the starting day and writes
DTEND == DTSTART. We read that literally: a yearly series with
DURATION:P0D, which the provider expands into no instances at all. The
import reported success and the events were nowhere — matching the
report exactly ("birthdays, memorials, dates").

An all-day event may no longer end at or before it starts, and an all-day
DURATION is floored at P1D. Verified against the released parser, which
returns days=0.0 for all three fixture events.

Do not generalise this into sniffing PRODID: the same exporter is correct
for UI-created events (endTS anchors at noon of the last day) and for
CalDAV rows, and Fossify's bundled holiday files are conformant while
naming Fossify in their PRODID. Both are kept as fixtures.

Also: existingUids counted DELETED rows, so re-importing after deleting
events skipped everything as duplicates.

Closes #225
2026-08-19 20:38:41 +02:00
3e3ff4fd2f chore: keep .ics test fixtures byte-exact (CRLF) 2026-08-19 20:26:20 +02:00
2ed18e9938 fix(import): make .ics import survive events the provider rejects (#225)
importEvents inserted every event in one unguarded loop, so a single row
the provider refused — it throws on a malformed RRULE straight out of
insert — aborted the batch and left the user with "couldn't read this
file" and nothing imported. Each event is now isolated and rejects are
counted into IcsImportSummary.failed and shown. sanitizeRrule drops empty
and malformed rule parts before the write.

Alongside that, several things a foreign file carries that we dropped:
VTODO components (silently lost, now imported as events), EXDATE, the
CATEGORIES calendar name, and colours — X-FOSSIFY-EVENT-COLOR / COLOR /
the category colour plus the X-SMT-* legacy spellings, snapped in Oklab
to the nearest key a palette account publishes since those reject a raw
EVENT_COLOR.

Two correctness fixes on our side: an all-day event may no longer end at
or before it starts (the provider expands a zero-length series into no
instances, so it just disappears), and imported all-day reminders now go
through the same encoding as hand-created ones instead of firing at UTC
midnight. A VALARM trigger pointing after the start is read as a time of
day rather than clamped to zero.

Note for later: their all-day DTEND is RFC-correct — endTS anchors at
noon of the last day and the exporter's +12h rounds it to the following
midnight. Do not "fix" it by sniffing PRODID; the holiday files bundled
in Fossify are conformant and name Fossify in theirs. One is kept as a
fixture.

Refs #225
2026-08-19 20:26:02 +02:00
41 changed files with 1676 additions and 1796 deletions

1
.gitattributes vendored
View File

@@ -5,3 +5,4 @@
*.jpg binary
*.gif binary
*.webp binary
app/src/test/resources/ics/*.ics -text

View File

@@ -551,11 +551,12 @@ jobs:
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `production` — the release itself is already the
# gate (a bumped versionName only reaches main after on-device review), so
# a second manual promotion in the Play Console bought nothing but delay.
# Override with the PLAY_TRACK repo variable to stage a release instead.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
# Where the bundle lands. `internal` by default so a release reaches
# testers rather than the public, and promotion to production stays a
# deliberate human action in the Play Console — the same posture as
# holding UI releases for on-device review. Override with the PLAY_TRACK
# repo variable once the flow is trusted.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload.

View File

@@ -8,32 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **A deleted occurrence no longer comes back when the series is re-timed.**
Delete a single occurrence of a repeating event, then change the time of the
whole series, and the occurrence you had removed reappeared. Doing it the
other way round — "This and all following events" — lost every removal in the
part of the series being changed. Removals now travel with the event: they
keep their place in the series across a time change, a move to a different
day, a timezone change and a switch to or from an all-day event, and they
carry over when a series is split. Repeating events on your device's own
calendars are affected, including the birthday and anniversary calendars
Calendula creates from your contacts ([#248]).
## [2.19.3] — 2026-08-22
- **A failed import no longer costs you the whole file.** One event the calendar
refuses used to abort the entire import with nothing on screen but "couldn't
read this file". Events are now added one at a time: anything rejected is
counted and shown, and the rest still arrive. A faulty repeat rule in the file
is repaired instead of being handed on ([#225]).
- **All-day reminders survive a backup and an import.** They fire at the hour you
chose, not at midnight UTC — the same rule the app already applied to all-day
events you create yourself — and an export no longer leaves them out of the
file entirely ([#225]).
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
Fossify writes the ones it mirrors from your contacts as events that start and
end on the same day, which the calendar read as lasting no time at all: they
imported without complaint and then appeared nowhere. Any all-day event that
ends where it starts, or carries no end at all, is now a one-day event
([#225]).
### Added
- **Czech, Japanese and Russian**, all three contributed as community
translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are early — Czech covers about a third of the app, Russian a fifth,
Japanese has just started — so untranslated parts still show in English until
they fill out. Pick any of them under Settings → Language or in Android's
per-app language settings. Thanks to James Trivette (Czech), ame0011
(Japanese), and chip and Pablo (Russian); help finishing them is very welcome.
### Changed
- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to
bkrz for carrying it the rest of the way.
- **More of an imported `.ics` survives the trip**: tasks come across as events
rather than being dropped in silence, deleted occurrences of a repeating event
stay deleted, a calendar named like the one the file came from is preselected,
and event colours are carried over — matched to the closest colour your
calendar's account offers, so a migrated calendar still reads at a glance
([#225]).
## [2.19.2] — 2026-08-17
@@ -1483,4 +1480,4 @@ automatically, with zero telemetry and no internet permission.
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225

View File

@@ -18,8 +18,6 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
&nbsp;
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
@@ -88,7 +86,7 @@ Pick whichever channel you already use — they all install the same app:
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
| [Google Play](#google-play) | Per release | Signed with Google's key — see below |
| [Google Play](#google-play-coming-soon) | — | Coming soon |
| [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended)
@@ -133,14 +131,18 @@ or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
one appears.
### Google Play
### Google Play (coming soon)
Calendula is live on Google Play:
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**.
Calendula is on its way to Google Play as an additional channel. It isn't live
yet — this section gets a link once it is. Play builds will be signed with
Google's key rather than mine, so switching between Play and any other channel
will require an uninstall.
Play builds are signed with Google's key rather than mine, so switching between
Play and any other channel requires an uninstall (and with it, a fresh start for
app settings — your events live in the system calendar and are unaffected).
> **Testers wanted.** Play requires a round of closed testing before the app can
> go public, and I'm still looking for testers. If you'd like to help, email
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
> Google account address you want to use — that address is what I need to add you
> to the closed test.
### Build from source
@@ -149,7 +151,7 @@ The build is a plain Gradle build with no proprietary dependencies — see
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
the same signing key, so you can switch freely between them without
reinstalling. Google Play is the exception — see above.</sub>
reinstalling.</sub>
## 📚 Documentation

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21903
versionName = "2.19.3"
versionCode = 21902
versionName = "2.19.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
@@ -90,8 +91,10 @@ interface CalendarDataSource {
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
* given, only those calendars are exported (still intersected with the
* eligible set); `null` exports every eligible calendar.
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
* lead times rather than raw provider offsets (see [toIcsEvent]).
*/
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -101,10 +104,18 @@ interface CalendarDataSource {
/**
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
* minting one when absent); returns the new `Events._ID`. Reminders are
* written as the file's raw lead minutes (METHOD_ALERT).
* minting one when absent); returns the new `Events._ID`.
*
* [colorPalette] is the target account's published event colours, looked up
* once per import; [allDayReminderTimeMinutes] is the user's preferred
* all-day firing time, applied exactly as a hand-created event's is.
*/
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
@@ -743,7 +754,10 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
@@ -778,6 +792,7 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
}
} ?: emptyList()
@@ -786,55 +801,53 @@ class AndroidCalendarDataSource @Inject constructor(
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events.UID_2445),
// DELETED rows linger until a sync adapter purges them; counting those
// as present would make a re-import skip everything the user has since
// deleted, reporting "all duplicates" and importing nothing.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL",
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " +
"${CalendarContract.Events.DELETED} = 0",
arrayOf(calendarId.toString()),
null,
)?.use { c ->
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
} ?: emptySet()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
val startMillis = event.start.toEpochMillis()
val endMillis = event.end.toEpochMillis()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
val values = buildImportedEventValues(
event = event,
calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
put(
CalendarContract.Events.UID_2445,
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
palette = colorPalette,
)
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri)
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
.atZone(ZoneOffset.UTC).toLocalDate()
event.semanticReminderMinutes().forEach { minutes ->
// An all-day reminder is stored the same way a hand-created one is,
// so it fires at the time the user picked rather than at UTC midnight.
val providerMinutes = if (event.isAllDay) {
toProviderAllDayMinutes(
semanticMinutes = minutes,
startDate = startDate,
zone = ZoneId.systemDefault(),
timeOfDayMinutes = allDayReminderTimeMinutes,
)
} else {
minutes
}
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
@@ -844,18 +857,6 @@ class AndroidCalendarDataSource @Inject constructor(
return eventId
}
/** Provider DURATION for an imported recurring row: whole days / seconds. */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
@@ -976,12 +977,10 @@ class AndroidCalendarDataSource @Inject constructor(
updated: EventForm,
allDayReminderTimeMinutes: Int,
) {
val row = querySeriesRow(eventId)
val values = buildEventUpdateValues(
original = original,
updated = updated,
seriesDtStartMillis = row.dtStartMillis,
seriesExdate = row.exdate,
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
zone = ZoneId.systemDefault(),
)
if (values.isNotEmpty()) {
@@ -1230,66 +1229,10 @@ class AndroidCalendarDataSource @Inject constructor(
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
carrySplitExdate(newEventId, row, beginMillis, original, updated)
truncateSeries(eventId, row, beginMillis)
return newEventId
}
/**
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
* series [newEventId] that now owns them, re-timed by the shift the split
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
* from the form, which knows nothing about them, so without this every
* occurrence the user had deleted from the tail of the series comes back.
*
* The whole time/recurrence set rides along with EXDATE for the reason
* [buildOccurrenceExdateValues] documents: on its own the provider does not
* read an EXDATE write as a recurrence change, and leaves the instances it
* expanded on insert standing.
*
* A failure rolls the new series back and throws, so the split fails whole
* rather than landing with the exclusions quietly dropped — the parent is
* still untruncated at this point, so the event is left exactly as it was.
*/
private fun carrySplitExdate(
newEventId: Long,
parent: SeriesRow,
beginMillis: Long,
original: EventForm,
updated: EventForm,
) {
// Dropping the recurrence in the same save leaves a one-off tail, and an
// exclusion means nothing on a row that doesn't recur.
if (updated.rrule.isNullOrBlank()) return
val carried = shiftedExdate(
existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay),
original = original,
updated = updated,
zone = ZoneId.systemDefault(),
) ?: return
val row = querySeriesRow(newEventId)
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
}
try {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) {
runCatching { deleteEvent(newEventId) }
throw t
}
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
// From the first occurrence on = the whole series; also the fallback

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log
import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Instant
@@ -210,26 +212,41 @@ class CalendarRepositoryImpl @Inject constructor(
}
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) }
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries.
val palette = dataSource.eventColorPalette(targetCalendarId)
val allDayMinutes = allDayReminderTimeMinutes()
var imported = 0
var skipped = 0
var failed = 0
for (event in events) {
// A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) {
skipped++
} else {
dataSource.insertImportedEvent(event, targetCalendarId)
continue
}
try {
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
imported++
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// One event the provider won't take must not cost the user the
// rest of the file — foreign exports do carry rows it rejects
// outright (a malformed RRULE throws straight out of insert).
failed++
Log.w(TAG, "Skipped an unimportable event", e)
}
}
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
IcsImportSummary(imported = imported, skippedDuplicate = skipped, failed = failed)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
@@ -288,6 +305,11 @@ class CalendarRepositoryImpl @Inject constructor(
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
private companion object {
const val TAG = "CalendarRepository"
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

View File

@@ -3,16 +3,17 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.ResolverStyle
import java.time.LocalDate as JavaLocalDate
import java.time.LocalDateTime as JavaLocalDateTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
@@ -137,21 +138,18 @@ internal fun buildEventInsertValues(
*
* Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
* shift the user applied to the displayed occurrence and is re-resolved in the
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
* replaces DTEND, RRULE is written, and the row's exclusions
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
* occurrences intact when someone edits a later occurrence's time, and keeps
* the anchor's time-of-day stable across a DST boundary or a zone change
* between the two.
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
* stable across a DST boundary or a zone change between the two.
*/
internal fun buildEventUpdateValues(
original: EventForm,
updated: EventForm,
seriesDtStartMillis: Long,
seriesExdate: String?,
zone: ZoneId,
): Map<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) {
@@ -191,10 +189,6 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null)
// The exclusions named occurrences of a series that no longer exists.
// Left behind they are dormant rather than harmless: adding a recurrence
// back later would punch the old holes into the new one.
put(CalendarContract.Events.EXDATE, null)
} else {
// Move the series anchor by the *wall-clock* shift the user applied to the
// displayed occurrence, then re-resolve it in the event's (possibly new)
@@ -216,14 +210,6 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
// only while the series keeps generating one at exactly that instant. The
// anchor has just moved, so every occurrence regenerates elsewhere and a
// stamp left behind matches none of them — the occurrence the user deleted
// comes back. Move the stamps the same way the anchor moved.
shiftedExdate(seriesExdate, original, updated, zone)
?.takeIf { it != seriesExdate }
?.let { put(CalendarContract.Events.EXDATE, it) }
}
}
@@ -438,7 +424,11 @@ internal fun buildOccurrenceExdateValues(
allDay: Int,
): Map<String, Any?> {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
val existing = existingExdate?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
val merged = (existing + stamp).distinct().joinToString(",")
return mapOf(
CalendarContract.Events.EXDATE to merged,
CalendarContract.Events.DTSTART to dtStartMillis,
@@ -449,122 +439,22 @@ internal fun buildOccurrenceExdateValues(
)
}
/**
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
* series anchor takes from [original] to [updated] — the exclusions' half of the
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
* new series when "this and following" splits one.
*
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
* the event's (possibly new) zone. A millisecond delta would instead bake in the
* offset that happened to apply at the edited occurrence — an hour off for any
* exclusion on the far side of a DST boundary. All-day-ness is read from
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
* convert into each other when the event switches.
*
* Null when there is nothing to carry: no stamps, or a stamp in a form Calendula
* never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those
* are left exactly as they are rather than guessed at — a stale stamp excludes
* nothing, but a mangled one could exclude the wrong occurrence.
*/
internal fun shiftedExdate(
existingExdate: String?,
original: EventForm,
updated: EventForm,
zone: ZoneId,
): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
val fromZone = original.writeZone(zone)
val toZone = updated.writeZone(zone)
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
return stamps
.map { stamp ->
val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: return null
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
}
.distinct()
.joinToString(",")
}
/**
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
* that belong to the *new* series once "this and following" splits a recurring
* event there. The parent keeps the full list; its stamps past the split point are
* simply inert once it stops generating those occurrences.
*
* The occurrence at [beginMillis] itself is never carried. It is the one the user
* is editing, so it exists by definition, and honouring a stale exclusion for it
* would swallow the edit whole.
*
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
*/
internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
return stamps
.filter { stamp ->
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null
utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis
}
.takeIf { it.isNotEmpty() }
?.joinToString(",")
}
/** The individual stamps of an EXDATE column value; it is a comma-separated list. */
private fun exdateStamps(exdate: String?): List<String> = exdate
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
* reads off the UTC calendar day.
*/
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
formatExdateStamp(
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
isAllDay = isAllDay,
zone = ZoneOffset.UTC,
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
return if (isAllDay) {
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
} else {
"%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
)
/**
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
*/
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
if (isAllDay) {
local.toLocalDate().format(ALL_DAY_EXDATE)
} else {
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
}
/**
* [stamp] as the wall clock it names in [zone] — the inverse of
* [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a
* caller can tell "not ours, leave it alone" from a value it may safely re-time.
*/
private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? =
runCatching {
if (isAllDay) {
JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay()
} else {
JavaLocalDateTime.parse(stamp, TIMED_EXDATE)
.atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime()
}
}.getOrNull()
/** `VALUE=DATE` EXDATE form, for an all-day series. */
private val ALL_DAY_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
/** UTC date-time EXDATE form, for a timed series. */
private val TIMED_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
@@ -613,3 +503,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
}
/**
* The `Events` row for a `.ics` event being imported into [calendarId].
*
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
* parser hands them over.
*
* [palette] is the target account's published event colours. A calendar that
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
*/
internal fun buildImportedEventValues(
event: ParsedIcsEvent,
calendarId: Long,
uid: String,
palette: List<EventColorOption>,
): Map<String, Any?> = buildMap {
val startMillis = event.start.toEpochMilliseconds()
val endMillis = event.end.toEpochMilliseconds()
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
event.exDates.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
event.color?.let { color ->
val key = palette.nearestTo(color)?.key
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
}
}
/**
* Provider `DURATION` for an imported recurring row: whole days for an all-day
* series, seconds otherwise. An all-day series is never shorter than a day —
* the provider expands a zero-length one into no instances at all, so the
* series would vanish (which is what a literal read of the Fossify family's
* inclusive DTEND used to produce; see "Importing foreign .ics" in
* docs/ARCHITECTURE.md).
*/
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}

View File

@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* [calendarName] the display name of its calendar (emitted as
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
* MapColumnReader.
*
* An all-day row's raw offset has the firing time of day encoded into it and is
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
* would be dropped as a trigger that fires after the event, losing the reminder.
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
* time the file should carry, which an import re-encodes for its own device.
*/
internal fun ColumnReader.toIcsEvent(
reminderMinutes: List<Int>,
calendarName: String?,
allDayReminderTimeMinutes: Int,
zone: ZoneId = ZoneId.systemDefault(),
): IcsEvent {
val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
@@ -40,13 +52,20 @@ internal fun ColumnReader.toIcsEvent(
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
isAllDay = isAllDay,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = reminderMinutes,
reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,

View File

@@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36
* apart that two swatches never read as the same colour in the grid.
*/
private const val MIN_DISTANCE = 0.025f
/**
* The palette entry closest to [argb], or null when the account publishes no
* palette (its calendars then take a raw `EVENT_COLOR` instead).
*
* Used to land a colour that came from outside the account — an imported `.ics`
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
* own keys. Distance is measured in Oklab, so the match is the one that looks
* closest rather than the one that is closest in RGB coordinates.
*/
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
val target = oklchOf(argb)
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
}

View File

@@ -0,0 +1,80 @@
package de.jeanlucmakiola.calendula.domain.ics
/** Opaque alpha, forced onto any colour that arrives without one. */
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
/**
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
*
* Three encodings are in the wild and all appear in the files Calendula is
* asked to import:
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
* (`COLOR:tomato`);
* - a raw signed Android colour int, which the Simple Calendar / Fossify
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
*
* Anything else — including the literal `null` Fossify emits when its calendar
* lookup misses — resolves to null.
*/
fun parseIcsColorValue(raw: String?): Int? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
if (value.startsWith("#")) {
val hex = value.substring(1)
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
return when (hex.length) {
6 -> parsed or OPAQUE_ALPHA
8 -> parsed
else -> null
}
}
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
return cssColors[value.lowercase()]
}
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
private val cssColors: Map<String, Int> by lazy {
CSS3_TABLE.split(' ').associate { entry ->
val name = entry.substringBefore('=')
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
}
}
private const val CSS3_TABLE =
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
"yellow=ffff00 yellowgreen=9acd32"

View File

@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
private const val DAY_MILLIS = 86_400_000L
private const val MINUTES_PER_DAY = 1_440
/**
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
* but [uid] is nullable (an incoming event may carry none; the insert layer
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
* how the writer encoded them.
*
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
* them — minutes *before* start, so a negative entry means "after start".
* [semanticReminderMinutes] turns those into the lead times Calendula models.
*/
data class ParsedIcsEvent(
val uid: String?,
@@ -30,8 +41,42 @@ data class ParsedIcsEvent(
val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy,
val calendarName: String? = null,
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
val color: Int? = null,
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
val exDates: List<String> = emptyList(),
/** True when this came from a `VTODO` rather than a `VEVENT`. */
val isTask: Boolean = false,
)
/**
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
* the event, never negative.
*
* For a timed event the raw lead time already is that. All-day reminders are
* whole days before the event, fired at the user's configured time of day (see
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
*
* Rounding to the nearest day instead would lose a day for every producer whose
* all-day notifications fire after noon: a file's raw offset is
* `days × 1440 timeOfDay`, so "1 day before at 18:00" arrives as
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
* ceils to zero days before.
*/
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
.map { raw ->
if (isAllDay) {
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
} else {
raw.coerceAtLeast(0)
}
}
.distinct()
.sorted()
/** Things the parser dropped rather than failing — surfaced in the import report. */
enum class IcsParseWarning {
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
@@ -45,6 +90,12 @@ enum class IcsParseWarning {
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
UnknownTimezone,
/** `VTODO` components were imported as events — Calendula has no task model. */
TasksImportedAsEvents,
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
RecurrenceRuleRepaired,
}
data class IcsParseResult(
@@ -52,8 +103,16 @@ data class IcsParseResult(
val warnings: Set<IcsParseWarning>,
)
/** Outcome of a bulk `.ics` import into one calendar. */
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
/**
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
* provider rejected — the import continues past them, so a single unusable
* event can't cost the user the rest of the file.
*/
data class IcsImportSummary(
val imported: Int,
val skippedDuplicate: Int,
val failed: Int = 0,
)
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
@@ -62,6 +121,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
* resolved against the OS tz database instead ([deviceZone] is the fallback).
*
* `VTODO` components are read as all-day/timed events: Calendula models no
* tasks, and dropping them would silently lose half of some exports.
*/
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
@@ -69,37 +131,49 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
var calendarName: String? = null
// Scanned up front rather than in document order: an X-WR-CALNAME after
// the first VEVENT still names the calendar every event came from.
val calendarName = lines.asSequence()
.mapNotNull(::parseContentLine)
.firstOrNull { it.name == "X-WR-CALNAME" }
?.let { unescapeText(it.value).trim().ifEmpty { null } }
var i = 0
while (i < lines.size) {
val line = parseContentLine(lines[i])
if (line == null) { i++; continue }
val component = when {
line.isBegin("VEVENT") -> "VEVENT"
line.isBegin("VTODO") -> "VTODO"
else -> null
}
when {
line.isBegin("VEVENT") -> {
val end = indexOfEnd(lines, i + 1, "VEVENT")
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
?.let(events::add)
component != null -> {
val end = indexOfEnd(lines, i + 1, component)
parseComponent(
body = lines.subList(i + 1, end),
fileCalendarName = calendarName,
warnings = warnings,
isTask = component == "VTODO",
)?.let(events::add)
i = end + 1
}
line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database.
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
}
line.name == "X-WR-CALNAME" -> {
calendarName = unescapeText(line.value).trim().ifEmpty { null }
i++
}
else -> i++
}
}
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
return IcsParseResult(events, warnings)
}
private fun parseVevent(
private fun parseComponent(
body: List<String>,
fileCalendarName: String?,
warnings: MutableSet<IcsParseWarning>,
isTask: Boolean,
): ParsedIcsEvent? {
var uid: String? = null
var summary = ""
@@ -112,7 +186,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
var status = EventStatus.Confirmed
var availability = Availability.Busy
var calendarName = fileCalendarName
var eventColor: Int? = null
var calendarColor: Int? = null
val reminders = mutableListOf<Int>()
val exDateLines = mutableListOf<IcsContentLine>()
var skipAsOverride = false
var i = 0
@@ -129,9 +206,12 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"UID" -> uid = line.value.trim().ifEmpty { null }
"SUMMARY" -> summary = unescapeText(line.value)
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
// A VTODO has no DTSTART of its own in most producers' output.
"DUE" -> if (isTask && dtStart == null) dtStart = parseIcsDateTime(line, warnings)
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
"DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
"EXDATE" -> exDateLines.add(line)
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value)
@@ -142,6 +222,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
// Fossify names the source calendar per event; it never writes
// an X-WR-CALNAME, so this is the only calendar label its files carry.
"CATEGORIES" -> calendarName = unescapeText(line.value).trim()
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
?: calendarName
// Per-event colour, most specific encoding first (see parseIcsColorValue).
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
eventColor = parseIcsColorValue(line.value) ?: eventColor
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
// The *calendar's* colour. Kept as a fallback so a migration that
// folds several source calendars into one keeps them apart visually.
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
}
i++
}
@@ -154,33 +247,111 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null
}
val end = dtEnd
?: duration?.let {
start.copy(
instant = Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
),
)
val cleanRrule = sanitizeRrule(rrule)
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) {
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
}
?: start
val end = resolveEnd(start, dtEnd, duration)
return ParsedIcsEvent(
uid = uid,
summary = summary,
start = start.instant,
end = end.instant,
end = end,
isAllDay = start.isAllDay,
zoneId = start.zoneId,
recurrenceRule = rrule,
recurrenceRule = cleanRrule,
location = location,
description = description,
reminderMinutes = reminders.distinct(),
status = status,
availability = availability,
calendarName = calendarName,
color = eventColor ?: calendarColor,
exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start),
isTask = isTask,
)
}
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
/**
* The event's end instant.
*
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
* since the provider expands a zero-length all-day series into no instances
* at all — an all-day event may never end at or before it starts.
*/
private fun resolveEnd(
start: IcsDateTime,
dtEnd: IcsDateTime?,
duration: String?,
): Instant {
val explicit = dtEnd?.instant ?: duration?.let {
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
}
/**
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
*
* Fossify stores its excluded occurrences as bare day codes and writes them
* out that way even for timed series, where the property is then ambiguous.
* Such a value is resolved against the series' own time of day, which is
* what it meant — its repetitions never move within the day.
*/
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) ->
if (token.isEmpty()) return@mapNotNull null
if (token.contains('T')) {
val instant = parseExDateTime(token, line, startZone) ?: return@mapNotNull null
if (start.isAllDay) utcDayCode(instant) else utcStamp(instant)
} else {
val date = parseBasicDate(token) ?: return@mapNotNull null
if (start.isAllDay) {
utcDayCode(date.atStartOfDayIn(TimeZone.UTC))
} else {
utcStamp(LocalDateTime(date, startTime).toInstant(startZone))
}
}
}
.distinct()
}
/**
* [startZone] is the series' own zone, which a floating value (no `Z`, no
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
* and reading it in the device's zone instead would put the exclusion on an
* instant no occurrence has.
*/
private fun parseExDateTime(
token: String,
line: IcsContentLine,
startZone: TimeZone,
): Instant? {
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when {
token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
?: startZone
}
return ldt.toInstant(zone)
}
/**
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
* trigger that fires after the start (which is how the Fossify family
* encodes "on the day at 09:00") comes back negative. Null when the trigger
* isn't a usable relative offset.
*/
private fun parseAlarmMinutes(body: List<String>): Int? {
val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) }
@@ -189,8 +360,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
val millis = parseRfc2445DurationMillis(trigger.value)
// Negative = before start (the normal case) → positive lead minutes.
return (-millis / 60_000L).toInt().coerceAtLeast(0)
return (-millis / 60_000L).toInt()
}
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
@@ -218,6 +388,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun utcDayCode(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02d".format(year, month.number, day)
}
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.domain.ics
private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
)
/** RRULE parts whose value is a comma-separated list, so empty items can be dropped. */
private val LIST_PARTS = setOf(
"BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY",
"BYWEEKNO", "BYMONTH", "BYSETPOS",
)
/**
* Make a foreign RRULE safe to hand `CalendarContract`, or return null when it
* can't be salvaged.
*
* The provider validates every RRULE through `EventRecurrence.parse`, which
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
* from a later read. One bad rule in an imported file would therefore abort the
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
* the state every weekly event it imported from elsewhere is left in.
*
* Empty and malformed parts are dropped rather than failing the rule; only a
* missing or unrecognised `FREQ` — without which there is no rule at all —
* returns null.
*/
fun sanitizeRrule(raw: String?): String? {
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
if (rule.isEmpty()) return null
var freqSeen = false
val parts = rule.split(';').mapNotNull { part ->
val key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
if (key.isEmpty() || value.isEmpty()) return@mapNotNull null
when {
key == "FREQ" -> {
if (value.uppercase() !in VALID_FREQ) return null
freqSeen = true
"FREQ=${value.uppercase()}"
}
// A non-positive or non-numeric INTERVAL is rejected by the provider.
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
key in LIST_PARTS -> value.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
?.let { "$key=${it.joinToString(",")}" }
else -> "$key=$value"
}
}
return if (freqSeen) parts.joinToString(";") else null
}

View File

@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
* calendar, exactly like a fresh create — the user confirms the target and
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
* handling (provider all-day times are UTC midnights with an exclusive end).
*
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
* is the first thing this form asks for, and that clears any colour (a raw ARGB
* is invalid on a calendar whose account publishes a palette). Bulk import,
* where the target is known up front, does keep it.
*/
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) {
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
end = end,
location = location.orEmpty(),
description = description.orEmpty(),
reminders = reminderMinutes.distinct().sorted(),
reminders = semanticReminderMinutes(),
availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)

View File

@@ -93,15 +93,11 @@ fun ImportScreen(
}
// Hoisted target calendar so the always-visible top-bar Import action can
// read it without the user scrolling to a bottom button. Defaults to the
// first *local* calendar — the first row the picker shows ("Your calendars"
// group leads) — so the pre-selection lines up with the top of the list;
// falls back to the first calendar if there are no local ones. Re-defaults
// when the "many" list first arrives (keyed on it), then holds the pick.
// read it without the user scrolling to a bottom button (see
// [defaultImportTarget] for the choice). Re-defaults when the "many" list
// first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many
val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
@@ -252,6 +248,15 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.summary.failed > 0) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.import_done_failed_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
@@ -290,6 +295,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.summary.failed > 0) {
ImportStatCard(
count = state.summary.failed,
label = stringResource(R.string.import_done_failed_label),
contentDescription = pluralStringResource(
R.plurals.import_done_failed,
state.summary.failed,
state.summary.failed,
),
container = MaterialTheme.colorScheme.errorContainer,
onContainer = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
Spacer(Modifier.weight(1f))
Button(
@@ -301,7 +319,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
}
}
/** A big-number tonal tile summarising one import outcome (added / skipped). */
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
@@ -343,6 +361,9 @@ private fun WarningText(warning: IcsParseWarning) {
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
IcsParseWarning.RecurrenceRuleRepaired ->
stringResource(R.string.import_warning_recurrence_repaired)
}
Text(
text = text,

View File

@@ -43,6 +43,12 @@ sealed interface ImportUiState {
val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>,
/**
* The calendar the file says its events came from, when it names exactly
* one — used to preselect a matching target. Null when the file names
* none or its events disagree.
*/
val fileCalendarName: String? = null,
) : ImportUiState
data class Done(val summary: IcsImportSummary) : ImportUiState
@@ -92,6 +98,10 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.isEventTarget },
fileCalendarName = parsed.events
.mapNotNull { it.calendarName }
.distinct()
.singleOrNull(),
)
}
}
@@ -113,3 +123,22 @@ class ImportViewModel @Inject constructor(
}
}
}
/**
* The target calendar to preselect for a bulk import.
*
* A calendar named like the one the file came from wins — restoring "Birthdays"
* onto the Birthdays calendar is what the user means. Otherwise the first
* *local* calendar, which is the first row the picker shows ("Your calendars"
* leads), so the pre-selection lines up with the top of the list; failing that
* the first calendar of any kind.
*/
internal fun defaultImportTarget(
calendars: List<CalendarSource>,
fileCalendarName: String?,
): Long? {
val named = fileCalendarName?.let { name ->
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
}
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
}

View File

@@ -38,7 +38,7 @@
<string name="settings_theme_system">النظام</string>
<string name="settings_theme_light">فاتح</string>
<string name="settings_theme_dark">داكن</string>
<string name="month_open_menu">فتح القائمة</string>
<string name="month_open_menu">افتح القائمة</string>
<string name="month_more_actions">المزيد من الإجراءات</string>
<string name="event_detail_edit">تعديل</string>
<string name="event_detail_delete">حذف</string>
@@ -176,7 +176,7 @@
<string name="reminder_at_time">في وقت الحدث</string>
<string name="event_untitled">(بدون عنوان)</string>
<string name="reminder_channel_name">تذكيرات الحدث</string>
<string name="reminder_channel_description">الإشعارات في أوقات التذكير بأحداثك</string>
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
<string name="reminder_onboarding_body">أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
@@ -238,7 +238,7 @@
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
<string name="agenda_range_day">اليوم</string>
<string name="settings_agenda_show_today">إظهار اليوم دائمًا</string>
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و أداته المصغرة، حتى بعد عدم وجود أي شيء اليوم.</string>
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و ودجته، حتى بعد عدم وجود أي شيء اليوم.</string>
<string name="agenda_range_this_month">هذا الشهر</string>
<string name="agenda_range_this_week">هذا الأسبوع</string>
<string name="settings_past_events_show">إظهار</string>
@@ -249,7 +249,7 @@
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق ”%1$s“</string>
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
<string name="event_edit_timezone_recent">الأخيرة</string>
<string name="event_edit_timezone_recent">الأحدث</string>
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
<plurals name="duration_minutes">
<item quantity="zero">(%d) لا دقائق</item>
@@ -286,7 +286,7 @@
<string name="agenda_span_starts">يبدأ %1$s</string>
<string name="agenda_span_ends">ينتهي %1$s</string>
<string name="today_jump_action">الانتقال إلى اليوم</string>
<string name="widget_refresh">إعادة تحميل</string>
<string name="widget_refresh">تحديث</string>
<string name="settings_app_name">اسم التطبيق</string>
<string name="settings_dim_completed">تعتيم الأحداث المكتملة</string>
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
@@ -335,8 +335,8 @@
<item quantity="many">%d يوم</item>
<item quantity="other">%d يوم</item>
</plurals>
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل المدى للجلسة</string>
<string name="settings_agenda_range_bar">شريط المدى</string>
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
<string name="settings_agenda_range_bar">شريط النطاق</string>
<string name="settings_month_header">عرض الشهر</string>
<string name="month_style_continuous">تمرير الشهور</string>
<string name="month_style_dense">أسابيع سلسة</string>
@@ -424,7 +424,7 @@
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و الذكرات السنوية</string>
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
<string name="settings_license">الترخيص</string>
@@ -432,7 +432,7 @@
<string name="settings_special_dates_disable_confirm">إيقاف</string>
<string name="settings_special_dates_sync_now">زامن الآن</string>
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات اتصالك، لذلك لا يتم تحديث هذه التقويمات.</string>
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
<string name="calendars_enable_all">تفعيل الكل</string>
<string name="calendars_disable_all">تعطيل الكل</string>
@@ -500,11 +500,11 @@
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
<string name="import_button">استيراد</string>
<string name="crash_dialog_dismiss">ليس الآن</string>
<string name="crash_report_copied">تم نسخ التقرير إلى حافظتك</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في حافظتك.</string>
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
<string name="crash_report_issue_title">تقرير العطل</string>
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من حافظتك هنا.)_</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
@@ -515,18 +515,18 @@
<string name="month_split_expand">إظهار الشهر بأكمله</string>
<string name="settings_week_day_header">الأسبوع و اليوم</string>
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طُرق العرض والأدوات المصغّرة.</string>
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
<string name="calendars_visibility_notice_message">Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات.</string>
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات اتصالك — يمكنك إدارة تقويماتك هنا.</string>
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
<string name="calendars_state_read_only">للقراءة فقط</string>
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات اتصالك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
<string name="duration_custom_max">كحد أقصى %1$s</string>
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
@@ -656,53 +656,26 @@
<string name="settings_special_dates_calendar_hint">اضبط لون كل تقويم وإمكانية ظهوره في إعدادات التقويمات.</string>
<string name="calendars_visibility_hint">قم بإيقاف تقويم لإخفائه على هذا الجهاز — تختفي أحداثه من التطبيق ويتوقف عن تذكيرك. هذا هو مفتاح التبديل نفسه الذي تستخدمه تطبيقات التقويم الأخرى خاصتك، لذلك تقوم بإخفائه أيضًا. لا شيء يتم حذفه، ولا يتأثر أي جهاز آخر، ويمكنك إعادة تشغيله من هنا في أي وقت.</string>
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
<string name="settings_agenda_widget_range">مدى أداة الجدول</string>
<string name="settings_widget_size">حجم أداة الجدول</string>
<string name="settings_widgets_subtitle">أداة الجدول، عنصر الإعدادات السريعة</string>
<string name="settings_agenda_widget_range">نطاق ودجت الجدول</string>
<string name="settings_widget_size">حجم ودجت الجدول</string>
<string name="settings_widgets_subtitle">ودجت الجدول، عنصر الإعدادات السريعة</string>
<string name="settings_drag_to_reschedule">السحب لتعديل الموعد</string>
<string name="settings_drag_to_reschedule_summary">انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر</string>
<string name="event_move_blocked_series_end">لا يمكن نقل حدث بعد نهاية سلسلته</string>
<string name="crash_dialog_report">تقرير</string>
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر في أعلى اليمين، واسحب لإعادة ترتيبهم. تظل طُرق العرض التي تم إيقافها متاحة من قائمة التنقل.</string>
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر الموجود في أعلى اليمين، واسحب لإعادة ترتيبها. تظل طرق العرض التي تم إيقاف تشغيلها متاحة من قائمة التنقل.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
<string name="settings_widgets_hint">إعدادات لوجدتز الشاشة الرئيسية وعنصر لوحة الإعدادات السريعة. أضف وجدت عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
<string name="settings_reliable_delivery">توصيل موثوق</string>
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula.</string>
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين إلى المدى المحفوظ الخاص بك عند إعادة فتح Calendula.</string>
<string name="settings_past_events_sample_morning">مراجعة الفريق</string>
<string name="settings_past_events_sample_midday">غداء مع روبن</string>
<string name="settings_past_events_sample_evening">ممارسة الجوقة</string>
<string name="settings_special_dates_last_synced">آخر مزامنة %1$s</string>
<string name="settings_special_dates_disable_type_message">يؤدي هذا لحذف التقويم ”%1$s“ وأحداثه. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليه.</string>
<string name="special_dates_default_title_birthday">عيد ميلاد {name} ({year})</string>
<string name="event_move_action">نقل…</string>
<string name="settings_font_specimen_heading">الخميس، 14 مايو</string>
<string name="settings_font_specimen_body">مراجعة الفريق في 10:00، ثم الغداء مع روبن في المقهى في ماركتبلاتز.</string>
<string name="settings_section_widgets">الأدوات المصغّرة والعناصر</string>
<string name="settings_widget_size_hint">مدى حجم النص الذي يعرضه جدول أداة الشاشة الرئيسية. أداة الشهر ليس لديه إعداد — تتناسب شبكته دائمًا نفسه للمساحة التي تمنحها إيّاه.</string>
<string name="settings_agenda_widget_range_hint">كم مدى يعرض جدول أداة الشاشة الرئيسية الأحداث.</string>
<string name="settings_agenda_range_hint">كم مدى يعرض شاشة الجدول الأحداث.</string>
<string name="app_name">Calendula</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="settings_special_dates_show_year">إظهار السنة</string>
<string name="settings_special_dates_show_year_hint">تضمين {year} في العناوين عندما يكون معروفًا</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="special_dates_default_title_anniversary">ذكرى {name} السنوية ({year})</string>
<string name="special_dates_calendar_anniversary">الذكرات السنوية</string>
<string name="crash_dialog_message">أُغلق %1$s بشكل غير متوقع في المرة الأخيرة. يمكنك المساعدة في إصلاحه عن طريق إرسال هذا التقرير كمشكلة. سيبقى على جهازك حتى تختار مشاركته، ولا يتضمن أي بيانات شخصية أو محتوى تقويم — فقط التفاصيل الفنية أدناه.</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="onboarding_backup_benefit_folder_title">مجلد تختاره</string>
<string name="import_warning_timezone">منطقة زمنية غير معروفة ترجع إلى الخاصة بجهازك.</string>
<string name="settings_calendar_reminders_managed_hint">تعيين في تواريخ جهات الاتصال الخاصة</string>
<string name="settings_special_dates_disable_all_message">يؤدي هذا إلى حذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
<string name="settings_special_dates_type_anniversary">الذكرات السنوية</string>
<string name="settings_special_dates_template_hint">استخدم {name} لجهة الاتصال و{year} للسنة (سنة الميلاد، أو سنة بدء الذكرى السنوية؛ مخفية عندما تكون غير معروفة).</string>
<string name="settings_special_dates_template">صيغة العنوان</string>
<string name="settings_calendar_reminders_hint">تجاوز الافتراضي لكل تقويم — بشكل منفصل للمحدد بوقت وأحداث طوال اليوم. يمكن لتقويم الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
<string name="agenda_empty_title">أنت على اطلاع</string>
</resources>

View File

@@ -1,149 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_tagline">Сучасны каляндар.</string>
<string name="state_loading">Загрузка…</string>
<string name="state_retry">Паўтарыць спробу</string>
<string name="state_failure_unknown">Узнікла нейкая праблема.</string>
<string name="state_failure_permission">Патрабуецца доступ да календара.</string>
<string name="state_failure_permission_action">Дазволіць доступ</string>
<string name="state_failure_no_calendars">Календары не сканфігураваныя.</string>
<string name="state_failure_no_calendars_action">Адкрыць сыстэмныя налады календароў</string>
<string name="state_failure_provider">Немагчыма прачытаць каляндар.</string>
<string name="permission_rationale_title">Прагляд усіх падзеяў у прыгожым выглядзе</string>
<string name="permission_rationale_body">Для паказу і кіраваньня падзеямі Calendula патрабуецца доступ да вашага календара.</string>
<string name="permission_request_button">Дазволіць доступ да календара</string>
<string name="permission_denied_title">Няма доступу да календара</string>
<string name="permission_denied_body">Calendula ня можа паказваць падзеі бяз доступу да календара.</string>
<string name="permission_open_settings_button">Адкрыць сыстэмныя налады</string>
<string name="permission_retry_button">Паўтарыць спробу</string>
<string name="permission_benefit_private_title">Распрацавана з акцэнтам на прыватнасьць</string>
<string name="permission_benefit_private_body">Бяз доступу да Інтэрнэту, без тэлемэтрыі — нічога і ніколі не пакідае ваш тэлефон.</string>
<string name="permission_benefit_sync_title">Усе вашы календары разам</string>
<string name="permission_benefit_sync_body">Google, CalDAV, лякальныя — паказваюцца ўсе, сынхранізаваныя з прыладай.</string>
<string name="permission_benefit_privacy_title">Ніякага адсочваньня, ніколі</string>
<string name="permission_benefit_privacy_body">Без тэлемэтрыі, без аналітыкі, без рэклямы.</string>
<string name="permission_privacy_footnote">Усё застаецца на прыладзе, доступ да Інтэрнэту не патрабуецца</string>
<string name="month_prev">Папярэдні месяц</string>
<string name="month_next">Наступны месяц</string>
<string name="month_today_action">Сёньня</string>
<string name="month_more_actions">Іншыя дзеяньні</string>
<string name="month_open_menu">Адкрыць мэню</string>
<string name="month_action_settings">Налады</string>
<string name="month_a11y_today_prefix">Сёньня</string>
<string name="week_today_action">Гэты тыдзень</string>
<string name="week_number_label">Тдз.</string>
<string name="day_today_action">Сёньня</string>
<string name="event_detail_back">Назад</string>
<string name="event_detail_edit">Праўка</string>
<string name="event_detail_delete">Выдаліць</string>
<string name="event_detail_share">Падзяліцца</string>
<string name="event_detail_duplicate">Дублікат</string>
<string name="event_share_chooser_title">Падзяліцца падзеяй</string>
<string name="event_share_failed">Не атрымалася падзяліцца гэтай падзеяй.</string>
<string name="event_delete_title">Выдаліць падзею?</string>
<string name="event_delete_body">Падзея выдаленая з вашага календара і з усіх прыладаў, зь якімі адбываецца сынхранізацыя.</string>
<string name="event_delete_recurring_title">Выдаліць пэрыядычную падзею</string>
<string name="event_delete_option_occurrence">Толькі гэтую падзею</string>
<string name="event_delete_option_following">Гэтую і ўсе наступныя падзеі</string>
<string name="event_delete_option_series">Усе падзеі ў пасьлядоўнасьці</string>
<string name="event_edit_recurring_title">Рэдагаваць пэрыядычную падзею</string>
<string name="event_delete_failed">Не атрымалася выдаліць падзею</string>
<string name="event_delete_write_denied">Для выдаленьня падзеяў Calendula патрабуецца дазвол на запіс</string>
<string name="event_move_action">Перанесці…</string>
<string name="event_move_recurring_title">Перанесці пэрыядычную падзею</string>
<string name="event_move_occurrence_only">Перанос усёй пасьлядоўнасьці зьменіць дні, на якія яна прыпадае, таму перанесці можна толькі гэтую падзею.</string>
<string name="event_move_done">Перанесена на %1$s</string>
<string name="event_move_undo">АдрабІць</string>
<string name="event_move_undone">Перанос скасаваны</string>
<string name="event_move_failed">Не атрымалася перанесьці падзею</string>
<string name="event_move_write_denied">Для пераносу падзеяў Calendula патрабуецца дазвол на запіс</string>
<string name="event_move_gone">Гэтай падзеі больш не існуе</string>
<string name="event_move_blocked_series_end">Нельга перанесьці падзею на дату пасьля заканчэньня яе пасьлядоўнасьці</string>
<string name="dialog_cancel">Адмена</string>
<string name="dialog_ok">ОК</string>
<string name="event_edit_new_title">Новая падзея</string>
<string name="event_edit_close">Закрыць</string>
<string name="event_edit_save">Захаваць</string>
<string name="event_edit_title_hint">Дадаць загаловак</string>
<string name="event_edit_managed_hint">Кіруецца «%1$s» — загаловак, дата і паўтор сынхранізуюцца з вашымі кантактамі. Напаміны, месцазнаходжаньне і нататкі даступныя вам для рэдагаваньня.</string>
<string name="event_edit_starts">Пачатак</string>
<string name="event_edit_ends">Заканчэньне</string>
<string name="event_edit_error_end_before_start">Заканчваецца раней, чым пачынаецца</string>
<string name="event_edit_error_no_calendar">Няма календара, у які дазволены запіс</string>
<string name="event_edit_save_failed">Не атрымалася захаваць падзею</string>
<string name="event_edit_write_denied">Для стварэньня падзеяў Calendula патрабуецца дазвол на запіс</string>
<string name="event_edit_more_fields">Дадатковыя палі</string>
<string name="event_edit_add">Дадаць</string>
<string name="event_edit_add_reminder">Дадаць напамін</string>
<string name="event_edit_remove_reminder">Выдаліць напамін</string>
<string name="event_edit_attendees">Наведвальнікі</string>
<string name="event_edit_add_guest">Дадаць наведвальніка</string>
<string name="event_edit_add_guest_hint">Дадаць наведвальніка праз электронную пошту…</string>
<string name="event_edit_add_guest_from_contacts">Дадаць з кантактаў</string>
<string name="event_edit_location_from_contacts">Выбраць адрас з кантактаў</string>
<string name="event_edit_remove_guest">Выдаліць наведвальніка</string>
<string name="event_edit_attendee_required">Неабходны</string>
<string name="event_edit_attendee_optional">Неабавязковы</string>
<string name="event_edit_attendees_note_synced">Calendula не рассылае запрашэньні. Наведвальнікі могуць атрымаць электронныя лісты з вашага ўліковага запісу календара пры сынхранізацыі.</string>
<string name="event_edit_attendees_note_local">Захавана на гэтай прыладзе. Ніхто не атрымаў абвесткі.</string>
<string name="event_edit_reminder_custom">Карыстальніцкі</string>
<string name="reminder_unit_minutes">хв.</string>
<string name="reminder_unit_hours">гадз.</string>
<string name="reminder_unit_days">сут.</string>
<string name="reminder_unit_weeks">тыд.</string>
<string name="event_edit_availability">Даступнасьць</string>
<string name="event_edit_visibility">Бачнасьць</string>
<string name="event_edit_timezone_device">Часавы пояс прылады</string>
<string name="event_edit_timezone_device_summary">Сочыць за вашым месцазнаходжаньнем</string>
<string name="event_edit_timezone_search">Пошук часавых паясоў</string>
<string name="event_edit_timezone_recent">Апошнія</string>
<string name="event_edit_timezone_all">Усе часавыя паясы</string>
<string name="event_edit_timezone_none">Няма часавага поясу, які адпавядае «%1$s»</string>
<string name="event_edit_timezone_local_time">%1$s паводле вашага часу</string>
<string name="event_edit_color">Колер</string>
<string name="event_edit_color_default">Колер календара</string>
<string name="event_edit_color_custom">Карыстальніцкі колер</string>
<string name="event_edit_color_reset">Скід</string>
<string name="event_edit_color_unsupported">Недаступна для гэтага календара</string>
<string name="event_edit_color_unsupported_hint">Для гэтага календара не апублікаваны набор колераў. Для падобных календароў у наладах можна задаць карыстальніцкія колеры.</string>
<string name="event_edit_color_sync_warning">Пры наступнай сынхранізацыі гэты каляндар можа страціць або перазапісаць колер.</string>
<string name="event_edit_conflict_title">Падзея зьмянілася ў іншым месцы</string>
<string name="event_edit_conflict_body">Падчас рэдагаваньня вамі гэтая падзея была зьменена ў выніку сынхранізацыі або іншай праграмай. Што адбудзецца з вашымі зьменамі?</string>
<string name="event_edit_conflict_overwrite">Захаваць мае змены</string>
<string name="event_edit_conflict_overwrite_hint">Толькі адрэдагаваныя вамі палі перазапішуць зьнешнія зьмены</string>
<string name="event_edit_conflict_discard">Адкінуць мае зьмены</string>
<string name="event_edit_conflict_discard_hint">Падзея застаецца такой, якой яна ёсьць зараз</string>
<string name="event_edit_gone_title">Падзея выдалена</string>
<string name="event_edit_gone_body">Гэтая падзея ўжо выдаленая, напрыклад на іншай прыладзе. Вашыя зьмены болей немагчыма захаваць.</string>
<string name="import_reminder_prompt_title">Ужыць напамін па змаўчаньні?</string>
<string name="import_reminder_prompt_body_none">Гэтая падзея была імпартаваная без напаміну.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
<item quantity="few">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
<item quantity="many">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
<item quantity="other">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
</plurals>
<string name="import_reminder_prompt_apply">Ужыць змаўчаньні</string>
<string name="import_reminder_prompt_keep">Пакінуць як ёсьць</string>
<string name="event_edit_recurrence_none">Не паўтараецца</string>
<string name="event_edit_recurrence_custom">Карыстальніцкі варыянт</string>
<string name="event_edit_recurrence_every">Кожн.</string>
<string name="recurrence_unit_days">сут.</string>
<string name="recurrence_unit_weeks">тыд.</string>
<string name="recurrence_unit_months">мес.</string>
<string name="recurrence_unit_years">год/гады</string>
<string name="event_edit_recurrence_incomplete">Увядзеце лік ад 1 да 999</string>
<string name="event_edit_recurrence_next">Наступ.: %1$s</string>
<string name="event_edit_recurrence_next_none">Гэтае правіла ніколі не паўтараецца</string>
<string name="event_edit_recurrence_ends">Заканчэньне</string>
<string name="event_edit_recurrence_end_never">Ніколі</string>
<string name="event_edit_recurrence_end_until">На дату</string>
<string name="event_edit_recurrence_end_count">Пасьля некалькіх разоў</string>
<string name="event_edit_recurrence_times">разы(оў)</string>
</resources>

View File

@@ -1,253 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Moderní kalendář.</string>
<string name="state_loading">Načítá se…</string>
<string name="state_retry">Opakovat</string>
<string name="state_failure_unknown">Něco se pokazilo.</string>
<string name="state_failure_permission">Je nutný přístup ke kalendáři.</string>
<string name="state_failure_permission_action">Udělit přístup</string>
<string name="state_failure_no_calendars">Nejsou konfigurovány žádné kalendáře.</string>
<string name="permission_request_button">Udělit přístup ke kalendáři</string>
<string name="permission_rationale_body">Calendula potřebuje přístup k vašemu kalendáři, aby mohla zobrazovat a spravovat vaše události.</string>
<string name="permission_benefit_sync_title">Všechny vaše kalendáře na jednom místě</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokální kalendáře — cokoliv synchronizovaného se zařízením.</string>
<string name="permission_benefit_private_title">Navrženo pro soukromí</string>
<string name="permission_benefit_private_body">Žádný přístup k internetu, žádná telemetrie — nikdy nedojde k úniku dat z vašeho telefonu.</string>
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
<string name="permission_rationale_title">Prohlížejte všechny události, hezky a přehledně</string>
<string name="permission_denied_title">Přístup ke kalendáři byl odepřen</string>
<string name="permission_denied_body">Calendula nemůže zobrazovat události bez uděleného přístupu ke kalendáři. Přístup můžete znovu udělit v nastavení systému.</string>
<string name="state_failure_no_calendars_action">Otevřít nastavení kalendáře systému</string>
<string name="permission_open_settings_button">Otevřít nastavení systému</string>
<string name="permission_retry_button">Zkusit to znovu</string>
<string name="state_failure_provider">Nepodařilo se načíst kalendář.</string>
<string name="permission_benefit_privacy_title">Žádné sledování, nikdy</string>
<string name="permission_benefit_privacy_body">Žádná telemetrie, žádné analytické nástroje, bez reklam.</string>
<string name="permission_privacy_footnote">Zůstává na vašem zařízení · bez přístupu k internetu</string>
<string name="month_prev">Předchozí měsíc</string>
<string name="month_next">Následující měsíc</string>
<string name="month_today_action">Dnes</string>
<string name="month_more_actions">Další akce</string>
<string name="month_open_menu">Otevřít nabídku</string>
<string name="month_action_settings">Nastavení</string>
<string name="month_a11y_today_prefix">Dnes</string>
<string name="week_today_action">Tento týden</string>
<string name="week_number_label">týd.</string>
<string name="day_today_action">Dnes</string>
<string name="event_detail_back">Zpět</string>
<string name="event_detail_edit">Upravit</string>
<string name="event_detail_delete">Vymazat</string>
<string name="event_detail_share">Sdílet</string>
<string name="event_detail_duplicate">Kopírovat</string>
<string name="event_share_chooser_title">Sdílet událost</string>
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
<string name="event_delete_title">Odstranit událost?</string>
<string name="event_delete_body">Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení.</string>
<string name="event_delete_recurring_title">Smazat opakující se událost</string>
<string name="event_delete_option_occurrence">Pouze tuto událost</string>
<string name="event_delete_option_following">Tuto a všechny následující události</string>
<string name="event_delete_option_series">Všechny události v sérii</string>
<string name="event_edit_recurring_title">Upravit opakující se událost</string>
<string name="event_delete_failed">Tuto událost nelze odstranit</string>
<string name="event_delete_write_denied">Calendula potřebuje oprávnění k zápisu, aby mohla mazat události</string>
<string name="event_move_action">Přesunout…</string>
<string name="event_move_recurring_title">Přesunout opakující se událost</string>
<string name="event_move_occurrence_only">Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost.</string>
<string name="event_move_done">Přesunuto do %1$s</string>
<string name="event_move_undo">Zpět</string>
<string name="event_move_undone">Vráceno zpět</string>
<string name="event_move_failed">Událost nelze přesunout</string>
<string name="event_move_write_denied">Calendula k přesouvání události vyžaduje oprávnění k zápisu</string>
<string name="event_move_gone">Tato událost již neexistuje</string>
<string name="event_move_blocked_series_end">Událost nelze přesunout za konec dané série</string>
<string name="dialog_cancel">Zrušit</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nová událost</string>
<string name="event_edit_close">Zavřít</string>
<string name="event_edit_save">Uložit</string>
<string name="event_edit_title_hint">Přidat název</string>
<string name="event_edit_managed_hint">Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Upomínky, polohu a poznámky si můžete upravit sami.</string>
<string name="event_edit_starts">Začátek</string>
<string name="event_edit_ends">Konec</string>
<string name="event_edit_error_end_before_start">Končí dříve, než začíná</string>
<string name="event_edit_error_no_calendar">Není k dispozici žádný kalendář, který by umožňoval zápis</string>
<string name="event_edit_save_failed">Nelze uložit událost</string>
<string name="event_edit_write_denied">Calendula pro vytváření událostí vyžaduje oprávnění k zápisu</string>
<string name="event_edit_more_fields">další pole</string>
<string name="event_edit_add">Přidat</string>
<string name="event_edit_add_reminder">Přidat upomínku</string>
<string name="event_edit_remove_reminder">Vymazat upomínku</string>
<string name="event_edit_attendees">Hosté</string>
<string name="event_edit_add_guest">Přidat hosta</string>
<string name="event_edit_add_guest_hint">Přidat hosta e-mailem…</string>
<string name="event_edit_add_guest_from_contacts">Přidat z kontaktů</string>
<string name="event_edit_location_from_contacts">Vybrat adresu z kontaktů</string>
<string name="event_edit_remove_guest">Odstranit hosta</string>
<string name="event_edit_attendee_required">Požadované</string>
<string name="event_edit_attendee_optional">Volitelné</string>
<string name="event_edit_attendees_note_synced">Calendula nezasílá pozvánky. Váš účet kalendáře může hosty zvát e-mailem při synchronizaci.</string>
<string name="event_edit_attendees_note_local">Uloženo v tomto zařízení. Nikomu nejsou odesílány notifikace.</string>
<string name="event_edit_reminder_custom">Vlastní</string>
<string name="reminder_unit_minutes">minuty</string>
<string name="reminder_unit_hours">hodiny</string>
<string name="reminder_unit_days">dny</string>
<string name="reminder_unit_weeks">týdny</string>
<string name="event_edit_availability">Dostupnost</string>
<string name="event_edit_visibility">Viditelnost</string>
<string name="event_edit_timezone_device">Časové pásmo zařízení</string>
<string name="event_edit_timezone_device_summary">Monitoruje vaši polohu, ať už jste kdekoliv</string>
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
<string name="event_edit_timezone_recent">Nedávné</string>
<string name="event_edit_timezone_all">Všechna časová pásma</string>
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá “%1$s”</string>
<string name="event_edit_timezone_local_time">%1$s vašeho času</string>
<string name="event_edit_color">Barva</string>
<string name="event_edit_color_default">Barva kalendáře</string>
<string name="event_edit_color_custom">Vlastní barva</string>
<string name="event_edit_color_reset">Obnovit</string>
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě.</string>
<string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string>
<string name="event_edit_conflict_overwrite">Uložit změny</string>
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
<string name="event_edit_conflict_discard">Zrušit změny</string>
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
<string name="event_edit_gone_title">Událost byla smazána</string>
<string name="event_edit_gone_body">Během úpravy došlo ke smazání události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Tato událost byla importována s %1$d upomínkou.</item>
<item quantity="few">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="many">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="other">Tato událost byla importována s %1$d upomínkami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Nastavit výchozí</string>
<string name="import_reminder_prompt_keep">Nechat beze změny</string>
<string name="event_edit_recurrence_none">Neopakuje se</string>
<string name="event_edit_recurrence_custom">Vlastní</string>
<string name="event_edit_recurrence_every">Každý</string>
<string name="recurrence_unit_days">dny</string>
<string name="recurrence_unit_weeks">týdny</string>
<string name="recurrence_unit_months">měsíce</string>
<string name="recurrence_unit_years">roky</string>
<string name="event_edit_recurrence_incomplete">Zadejte číslo v rozmezí 1 až 999</string>
<string name="event_edit_recurrence_next">Následující: %1$s</string>
<string name="event_edit_recurrence_next_none">Toto pravidlo se nikdy neopakuje</string>
<string name="event_edit_recurrence_ends">Konec</string>
<string name="event_edit_recurrence_end_never">Nikdy</string>
<string name="event_edit_recurrence_end_until">K určitému datu</string>
<string name="event_edit_recurrence_end_count">Po určitém počtu opakování</string>
<string name="event_edit_recurrence_times">opakování</string>
<string name="event_edit_error_recurrence_ends_before_start">Opakování končí před začátkem události</string>
<string name="event_availability_busy">Zaneprázdněný</string>
<string name="event_access_default">Výchozí</string>
<string name="event_access_public">Veřejný</string>
<string name="event_detail_all_day">Celý den</string>
<string name="event_detail_calendar">Kalendář</string>
<string name="event_detail_calendar_unknown">Neznámý kalendář</string>
<string name="event_detail_location">Umístění</string>
<string name="event_detail_description">Popis</string>
<string name="event_detail_attendees">Účastníci</string>
<string name="event_detail_recurrence">Opakování</string>
<string name="event_detail_recurring">Opakující se událost</string>
<string name="recurrence_daily">Každý den</string>
<string name="recurrence_weekly">Každý týden</string>
<string name="recurrence_monthly">Každý měsíc</string>
<string name="recurrence_yearly">Každý rok</string>
<string name="recurrence_every_n_days">Každý %1$d. den</string>
<string name="recurrence_every_n_weeks">Každý %1$d. týden</string>
<string name="recurrence_every_n_months">Každý %1$d. měsíc</string>
<string name="recurrence_every_n_years">Každý %1$d. rok</string>
<string name="recurrence_on_days">%1$s v %2$s</string>
<string name="recurrence_with_until">%1$s do%2$s</string>
<string name="recurrence_with_count">%1$s, %2$d opakování</string>
<string name="event_detail_not_found">Tato událost již neexistuje.</string>
<string name="event_attendee_accepted">Přijato</string>
<string name="event_attendee_declined">Odmítnuto</string>
<string name="event_attendee_tentative">Předběžné</string>
<string name="event_attendee_needs_action">Bez odezvy</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Upomínky</string>
<string name="event_detail_timezone">Časové pásmo</string>
<string name="event_status_tentative">Přeběžné</string>
<string name="event_status_cancelled">Zrušeno</string>
<string name="event_availability_free">Volné</string>
<string name="event_access_private">Soukromé</string>
<string name="event_access_confidential">Utajené</string>
<string name="event_access_default_summary">Běžná činnost kalendáře</string>
<string name="event_access_public_summary">Kdokoliv s možností přístupu uvidí veškeré podrobnosti</string>
<string name="event_access_private_summary">Ostatní uvidí pouze to, že jste zaneprázdněn</string>
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
<string name="event_attendee_organizer">Pořadatel</string>
<string name="event_attendee_optional">Volitelné</string>
<string name="event_attendee_resource">Zdroj</string>
<string name="event_detail_self_response">Vaše odpověď: %1$s</string>
<string name="reminder_at_time">V době konání události</string>
<string name="reminder_default">Výchozí upomínka</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minutu předtím</item>
<item quantity="few">%d minuty předtím</item>
<item quantity="many">%d minut předtím</item>
<item quantity="other">%d minut předtím</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hodinu předtím</item>
<item quantity="few">%d hodiny předtím</item>
<item quantity="many">%d hodin předtím</item>
<item quantity="other">%d hodin předtím</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d den předtím</item>
<item quantity="few">%d dny předtím</item>
<item quantity="many">%d dnů předtím</item>
<item quantity="other">%d dnů předtím</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d týden předtím</item>
<item quantity="few">%d týdny předtím</item>
<item quantity="many">%d týdnů předtím</item>
<item quantity="other">%d týdnů předtím</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuta</item>
<item quantity="few">%d minuty</item>
<item quantity="many">%d minut</item>
<item quantity="other">%d minut</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hodina</item>
<item quantity="few">%d hodiny</item>
<item quantity="many">%d hodin</item>
<item quantity="other">%d hodin</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d den</item>
<item quantity="few">%d dny</item>
<item quantity="many">%d dnů</item>
<item quantity="other">%d dnů</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Nanejvýš %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d týden</item>
<item quantity="few">%d týdny</item>
<item quantity="many">%d týdnů</item>
<item quantity="other">%d týdnů</item>
</plurals>
<string name="event_untitled">(Bez názvu)</string>
<string name="onboarding_backup_title">Vaši událost naleznete pouze zde</string>
<string name="onboarding_backup_body">V tomto telefonu se nesynchronizuje žádný účet, takže pokud ho ztratíte, přijdete i o svůj kalendář. Calendula může vytvořit lokální zálohu.</string>
<string name="onboarding_backup_benefit_folder_title">Složka, kterou si vyberete</string>
<string name="onboarding_backup_benefit_folder_body">Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu.</string>
<string name="onboarding_backup_benefit_daily_title">Jednou denně, automaticky</string>
</resources>

View File

@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_tagline">モダンなカレンダー。</string>
<string name="state_loading">読み込み中…</string>
<string name="state_retry">リトライ</string>
<string name="month_today_action">今日</string>
<string name="month_action_settings">設定</string>
<string name="event_detail_description">説明</string>
<string name="event_edit_add_reminder">リマインダーを追加</string>
<string name="event_edit_save">保存</string>
<string name="event_detail_all_day">終日</string>
<string name="view_month"></string>
<string name="view_week"></string>
<string name="view_day"></string>
<string name="settings_section_backup">バックアップと復元</string>
<string name="settings_special_dates_type_birthday">誕生日</string>
<string name="settings_special_dates_sync_now">今すぐ同期</string>
<string name="settings_special_dates_last_synced">最終同期 %1$s</string>
<string name="settings_section_notifications">通知</string>
<string name="drawer_jump_to_date">日付に移動</string>
<string name="state_failure_permission">カレンダーへのアクセス権限が必要です。</string>
<string name="permission_rationale_body">Calendulaで予定を表示・管理するには、カレンダーへのアクセス許可が必要です。</string>
<string name="permission_request_button">カレンダーへのアクセスを許可</string>
<string name="permission_denied_title">カレンダーへのアクセスが拒否されました</string>
<string name="permission_denied_body">Calendulaはカレンダーへのアクセス権がないと予定を表示できません。システム設定から再度許可をお願いします。</string>
<string name="permission_open_settings_button">システム設定を開く</string>
<string name="permission_retry_button">再試行</string>
<string name="permission_benefit_private_title">プライバシーを重視した設計</string>
<string name="permission_benefit_private_body">インターネットへのアクセス権限は不要で、テレメトリも送信しません。すべてのデータはスマートフォン内に保持されます。</string>
<string name="permission_benefit_sync_title">すべてのカレンダーをひとつに</string>
<string name="permission_benefit_sync_body">Google、CalDAV、ローカルなど、デバイスに同期されているすべてのカレンダーが自動的に表示されます。</string>
<string name="event_detail_delete">削除</string>
<string name="event_detail_share">共有</string>
<string name="event_detail_duplicate">複製</string>
<string name="event_delete_title">予定を削除しますか?</string>
<string name="event_delete_body">この予定はカレンダーだけでなく、同期しているすべてのデバイスからも削除されます。</string>
<string name="event_delete_recurring_title">定期的な予定を削除</string>
<string name="event_delete_option_occurrence">この予定のみ</string>
<string name="event_delete_option_following">この予定とそれ以降すべて</string>
<string name="event_edit_recurring_title">定期的な予定を編集</string>
<string name="event_delete_failed">予定を削除できませんでした</string>
<string name="recurrence_unit_years">毎年</string>
<string name="reminder_unit_weeks"></string>
<string name="recurrence_unit_weeks">毎週</string>
<string name="recurrence_unit_months">毎月</string>
<string name="reminder_unit_minutes"></string>
<string name="reminder_unit_hours">時間</string>
<string name="reminder_unit_days"></string>
<string name="recurrence_unit_days">毎日</string>
<string name="reminder_day_yesterday">前日</string>
<string name="settings_section_widgets">ウィジットとタイル</string>
<string name="settings_week_start">週の開始日</string>
<string name="settings_week_numbers">週番号</string>
<string name="settings_theme_light">ライト</string>
<string name="settings_theme_dark">ダーク</string>
<string name="settings_font_headings">見出しのフォント</string>
<string name="settings_font_body">本文のフォント</string>
<string name="settings_font_system">システムの標準</string>
<string name="settings_font_choose_file">ファイルを選択…</string>
<string name="settings_week_start_auto">自動</string>
<string name="settings_week_start_auto_summary">現在は %1$s</string>
<string name="settings_app_name">アプリの名前</string>
<string name="settings_section_language">言語</string>
<string name="settings_language">アプリの言語</string>
<string name="settings_language_auto">システムの既定</string>
<string name="settings_group_app">アプリ</string>
<string name="settings_appearance_subtitle">テーマ、カラー、フォント</string>
<string name="settings_about_privacy">プライバシーポリシー</string>
<string name="settings_about_support">開発を支援する</string>
<string name="settings_license">ライセンス</string>
<string name="settings_report_problem">問題を報告</string>
<string name="settings_title">設定</string>
<string name="settings_theme">テーマ</string>
<string name="settings_theme_system">システム</string>
<string name="settings_dynamic_color">ダイナミックカラー</string>
</resources>

View File

@@ -207,445 +207,4 @@
<item quantity="other">%d minutos</item>
</plurals>
<string name="settings_special_dates_grant">Conceder acesso</string>
<string name="event_move_action">Mover…</string>
<string name="event_move_recurring_title">Mover evento recorrente</string>
<string name="event_move_occurrence_only">Mover a série inteira mudaria os dias em que o evento cai, então somente este evento pode se mover.</string>
<string name="event_move_done">Movido para %1$s</string>
<string name="event_move_undo">Desfazer</string>
<string name="event_move_undone">Mover desfeito</string>
<string name="event_move_failed">Não foi possível mover o evento</string>
<string name="event_move_write_denied">O Calendula precisa de permissão de escrita para mover eventos</string>
<string name="event_move_gone">Este evento não existe mais</string>
<string name="event_move_blocked_series_end">Impossível mover um evento após o fim de sua série</string>
<string name="event_status_tentative">Talvez</string>
<plurals name="duration_hours">
<item quantity="one">%d hora</item>
<item quantity="many">%d horas</item>
<item quantity="other">%d horas</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">No máximo %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d semana</item>
<item quantity="many">%d semanas</item>
<item quantity="other">%d semanas</item>
</plurals>
<string name="event_untitled">(Sem título)</string>
<string name="onboarding_step_counter">Passo %1$d de %2$d</string>
<string name="onboarding_backup_title">Seus eventos vivem somente aqui</string>
<string name="onboarding_backup_body">Nada neste aparelho sincroniza para uma conta, então perdê-lo levaria seu calendário junto. O Calendula pode criar um backup para você.</string>
<string name="onboarding_backup_benefit_folder_title">Uma pasta à sua escolha</string>
<string name="onboarding_backup_benefit_folder_body">Os backups são arquivos .ics — coloque-os em um lugar para sincronizar, ou um cartão SD.</string>
<string name="onboarding_backup_benefit_daily_title">Uma vez por dia, sozinho</string>
<string name="onboarding_backup_benefit_daily_body">O Calendula exporta seus calendários locais em segundo plano. Mude a frequência em Ajustes.</string>
<string name="onboarding_backup_enable_button">Escolher pasta e fazer backup</string>
<string name="onboarding_backup_skip_button">Agora não</string>
<string name="onboarding_view_title">O que deve abrir primeiro?</string>
<string name="onboarding_month_style_title">Como o Mês deve se parecer?</string>
<string name="onboarding_view_continue_button">Continuar</string>
<string name="onboarding_back">Voltar</string>
<string name="onboarding_visibility_button">Entendi</string>
<string name="onboarding_done_title">Tudo pronto</string>
<string name="onboarding_done_body">Seus calendários estão prontos. Todas as suas escolhas podem ser alteradas depois em Ajustes.</string>
<string name="onboarding_done_button">Abrir meu calendário</string>
<string name="reminder_channel_name">Lembretes de evento</string>
<string name="reminder_channel_description">Notificações na hora dos lembretes dos seus eventos</string>
<string name="reminder_onboarding_title">Nunca perca um evento</string>
<string name="reminder_onboarding_body">O Android não mostra lembretes sozinho — um app de calendário deve fazer isso. Deixe o Calendula cuidar disso.</string>
<string name="reminder_benefit_delivery_title">Lembretes, entregues</string>
<string name="reminder_benefit_delivery_body">Todos os seus lembretes de evento chegam em notificação, na hora certa.</string>
<string name="reminder_benefit_duplicates_title">Usando outro app de calendário?</string>
<string name="reminder_benefit_duplicates_body">Se outro app também postar lembretes, você verá eles em dobro — desligue aqui ou lá.</string>
<string name="reminder_benefit_reversible_title">Mude a qualquer hora</string>
<string name="reminder_benefit_reversible_body">O botão está em Ajustes, dentro de Notificações.</string>
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
<string name="reminder_onboarding_skip_button">Agora não</string>
<string name="reminder_action_snooze">Soneca</string>
<string name="reminder_action_dismiss">Dispensar</string>
<string name="reminder_day_tomorrow">Amanhã</string>
<string name="reminder_day_yesterday">Ontem</string>
<string name="view_month">Mês</string>
<string name="view_week">Semana</string>
<string name="view_day">Dia</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Vista</string>
<string name="drawer_jump_to_date">Ir para data</string>
<string name="agenda_today_action">Hoje</string>
<string name="agenda_header_today">Hoje</string>
<string name="agenda_header_tomorrow">Amanhã</string>
<string name="agenda_empty_title">Tudo em dia</string>
<string name="agenda_no_more_today">Sem mais eventos hoje</string>
<string name="agenda_span_starts">Começa às %1$s</string>
<string name="agenda_span_ends">Termina às %1$s</string>
<string name="today_jump_action">Ir para Hoje</string>
<string name="search_action">Buscar</string>
<string name="search_hint">Buscar eventos</string>
<string name="search_back">Voltar</string>
<string name="search_clear">Limpar</string>
<string name="search_idle_hint">Busque seus eventos por título, local ou notas.</string>
<string name="search_empty">Nenhum evento corresponde a \"%1$s\".</string>
<plurals name="search_selected_count">
<item quantity="one">%d selecionado</item>
<item quantity="many">%d selecionados</item>
<item quantity="other">%d selecionados</item>
</plurals>
<string name="search_in_descriptions">Encontrado em descrições</string>
<string name="search_selection_close">Cancelar seleção</string>
<string name="search_select_all">Selecionar tudo</string>
<string name="search_delete_selected">Deletar selecionados</string>
<plurals name="search_delete_title">
<item quantity="one">Deletar %d evento?</item>
<item quantity="many">Deletar %d eventos?</item>
<item quantity="other">Deletar %d eventos?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d evento deletado</item>
<item quantity="many">%d eventos deletados</item>
<item quantity="other">%d eventos deletados</item>
</plurals>
<string name="search_delete_partial">%1$d deletado, %2$d não pôde ser deletado</string>
<string name="search_delete_denied_partial">%1$d deletado, depois permissão de escrita removida</string>
<string name="widget_agenda_title">A seguir</string>
<string name="widget_agenda_label">Agenda do Calendula</string>
<string name="widget_month_label">Mês do Calendula</string>
<string name="widget_refresh">Atualizar</string>
<string name="widget_new_event">Novo evento</string>
<string name="widget_needs_permission">Abra o Calendula para conceder acesso ao calendário</string>
<string name="widget_prev_month">Mês passado</string>
<string name="widget_next_month">Mês que vem</string>
<string name="widget_today">Hoje</string>
<string name="filter_title">Calendários</string>
<string name="settings_title">Ajustes</string>
<string name="settings_back">Voltar</string>
<string name="back">Voltar</string>
<string name="settings_section_appearance">Aparência</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Sistema</string>
<string name="settings_theme_light">Claro</string>
<string name="settings_theme_dark">Escuro</string>
<string name="settings_theme_hint">Se o app é claro ou escuro. Aplica imediatamente.</string>
<string name="settings_theme_system_summary">No momento %1$s</string>
<string name="settings_default_view">Vista padrão</string>
<string name="settings_dynamic_color">Cor dinâmica</string>
<string name="settings_dynamic_color_unavailable">Requer Android 12 ou superior</string>
<string name="settings_soften_colors">Harmonizar cores do calendário</string>
<string name="settings_soften_colors_summary">Mantenha cada tom do calendário até mesmo sem brilho, para que cada evento continue legível e as cores combinem. Desative para mostrar as cores cruas da raiz do calendário.</string>
<string name="settings_font_headings">Fonte dos cabeçalhos</string>
<string name="settings_font_body">Fonte do corpo</string>
<string name="settings_font_system">Padrão do sistema</string>
<string name="font_atkinson_hyperlegible">Atkinson Hiperlegível</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Escolher arquivo…</string>
<string name="settings_font_custom_selected">Fonte personalizada</string>
<string name="settings_font_import_failed">Não pôde ler arquivo como fonte</string>
<string name="settings_font_specimen_heading">Quinta-feira, 14 de maio</string>
<string name="settings_font_specimen_body">Reunião de equipe às 10:00, depois almoço com Robin no café em Markplatz.</string>
<string name="settings_week_start">A semana inicia em</string>
<string name="settings_week_start_auto">Automático</string>
<string name="settings_week_start_auto_summary">No momento %1$s</string>
<string name="settings_week_numbers">Números das semanas</string>
<string name="settings_week_numbers_summary">Mostrar números das semanas na vista do mês</string>
<string name="settings_today_toolbar">Botão \"Hoje\" na barra de ferramentas</string>
<string name="settings_today_toolbar_summary">Mostrar um botão \"Hoje\" na barra de ferramentas em vez de um botão flutuante</string>
<string name="settings_app_name">Nome do app</string>
<string name="settings_app_name_summary">Mostrar o Calendula como \"Calendário\" no seu launcher. Somente o nome do launcher muda; o ícone pode mudar para outro lugar após a troca.</string>
<string name="settings_time_format">Formato de hora</string>
<string name="settings_time_format_auto">Automático</string>
<string name="settings_time_format_12h">12 horas (2:00 PM)</string>
<string name="settings_time_format_24h">24 horas (14:00)</string>
<string name="settings_time_format_auto_summary">Seguir o sistema: %1$s</string>
<string name="settings_hour_lines">Linhas de hora</string>
<string name="settings_hour_lines_summary">Mostrar um separador a cada hora nas vistas de semana e dia</string>
<string name="settings_timeline_scale">Altura da hora</string>
<string name="settings_timeline_scale_hint">Quanto espaço vertical que a hora ocupa nas vistas de semana e dia. Ambas as vistas compartilham esse ajuste. Você também pode pinçar a linha do tempo com dois dedos para colocar uma altura no meio.</string>
<string name="timeline_scale_fit_day">Encaixar o dia todo</string>
<string name="timeline_scale_fit_day_summary">24 horas na tela, sem rolagem</string>
<string name="timeline_scale_compact">Compacto</string>
<string name="timeline_scale_compact_summary">Mais horas por tela, blocos menores</string>
<string name="timeline_scale_regular">Regular</string>
<string name="timeline_scale_regular_summary">Espaçamento padrão</string>
<string name="timeline_scale_comfortable">Confortável</string>
<string name="timeline_scale_comfortable_summary">Blocos maiores, mais rolagem</string>
<string name="timeline_scale_custom">Personalizado</string>
<string name="timeline_scale_custom_summary">A altura em que você pinçou a linha do tempo</string>
<string name="settings_dim_completed">Esmaecer eventos passados</string>
<string name="settings_dim_completed_summary">Esmaecer eventos que já terminaram nas vistas do mês e semana</string>
<string name="settings_drag_to_reschedule">Arraste para reagendar</string>
<string name="settings_drag_to_reschedule_summary">Mova um evento arrastando para outro dia ou hora</string>
<string name="settings_past_events">Eventos passados</string>
<string name="settings_past_events_show">Mostrar</string>
<string name="settings_past_events_dim">Esmaecer</string>
<string name="settings_past_events_hide">Ocultar</string>
<string name="settings_past_events_sample_morning">Revisão de equipe</string>
<string name="settings_past_events_sample_midday">Almoço com Robin</string>
<string name="settings_past_events_sample_evening">Treino do coral</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Amplitude da agenda</string>
<string name="settings_agenda_range_hint">O quanto à frente a tela de Agenda lista eventos.</string>
<string name="settings_agenda_widget_range">Amplitude do widget de Agenda</string>
<string name="settings_agenda_widget_range_hint">O quanto à frente o widget de Agenda lista eventos.</string>
<string name="settings_widget_size">Tamanho do widget de Agenda</string>
<string name="settings_widget_size_hint">O quão largo o texto do widget de Agenda é desenhado. O widget de mês não tem ajuste — sua grade sempre encaixa no espaço que você der.</string>
<string name="settings_widget_size_small">Pequeno</string>
<string name="settings_widget_size_medium">Médio</string>
<string name="settings_widget_size_large">Grande</string>
<string name="settings_widget_size_extra_large">Extra grande</string>
<string name="settings_widget_size_summary">Texto do evento %1$d%%</string>
<string name="settings_agenda_show_today">Sempre mostrar Hoje</string>
<string name="settings_agenda_show_today_hint">Mantenha o dia de hoje no topo da agenda e no widget, mesmo que não tenha mais nada hoje.</string>
<string name="settings_agenda_range_bar">Barra de amplitude</string>
<string name="settings_agenda_range_bar_hint">Mostrar uma barra no topo da agenda nomeando as datas mostradas, com um botão para mudar a amplitude para a sessão</string>
<string name="agenda_range_day">Hoje</string>
<string name="agenda_range_this_week">Essa semana</string>
<string name="agenda_range_this_month">Esse mês</string>
<string name="agenda_range_week">Próximos 7 dias</string>
<string name="agenda_range_month">Próximos 30 dias</string>
<string name="agenda_range_custom">Personalizado…</string>
<string name="agenda_range_custom_hint">Dias</string>
<string name="agenda_range_override_hint">Só por agora — reajusta para sua amplitude salva ao reabrir o Calendula.</string>
<string name="agenda_range_showing_label">Mostrando eventos a seguir para</string>
<plurals name="agenda_range_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="settings_section_views">Vistas</string>
<string name="settings_month_header">Vista do mês</string>
<string name="settings_month_view_style">Estilo de vista do mês</string>
<string name="month_style_paged">Páginas</string>
<string name="month_style_paged_summary">Um mês ocupa a tela. Deslize para os lados para mudar o mês.</string>
<string name="month_style_continuous">Meses contínuos</string>
<string name="month_style_continuous_summary">Cada mês tem o próprio cabeçalho, com um espaço separando-o do próximo.</string>
<string name="month_style_dense">Semanas integradas</string>
<string name="month_style_dense_summary">As semanas seguem sem separação, cada mês terminando no início do outro sem distinção entre eles.</string>
<string name="month_style_split">Dividido</string>
<string name="month_style_split_summary">Uma grade compacta marca os dias com eventos, e o dia clicado é listado abaixo.</string>
<string name="month_split_no_events">Nada agendado</string>
<string name="month_split_expand">Mostrar o mês inteiro</string>
<string name="month_split_collapse">Mostrar eventos do dia</string>
<string name="settings_quick_switch_header">Botão de troca rápida</string>
<string name="settings_quick_switch_hint">Escolha por quais vistas o botão superior direito circula, e arraste para reordenar. Vistas desativadas continuam acessíveis pelo menu de navegação.</string>
<string name="settings_drawer_order_header">Menu de navegação</string>
<string name="settings_drawer_order_hint">Arraste para reordenar vistas listadas no menu de navegação.</string>
<string name="reorder_drag_handle">Arraste para reordenar</string>
<string name="settings_section_event_form">Novo formulário de evento</string>
<string name="settings_form_fields_hint">Campos padrão — o restante está dentro de \"Mais campos\"</string>
<string name="settings_autofocus_title">Focar no título em novo evento</string>
<string name="settings_autofocus_title_hint">Ao iniciar um novo evento, colocar o cursor no campo de título e abrir o teclado.</string>
<string name="settings_event_duration">Duração padrão</string>
<string name="settings_event_duration_hint">Quanto tempo um novo evento dura até você mudar o tempo de encerramento. Eventos de dia inteiro não são afetados.</string>
<string name="settings_calendar_durations_title">Duração por calendário</string>
<string name="settings_calendar_durations_hint">Dar a um calendário a sua própria duração padrão — ex.: 8 horas para trabalho.</string>
<string name="settings_calendar_duration_inherits">Padrão (%1$s)</string>
<string name="settings_calendar_duration_use_default">Usar duração padrão (%1$s)</string>
<string name="settings_color_unsupported">Permitir cores em calendários não suportados</string>
<string name="settings_color_unsupported_hint">Alguns calendários (ex.: certos CalDAV) não publicam cores; uma cor de evento personalizada pode ser apagada ou sobrescrita na próxima sincronização. Esta é uma limitação destes calendários, e não é algo que o Calendula pode consertar.</string>
<string name="settings_section_notifications">Notificações</string>
<string name="settings_reminders">Lembretes de evento</string>
<string name="settings_reminders_hint">Vendo lembretes dobrados? Outro calendário está postando também — desligue em um dos dois.</string>
<string name="settings_default_reminder">Lembrete padrão</string>
<string name="settings_default_reminder_allday">Eventos de dia todo</string>
<string name="settings_allday_reminder_time">Tempo de lembrete de dia todo</string>
<string name="settings_allday_reminder_time_hint">Lembretes para eventos de dia todo disparam às %1$s</string>
<string name="settings_allday_reminder_fires_at">Notifica às %1$s</string>
<string name="reminder_none">Nenhum</string>
<string name="reminder_use_default">Usar lembrete padrão</string>
<string name="reminder_custom_amount">Quantidade</string>
<string name="reminder_custom_set">Ajustar</string>
<string name="settings_calendar_reminders_title">Lembretes por calendário</string>
<string name="settings_calendar_reminders_hint">Mude o padrão por calendário — separadamente para eventos com horário e de dia todo. Um calendário pode manter o padrão, removê-lo, ou ajustar seu próprio.</string>
<string name="settings_calendar_reminder_inherits">Padrão (%1$s)</string>
<string name="settings_reliable_delivery">Entrega confiável</string>
<string name="settings_reliable_delivery_hint">O Android pode atrasar lembretes para economizar bateria. Autorize o Calendula para que possam chegar na hora.</string>
<string name="settings_reliable_delivery_exempt">Isentar de otimização de bateria — lembretes chegam na hora.</string>
<string name="settings_snooze_duration">Duração de soneca</string>
<string name="settings_section_calendars">Calendários</string>
<string name="settings_manage_calendars">Gerenciar calendários</string>
<string name="settings_manage_calendars_hint">Crie calendários locais; gerencie os sincronizados</string>
<string name="settings_section_language">Idioma</string>
<string name="settings_language">Idioma do app</string>
<string name="settings_language_auto">Padrão do sistema</string>
<string name="settings_translate">Ajude a traduzir</string>
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
<string name="settings_group_look">Aparência e comportamento</string>
<string name="settings_group_data">Dados</string>
<string name="settings_group_app">App</string>
<string name="settings_group_about">Sobre</string>
<string name="settings_appearance_subtitle">Tema, cores, fontes</string>
<string name="settings_views_subtitle">Vista, layout e ordem padrão</string>
<string name="settings_event_form_subtitle">Campos e comportamento padrão</string>
<string name="settings_notifications_subtitle">Lembretes e entrega</string>
<string name="settings_special_dates_subtitle">Aniversários e datas de contato</string>
<string name="settings_widgets_subtitle">Widget de Agenda, bloco de Ajustes Rápidos</string>
<string name="settings_backup_subtitle">Exportar, importar, backup automático</string>
<string name="settings_section_widgets">WIdgets e blocos</string>
<string name="settings_widgets_hint">Ajustes para widgets da tela inicial e bloco de Ajustes Rápidos. Adicione um widget pressionando e segurando a tela inicial.</string>
<string name="settings_section_backup">Backup e restaurar</string>
<string name="settings_views_all_header">Todas as vistas</string>
<string name="settings_week_day_header">Semana e dia</string>
<string name="settings_default_view_hint">A vista que o Calendula abre ao iniciar.</string>
<string name="settings_week_start_hint">O dia em que cada semana inicia, em todas as vistas e widgets.</string>
<string name="settings_time_format_hint">Como os horários são escritos pelo app. \"Automático\" segue a configuração do sistema.</string>
<string name="settings_past_events_hint">O que a agenda faz com eventos que já terminaram.</string>
<string name="settings_dynamic_color_summary">Use as cores do app de acordo com seu papel de parede.</string>
<string name="settings_section_special_dates">Datas especiais de contatos</string>
<string name="settings_special_dates_enable">Mostrar datas de contatos</string>
<string name="settings_special_dates_enable_hint">Espelhe os aniversários e outras datas dos contatos para calendários locais. Lê os contatos somente neste aparelho — nada é enviado, e seus contatos nunca são alterados.</string>
<string name="settings_special_dates_type_birthday">Aniversários</string>
<string name="settings_special_dates_type_anniversary">Aniversários de relacionamento</string>
<string name="settings_special_dates_type_custom">Outras datas</string>
<string name="settings_special_dates_template">Formato do título</string>
<string name="settings_special_dates_template_hint">Use {nome} para o contato e {ano} para o ano (de nascimento, ou de início do relacionamento; oculto quando não informado).</string>
<string name="settings_special_dates_reminders">Lembretes</string>
<string name="settings_special_dates_show_year">Mostrar ano</string>
<string name="settings_special_dates_show_year_hint">Incluir {ano} nos títulos caso informado</string>
<string name="settings_special_dates_sync_now">Sincronizar agora</string>
<string name="settings_special_dates_never_synced">Não sincronizado ainda</string>
<string name="settings_special_dates_last_synced">Sincronizado por último %1$s</string>
<string name="settings_special_dates_calendar_hint">Ajustar cada cor e visibilidade de calendário nos ajustes de Calendários.</string>
<string name="settings_calendar_reminders_managed_hint">Ajustar em datas especiais de Contatos</string>
<string name="settings_special_dates_paused_title">Pausado</string>
<string name="settings_special_dates_paused_hint">O Calendula não consegue mais ler seus contatos, então estes calendários não estão atualizando.</string>
<string name="settings_special_dates_disable_title">Desligar datas de contatos?</string>
<string name="settings_special_dates_disable_all_message">Isto deleta calendários de datas de contatos e seus eventos. Qualquer lembrete ou nota adicionada serão perdidos.</string>
<string name="settings_special_dates_disable_type_message">Isto deleta o calendário \"%1$s\" e seus eventos. Qualquer lembrete ou nota adicionados serão perdidos.</string>
<string name="settings_special_dates_disable_confirm">Desativar</string>
<string name="dialog_save">Salvar</string>
<string name="settings_section_about">Sobre</string>
<string name="settings_license">Licença</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">por Jean-Luc Makiola</string>
<string name="settings_about_source">Código-fonte</string>
<string name="settings_about_privacy">Política de privacidade</string>
<string name="settings_about_support">Apoie o desenvolvimento</string>
<string name="settings_about_version">Versão %1$s</string>
<string name="settings_about_logo_desc">Ícone de app do Calendula</string>
<string name="settings_report_problem">Reportar um problema</string>
<string name="settings_report_problem_hint">Envie um reporte de falha ou abra o tracker de problemas</string>
<string name="calendars_title">Calendários</string>
<string name="calendars_local_header">Seus calendários</string>
<string name="calendars_local_empty">Nenhum calendário local ainda. Crie um para manter eventos somente neste dispositivo.</string>
<string name="calendars_add">Adicionar calendário</string>
<string name="calendars_visibility_hint">Desative um calendário para escondê-lo neste aparelho — seus eventos desaparecem do app e param de ativar lembretes. Este é o mesmo botão que seus outros apps de calendário usam, então eles também escondem. Nada é deletado, nenhum outro aparelho é afetado, e você pode reativar aqui a qualquer momento.</string>
<string name="calendars_visibility_a11y">Mostrar \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Alguns calendários estão desativados</string>
<string name="calendars_visibility_notice_message">O Calendula mostra os calendários que estão ativados neste dispositivo, e alguns dos seus estão desativados. Para ver seus eventos, ative-os em Ajustes → Calendários.</string>
<string name="calendar_picker_missing_title">Sentindo falta de um calendário?</string>
<string name="calendar_picker_missing_summary">Pode estar desativado, somente-leitura, ou preenchido dos seus contatos — gerencie seus calendários aqui.</string>
<string name="calendars_state_read_only">Somente leitura</string>
<string name="calendars_state_not_synced">Não sincronizado neste dispositivo</string>
<string name="calendars_state_managed">Preenchido dos seus contatos</string>
<string name="calendars_managed_delete_locked">Este calendário é preenchido de seus contatos, então o Calendula o criaria de novo na próxima sincronização. Desative datas especiais em Ajustes → Datas especiais para deletar.</string>
<string name="calendars_synced_header">Calendários sincronizados</string>
<string name="calendars_synced_hint">Estes são de contas no seu dispositivo. Crie e edite-os em seus próprios apps.</string>
<string name="calendars_manage_in_app">Gerenciar no app</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">Mais opções para %1$s</string>
<string name="calendars_enable_all">Habilitar todos</string>
<string name="calendars_disable_all">Desabilitar todos</string>
<string name="calendars_add_account">Adicionar conta</string>
<string name="calendars_new_title">Novo calendário</string>
<string name="calendars_edit_title">Editar calendário</string>
<string name="calendars_name_label">Nome</string>
<string name="calendars_color_label">Cor</string>
<string name="calendars_description_hint">Adicionar descrição</string>
<string name="calendars_delete_confirm_title">Deletar calendário?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" e todos os seus eventos serão permanentemente removidos deste dispositivo.</string>
<string name="calendars_write_error">Não foi possível salvar a alteração.</string>
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Calendários locais não são sincronizados, então exporte-os para um arquivo .ics para manter uma cópia.</string>
<string name="calendars_backup_action">Exportar como arquivo .ics</string>
<string name="calendars_export_title">Exportar calendários</string>
<string name="calendars_export_hint">Escolha quais calendários incluir no arquivo .ics.</string>
<string name="calendars_export_action">Exportar</string>
<string name="calendars_restore_header">Restaurar</string>
<string name="calendars_restore_action">Restaurar de um arquivo .ics</string>
<string name="calendars_restore_hint">Importar eventos de um backup ou outro app de calendário.</string>
<string name="calendars_auto_backup">Backup automático</string>
<string name="calendars_auto_backup_hint">Exportar periodicamente seu calendário local para uma pasta como um arquivo .ics.</string>
<string name="calendars_auto_backup_folder">Pasta de backup</string>
<string name="calendars_auto_backup_folder_unset">Toque para escolher uma pasta</string>
<string name="calendars_auto_backup_interval">Intervalo</string>
<string name="calendars_auto_backup_every">A cada %1$s</string>
<string name="calendars_auto_backup_interval_min">Mínimo de 30 minutos.</string>
<string name="calendars_auto_backup_status_never">Nenhum backup automático ainda</string>
<string name="calendars_auto_backup_status_ok">Último backup: %1$s</string>
<string name="calendars_auto_backup_status_failed">Último backup falho: %1$s</string>
<string name="backup_channel_name">Backup</string>
<string name="backup_channel_description">Avisa se os backups automáticos falharem repetidamente.</string>
<string name="backup_failed_title">Backup automático falhou</string>
<string name="backup_failed_text">O Calendula não pôde salvar o arquivo de backup. Verifique a pasta de backup em Ajustes.</string>
<string name="calendars_backup_failed">Não foi possível exportar o backup.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Exportou %d evento.</item>
<item quantity="many">Exportou %d eventos.</item>
<item quantity="other">Exportou %d eventos.</item>
</plurals>
<string name="import_title">Importar eventos</string>
<string name="import_target_header">Adicionar ao calendário</string>
<string name="import_empty">Nenhum evento encontrado neste arquivo.</string>
<string name="import_failed">Não foi possível ler este arquivo.</string>
<string name="import_no_calendar">Nenhum calendário salvável para importar. Crie um calendário local primeiro.</string>
<string name="import_done_title">Importação concluída</string>
<string name="import_done_dedup_note">Eventos já no calendário foram pulados.</string>
<string name="import_done_added_label">Adicionado</string>
<string name="import_done_skipped_label">Duplicatas</string>
<string name="import_close">Fechar</string>
<string name="import_warning_recurrence">Algumas ocorrências alteradas de eventos recorrentes foram puladas.</string>
<string name="import_warning_no_start">Um evento sem horário de início foi pulado.</string>
<string name="import_warning_attendees">Listas de convidados não foram importadas.</string>
<string name="import_warning_timezone">Um fuso-horário desconhecido foi alterado para o do seu dispositivo.</string>
<string name="import_button">Importar</string>
<plurals name="import_title_count">
<item quantity="one">Importando %d evento</item>
<item quantity="many">Importando %d eventos</item>
<item quantity="other">Importando %d eventos</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d evento neste arquivo.</item>
<item quantity="many">%d eventos neste arquivo.</item>
<item quantity="other">%d eventos neste arquivo.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importar %d evento</item>
<item quantity="many">Importar %d eventos</item>
<item quantity="other">Importar %d eventos</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Importou %d evento.</item>
<item quantity="many">Importou %d eventos.</item>
<item quantity="other">Importou %d eventos.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Pulou %d já neste calendário.</item>
<item quantity="many">Pulou %d já neste calendário.</item>
<item quantity="other">Pulou %d já neste calendário.</item>
</plurals>
<string name="shortcut_new_event_short">Novo evento</string>
<string name="shortcut_new_event_long">Criar novo evento</string>
<string name="qs_tile_new_event_label">Novo evento</string>
<string name="settings_qs_tile">Adicionar bloco Ajustes Rápidos</string>
<string name="settings_qs_tile_hint">Adicionar bloo \"Novo evento\" no painel Ajustes Rápidos.</string>
<string name="crash_dialog_title">%1$s quebrou</string>
<string name="crash_dialog_message">%1$s fechou inesperadamente da última vez. Você pode ajudar a consertar enviando este reporte como um problema. Ele fica no seu dispositivo até que decida enviá-lo, e não inclui dados pessoais ou conteúdo de calendário — somente os detalhes técnicos abaixo.</string>
<string name="crash_dialog_report">Reportar</string>
<string name="crash_dialog_dismiss">Agora não</string>
<string name="crash_report_issue_title">Reportar quebra</string>
<string name="crash_report_clip_label">%1$s relatório de quebra</string>
<string name="crash_report_copied">Relatório copiado para a área de transferência</string>
<string name="crash_report_open_failed">Não foi possível abrir o tracker de problemas. O relatório está na área de transferência.</string>
<string name="crash_report_body_template">Obrigado por reportar uma quebra em %1$s. Por favor, escreva o que estava fazendo, depois envie. \n### O que aconteceu \n\n\n### Relatório de quebra \n%2$s\n</string>
<string name="crash_report_body_paste">_(O reporte era muito longo para este link — cole da área de transferência aqui.)_</string>
<string name="special_dates_calendar_birthday">Aniversários</string>
<string name="special_dates_calendar_anniversary">Aniversários de relacionamento</string>
<string name="special_dates_calendar_custom">Datas especiais</string>
<string name="special_dates_default_title_birthday">Aniversário de {nome} ({ano})</string>
<string name="special_dates_default_title_anniversary">Aniversário de relacionamento de {nome} ({ano})</string>
<string name="special_dates_default_title_custom">{nome}</string>
</resources>

View File

@@ -133,47 +133,4 @@
<string name="import_reminder_prompt_apply">Применить стандартное</string>
<string name="import_reminder_prompt_keep">Оставить как есть</string>
<string name="event_edit_recurrence_none">Не повторяется</string>
<string name="event_edit_recurrence_custom">Свой</string>
<string name="event_edit_recurrence_every">Каждый</string>
<string name="recurrence_unit_days">дни</string>
<string name="recurrence_unit_weeks">недели</string>
<string name="recurrence_unit_months">месяцы</string>
<string name="recurrence_unit_years">года</string>
<string name="event_edit_recurrence_incomplete">Введите число от 1 до 999</string>
<string name="event_edit_recurrence_next_none">Это правило никогда не повторяется</string>
<string name="event_edit_recurrence_ends">Заканчивается</string>
<string name="event_edit_recurrence_end_never">Никогда</string>
<string name="event_edit_recurrence_end_until">До</string>
<string name="event_edit_recurrence_end_count">После определённого количества</string>
<string name="event_availability_busy">Занят</string>
<string name="event_access_default">Стандартный</string>
<string name="event_access_public">Публичный</string>
<string name="event_detail_all_day">Весь день</string>
<string name="event_detail_calendar">Календарь</string>
<string name="event_detail_calendar_unknown">Неизвестный календарь</string>
<string name="event_detail_location">Местоположение</string>
<string name="event_detail_description">Описание</string>
<string name="event_detail_recurring">Повторяющееся событие</string>
<string name="recurrence_daily">Каждый день</string>
<string name="recurrence_weekly">Каждую неделю</string>
<string name="recurrence_monthly">Каждый месяц</string>
<string name="recurrence_yearly">Каждый год</string>
<string name="recurrence_every_n_days">Каждые %1$d дней</string>
<string name="recurrence_every_n_weeks">Каждые %1$d недель</string>
<string name="recurrence_every_n_months">Каждые %1$d месяцев</string>
<string name="recurrence_every_n_years">Каждые %1$d лет</string>
<string name="event_detail_not_found">Это событие больше не существует.</string>
<string name="event_attendee_accepted">Принято</string>
<string name="event_attendee_declined">Отклонено</string>
<string name="event_attendee_needs_action">Без ответа</string>
<string name="event_detail_reminders">Напоминания</string>
<string name="event_detail_timezone">Временная зона</string>
<string name="event_status_cancelled">Отменён</string>
<string name="event_availability_free">Бесплатно</string>
<string name="event_access_private">Приватно</string>
<string name="event_access_confidential">Конфиденциально</string>
<string name="event_access_public_summary">Любой с доступом может видеть все детали</string>
<string name="event_access_private_summary">Другие видят только, что вы заняты</string>
<string name="event_detail_self_response">Ваш ответ: %1$s</string>
<string name="reminder_at_time">Во время начала события</string>
</resources>

View File

@@ -1,190 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Moderný kalendár.</string>
<string name="state_loading">Načítava sa…</string>
<string name="state_retry">Opakovať</string>
<string name="state_failure_unknown">Niečo sa pokazilo.</string>
<string name="state_failure_permission">Je potrebný prístup ku kalendáru.</string>
<string name="state_failure_permission_action">Udeliť prístup</string>
<string name="state_failure_no_calendars">Nie sú nakonfigurované žiadne kalendáre.</string>
<string name="state_failure_no_calendars_action">Otvoriť systémové nastavenie kalendára</string>
<string name="state_failure_provider">Nepodarilo sa načítať kalendár.</string>
<string name="permission_rationale_title">Prehľadávajte všetky svoje udalosti v krásnom prevedení</string>
<string name="permission_rationale_body">Calendula potrebuje prístup k vášmu kalendáru, aby mohla zobraziť a spravovať vaše udalosti.</string>
<string name="permission_request_button">Udeliť prístup k kalendáru</string>
<string name="permission_denied_title">Prístup ku kalendáru bol zamietnutý</string>
<string name="permission_denied_body">Calendula nemôže zobraziť udalosti bez prístupu ku kalendáru. Môžete ho znova udeliť v nastavení systému.</string>
<string name="permission_open_settings_button">Otvoriť systémové nastavenia</string>
<string name="permission_retry_button">Skúsiť znova</string>
<string name="permission_benefit_private_title">Súkromné už od začiatku</string>
<string name="permission_benefit_private_body">Žiadny prístup k internetu, žiadna telemetria - nikdy nič z vášho telefónu neunikne.</string>
<string name="permission_benefit_sync_title">Všetky vaše kalendáre pohromade</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokálne kalendáre - čokoľvek synchronizované zo zariadením sa proste objaví.</string>
<string name="permission_benefit_privacy_title">Nikdy žiadne sledovanie</string>
<string name="permission_benefit_privacy_body">Žiadna telemetria, žiadne analytické nástroje, bez reklám.</string>
<string name="permission_privacy_footnote">Ostáva na vašom zariadení · bez prístupu k internetu</string>
<string name="month_prev">Predchádzajúci mesiac</string>
<string name="month_next">Nasledujúci mesiac</string>
<string name="month_today_action">Dnes</string>
<string name="month_more_actions">Ďalšie možnosti</string>
<string name="month_open_menu">Otvoriť menu</string>
<string name="month_action_settings">Nastavenia</string>
<string name="month_a11y_today_prefix">Dnes</string>
<string name="week_today_action">Tento víkend</string>
<string name="week_number_label">týžd.</string>
<string name="day_today_action">Dnes</string>
<string name="event_detail_back">Späť</string>
<string name="event_detail_edit">Upraviť</string>
<string name="event_detail_delete">Vymazať</string>
<string name="event_detail_share">Zdielať</string>
<string name="event_detail_duplicate">Duplikovať</string>
<string name="event_share_chooser_title">Zdielať udalosť</string>
<string name="event_share_failed">Túto udalosť nieje možné zdieľať.</string>
<string name="event_delete_title">Odstrániť udalosť?</string>
<string name="event_delete_body">Udalosť je odstránená z vášho kalendára aj zo všetkých synchronizovaných zariadení.</string>
<string name="event_delete_recurring_title">Odstrániť opakujúcu sa udalosť</string>
<string name="event_delete_option_occurrence">Iba túto udalosť</string>
<string name="event_delete_option_following">Túto aj všetky nasledujúce udalosti</string>
<string name="event_delete_option_series">Všetky udalosti v sérii</string>
<string name="event_edit_recurring_title">Upraviť opakujúcu sa udalosť</string>
<string name="event_delete_failed">Túto udalosť nebolo možné odstrániť</string>
<string name="event_delete_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla odstrániť udalosti</string>
<string name="event_move_action">Presunúť…</string>
<string name="event_move_recurring_title">Presunúť opakujúcu sa udalosť</string>
<string name="event_move_occurrence_only">Presunútie celej série by sa zmenilo poradie dňou, na ktorú séria pripadá, takže presunúť je možné iba túto udalosť.</string>
<string name="event_move_done">Presunuté na %1$s</string>
<string name="event_move_undo">Vrátiť späť</string>
<string name="event_move_undone">Navrátené naspäť</string>
<string name="event_move_failed">Nemožno presunúť udalosť</string>
<string name="event_move_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla presúvať udalosti</string>
<string name="event_move_gone">Táto udalosť už neexistuje</string>
<string name="event_move_blocked_series_end">Nie je možné presunúť udalosť za koniec danej série</string>
<string name="dialog_cancel">Zrušiť</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nová udalosť</string>
<string name="event_edit_close">Zavrieť</string>
<string name="event_edit_save">Uložiť</string>
<string name="event_edit_title_hint">Pridať názov</string>
<string name="event_edit_managed_hint">Spravové kalendárom “%1$s” — názov, dátum a opakovanie je synchronizované z vašich kontaktov. Pripomienky, poloha a poznámky môžte upraviť.</string>
<string name="event_edit_starts">Začiatok</string>
<string name="event_edit_ends">Koniec</string>
<string name="event_edit_error_end_before_start">Končí pred začiatkom</string>
<string name="event_edit_error_no_calendar">Není dostupný kalendár, ktorý je možno upravovať</string>
<string name="event_edit_save_failed">Nemožno udalosť uložiť</string>
<string name="event_edit_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla vytvárať udalosti</string>
<string name="event_edit_more_fields">Viac poli</string>
<string name="event_edit_add">Pridať</string>
<string name="event_edit_add_reminder">Pridať pripomienku</string>
<string name="event_edit_remove_reminder">Odstrániť pripomienku</string>
<string name="event_edit_attendees">Hostia</string>
<string name="event_edit_add_guest">Pridať hosťa</string>
<string name="event_edit_add_guest_hint">Pridať hosťa e-mailom…</string>
<string name="event_edit_add_guest_from_contacts">Pridať z kontaktov</string>
<string name="event_edit_location_from_contacts">Vybrať adresu z kontaktov</string>
<string name="event_edit_remove_guest">Odstrániť hosťa</string>
<string name="event_edit_attendee_required">Nutné</string>
<string name="event_edit_attendee_optional">Voliteľné</string>
<string name="event_edit_attendees_note_synced">Calendula neposiela pozvánky. Váš kalendárny účet ich môže poslať keď sa synchronizuje.</string>
<string name="event_edit_attendees_note_local">Uložené na tomto zariadení. Nikomu nebude poslaná notifikácia.</string>
<string name="event_edit_reminder_custom">Vlastné</string>
<string name="reminder_unit_minutes">minúty</string>
<string name="reminder_unit_hours">hodiny</string>
<string name="reminder_unit_days">dni</string>
<string name="reminder_unit_weeks">týždne</string>
<string name="event_edit_availability">Dostupnosť</string>
<string name="event_edit_visibility">Viditeľnosť</string>
<string name="event_edit_timezone_device">Časové pásmo zariadenia</string>
<string name="event_edit_timezone_device_summary">Sleduje vás, nech ste kdekoľvek</string>
<string name="event_edit_timezone_search">Vyhľadať časové pásmo</string>
<string name="event_edit_timezone_recent">Nedávne</string>
<string name="event_edit_timezone_all">Všetky časové pásma</string>
<string name="event_edit_timezone_none">Žiadne časové pásmo nezodpovedá “%1$s”</string>
<string name="event_edit_timezone_local_time">%1$s vášho času</string>
<string name="event_edit_color">Farba</string>
<string name="event_edit_color_default">Farba kalendáru</string>
<string name="event_edit_color_custom">Vlastná farba</string>
<string name="event_edit_color_reset">Obnoviť</string>
<string name="event_edit_color_unsupported">Není k dispozícii pre tento kalendár</string>
<string name="event_edit_color_unsupported_hint">Tento kalendár nemá vlastnú sadu farieb. Pre takýto kalendár môžte povoliť vlastné farby v nastaveniach.</string>
<string name="event_edit_color_sync_warning">Tento kalendár môže vymazať alebo prepísať farbu pri ďalšej synchronizácii.</string>
<string name="event_edit_conflict_title">Udalosť bola zmenená niekde inde</string>
<string name="event_edit_conflict_body">Počas úprav sa táto udalosť zmenila — synchronizáciou alebo inou aplikáciou. Čo sa má stať s vašimi zmenami?</string>
<string name="event_edit_conflict_overwrite">Uložiť zmeny</string>
<string name="event_edit_conflict_overwrite_hint">Iba u upravených polí sa prepíše zmena z iného zdroja</string>
<string name="event_edit_conflict_discard">Zrušiť zmeny</string>
<string name="event_edit_conflict_discard_hint">Udalosť ostáva bez zmeny</string>
<string name="event_edit_gone_title">Udalosť odstránená</string>
<string name="event_edit_gone_body">Počas úprav bola táto udalosť odstránená, napríklad na inom zariadení. Vaše zmeny už nie je možné uložiť.</string>
<string name="import_reminder_prompt_title">Chcete použiť predvolenú pripomienku?</string>
<string name="import_reminder_prompt_body_none">Táto udalosť bola importovaná bez pripomienky.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Táto udalosť bola importovaná s %1$d pripomienkou.</item>
<item quantity="few">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
<item quantity="many">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
<item quantity="other">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Nastaviť predvolené</string>
<string name="import_reminder_prompt_keep">Nechať bez zmeny</string>
<string name="event_edit_recurrence_none">Neopakuje sa</string>
<string name="event_edit_recurrence_custom">Vlastné</string>
<string name="event_edit_recurrence_every">Každý</string>
<string name="recurrence_unit_days">dni</string>
<string name="recurrence_unit_weeks">týždne</string>
<string name="recurrence_unit_months">mesiace</string>
<string name="recurrence_unit_years">roky</string>
<string name="event_edit_recurrence_incomplete">Zadajte číslo od 1 do 999</string>
<string name="event_edit_recurrence_next">Nasledujúce: %1$s</string>
<string name="event_edit_recurrence_next_none">Toto pravidlo sa nikdy neopakuje</string>
<string name="event_edit_recurrence_ends">Koniec</string>
<string name="event_edit_recurrence_end_never">Nikdy</string>
<string name="event_edit_recurrence_end_until">K určitému dátumu</string>
<string name="event_edit_recurrence_end_count">Po určitom počtu opakovaní</string>
<string name="event_edit_recurrence_times">opakovaní</string>
<string name="event_edit_error_recurrence_ends_before_start">Opakovanie končí pred začiatkom udalosti</string>
<string name="event_availability_busy">Zaneprázdnený</string>
<string name="event_access_default">Predvolený</string>
<string name="event_access_public">Verejný</string>
<string name="event_detail_all_day">Celý deň</string>
<string name="event_detail_calendar">Kalendár</string>
<string name="event_detail_calendar_unknown">Neznámy kalendár</string>
<string name="event_detail_location">Poloha</string>
<string name="event_detail_description">Popis</string>
<string name="event_detail_attendees">Účastníci</string>
<string name="event_detail_recurrence">Opakovanie</string>
<string name="event_detail_recurring">Opakovaná udalosť</string>
<string name="recurrence_daily">Každý deň</string>
<string name="recurrence_weekly">Každý týždeň</string>
<string name="recurrence_monthly">Každý mesiac</string>
<string name="recurrence_yearly">Každý rok</string>
<string name="recurrence_every_n_days">Každý %1$d. deň</string>
<string name="recurrence_every_n_weeks">Každý %1$d. týždeň</string>
<string name="recurrence_every_n_months">Každý %1$d. mesiac</string>
<string name="recurrence_every_n_years">Každý %1$d. rok</string>
<string name="recurrence_on_days">%1$s v %2$s</string>
<string name="recurrence_with_until">%1$s do%2$s</string>
<string name="recurrence_with_count">%1$s, %2$d opakovaní</string>
<string name="event_detail_not_found">Táto udalosť už neexistuje.</string>
<string name="event_attendee_accepted">Akceptované</string>
<string name="event_attendee_declined">Zamietnuté</string>
<string name="event_attendee_tentative">Predbežne</string>
<string name="event_attendee_needs_action">Bez odozvy</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Pripomienky</string>
<string name="event_detail_timezone">Časové pásmo</string>
<string name="event_status_tentative">Predbežne</string>
<string name="event_status_cancelled">Zrušené</string>
<string name="event_availability_free">Voľno</string>
<string name="event_access_private">Súkromné</string>
<string name="event_access_confidential">Tajné</string>
<string name="event_access_default_summary">Bežná činnosť kalendáru</string>
<string name="event_access_public_summary">Kdekoľvek s prístupom vidí všetky detaily</string>
<string name="event_access_private_summary">Ostatný vidia iba že ste zaneprázdnený</string>
<string name="event_access_confidential_summary">Označené ako utajené; záleží na kalendárnom účtu čo toto znamená</string>
</resources>

View File

@@ -693,11 +693,15 @@
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
<string name="import_done_added_label">Added</string>
<string name="import_done_skipped_label">Duplicates</string>
<string name="import_done_failed_label">Not added</string>
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
<string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string>
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
<string name="import_warning_tasks">Tasks in this file were added as events.</string>
<string name="import_warning_recurrence_repaired">A faulty repeat rule was repaired.</string>
<string name="import_button">Import</string>
<plurals name="import_title_count">
<item quantity="one">Importing %d event</item>
@@ -719,6 +723,10 @@
<item quantity="one">Skipped %d already in this calendar.</item>
<item quantity="other">Skipped %d already in this calendar.</item>
</plurals>
<plurals name="import_done_failed">
<item quantity="one">%d event couldn\'t be added.</item>
<item quantity="other">%d events couldn\'t be added.</item>
</plurals>
<!-- Launcher long-press shortcuts -->
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>

View File

@@ -10,14 +10,11 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
<locale android:name="cs" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="fr" />
<locale android:name="it" />
<locale android:name="ja" />
<locale android:name="pl" />
<locale android:name="pt-BR" />
<locale android:name="ru" />
<locale android:name="zh-CN" />
</locale-config>

View File

@@ -801,6 +801,42 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `importEvents carries on past an event the provider rejects`(
@TempDir tempDir: Path,
) = runTest {
// A foreign export can hold a row the provider throws on outright; that
// must cost the user one event, not the whole file (Codeberg #225).
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val events = listOf(
parsedEvent("a@x", summary = "good"),
parsedEvent("b@x", summary = "bad"),
parsedEvent("c@x", summary = "good"),
)
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
assertThat(summary.imported).isEqualTo(2)
assertThat(summary.failed).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
}
@Test
fun `importEvents looks the target palette up once, not per event`(
@TempDir tempDir: Path,
) = runTest {
var lookups = 0
val fake = FakeCalendarDataSource().apply {
eventColorPaletteResult = { lookups++; emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
assertThat(lookups).isEqualTo(1)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
@@ -825,9 +861,10 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
private fun parsedEvent(uid: String?, summary: String = "E") =
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",
summary = summary,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,

View File

@@ -128,8 +128,7 @@ class EventWriteMapperTest {
original: EventForm,
updated: EventForm,
series: Long = seriesStart,
exdate: String? = null,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
/** The instant [local] names in [zoneId], as the provider would store it. */
private fun instantAt(local: String, zoneId: String): Long =
@@ -559,239 +558,6 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
// --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:0010:00. */
private fun julySeries(): EventForm = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
private fun EventForm.atHour(hour: Int): EventForm = copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
)
@Test
fun `a series time edit moves its exclusions with the anchor`() {
// The bug: the stamp stayed at the old instant, which the moved series no
// longer generates, so the occurrence the user deleted came back.
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
// 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z.
val values = update(original, original.atHour(10), series, "20260708T070000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z")
}
@Test
fun `an exclusion keeps its wall clock across a DST boundary`() {
// Anchor and edited occurrence are in July (CEST, +2); the excluded
// occurrence sits in January (CET, +1). Shifting by the millisecond delta
// measured at the edited occurrence would leave it an hour off.
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
// 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z.
val values = update(original, original.atHour(10), series, "20260114T080000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z")
}
@Test
fun `pinning a series to another zone re-resolves its exclusions there`() {
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
val values = update(
original,
original.copy(timezone = "Asia/Tokyo"),
series,
"20260716T070000Z",
)
// The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z.
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z")
}
@Test
fun `an all-day series move shifts its exclusions by whole days`() {
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
.isEqualTo("20260724")
}
@Test
fun `switching a series to all-day rewrites its exclusions as dates`() {
// The two forms aren't interchangeable: a date-time stamp on an all-day
// series matches no occurrence, so the exclusion would be lost.
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722")
}
@Test
fun `switching a series back to timed rewrites its exclusions as instants`() {
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val timed = original.copy(
isAllDay = false,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
)
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
.isEqualTo("20260722T070000Z")
}
@Test
fun `a text-only edit leaves the exclusions alone`() {
val original = julySeries()
val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `changing only the rule rewrites no exclusions`() {
// The times are untouched, so every surviving occurrence keeps its instant
// and the stamps still name the right ones.
val original = julySeries()
val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z")
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an exdate form we do not write is left untouched`() {
// A sync adapter may store a TZID-parameterised or floating stamp. A stale
// stamp excludes nothing; a mangled one could exclude the wrong occurrence.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `removing the recurrence clears the exclusions with it`() {
// Dormant, not harmless: adding a recurrence back later would punch the old
// holes into the new one.
val original = julySeries()
val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z")
assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null)
}
@Test
fun `undoing a series move lands the exclusions back where they started`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
)
val exdate = "20260722T070000Z"
val forward = update(original, moved, series, exdate)
val movedExdate = forward[CalendarContract.Events.EXDATE] as String
assertThat(movedExdate).isNotEqualTo(exdate)
val back = update(
moved,
original,
forward[CalendarContract.Events.DTSTART] as Long,
movedExdate,
)
assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate)
}
@Test
fun `a series with no exclusions writes no exdate column`() {
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
assertThat(update(original, original.atHour(10), series, exdate = null))
.doesNotContainKey(CalendarContract.Events.EXDATE)
}
// --- exdateAfter (the exclusions a "this and following" split inherits) ---
@Test
fun `a split carries only the exclusions past the split point`() {
// The occurrence at the split point is the one being edited, so it exists
// by definition — a stale exclusion for it would swallow the edit whole.
assertThat(
exdateAfter(
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
),
).isEqualTo("20260722T080000Z")
}
@Test
fun `a split carries nothing when every exclusion is behind it`() {
assertThat(
exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false),
).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true),
).isEqualTo("20260722")
}
@Test
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
"20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
),
).isNull()
}
@Test
fun `split exclusions move by the same shift as the new series start`() {
// What the split path composes: filter to the tail, then re-time it by the
// shift the user applied to the occurrence they split at.
val original = julySeries()
val carried = shiftedExdate(
existingExdate = exdateAfter(
"20260716T070000Z",
instantAt("2026-07-15T07:00", "UTC"),
isAllDay = false,
),
original = original,
updated = original.atHour(11),
zone = berlin,
)
// 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z.
assertThat(carried).isEqualTo("20260716T090000Z")
}
@Test
fun `nothing to shift yields no exdate`() {
assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull()
assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull()
}
// --- per-event colour ---
@Test

View File

@@ -81,7 +81,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
@@ -91,8 +94,17 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}

View File

@@ -4,6 +4,7 @@ import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test
import java.time.ZoneId
class IcsExportMapperTest {
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
)
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
val event = reader.toIcsEvent(
reminderMinutes = listOf(10),
calendarName = "Personal",
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup")
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
@@ -65,6 +74,32 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
}
@Test
fun `an all-day reminder is exported as its whole-day lead time`() {
// The raw offset encodes the firing time and is normally negative; left
// raw it would be written as a trigger after the event and dropped, so
// the backup would come back with no reminder at all.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 1L,
EventExportProjection.IDX_TITLE to "Anna Birthday",
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
assertThat(onTheDay.reminderMinutes).containsExactly(0)
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
}
private companion object {
const val NINE_AM = 9 * 60
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
}
}

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ImportedEventValuesTest {
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
private fun allDay(
rrule: String? = null,
exDates: List<String> = emptyList(),
color: Int? = null,
days: Int = 1,
) = ParsedIcsEvent(
uid = "u@x",
summary = "Anna Birthday",
start = dayStart,
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = rrule,
exDates = exDates,
color = color,
)
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
@Test
fun `a one-off carries DTEND and no recurrence`() {
val v = values(allDay())
assertThat(v[CalendarContract.Events.DTEND])
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
}
@Test
fun `a recurring row carries DURATION instead of DTEND`() {
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `an all-day series is never zero days long`() {
// A degenerate span would expand into no instances at all — the series
// would simply not exist (Codeberg #225).
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `EXDATEs ride along with the recurrence`() {
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
}
@Test
fun `EXDATEs are not written without a recurrence to exclude from`() {
val v = values(allDay(exDates = listOf("20260820")))
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an imported colour is written raw when the account publishes no palette`() {
val v = values(allDay(color = -2818048))
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
}
@Test
fun `an imported colour snaps to the nearest published palette key`() {
val palette = listOf(
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
)
// Tomato — visually an orangered, nothing like the blue or the green.
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
// A raw colour alongside a key is what a palette calendar rejects.
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `an event with no colour touches neither colour column`() {
val v = values(allDay())
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `a timed event keeps its own zone and a seconds duration`() {
val start = Instant.parse("2026-08-19T08:00:00Z")
val event = ParsedIcsEvent(
uid = "u@x",
summary = "Standup",
start = start,
end = Instant.parse("2026-08-19T08:15:00Z"),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val v = values(event)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsColorTest {
@Test
fun `a raw Android colour int round-trips`() {
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
}
@Test
fun `a CSS3 name resolves opaque`() {
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
}
@Test
fun `hex forms are accepted with and without alpha`() {
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
}
@Test
fun `a colour with no alpha byte is made opaque`() {
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
}
@Test
fun `unusable values resolve to null`() {
assertThat(parseIcsColorValue(null)).isNull()
assertThat(parseIcsColorValue("")).isNull()
// Fossify interpolates a nullable calendar straight into the property.
assertThat(parseIcsColorValue("null")).isNull()
assertThat(parseIcsColorValue("chartreusey")).isNull()
assertThat(parseIcsColorValue("#abc")).isNull()
}
}

View File

@@ -0,0 +1,470 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
/**
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
*
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
*
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
* following midnight. Shifting those by a day would corrupt them.
*
* The exception — and the whole of #225 — is events mirrored from Contacts.
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
* lands back on the starting day and they export zero length.
*/
class IcsFossifyImportTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
private fun fossify(vararg body: String) = (
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
"VERSION:2.0",
) + body + listOf("END:VCALENDAR")
).joinToString("\r\n")
private fun days(event: ParsedIcsEvent): Long =
(event.end - event.start).inWholeMilliseconds / 86_400_000L
@Test
fun `an all-day event keeps the exact span the file gives it`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.isAllDay).isTrue()
assertThat(days(event)).isEqualTo(1)
}
@Test
fun `contact-mirrored birthdays export zero length and must not vanish`() {
// The reported failure (#225). Fossify mirrors Contacts birthdays and
// anniversaries with startTS == endTS, so the exporter's +12h rounds
// back to the starting day: DTSTART == DTEND. Read literally these are
// yearly series of zero-length occurrences, which the provider expands
// into nothing at all — the birthdays import "successfully" and are
// then nowhere to be seen.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events).hasSize(3)
result.events.forEach {
assertThat(it.isAllDay).isTrue()
assertThat(days(it)).isEqualTo(1)
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
// Their all-day reminder encoding: "on the day", not midnight UTC.
assertThat(it.semanticReminderMinutes()).containsExactly(0)
}
}
@Test
fun `a real Fossify holiday file imports unchanged`() {
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
// it — the file is plain, conformant iCalendar.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events.map { it.summary })
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
}
@Test
fun `an all-day event with no DTEND lasts one day`() {
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a zero-length all-day event is widened rather than left to vanish`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
"END:VEVENT",
),
)
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
}
@Test
fun `a task is imported as an event and reported`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.isTask).isTrue()
assertThat(task.summary).isEqualTo("Pay rent")
assertThat(days(task)).isEqualTo(1)
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
}
@Test
fun `a task dated only by DUE still imports`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:File taxes",
"DUE;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.single().summary).isEqualTo("File taxes")
}
@Test
fun `the per-event colour wins over the calendar colour`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"COLOR:tomato",
"X-FOSSIFY-EVENT-COLOR:-2818048",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-2818048)
}
@Test
fun `the calendar colour stands in when the event has none of its own`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the legacy Simple Calendar colour spellings are read too`() {
val result = parser.parse(
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-SMT-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
// Both properties interpolate a nullable calendar straight into the value.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:null",
"CATEGORIES:null",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.color).isNull()
assertThat(event.calendarName).isNull()
}
@Test
fun `CATEGORIES names the source calendar`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"CATEGORIES:Birthdays",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
}
@Test
fun `a reminder written as a positive trigger is not read as a lead time`() {
// The exporter flips the sign for reminders stored below -1
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
// point after the start. On an all-day event that means a time of day.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:P0DT9H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.reminderMinutes).containsExactly(-540)
assertThat(event.semanticReminderMinutes()).containsExactly(0)
}
@Test
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
// time puts "1 day before" only six hours ahead of the UTC midnight.
// Rounding to the nearest day would read that as "on the day".
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT6H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `an all-day reminder rounds to whole days before`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
// 15h before midnight == "1 day before at 09:00".
"TRIGGER:-P0DT15H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `a timed reminder keeps its exact lead time`() {
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT0H10M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
}
@Test
fun `an all-day series' EXDATE day codes carry over`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"EXDATE:20260822",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
}
@Test
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
// They store excluded occurrences as day codes and write them out that
// way even for a timed series, where the property alone is ambiguous.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
}
@Test
fun `a floating EXDATE is read in the series' zone, not the device's`() {
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
// value is New York wall time. Reading it in the device's Berlin zone
// would land on an instant no occurrence has, and the excluded
// occurrence would come back.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;TZID=America/New_York:20260819T100000",
"DTEND;TZID=America/New_York:20260819T103000",
"RRULE:FREQ=WEEKLY",
"EXDATE:20260826T100000",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
}
@Test
fun `EXDATE is dropped along with an unusable recurrence rule`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.recurrenceRule).isNull()
assertThat(event.exDates).isEmpty()
}
@Test
fun `a whole export imports every component`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"CATEGORIES:Birthdays",
"LAST-MODIFIED:20260101T120000Z",
"TRANSP:TRANSPARENT",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"DTSTAMP:20260819T100000Z",
"CLASS:PUBLIC",
"STATUS:CONFIRMED",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"UID:def456",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
"END:VEVENT",
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"UID:ghi789",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.map { it.summary })
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
// Nothing may reach the provider as a zero-length all-day row.
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
}
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsRecurrenceTest {
@Test
fun `a well-formed rule is unchanged`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
}
@Test
fun `the RRULE prefix is stripped`() {
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY")
}
@Test
fun `an empty list part is dropped`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY="))
.isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH="))
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `a nonsensical INTERVAL is dropped, not fatal`() {
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY")
}
@Test
fun `a rule without a usable FREQ is unsalvageable`() {
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull()
assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull()
assertThat(sanitizeRrule("")).isNull()
assertThat(sanitizeRrule(null)).isNull()
}
@Test
fun `FREQ is normalised to upper case`() {
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3")
}
@Test
fun `unknown parts pass through untouched`() {
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7"))
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7")
}
}

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.ui.imports
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test
/** Which calendar a bulk import lands on before the user touches the picker. */
class ImportTargetTest {
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = name,
accountName = "acc@local",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
private val calendars = listOf(
cal(1L, "Work"),
cal(2L, "Personal", local = true),
cal(3L, "Birthdays", local = true),
)
@Test
fun `a calendar named like the file's wins`() {
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
}
@Test
fun `the name match ignores case`() {
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
}
@Test
fun `an unmatched name falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
}
@Test
fun `no name at all falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
}
@Test
fun `with no local calendars the first of any kind is taken`() {
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
}
@Test
fun `an empty list preselects nothing`() {
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
}
}

View File

@@ -0,0 +1,64 @@
BEGIN:VCALENDAR
PRODID:-//Fossify//NONSGML Event Calendar//EN
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Anna Schmidt
UID:101
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19900412
DTEND;VALUE=DATE:19900412
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Opa
UID:102
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19700603
DTEND;VALUE=DATE:19700603
X-FOSSIFY-MISSING-YEAR:1
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Hochzeitstag
UID:103
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Anniversaries
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:20050917
DTEND;VALUE=DATE:20050917
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,25 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:Fossify Calendar Holiday Generator
METHOD:PUBLISH
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
SUMMARY:Neujahr
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800101
DTEND;VALUE=DATE:19800102
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
SUMMARY:Heilige Drei Könige
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800106
DTEND;VALUE=DATE:19800107
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR

View File

@@ -346,6 +346,69 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
be copied, because our failure mode includes a broadcast that arrives with no
alert row behind it.
## Importing foreign `.ics`
`IcsParser` is deliberately liberal, because the files it is handed were written
by other people's calendars. What that costs us, learned from reading the Simple
Calendar / Fossify exporter (#225):
**An all-day event may never reach the provider zero days long.** This is what
#225 was: Fossify mirrors Contacts birthdays and anniversaries with
`startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)`
rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally
that is a yearly series of zero-length occurrences, which the provider expands
into no instances at all — the import reports success and the birthdays are
nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an
all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with
no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day
`DURATION` at `P1D`.
Do **not** generalise that into correcting the producer's `DTEND` by sniffing its
`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a
UI-created all-day event at *noon* of its last day
(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row
came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight.
Shifting those would push every one of them out by a day. The holiday files
bundled inside Fossify are a second trap: their `PRODID` reads
`Fossify Calendar Holiday Generator` and their content is plain conformant
iCalendar. Both that file and a contact-birthday export are kept as fixtures in
`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the
conformant one.
**One bad event must not cost the file.** The provider validates `RRULE` through
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
read. `sanitizeRrule` drops empty and malformed parts before the write, and
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
`IcsImportSummary.failed` rather than unwinding the batch and reporting only
"couldn't read this file".
Smaller dialect handling: `VTODO` components import as events (Calendula models
no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands
in for the `X-WR-CALNAME` Fossify never writes and preselects a target calendar
of that name, bare `EXDATE` day codes on a timed series are resolved against the
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
to `DTSTART`'s zone, not the device's), and a `VALARM` trigger pointing *after*
the start — which is how that family encodes "on the day at 09:00" — is read as
zero days before rather than clamped to a lead time of zero.
All-day reminder offsets are **whole days, rounded up** in both directions. A
file's raw offset is `days × 1440 timeOfDay`, so rounding to the nearest day
would drop a day for every producer whose all-day notifications fire after noon
("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the
mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded
into it and is normally *negative*, which the writer used to discard as a trigger
after the event — so `toIcsEvent` decodes it back through
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
its own setting.
Colour arrives as a raw ARGB from an app with no idea which account it is landing
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
`buildImportedEventValues` snaps the imported colour to the nearest published key
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
palette. The single-event review form deliberately keeps no colour at all: its
first question is which calendar to use, and answering it clears the colour
anyway.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:

View File

@@ -172,12 +172,9 @@ F-Droid but not Play:
Until those are fixed, Play's graphics are managed by hand in the console. Then
pass `images:true` to the `listing` lane.
**Track.** Uploads go straight to `production` at a full rollout
(`PLAY_RELEASE_STATUS=completed`). The merge to `main` is already the human
gate — a release only gets there after on-device review — so a second manual
promotion in the Play Console added delay without adding a decision. Set the
`PLAY_TRACK` repo variable (e.g. `internal`) to stage a release instead, or
`PLAY_RELEASE_STATUS=draft` to hold it unpublished.
**Track.** Uploads go to `internal` by default; promotion to production stays a
manual action in the Play Console, matching the rule that UI releases wait for
on-device review. Override with the `PLAY_TRACK` repo variable.
### Manual re-sign / recovery
@@ -236,7 +233,7 @@ Two consequences worth remembering:
| Variable | Default | Purpose |
| --- | --- | --- |
| `PLAY_TRACK` | `production` | Play track the bundle is uploaded to. Set to `internal`/`alpha`/`beta` to stage instead. |
| `PLAY_TRACK` | `internal` | Play track the bundle is uploaded to. |
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit against the API and discards it — use to rehearse. |

View File

@@ -29,7 +29,7 @@ platform :android do
supply(
aab: aab,
track: options[:track] || "production",
track: options[:track] || "internal",
release_status: options[:release_status] || "completed",
# The APK belongs to F-Droid and the Codeberg download; Play only ever

View File

@@ -1,5 +0,0 @@
Added
• Calendula now speaks Czech, Japanese and Russian, contributed by community translators on Weblate. All three are early, so untranslated parts still show in English — pick one under Settings → Language.
Changed
• The Brazilian Portuguese translation is now complete.

View File

@@ -5,16 +5,16 @@ ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.11.0"
lifecycleRuntime = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
# Pin explicitly to override the BOM (which ships stable 1.4.0).
# Re-evaluate when 1.5.0 stable lands.
material3 = "1.5.0-alpha26"
material3 = "1.5.0-alpha25"
datastore = "1.2.1"
junit = "6.1.3"
junitPlatform = "6.1.3"
junit = "6.1.2"
junitPlatform = "6.1.2"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"