Compare commits

..

17 Commits

Author SHA1 Message Date
3e576e8726 chore(deps): update dependency fastlane to v2.238.0 2026-08-24 05:02:24 +00:00
Jean-Luc Makiola
5b099714f6 2.19.3 — Czech, Japanese and Russian in the language picker (#231)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m33s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
Renovate / renovate (push) Successful in 2m24s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/231
2026-08-22 11:18:37 +02:00
163d821d1f 2.19.3 — Czech, Japanese and Russian in the language picker
Surface the community translations that landed via Weblate but were never
listed in locales_config.xml. European Portuguese stays out: it has one
translated string.
2026-08-22 11:10:06 +02:00
Jean-Luc Makiola
fe98b1ed66 Translations update from Weblate (#223)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/223
2026-08-22 11:03:17 +02:00
James Trivette
c0270ab3a0 Translated using Weblate (Czech)
Currently translated at 36.0% (207 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 13:13:33 +00:00
James Trivette
6019d6c0b8 Translated using Weblate (Czech)
Currently translated at 33.7% (194 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 05:51:15 +00:00
James Trivette
19bc5967a4 Translated using Weblate (Czech)
Currently translated at 33.6% (193 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 05:51:05 +00:00
ame0011
ddab3454bb Translated using Weblate (Japanese)
Currently translated at 13.9% (80 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 18:48:25 +00:00
ame0011
247c9ee6cd Translated using Weblate (Japanese)
Currently translated at 11.1% (64 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 16:29:43 +00:00
Bernardo Krzysczak
6be170cae2 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-08-20 16:29:42 +00:00
James Trivette
8133584f1e Translated using Weblate (Czech)
Currently translated at 16.3% (94 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-20 09:52:46 +00:00
Weblate
4cdb42afba Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-20 05:43:16 +00:00
James Trivette
19a4c05e93 Added translation using Weblate (Czech) 2026-08-20 05:43:16 +00:00
ame0011
aba06affbf Translated using Weblate (Japanese)
Currently translated at 9.4% (54 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 05:43:16 +00:00
ame0011
2b132a5b7a Translated using Weblate (Japanese)
Currently translated at 4.3% (25 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-19 16:12:28 +00:00
Weblate
52ecce03b8 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-19 09:41:07 +00:00
ame0011
0eb76db12d Added translation using Weblate (Japanese) 2026-08-19 09:41:07 +00:00
32 changed files with 902 additions and 1610 deletions

1
.gitattributes vendored
View File

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

View File

@@ -7,30 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **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]).
## [2.19.3] — 2026-08-22
### Added
- **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]).
- **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.
## [2.19.2] — 2026-08-17
@@ -1480,4 +1471,3 @@ automatically, with zero telemetry and no internet permission.
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225

View File

@@ -7,4 +7,4 @@ source "https://rubygems.org"
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
gem "fastlane", "2.237.0"
gem "fastlane", "2.238.0"

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 = 21902
versionName = "2.19.2"
versionCode = 21903
versionName = "2.19.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -35,7 +35,6 @@ 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
@@ -91,10 +90,8 @@ 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>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -104,18 +101,10 @@ interface CalendarDataSource {
/**
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
* 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.
* minting one when absent); returns the new `Events._ID`. Reminders are
* written as the file's raw lead minutes (METHOD_ALERT).
*/
fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
@@ -754,10 +743,7 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
override fun exportableEvents(calendarIds: Set<Long>?): 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
@@ -792,7 +778,6 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
}
} ?: emptyList()
@@ -801,53 +786,55 @@ 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 AND " +
"${CalendarContract.Events.DELETED} = 0",
"${CalendarContract.Events.UID_2445} IS NOT NULL",
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,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
val values = buildImportedEventValues(
event = event,
calendarId = calendarId,
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)
// Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none.
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
palette = colorPalette,
)
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
put(
CalendarContract.Events.UID_2445,
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
)
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)
?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri)
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
}
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
@@ -857,6 +844,18 @@ 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),

View File

@@ -1,6 +1,5 @@
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
@@ -27,7 +26,6 @@ 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
@@ -212,41 +210,26 @@ class CalendarRepositoryImpl @Inject constructor(
}
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
withContext(io) { dataSource.exportableEvents(calendarIds) }
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++
continue
}
try {
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
} else {
dataSource.insertImportedEvent(event, targetCalendarId)
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, failed = failed)
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
@@ -305,11 +288,6 @@ 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,11 +3,7 @@ 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
@@ -503,73 +499,3 @@ 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,9 +5,6 @@ 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]
@@ -15,23 +12,14 @@ import java.time.ZoneOffset
* [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.
@@ -52,20 +40,13 @@ internal fun ColumnReader.toIcsEvent(
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = isAllDay,
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
reminderMinutes = reminderMinutes,
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,

View File

@@ -110,17 +110,3 @@ 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

@@ -1,80 +0,0 @@
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,25 +7,14 @@ 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?,
@@ -41,42 +30,8 @@ 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). */
@@ -90,12 +45,6 @@ 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(
@@ -103,16 +52,8 @@ data class IcsParseResult(
val warnings: Set<IcsParseWarning>,
)
/**
* 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,
)
/** Outcome of a bulk `.ics` import into one calendar. */
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
@@ -121,9 +62,6 @@ data class IcsImportSummary(
* (`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()) {
@@ -131,49 +69,37 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
// 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 calendarName: String? = 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 {
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)
line.isBegin("VEVENT") -> {
val end = indexOfEnd(lines, i + 1, "VEVENT")
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
?.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 parseComponent(
private fun parseVevent(
body: List<String>,
fileCalendarName: String?,
warnings: MutableSet<IcsParseWarning>,
isTask: Boolean,
): ParsedIcsEvent? {
var uid: String? = null
var summary = ""
@@ -186,10 +112,7 @@ 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
@@ -206,12 +129,9 @@ 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)
@@ -222,19 +142,6 @@ 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++
}
@@ -247,111 +154,33 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null
}
val cleanRrule = sanitizeRrule(rrule)
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) {
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
}
val end = resolveEnd(start, dtEnd, duration)
val end = dtEnd
?: duration?.let {
start.copy(
instant = Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
),
)
}
?: start
return ParsedIcsEvent(
uid = uid,
summary = summary,
start = start.instant,
end = end,
end = end.instant,
isAllDay = start.isAllDay,
zoneId = start.zoneId,
recurrenceRule = cleanRrule,
recurrenceRule = rrule,
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,
)
}
/**
* 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.
*/
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
private fun parseAlarmMinutes(body: List<String>): Int? {
val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) }
@@ -360,7 +189,8 @@ 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)
return (-millis / 60_000L).toInt()
// Negative = before start (the normal case) → positive lead minutes.
return (-millis / 60_000L).toInt().coerceAtLeast(0)
}
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
@@ -388,17 +218,6 @@ 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

@@ -1,55 +0,0 @@
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,11 +13,6 @@ 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) {
@@ -36,7 +31,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
end = end,
location = location.orEmpty(),
description = description.orEmpty(),
reminders = semanticReminderMinutes(),
reminders = reminderMinutes.distinct().sorted(),
availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
)

View File

@@ -93,11 +93,15 @@ fun ImportScreen(
}
// Hoisted target calendar so the always-visible top-bar Import action can
// 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.
// 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.
val many = state as? ImportUiState.Many
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
@@ -248,15 +252,6 @@ 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(
@@ -295,19 +290,6 @@ 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(
@@ -319,7 +301,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
}
}
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
/** A big-number tonal tile summarising one import outcome (added / skipped). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
@@ -361,9 +343,6 @@ 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,12 +43,6 @@ 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
@@ -98,10 +92,6 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.isEventTarget },
fileCalendarName = parsed.events
.mapNotNull { it.calendarName }
.distinct()
.singleOrNull(),
)
}
}
@@ -123,22 +113,3 @@ 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

@@ -0,0 +1,253 @@
<?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

@@ -0,0 +1,82 @@
<?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,4 +207,445 @@
<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

@@ -693,15 +693,11 @@
<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>
@@ -723,10 +719,6 @@
<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,11 +10,14 @@
<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,42 +801,6 @@ 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,
@@ -861,10 +825,9 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?, summary: String = "E") =
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = summary,
summary = "E",
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,

View File

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

View File

@@ -4,7 +4,6 @@ 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 {
@@ -21,11 +20,7 @@ class IcsExportMapperTest {
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
)
val event = reader.toIcsEvent(
reminderMinutes = listOf(10),
calendarName = "Personal",
allDayReminderTimeMinutes = NINE_AM,
)
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup")
@@ -52,11 +47,7 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
@@ -74,32 +65,6 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
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")
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
}
}

View File

@@ -1,119 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -1,470 +0,0 @@
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

@@ -1,52 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,64 +0,0 @@
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

@@ -1,25 +0,0 @@
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,69 +346,6 @@ 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

@@ -0,0 +1,5 @@
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.