Add offline resilience, undo-delete, search, week view, BYDAY recurrence, import/export, and single-occurrence recurring edits

Closes out the full post-launch feature backlog:
- Offline write resilience for save/delete, with automatic flush on reconnect
- Undo-after-delete via a snackbar, with a fresh-UID recreate (Nextcloud's
  trashbin rejects reusing a just-deleted item's UID)
- Custom (non-preset) reminder offsets
- Search across events and tasks
- Week view alongside the existing Agenda/Month views
- BYDAY support in the Repeats picker ("every Mon/Wed/Fri"), including a
  proper RRULE evaluator extension for it
- Import/export .ics via the system share sheet and file picker
- Single-occurrence recurring-event editing/deleting ("this event only" vs
  "all events") via RFC 5545 RECURRENCE-ID overrides and EXDATE

A repository-wide write mutex now serializes sync against save/delete/undo,
fixing a real race where a background sync could clobber a just-written item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
RomanNum3ral 2026-07-17 18:53:39 -04:00
parent 79cc6925e8
commit 3d0f6589a9
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
23 changed files with 2214 additions and 214 deletions

28
.gitea/workflows/test.yml Normal file
View File

@ -0,0 +1,28 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ncal
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Run unit tests
run: ./gradlew test --stacktrace

View File

@ -118,4 +118,5 @@ dependencies {
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}

View File

@ -28,6 +28,16 @@
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver
android:name=".notifications.ReminderReceiver"
android:exported="false" />

View File

@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
@Database(
entities = [CollectionEntity::class, ItemEntity::class],
version = 6,
version = 9,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@ -18,7 +18,18 @@ abstract class AppDatabase : RoomDatabase() {
companion object {
@Volatile private var instance: AppDatabase? = null
/** [passphrase] encrypts the database file at rest via SQLCipher - see [com.homelab.ncal.data.prefs.SecurePrefs.databaseKey]. */
/**
* [passphrase] encrypts the database file at rest via SQLCipher - see
* [com.homelab.ncal.data.prefs.SecurePrefs.databaseKey].
*
* [fallbackToDestructiveMigration] has been safe so far because this DB is purely a
* re-fetchable mirror of server state - a schema bump just means one extra resync.
* That's no longer strictly true as of the `syncStatus` column (v7): a `PENDING_UPDATE`/
* `PENDING_DELETE` row is local-only data (an offline edit/delete not yet pushed) that a
* destructive wipe would genuinely lose, not just re-fetch. Worth revisiting with a real
* `Migration` if that turns out to matter in practice - for now the destructive fallback
* stays for consistency with the rest of the schema history.
*/
fun get(context: Context, passphrase: ByteArray): AppDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(

View File

@ -32,22 +32,25 @@ data class ItemEntity(
val recurrenceInterval: Int,
val recurrenceCount: Int?,
val recurrenceUntil: Long?,
val recurrenceByDay: String, // comma-separated java.time.DayOfWeek names, "" if none (WEEKLY only)
val isRecurring: Boolean,
val recurrenceMasterStart: Long?,
val recurrenceMasterEnd: Long?,
val recurrenceMasterDue: Long?,
val occurrenceDate: Long?, // RECURRENCE-ID anchor for "edit/delete this event only" - see CalendarItem
val syncStatus: String, // SyncStatus name - PENDING_DELETE rows are hidden from every observeXxx query
val rawIcs: String?
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items WHERE calendarUrl IN (:calendarUrls) ORDER BY COALESCE(start, due) ASC")
@Query("SELECT * FROM items WHERE calendarUrl IN (:calendarUrls) AND syncStatus != 'PENDING_DELETE' ORDER BY COALESCE(start, due) ASC")
fun observeForCalendars(calendarUrls: List<String>): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE type = 'TASK' AND calendarUrl IN (:calendarUrls) ORDER BY completed ASC, COALESCE(due, 9223372036854775807) ASC")
@Query("SELECT * FROM items WHERE type = 'TASK' AND calendarUrl IN (:calendarUrls) AND syncStatus != 'PENDING_DELETE' ORDER BY completed ASC, COALESCE(due, 9223372036854775807) ASC")
fun observeTasks(calendarUrls: List<String>): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE type = 'EVENT' AND calendarUrl IN (:calendarUrls) ORDER BY start ASC")
@Query("SELECT * FROM items WHERE type = 'EVENT' AND calendarUrl IN (:calendarUrls) AND syncStatus != 'PENDING_DELETE' ORDER BY start ASC")
fun observeEvents(calendarUrls: List<String>): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE href = :href LIMIT 1")
@ -56,6 +59,12 @@ interface ItemDao {
@Query("SELECT * FROM items")
suspend fun getAllOnce(): List<ItemEntity>
@Query("SELECT * FROM items WHERE calendarUrl = :calendarUrl AND syncStatus != 'PENDING_DELETE' ORDER BY COALESCE(start, due) ASC")
suspend fun getForCalendarOnce(calendarUrl: String): List<ItemEntity>
@Query("SELECT * FROM items WHERE syncStatus != 'SYNCED'")
suspend fun getPendingSync(): List<ItemEntity>
@Upsert
suspend fun upsertAll(items: List<ItemEntity>)

View File

@ -1,5 +1,7 @@
package com.homelab.ncal.data.model
import java.time.DayOfWeek
enum class ItemType { EVENT, TASK }
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
@ -10,6 +12,16 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
* round-trips fine via [CalendarItem.rawIcs] - it just isn't editable through the picker. */
enum class RecurrenceFrequency { DAILY, WEEKLY, MONTHLY, YEARLY }
/**
* Whether a local copy of an existing item matches the server (SYNCED), has local edits the
* server hasn't seen yet because the save happened while offline (PENDING_UPDATE), or is queued
* for deletion on the server once back online (PENDING_DELETE - hidden from every list in the
* meantime). Only applies to *existing* items (a real href already assigned); creating a brand
* new item still requires connectivity, since there's nothing to key an offline-only row on
* until the server assigns the real href.
*/
enum class SyncStatus { SYNCED, PENDING_UPDATE, PENDING_DELETE }
/**
* A single VEVENT or VTODO. Times are stored as epoch millis (UTC).
* [rawIcs] retains the full round-tripped iCalendar object so fields we don't
@ -43,6 +55,7 @@ data class CalendarItem(
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount
val recurrenceByDay: Set<DayOfWeek> = emptySet(), // WEEKLY only: which days ("every Mon/Wed/Fri") - empty means every occurrence of the interval, i.e. just DTSTART's own weekday
val isRecurring: Boolean = false, // true for ANY RRULE, even ones recurrenceFrequency can't represent -
// [start]/[end]/[due] are the resolved *next occurrence* when true, not
// the master date - see recurrenceMaster* below for that
@ -55,6 +68,15 @@ data class CalendarItem(
val recurrenceMasterEnd: Long? = null, // events only
val recurrenceMasterDue: Long? = null, // tasks only
// The RRULE-computed slot (RECURRENCE-ID) this specific occurrence falls on - only meaningful
// when isRecurring. Stable across edits to [start]/[due] (unlike those fields, which change
// when the user moves *this* occurrence), so it's the anchor used to write/find this
// occurrence's override VEVENT/VTODO for "edit/delete this event only". Null for a
// non-recurring item, or a recurring item whose series has already ended.
val occurrenceDate: Long? = null,
val syncStatus: SyncStatus = SyncStatus.SYNCED,
val rawIcs: String? = null
) {
val isNew: Boolean get() = href.isEmpty()

View File

@ -8,6 +8,7 @@ import com.homelab.ncal.data.model.CalendarCollection
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
import com.homelab.ncal.data.model.RecurrenceFrequency
import com.homelab.ncal.data.model.SyncStatus
import com.homelab.ncal.data.model.TaskStatus
import com.homelab.ncal.data.network.CalDavClient
import com.homelab.ncal.data.network.CalDavException
@ -18,16 +19,44 @@ import com.homelab.ncal.util.IcsMapper
import com.homelab.ncal.widget.MonthGridWidget
import com.homelab.ncal.widget.NextTasksWidget
import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.UUID
class NextcloudRepository(context: Context) {
private val appContext = context.applicationContext
private val prefs = SecurePrefs(context)
private val db = AppDatabase.get(context, prefs.databaseKey)
private val repoScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Serializes [syncItems] against [save]/[delete]/[undoDelete] so a pull from the server can
* never race a concurrent write. Without this, a full sync's `pruneMissing` step can act on a
* collection snapshot fetched *before* a concurrent write lands, and delete the just-written
* row right back out again - this is exactly what happens to an [undoDelete] recreate if it
* runs while the delete's own post-navigation refresh is still in flight.
*/
private val writeMutex = Mutex()
/** The most recently deleted item, for the "Undo" snackbar - see [undoDelete]. Cleared
* automatically a few seconds after each delete, or immediately once undone. */
private val _lastDeleted = MutableStateFlow<CalendarItem?>(null)
val lastDeleted: StateFlow<CalendarItem?> = _lastDeleted.asStateFlow()
private var lastDeletedClearJob: Job? = null
private fun client(): CalDavClient {
val server = prefs.serverUrl ?: error("Not logged in")
@ -104,8 +133,20 @@ class NextcloudRepository(context: Context) {
db.itemDao().getAllOnce().map { it.toModel() }
}
/** Pulls fresh events + tasks for every enabled collection. */
/**
* Pulls fresh events + tasks for every enabled collection. Holds [writeMutex] for its whole
* duration - see the field doc for why a concurrent [save]/[delete]/[undoDelete] must never
* interleave with this.
*/
suspend fun syncItems() = withContext(Dispatchers.IO) {
writeMutex.withLock {
// Push any offline edits/deletes first - otherwise the pull below would overwrite them
// with the (pre-edit) server copy. Anything that's still pending after this (still
// offline, or a conflict that needs the user's attention) is explicitly protected from
// the pull further down rather than silently clobbered.
flushPendingChanges()
val stillPendingHrefs = db.itemDao().getPendingSync().map { it.href }.toSet()
val c = client()
val collections = db.collectionDao().getAll().filter { it.enabled }
android.util.Log.d("NCalSync", "Syncing ${collections.size} enabled collection(s): ${collections.map { "${it.displayName}(events=${it.supportsEvents},tasks=${it.supportsTasks})" }}")
@ -126,7 +167,7 @@ class NextcloudRepository(context: Context) {
keepHrefs += remote.map { it.href.toAbsolute() }
val entities = remote.mapNotNull { dav ->
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
}
}.filter { it.href !in stillPendingHrefs }
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VEVENT(s), parsed ${entities.size}")
db.itemDao().upsertAll(entities)
}
@ -135,7 +176,7 @@ class NextcloudRepository(context: Context) {
keepHrefs += remote.map { it.href.toAbsolute() }
val entities = remote.mapNotNull { dav ->
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
}
}.filter { it.href !in stillPendingHrefs }
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VTODO(s), parsed ${entities.size}")
db.itemDao().upsertAll(entities)
}
@ -146,6 +187,49 @@ class NextcloudRepository(context: Context) {
ReminderScheduler.rescheduleAll(appContext, db.itemDao().getAllOnce().map { it.toModel() })
updateWidgets()
}
}
/**
* Pushes every locally-pending offline edit/delete to the server. Called automatically at
* the start of every [syncItems] (app open, pull-to-refresh, periodic background sync), so
* an offline save/delete gets flushed the next time the device has connectivity without any
* extra user action. A row that's still pending afterwards - still offline, or a genuine
* conflict was hit - is left exactly as it was for the next attempt; conflicts aren't
* auto-resolved here since there's no UI to ask the user from a background sync.
*/
private suspend fun flushPendingChanges() {
val pending = db.itemDao().getPendingSync().map { it.toModel() }
if (pending.isEmpty()) return
android.util.Log.d("NCalSync", "Flushing ${pending.size} pending offline change(s)")
val c = client()
for (item in pending) {
try {
when (item.syncStatus) {
SyncStatus.PENDING_UPDATE -> {
val ics = item.rawIcs ?: IcsMapper.toIcs(item)
val etag = c.updateItem(item.href, ics, item.etag)
val synced = item.copy(etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
db.itemDao().upsert(synced.toEntity())
ReminderScheduler.reschedule(appContext, synced)
}
SyncStatus.PENDING_DELETE -> {
c.deleteItem(item.href, item.etag)
db.itemDao().deleteByHref(item.href)
}
SyncStatus.SYNCED -> Unit // getPendingSync() excludes these; unreachable
}
} catch (e: CalDavException) {
// A conflict (412) or a real server error (403/500/etc) - leave it pending rather
// than guess. The user will see it again (and get the normal conflict dialog) the
// next time they open and re-save this item through the edit screen.
android.util.Log.w("NCalSync", "Pending change for ${item.href} could not be flushed (HTTP ${e.httpCode}) - leaving pending", e)
} catch (e: IOException) {
android.util.Log.d("NCalSync", "Still offline - ${item.href} stays pending")
}
}
updateWidgets()
}
suspend fun fullSync() {
@ -156,21 +240,29 @@ class NextcloudRepository(context: Context) {
/**
* Creates or updates an item both on the server and in the local cache.
*
* If the write fails purely because the device has no connectivity ([IOException] other than
* a [CalDavException], which means an HTTP response - even an error one - was received),
* *and* the item already exists (a brand new item has no href to key an offline-only row on,
* so creating one still requires connectivity), the edit is saved locally as
* [SyncStatus.PENDING_UPDATE] instead of being lost, and gets pushed automatically the next
* time [syncItems] runs with connectivity back - see [flushPendingChanges].
*
* @throws SaveConflictException if the server rejects the write with HTTP 412 (the item was
* changed or deleted elsewhere since this edit started) - carries the server's current
* version so the caller can offer the user a real choice instead of just an error.
*/
suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
writeMutex.withLock {
val c = client()
val ics = IcsMapper.toIcs(item)
val saved = try {
if (item.isNew) {
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
item.copy(href = href, etag = etag, rawIcs = ics)
item.copy(href = href, etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
} else {
val etag = c.updateItem(item.href, ics, item.etag)
item.copy(etag = etag, rawIcs = ics)
item.copy(etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
}
} catch (e: CalDavException) {
if (e.httpCode == 412 && !item.isNew) {
@ -181,25 +273,218 @@ class NextcloudRepository(context: Context) {
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
}
throw e
} catch (e: IOException) {
if (item.isNew) throw e
item.copy(rawIcs = ics, syncStatus = SyncStatus.PENDING_UPDATE)
}
db.itemDao().upsert(saved.toEntity())
ReminderScheduler.reschedule(appContext, saved)
updateWidgets()
saved
}
}
suspend fun delete(item: CalendarItem) = withContext(Dispatchers.IO) {
if (!item.isNew) {
client().deleteItem(item.href, item.etag)
/**
* Deletes an item both on the server and in the local cache. If the device has no
* connectivity, an *existing* item is instead marked [SyncStatus.PENDING_DELETE] (hidden
* from every list immediately, actually removed once the delete reaches the server via
* [flushPendingChanges]) rather than the delete silently failing.
*
* @return true if the delete actually reached the server, false if it was only queued
* locally (offline) - lets the caller show "deleted" vs "will delete once back online".
*/
suspend fun delete(item: CalendarItem): Boolean = withContext(Dispatchers.IO) {
writeMutex.withLock {
if (item.isNew) {
db.itemDao().deleteByHref(item.href)
ReminderScheduler.cancel(appContext, item)
updateWidgets()
return@withContext true
}
val deletedOnServer = try {
client().deleteItem(item.href, item.etag)
db.itemDao().deleteByHref(item.href)
true
} catch (e: CalDavException) {
throw e
} catch (e: IOException) {
db.itemDao().upsert(item.copy(syncStatus = SyncStatus.PENDING_DELETE).toEntity())
false
}
db.itemDao().deleteByHref(item.href)
ReminderScheduler.cancel(appContext, item)
updateWidgets()
announceDeleted(item)
deletedOnServer
}
}
private fun announceDeleted(item: CalendarItem) {
lastDeletedClearJob?.cancel()
_lastDeleted.value = item
lastDeletedClearJob = repoScope.launch {
delay(6000)
_lastDeleted.value = null
}
}
/**
* Reverses the most recent [delete] (within its short display window). If the delete never
* actually reached the server (offline - the row is still sitting locally as
* [SyncStatus.PENDING_DELETE]), this is a free local-only flip back to synced. Otherwise the
* item is genuinely gone from the server, so it's recreated there as a brand new resource -
* with a *fresh* UID, not the original one. Reusing the old UID reliably fails on Nextcloud:
* its calendar trashbin keeps deleted objects (and their UID) around for 30 days, so the
* server rejects a same-UID recreate with an HTTP 500 unique-constraint violation. The user
* never sees the UID, so a new one is harmless here.
*/
fun undoDelete() {
val item = _lastDeleted.value ?: return
lastDeletedClearJob?.cancel()
_lastDeleted.value = null
repoScope.launch {
// Also serialized against syncItems()/save()/delete() - same reasoning as the field
// doc on writeMutex: this must not read-then-write across a concurrent sync's own
// fetch/prune pass.
val restoredLocally = writeMutex.withLock {
val current = db.itemDao().getByHref(item.href)
if (current != null && current.syncStatus == SyncStatus.PENDING_DELETE.name) {
val restored = current.copy(syncStatus = SyncStatus.SYNCED.name)
db.itemDao().upsert(restored)
ReminderScheduler.reschedule(appContext, restored.toModel())
updateWidgets()
true
} else {
false
}
}
if (!restoredLocally) {
try {
save(item.copy(href = "", etag = null, uid = UUID.randomUUID().toString(), syncStatus = SyncStatus.SYNCED))
} catch (e: Exception) {
android.util.Log.e("NCalError", "undoDelete() failed to recreate uid=${item.uid}", e)
}
}
}
}
suspend fun toggleTaskComplete(item: CalendarItem): CalendarItem =
save(item.withStatus(if (item.completed) TaskStatus.NEEDS_ACTION else TaskStatus.COMPLETED))
// ---------- Single-occurrence ("this event only") edits ----------
/**
* Saves changes to just the currently-displayed occurrence of a recurring [item], via a
* RECURRENCE-ID override - see [IcsMapper.buildOccurrenceOverrideIcs]. The master (and every
* other occurrence in the series) is untouched. Unlike [save], there's no offline fallback
* here - an occurrence-level edit made while offline just fails with the usual connectivity
* error rather than queueing, since [SyncStatus.PENDING_UPDATE] was designed around whole-
* resource edits and doesn't have a way to represent "this one occurrence has an unsynced
* override" without a larger model change.
*/
suspend fun saveOccurrence(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
writeMutex.withLock {
val c = client()
val ics = IcsMapper.buildOccurrenceOverrideIcs(item)
val saved = try {
val etag = c.updateItem(item.href, ics, item.etag)
item.copy(etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
} catch (e: CalDavException) {
if (e.httpCode == 412) {
val serverCopy = c.getItem(item.href)
val serverVersion = serverCopy?.let { (serverIcs, serverEtag) ->
IcsMapper.parse(serverIcs, item.href, serverEtag, item.calendarUrl)
}
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
}
throw e
}
db.itemDao().upsert(saved.toEntity())
ReminderScheduler.reschedule(appContext, saved)
updateWidgets()
saved
}
}
/**
* Deletes just the currently-displayed occurrence of a recurring [item], via EXDATE - see
* [IcsMapper.buildOccurrenceDeletionIcs]. Returns whatever the series' next occurrence now
* resolves to (so the caller can e.g. show it was deleted and the list moves on), or null if
* that was the last remaining occurrence in the series. No "Undo" snackbar for this path
* (unlike [delete]) - EXDATE/override edits aren't a fit for the whole-resource undo
* mechanism built around [lastDeleted].
*/
suspend fun deleteOccurrence(item: CalendarItem): CalendarItem? = withContext(Dispatchers.IO) {
writeMutex.withLock {
val c = client()
val ics = IcsMapper.buildOccurrenceDeletionIcs(item)
val etag = try {
c.updateItem(item.href, ics, item.etag)
} catch (e: CalDavException) {
if (e.httpCode == 412) {
val serverCopy = c.getItem(item.href)
val serverVersion = serverCopy?.let { (serverIcs, serverEtag) ->
IcsMapper.parse(serverIcs, item.href, serverEtag, item.calendarUrl)
}
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
}
throw e
}
val serverItem = IcsMapper.parse(ics, item.href, etag, item.calendarUrl)
if (serverItem != null) {
db.itemDao().upsert(serverItem.toEntity())
ReminderScheduler.reschedule(appContext, serverItem)
} else {
db.itemDao().deleteByHref(item.href)
ReminderScheduler.cancel(appContext, item)
}
updateWidgets()
serverItem
}
}
// ---------- Import / export ----------
/** Combines every cached item in [calendarUrl] into one multi-component .ics document. */
suspend fun exportIcs(calendarUrl: String): String = withContext(Dispatchers.IO) {
val items = db.itemDao().getForCalendarOnce(calendarUrl).map { it.toModel() }
IcsMapper.buildCombinedIcs(items)
}
data class ImportResult(val imported: Int, val skipped: Int, val failed: Int)
/**
* Imports every VEVENT/VTODO in [ics] as a brand-new item in [calendarUrl] - see
* [IcsMapper.parseAllForImport] for why these are always fresh creations (new UID, no href)
* rather than an attempt to preserve/merge with anything already on the server. An entry
* whose type the target calendar doesn't support (e.g. a VTODO imported into an events-only
* calendar) is counted as skipped, not failed.
*/
suspend fun importIcs(ics: String, calendarUrl: String): ImportResult = withContext(Dispatchers.IO) {
val collection = db.collectionDao().getAll().firstOrNull { it.url == calendarUrl }
val candidates = IcsMapper.parseAllForImport(ics, calendarUrl)
var imported = 0
var skipped = 0
var failed = 0
for (item in candidates) {
val supported = when (item.type) {
ItemType.EVENT -> collection?.supportsEvents ?: true
ItemType.TASK -> collection?.supportsTasks ?: true
}
if (!supported) {
skipped++
continue
}
try {
save(item)
imported++
} catch (e: Exception) {
android.util.Log.e("NCalError", "importIcs: failed to save uid=${item.uid}", e)
failed++
}
}
ImportResult(imported, skipped, failed)
}
private suspend fun updateWidgets() {
MonthGridWidget().updateAll(appContext)
NextTasksWidget().updateAll(appContext)
@ -221,8 +506,11 @@ class NextcloudRepository(context: Context) {
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
recurrenceByDay = recurrenceByDay.toDayOfWeekSet(),
isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
occurrenceDate = occurrenceDate,
syncStatus = runCatching { SyncStatus.valueOf(syncStatus) }.getOrDefault(SyncStatus.SYNCED),
rawIcs = rawIcs
)
@ -233,14 +521,20 @@ class NextcloudRepository(context: Context) {
status = status.name, percentComplete = percentComplete,
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, isRecurring = isRecurring,
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
recurrenceByDay = recurrenceByDay.joinToString(",") { it.name }, isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
occurrenceDate = occurrenceDate,
syncStatus = syncStatus.name,
rawIcs = rawIcs
)
private fun String.toMinutesList(): List<Int> =
if (isBlank()) emptyList() else split(",").mapNotNull { it.trim().toIntOrNull() }
private fun String.toDayOfWeekSet(): Set<java.time.DayOfWeek> =
if (isBlank()) emptySet() else split(",").mapNotNull { runCatching { java.time.DayOfWeek.valueOf(it.trim()) }.getOrNull() }.toSet()
private fun List<Int>.toCsv(): String = joinToString(",")
/** Server sometimes returns relative hrefs; normalize against the calendar home. */

View File

@ -16,7 +16,9 @@ import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ViewWeek
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -52,7 +54,9 @@ fun AgendaScreen(
onNewEvent: () -> Unit,
onOpenTasks: () -> Unit,
onOpenCollections: () -> Unit,
onOpenMonth: () -> Unit
onOpenMonth: () -> Unit,
onOpenWeek: () -> Unit,
onOpenSearch: () -> Unit
) {
val state by viewModel.state.collectAsState()
@ -65,6 +69,8 @@ fun AgendaScreen(
CenterAlignedTopAppBar(
title = { Text("Agenda") },
actions = {
IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") }
IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") }

View File

@ -1,15 +1,25 @@
package com.homelab.ncal.ui.collections
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@ -20,13 +30,23 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.homelab.ncal.data.model.CalendarCollection
import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -35,6 +55,14 @@ fun CollectionsScreen(
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pendingImportUri by remember { mutableStateOf<Uri?>(null) }
val openDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingImportUri = uri
}
Scaffold(
topBar = {
@ -44,6 +72,9 @@ fun CollectionsScreen(
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
},
actions = {
IconButton(onClick = { openDocument.launch(arrayOf("text/calendar", "*/*")) }) {
Icon(Icons.Filled.FileUpload, "Import .ics")
}
IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "Refresh") }
}
)
@ -56,6 +87,15 @@ fun CollectionsScreen(
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
}
state.importStatus?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween
) {
Text(it, color = MaterialTheme.colorScheme.primary)
TextButton(onClick = viewModel::dismissImportStatus) { Text("Dismiss") }
}
}
LazyColumn {
items(state.collections, key = { it.url }) { col ->
ListItem(
@ -69,16 +109,83 @@ fun CollectionsScreen(
}
},
trailingContent = {
Checkbox(
checked = col.enabled,
onCheckedChange = { viewModel.setEnabled(col.url, it) }
)
Row {
IconButton(onClick = {
scope.launch {
val ics = viewModel.exportIcs(col.url)
shareIcs(context, ics, col.displayName)
}
}) {
Icon(Icons.Filled.FileDownload, "Export ${col.displayName}")
}
Checkbox(
checked = col.enabled,
onCheckedChange = { viewModel.setEnabled(col.url, it) }
)
}
}
)
}
}
}
}
pendingImportUri?.let { uri ->
ImportTargetDialog(
collections = state.collections,
onDismiss = { pendingImportUri = null },
onSelect = { calendarUrl ->
val text = runCatching {
context.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
}.getOrNull()
pendingImportUri = null
if (text != null) {
viewModel.importIcs(text, calendarUrl)
}
}
)
}
}
@Composable
private fun ImportTargetDialog(
collections: List<CalendarCollection>,
onDismiss: () -> Unit,
onSelect: (String) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Import into which calendar?") },
text = {
Column {
collections.forEach { col ->
ListItem(
headlineContent = { Text(col.displayName) },
supportingContent = { Text(componentLabel(col.supportsEvents, col.supportsTasks)) },
modifier = Modifier.fillMaxWidth().clickable { onSelect(col.url) }
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
private fun shareIcs(context: android.content.Context, ics: String, calendarName: String) {
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val safeName = calendarName.replace(Regex("[^A-Za-z0-9._-]"), "_")
val file = File(dir, "$safeName.ics")
file.writeText(ics)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Export $calendarName"))
}
private fun componentLabel(events: Boolean, tasks: Boolean): String = when {

View File

@ -14,18 +14,20 @@ import kotlinx.coroutines.launch
data class CollectionsUiState(
val collections: List<CalendarCollection> = emptyList(),
val refreshing: Boolean = false,
val error: String? = null
val error: String? = null,
val importStatus: String? = null
)
class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() {
private val refreshing = MutableStateFlow(false)
private val error = MutableStateFlow<String?>(null)
private val importStatus = MutableStateFlow<String?>(null)
val state: StateFlow<CollectionsUiState> = combine(
repo.observeCollections(), refreshing, error
) { collections, isRefreshing, err ->
CollectionsUiState(collections, isRefreshing, err)
repo.observeCollections(), refreshing, error, importStatus
) { collections, isRefreshing, err, importMsg ->
CollectionsUiState(collections, isRefreshing, err, importMsg)
}.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState())
fun refresh() {
@ -59,4 +61,27 @@ class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel()
}
}
}
suspend fun exportIcs(url: String): String = repo.exportIcs(url)
fun importIcs(ics: String, calendarUrl: String) {
viewModelScope.launch {
try {
val result = repo.importIcs(ics, calendarUrl)
importStatus.value = buildString {
append("Imported ${result.imported} item(s)")
if (result.skipped > 0) append(", skipped ${result.skipped} (unsupported type)")
if (result.failed > 0) append(", ${result.failed} failed")
}
error.value = null
} catch (e: Exception) {
android.util.Log.e("NCalError", "importIcs failed for $calendarUrl", e)
error.value = e.toUserMessage()
}
}
}
fun dismissImportStatus() {
importStatus.value = null
}
}

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -27,6 +28,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -58,6 +60,8 @@ import com.homelab.ncal.util.priorityColor
import com.homelab.ncal.util.priorityLabel
import com.homelab.ncal.util.statusLabel
import java.text.SimpleDateFormat
import java.time.DayOfWeek
import java.time.temporal.WeekFields
import java.util.Calendar
import java.util.Date
import java.util.Locale
@ -70,8 +74,16 @@ fun ItemEditScreen(
) {
val state by viewModel.state.collectAsState()
var showDeleteConfirm by remember { mutableStateOf(false) }
var showSaveScopeConfirm by remember { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(state.done, state.deleted) {
if (state.savedOffline) {
android.widget.Toast.makeText(context, "Saved offline — will sync when you're back online", android.widget.Toast.LENGTH_LONG).show()
}
if (state.deletedOffline) {
android.widget.Toast.makeText(context, "Will delete once you're back online", android.widget.Toast.LENGTH_LONG).show()
}
if (state.done || state.deleted) onDone()
}
@ -148,27 +160,21 @@ fun ItemEditScreen(
if (item.type == ItemType.EVENT) {
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
if (item.isRecurring) {
RecurringDateTimePicker(
millis = item.recurrenceMasterStart,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
)
} else {
DateTimePicker(
millis = item.start,
onChange = { v -> viewModel.update { it.copy(start = v) } }
)
}
DateTimePicker(
millis = item.start,
onChange = { v -> viewModel.update { it.copy(start = v) } }
)
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
DateTimePicker(
millis = item.end,
onChange = { v -> viewModel.update { it.copy(end = v) } }
)
if (item.isRecurring) {
RecurringDateTimePicker(
millis = item.recurrenceMasterEnd,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterEnd = v) } }
)
} else {
DateTimePicker(
millis = item.end,
onChange = { v -> viewModel.update { it.copy(end = v) } }
Text(
"You'll be asked whether a date change applies to just this event or the whole series.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp)
)
}
@ -184,27 +190,21 @@ fun ItemEditScreen(
}
} else {
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
if (item.isRecurring) {
RecurringDateTimePicker(
millis = item.recurrenceMasterStart,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
)
} else {
DateTimePicker(
millis = item.start,
onChange = { v -> viewModel.update { it.copy(start = v) } }
)
}
DateTimePicker(
millis = item.start,
onChange = { v -> viewModel.update { it.copy(start = v) } }
)
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
DateTimePicker(
millis = item.due,
onChange = { v -> viewModel.update { it.copy(due = v) } }
)
if (item.isRecurring) {
RecurringDateTimePicker(
millis = item.recurrenceMasterDue,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterDue = v) } }
)
} else {
DateTimePicker(
millis = item.due,
onChange = { v -> viewModel.update { it.copy(due = v) } }
Text(
"You'll be asked whether a date change applies to just this task or the whole series.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp)
)
}
@ -305,7 +305,10 @@ fun ItemEditScreen(
}
androidx.compose.material3.Button(
onClick = viewModel::save,
onClick = {
val canSave = item.summary.isNotBlank() && item.calendarUrl.isNotBlank()
if (item.isRecurring && canSave) showSaveScopeConfirm = true else viewModel.save()
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth().padding(top = 24.dp)
) {
@ -317,23 +320,74 @@ fun ItemEditScreen(
}
}
if (showDeleteConfirm) {
if (showSaveScopeConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
text = { Text("This can't be undone.") },
confirmButton = {
TextButton(onClick = {
showDeleteConfirm = false
viewModel.delete()
}) { Text("Delete") }
onDismissRequest = { showSaveScopeConfirm = false },
title = { Text("Save changes to...") },
text = {
Column {
TextButton(
onClick = { showSaveScopeConfirm = false; viewModel.saveThisOccurrenceOnly() },
modifier = Modifier.fillMaxWidth()
) { Text("This event only") }
TextButton(
onClick = { showSaveScopeConfirm = false; viewModel.save() },
modifier = Modifier.fillMaxWidth()
) { Text("All events") }
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
TextButton(onClick = { showSaveScopeConfirm = false }) { Text("Cancel") }
}
)
}
if (showDeleteConfirm) {
if (item.isRecurring) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
text = {
Column {
Text(
"This is part of a repeating series. You can undo this for a few seconds after deleting.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
TextButton(
onClick = { showDeleteConfirm = false; viewModel.deleteThisOccurrence() },
modifier = Modifier.fillMaxWidth()
) { Text("This event only") }
TextButton(
onClick = { showDeleteConfirm = false; viewModel.deleteAllOccurrences() },
modifier = Modifier.fillMaxWidth()
) { Text("All events") }
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
}
)
} else {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
text = { Text("You can undo this for a few seconds after deleting.") },
confirmButton = {
TextButton(onClick = {
showDeleteConfirm = false
viewModel.deleteAllOccurrences()
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
}
)
}
}
state.conflict?.let { conflict ->
if (conflict.serverVersion != null && state.conflictFields.isNotEmpty()) {
ConflictMergeDialog(
@ -520,22 +574,6 @@ private fun PriorityPicker(selected: Int, onSelect: (Int) -> Unit) {
}
}
/** Edits a recurring item's true master date ([CalendarItem.recurrenceMasterStart] etc.) - NOT
* the same field the non-recurring [DateTimePicker] call sites bind to, since that one holds
* the resolved next occurrence rather than the series' actual start. */
@Composable
private fun RecurringDateTimePicker(millis: Long?, onChange: (Long?) -> Unit) {
Column {
DateTimePicker(millis = millis, onChange = onChange)
Text(
"This changes the whole series, not just one occurrence.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp)
)
}
}
private val recurrenceFrequencyLabels = linkedMapOf<RecurrenceFrequency?, String>(
null to "Does not repeat",
RecurrenceFrequency.DAILY to "Daily",
@ -586,9 +624,9 @@ private fun RecurrencePicker(
onClick = {
onUpdate { cur ->
if (freq == null) {
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null)
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null, recurrenceByDay = emptySet())
} else {
cur.copy(recurrenceFrequency = freq)
cur.copy(recurrenceFrequency = freq, recurrenceByDay = if (freq == RecurrenceFrequency.WEEKLY) cur.recurrenceByDay else emptySet())
}
}
expanded = false
@ -614,6 +652,18 @@ private fun RecurrencePicker(
Text(intervalUnitLabel(freq, item.recurrenceInterval), modifier = Modifier.padding(start = 8.dp))
}
if (freq == RecurrenceFrequency.WEEKLY) {
Text("Repeats on", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
WeekdayChipRow(
selected = item.recurrenceByDay,
onToggle = { day ->
onUpdate { cur ->
cur.copy(recurrenceByDay = if (day in cur.recurrenceByDay) cur.recurrenceByDay - day else cur.recurrenceByDay + day)
}
}
)
}
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
RecurrenceEndPicker(
count = item.recurrenceCount,
@ -625,6 +675,26 @@ private fun RecurrencePicker(
}
}
/** Toggleable Mon-Sun (locale-ordered) chips for a WEEKLY rule's BYDAY. An empty [selected] set
* means "just DTSTART's own weekday" (no BYDAY written at all) - matches the pre-BYDAY behavior. */
@Composable
private fun WeekdayChipRow(selected: Set<DayOfWeek>, onToggle: (DayOfWeek) -> Unit) {
val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek
val days = (0..6).map { firstDow.plus(it.toLong()) }
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
days.forEach { day ->
FilterChip(
selected = day in selected,
onClick = { onToggle(day) },
label = { Text(day.getDisplayName(java.time.format.TextStyle.NARROW, Locale.getDefault())) }
)
}
}
}
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
@OptIn(ExperimentalMaterial3Api::class)
@ -787,13 +857,21 @@ private fun reminderLabel(minutes: Int): String = when {
else -> "$minutes minutes before"
}
private enum class ReminderUnit(val label: String, val minutes: Int) {
MINUTES("minutes", 1),
HOURS("hours", 60),
DAYS("days", 1440),
WEEKS("weeks", 10080)
}
@Composable
private fun AddReminderButton(existing: List<Int>, onAdd: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) }
var showCustom by remember { mutableStateOf(false) }
val available = reminderPresets.filter { it !in existing }
Box {
TextButton(onClick = { expanded = true }, enabled = available.isNotEmpty()) {
TextButton(onClick = { expanded = true }) {
Text("+ Add reminder")
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@ -806,6 +884,79 @@ private fun AddReminderButton(existing: List<Int>, onAdd: (Int) -> Unit) {
}
)
}
DropdownMenuItem(
text = { Text("Custom…") },
onClick = {
showCustom = true
expanded = false
}
)
}
}
if (showCustom) {
CustomReminderRow(
onAdd = { minutes ->
onAdd(minutes)
showCustom = false
},
onCancel = { showCustom = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CustomReminderRow(onAdd: (Int) -> Unit, onCancel: () -> Unit) {
var amountText by remember { mutableStateOf("10") }
var unit by remember { mutableStateOf(ReminderUnit.MINUTES) }
var unitExpanded by remember { mutableStateOf(false) }
val amount = amountText.toIntOrNull()
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
OutlinedTextField(
value = amountText,
onValueChange = { v -> if (v.length <= 5) amountText = v.filter { it.isDigit() } },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(80.dp)
)
Spacer(modifier = Modifier.width(8.dp))
ExposedDropdownMenuBox(
expanded = unitExpanded,
onExpandedChange = { unitExpanded = it },
modifier = Modifier.width(150.dp)
) {
OutlinedTextField(
value = unit.label,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) {
ReminderUnit.entries.forEach { u ->
DropdownMenuItem(
text = { Text(u.label) },
onClick = { unit = u; unitExpanded = false }
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
Text("before", modifier = Modifier.padding(end = 8.dp))
}
Row(modifier = Modifier.padding(top = 4.dp)) {
TextButton(
onClick = { amount?.let { onAdd(it * unit.minutes) } },
enabled = amount != null && amount > 0
) {
Text("Add")
}
TextButton(onClick = onCancel) {
Text("Cancel")
}
}
}

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.homelab.ncal.data.model.CalendarCollection
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
import com.homelab.ncal.data.model.SyncStatus
import com.homelab.ncal.data.repository.NextcloudRepository
import com.homelab.ncal.data.repository.SaveConflictException
import com.homelab.ncal.util.ConflictField
@ -27,6 +28,8 @@ data class ItemEditUiState(
val error: String? = null,
val done: Boolean = false,
val deleted: Boolean = false,
val savedOffline: Boolean = false, // true if [done] happened via a local-only pending save (no connectivity)
val deletedOffline: Boolean = false, // true if [deleted] happened via a local-only pending delete
val conflict: SaveConflictException? = null,
val conflictFields: List<ConflictField> = emptyList(),
val conflictChoices: Map<String, Boolean> = emptyMap() // field key -> true means "take theirs"
@ -110,6 +113,12 @@ class ItemEditViewModel(
_state.value = _state.value.copy(item = _state.value.item?.let(transform))
}
/** True once a save/delete has actually gone out as a single-occurrence ("this event only")
* write, so a conflict retry ([keepMyChanges]/[resolveConflictMerge]) repeats the SAME scope
* instead of silently falling back to a whole-series edit. */
private var pendingScopeWasOccurrenceOnly = false
/** Saves the whole series (or a non-recurring item, the common case). */
fun save() {
val item = _state.value.item ?: return
if (item.summary.isBlank()) {
@ -120,11 +129,54 @@ class ItemEditViewModel(
_state.value = _state.value.copy(error = "Pick a calendar first")
return
}
commitSave(applyMasterDateShift(item))
}
/** Saves changes to just this occurrence of a recurring item, via a RECURRENCE-ID override. */
fun saveThisOccurrenceOnly() {
val item = _state.value.item ?: return
if (item.summary.isBlank()) {
_state.value = _state.value.copy(error = "Title is required")
return
}
commitOccurrenceSave(item)
}
/**
* The visible Starts/Ends/Due fields are bound to *this occurrence's* date
* ([CalendarItem.start]/[end]/[due]), not the series' master date - editing them and then
* choosing "all events" needs to shift the master by however much the user just moved this
* occurrence, using [CalendarItem.occurrenceDate] (the occurrence's original, un-edited slot)
* as the anchor to measure that shift against. A pure non-date edit (title, etc.) leaves
* start == occurrenceDate, so the computed delta is zero and the master date is untouched.
*/
private fun applyMasterDateShift(item: CalendarItem): CalendarItem {
val occurrenceDate = item.occurrenceDate ?: return item
return when (item.type) {
ItemType.EVENT -> {
val delta = (item.start ?: occurrenceDate) - occurrenceDate
if (delta == 0L) item else item.copy(
recurrenceMasterStart = item.recurrenceMasterStart?.plus(delta),
recurrenceMasterEnd = item.recurrenceMasterEnd?.plus(delta)
)
}
ItemType.TASK -> {
val delta = (item.due ?: occurrenceDate) - occurrenceDate
if (delta == 0L) item else item.copy(
recurrenceMasterStart = item.recurrenceMasterStart?.plus(delta),
recurrenceMasterDue = item.recurrenceMasterDue?.plus(delta)
)
}
}
}
private fun commitSave(item: CalendarItem) {
pendingScopeWasOccurrenceOnly = false
_state.value = _state.value.copy(saving = true, error = null)
viewModelScope.launch {
try {
repo.save(item)
_state.value = _state.value.copy(saving = false, done = true)
val saved = repo.save(item)
_state.value = _state.value.copy(saving = false, done = true, savedOffline = saved.syncStatus == SyncStatus.PENDING_UPDATE)
} catch (e: SaveConflictException) {
val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
android.util.Log.d(
@ -139,16 +191,38 @@ class ItemEditViewModel(
}
}
private fun commitOccurrenceSave(item: CalendarItem) {
pendingScopeWasOccurrenceOnly = true
_state.value = _state.value.copy(saving = true, error = null)
viewModelScope.launch {
try {
repo.saveOccurrence(item)
_state.value = _state.value.copy(saving = false, done = true)
} catch (e: SaveConflictException) {
val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
android.util.Log.d(
"NCalConflict",
"saveThisOccurrenceOnly() 412 conflict for href=${item.href}, server version present=${e.serverVersion != null}, differing fields=${fields.map { it.key }}"
)
_state.value = _state.value.copy(saving = false, conflict = e, conflictFields = fields, conflictChoices = emptyMap())
} catch (e: Exception) {
android.util.Log.e("NCalError", "saveThisOccurrenceOnly() failed for href=${item.href} calendar=${item.calendarUrl}", e)
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
}
}
}
/** Conflict resolution (simple path - used when there's nothing to diff, e.g. the item was
* deleted server-side): retry the write with the server's current etag, overwriting
* whatever changed there with this edit. */
fun keepMyChanges() {
val conflict = _state.value.conflict ?: return
val retryItem = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
_state.value = _state.value.copy(
conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(),
item = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
item = retryItem
)
save()
if (pendingScopeWasOccurrenceOnly) commitOccurrenceSave(retryItem) else commitSave(retryItem)
}
/** Conflict resolution (simple path): discard this edit and load whatever's on the server
@ -186,19 +260,40 @@ class ItemEditViewModel(
}
}
_state.value = _state.value.copy(conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(), item = merged)
save()
if (pendingScopeWasOccurrenceOnly) commitOccurrenceSave(merged) else commitSave(merged)
}
fun delete() {
/** Deletes the whole series (or a non-recurring item, the common case). */
fun deleteAllOccurrences() {
val item = _state.value.item ?: return
if (item.isNew) return
_state.value = _state.value.copy(saving = true, error = null)
viewModelScope.launch {
try {
repo.delete(item)
_state.value = _state.value.copy(saving = false, deleted = true)
val deletedOnServer = repo.delete(item)
_state.value = _state.value.copy(saving = false, deleted = true, deletedOffline = !deletedOnServer)
} catch (e: Exception) {
android.util.Log.e("NCalError", "delete() failed for href=${item.href} calendar=${item.calendarUrl}", e)
android.util.Log.e("NCalError", "deleteAllOccurrences() failed for href=${item.href} calendar=${item.calendarUrl}", e)
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
}
}
}
/** Deletes just this occurrence of a recurring item, via EXDATE. The series (and any other
* occurrence) is untouched. */
fun deleteThisOccurrence() {
val item = _state.value.item ?: return
if (item.isNew || item.occurrenceDate == null) return
_state.value = _state.value.copy(saving = true, error = null)
viewModelScope.launch {
try {
repo.deleteOccurrence(item)
_state.value = _state.value.copy(saving = false, deleted = true)
} catch (e: SaveConflictException) {
val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
_state.value = _state.value.copy(saving = false, conflict = e, conflictFields = fields, conflictChoices = emptyMap())
} catch (e: Exception) {
android.util.Log.e("NCalError", "deleteThisOccurrence() failed for href=${item.href} calendar=${item.calendarUrl}", e)
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
}
}

View File

@ -1,7 +1,21 @@
package com.homelab.ncal.ui.nav
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -21,8 +35,12 @@ import com.homelab.ncal.ui.login.LoginScreen
import com.homelab.ncal.ui.login.LoginViewModel
import com.homelab.ncal.ui.month.MonthScreen
import com.homelab.ncal.ui.month.MonthViewModel
import com.homelab.ncal.ui.search.SearchScreen
import com.homelab.ncal.ui.search.SearchViewModel
import com.homelab.ncal.ui.tasks.TasksScreen
import com.homelab.ncal.ui.tasks.TasksViewModel
import com.homelab.ncal.ui.week.WeekScreen
import com.homelab.ncal.ui.week.WeekViewModel
import java.net.URLEncoder
private object Routes {
@ -30,7 +48,9 @@ private object Routes {
const val AGENDA = "agenda"
const val TASKS = "tasks"
const val MONTH = "month"
const val WEEK = "week"
const val COLLECTIONS = "collections"
const val SEARCH = "search"
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
}
@ -56,69 +76,121 @@ fun NcalNavGraph(
}
}
NavHost(navController = navController, startDestination = startDestination) {
composable(Routes.LOGIN) {
val vm: LoginViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> LoginViewModel(repo) })
LoginScreen(
viewModel = vm,
onLoggedIn = {
navController.navigate(Routes.AGENDA) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
}
)
}
composable(Routes.AGENDA) {
val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) })
AgendaScreen(
viewModel = vm,
onOpenItem = { item: CalendarItem ->
navController.navigate(editRoute(item.type.name, item.href))
},
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
onOpenTasks = { navController.navigate(Routes.TASKS) },
onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
onOpenMonth = { navController.navigate(Routes.MONTH) }
)
}
composable(Routes.MONTH) {
val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) })
MonthScreen(
viewModel = vm,
onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
onBack = { navController.popBackStack() }
)
}
composable(Routes.TASKS) {
val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
TasksScreen(
viewModel = vm,
onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) },
onNewTask = { navController.navigate(editRoute("TASK", null)) },
onBack = { navController.popBackStack() }
)
}
composable(Routes.COLLECTIONS) {
val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) })
CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() })
}
composable(
route = Routes.EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("href") { type = NavType.StringType },
navArgument("calendarUrl") { type = NavType.StringType }
)
) {
val vm: ItemEditViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, handle -> ItemEditViewModel(repo, handle) })
ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() })
// Central "Undo" snackbar for deletes - lives above the NavHost so it survives the
// pop-back-stack navigation that follows every delete (the screen that triggered the
// delete is usually gone by the time the user could tap Undo).
val snackbarHostState = remember { SnackbarHostState() }
val lastDeleted by app.repository.lastDeleted.collectAsState()
LaunchedEffect(lastDeleted) {
val item = lastDeleted ?: return@LaunchedEffect
val label = item.summary.ifBlank { "Deleted" }
val result = snackbarHostState.showSnackbar(
message = "Deleted \"$label\"",
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
app.repository.undoDelete()
}
}
Box(modifier = Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = startDestination) {
composable(Routes.LOGIN) {
val vm: LoginViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> LoginViewModel(repo) })
LoginScreen(
viewModel = vm,
onLoggedIn = {
navController.navigate(Routes.AGENDA) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
}
)
}
composable(Routes.AGENDA) {
val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) })
AgendaScreen(
viewModel = vm,
onOpenItem = { item: CalendarItem ->
navController.navigate(editRoute(item.type.name, item.href))
},
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
onOpenTasks = { navController.navigate(Routes.TASKS) },
onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
onOpenMonth = { navController.navigate(Routes.MONTH) },
onOpenWeek = { navController.navigate(Routes.WEEK) },
onOpenSearch = { navController.navigate(Routes.SEARCH) }
)
}
composable(Routes.SEARCH) {
val vm: SearchViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> SearchViewModel(repo) })
SearchScreen(
viewModel = vm,
onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
onBack = { navController.popBackStack() }
)
}
composable(Routes.MONTH) {
val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) })
MonthScreen(
viewModel = vm,
onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
onBack = { navController.popBackStack() }
)
}
composable(Routes.WEEK) {
val vm: WeekViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> WeekViewModel(repo) })
WeekScreen(
viewModel = vm,
onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
onBack = { navController.popBackStack() }
)
}
composable(Routes.TASKS) {
val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
TasksScreen(
viewModel = vm,
onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) },
onNewTask = { navController.navigate(editRoute("TASK", null)) },
onBack = { navController.popBackStack() }
)
}
composable(Routes.COLLECTIONS) {
val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) })
CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() })
}
composable(
route = Routes.EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("href") { type = NavType.StringType },
navArgument("calendarUrl") { type = NavType.StringType }
)
) {
val vm: ItemEditViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, handle -> ItemEditViewModel(repo, handle) })
ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() })
}
}
SnackbarHost(
hostState = snackbarHostState,
// navigationBarsPadding() alone clears the gesture-nav strip but lands right on top
// of the Agenda/Month FABs (this snackbar lives above the NavHost, so it doesn't get
// Scaffold's usual automatic FAB-avoidance) - the extra fixed padding clears a
// standard-size FAB too.
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 80.dp)
)
}
}

View File

@ -0,0 +1,137 @@
package com.homelab.ncal.ui.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
viewModel: SearchViewModel,
onOpenItem: (CalendarItem) -> Unit,
onBack: () -> Unit
) {
val query by viewModel.query.collectAsState()
val results by viewModel.results.collectAsState()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Search") },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
}
)
}
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::setQuery,
singleLine = true,
placeholder = { Text("Search events and tasks") },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(Icons.Filled.Clear, "Clear")
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.focusRequester(focusRequester)
)
when {
query.trim().length < 2 -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Type at least 2 characters to search", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> LazyColumn {
items(results, key = { it.href.ifEmpty { it.uid } }) { result ->
SearchResultRow(item = result, onClick = { onOpenItem(result) })
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun SearchResultRow(item: CalendarItem, onClick: () -> Unit) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = {
Text(item.summary, textDecoration = if (item.completed) TextDecoration.LineThrough else null)
},
supportingContent = {
val whenText = item.start?.let { formatDateTime(it, item.allDay) }
?: item.due?.let { "Due " + formatDateTime(it, false) }
whenText?.let { Text(it) }
},
leadingContent = {
if (item.type == ItemType.TASK) {
Icon(
imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
} else {
Icon(Icons.Filled.Event, contentDescription = null)
}
}
)
}
private fun formatDateTime(millis: Long, allDay: Boolean): String {
val zone = ZoneId.systemDefault()
val instant = Instant.ofEpochMilli(millis)
val pattern = if (allDay) "MMM d, yyyy" else "MMM d, yyyy h:mm a"
return instant.atZone(zone).format(DateTimeFormatter.ofPattern(pattern))
}

View File

@ -0,0 +1,46 @@
package com.homelab.ncal.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.repository.NextcloudRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
class SearchViewModel(private val repo: NextcloudRepository) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val allItems = repo.observeCollections()
.flatMapLatest { collections ->
val urls = collections.filter { it.enabled }.map { it.url }
repo.observeItems(urls)
}
val results: StateFlow<List<CalendarItem>> = combine(_query, allItems) { q, items ->
val needle = q.trim()
if (needle.length < 2) {
emptyList()
} else {
items.filter { matches(it, needle) }
.sortedByDescending { it.start ?: it.due ?: 0L }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun setQuery(q: String) {
_query.value = q
}
private fun matches(item: CalendarItem, needle: String): Boolean {
val n = needle.lowercase()
return item.summary.lowercase().contains(n) ||
item.description.lowercase().contains(n) ||
item.location.lowercase().contains(n)
}
}

View File

@ -0,0 +1,180 @@
package com.homelab.ncal.ui.week
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeekScreen(
viewModel: WeekViewModel,
onOpenItem: (CalendarItem) -> Unit,
onNewEvent: () -> Unit,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(weekRangeLabel(state.weekStart)) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
},
actions = {
IconButton(onClick = viewModel::goToToday) { Icon(Icons.Filled.Today, "Today") }
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") }
}
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
state.error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(16.dp)
)
}
WeekHeader(onPrev = viewModel::prevWeek, onNext = viewModel::nextWeek)
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.weight(1f)
) {
LazyColumn {
items(state.itemsByDay.entries.toList(), key = { it.key.toString() }) { (day, dayItems) ->
DaySection(
day = day,
items = dayItems,
onOpenItem = onOpenItem,
onToggleTask = viewModel::toggleTask
)
}
}
}
}
}
}
@Composable
private fun WeekHeader(onPrev: () -> Unit, onNext: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = onPrev) { Icon(Icons.Filled.ChevronLeft, "Previous week") }
IconButton(onClick = onNext) { Icon(Icons.Filled.ChevronRight, "Next week") }
}
}
@Composable
private fun DaySection(
day: LocalDate,
items: List<CalendarItem>,
onOpenItem: (CalendarItem) -> Unit,
onToggleTask: (CalendarItem) -> Unit
) {
val today = LocalDate.now()
Column {
Text(
day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")),
style = MaterialTheme.typography.titleSmall,
color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
if (items.isEmpty()) {
Text(
"Nothing scheduled",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
items.forEach { item ->
ListItem(
modifier = Modifier.clickable { onOpenItem(item) },
headlineContent = {
Text(item.summary, textDecoration = if (item.completed) TextDecoration.LineThrough else null)
},
supportingContent = {
val time = item.start?.let { formatTime(it, item.allDay) }
?: item.due?.let { "Due " + formatTime(it, false) }
time?.let { Text(it) }
},
leadingContent = {
if (item.type == ItemType.TASK) {
Icon(
imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
contentDescription = "Toggle complete",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onToggleTask(item) }
)
} else {
Icon(Icons.Filled.Event, contentDescription = null)
}
}
)
}
HorizontalDivider()
}
}
private fun weekRangeLabel(weekStart: LocalDate): String {
val weekEnd = weekStart.plusDays(6)
val startFmt = if (weekStart.year == weekEnd.year) "MMM d" else "MMM d, yyyy"
val endFmt = if (weekStart.month == weekEnd.month && weekStart.year == weekEnd.year) "d, yyyy" else "MMM d, yyyy"
return "${weekStart.format(DateTimeFormatter.ofPattern(startFmt))} ${weekEnd.format(DateTimeFormatter.ofPattern(endFmt))}"
}
private fun formatTime(millis: Long, allDay: Boolean): String {
val zone = java.time.ZoneId.systemDefault()
val instant = java.time.Instant.ofEpochMilli(millis)
return if (allDay) "All day" else instant.atZone(zone).format(DateTimeFormatter.ofPattern("h:mm a"))
}

View File

@ -0,0 +1,87 @@
package com.homelab.ncal.ui.week
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.repository.NextcloudRepository
import com.homelab.ncal.util.groupByAllOccupiedDays
import com.homelab.ncal.util.toUserMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.WeekFields
import java.util.Locale
data class WeekUiState(
val weekStart: LocalDate = LocalDate.now(),
val itemsByDay: Map<LocalDate, List<CalendarItem>> = emptyMap(),
val refreshing: Boolean = false,
val error: String? = null
)
class WeekViewModel(private val repo: NextcloudRepository) : ViewModel() {
private val zone = ZoneId.systemDefault()
private val firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek
private fun alignToWeekStart(date: LocalDate): LocalDate {
var d = date
while (d.dayOfWeek != firstDayOfWeek) d = d.minusDays(1)
return d
}
private val weekStart = MutableStateFlow(alignToWeekStart(LocalDate.now()))
private val refreshing = MutableStateFlow(false)
private val error = MutableStateFlow<String?>(null)
private val allItems = repo.observeCollections()
.flatMapLatest { collections ->
val urls = collections.filter { it.enabled }.map { it.url }
repo.observeItems(urls)
}
val state: StateFlow<WeekUiState> = combine(allItems, weekStart, refreshing, error) { items, start, r, e ->
val grouped = items.groupByAllOccupiedDays(zone)
val itemsByDay = (0..6).associate { offset ->
val day = start.plusDays(offset.toLong())
day to (grouped[day] ?: emptyList()).sortedBy { it.start ?: it.due }
}
WeekUiState(weekStart = start, itemsByDay = itemsByDay, refreshing = r, error = e)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeekUiState(weekStart = weekStart.value))
fun nextWeek() { weekStart.value = weekStart.value.plusWeeks(1) }
fun prevWeek() { weekStart.value = weekStart.value.minusWeeks(1) }
fun goToToday() { weekStart.value = alignToWeekStart(LocalDate.now()) }
fun toggleTask(item: CalendarItem) {
viewModelScope.launch {
try {
repo.toggleTaskComplete(item)
error.value = null
} catch (e: Exception) {
android.util.Log.e("NCalError", "toggleTask failed for href=${item.href} calendar=${item.calendarUrl}", e)
error.value = e.toUserMessage()
}
}
}
fun refresh() {
refreshing.value = true
viewModelScope.launch {
try {
repo.fullSync()
error.value = null
} catch (e: Exception) {
android.util.Log.e("NCalError", "Week fullSync failed", e)
error.value = e.toUserMessage()
}
refreshing.value = false
}
}
}

View File

@ -6,11 +6,14 @@ import biweekly.component.VAlarm
import biweekly.component.VEvent
import biweekly.component.VTodo
import biweekly.parameter.Related
import biweekly.property.ExceptionDates
import biweekly.property.RecurrenceId
import biweekly.property.RelatedTo
import biweekly.property.Status
import biweekly.property.Trigger
import biweekly.util.Duration
import biweekly.util.Frequency
import biweekly.util.ICalDate
import biweekly.util.Recurrence
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
@ -36,16 +39,19 @@ object IcsMapper {
TaskStatus.CANCELLED -> Status.cancelled()
}
/** null if this rule uses anything the picker can't represent (BYDAY/BYMONTH/etc, or a
* frequency other than the 4 simple ones) - not just an unsupported FREQ. A rule can have
* FREQ=WEEKLY and still be BYDAY-based ("every Mon/Wed/Fri"), which the picker must never
* silently flatten into a plain weekly rule on an unrelated edit. */
/** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, an
* ordinal BYDAY like "3rd Monday", BYDAY on anything but WEEKLY, or a frequency other than
* the 4 simple ones). A plain WEEKLY rule with a BYDAY of unordinaled days ("every Mon/Wed/
* Fri") *is* representable - see [recurrenceByDaySet] - so this must never treat "has BYDAY"
* as automatically unsupported like it once did, or a BYDAY edit would keep getting
* silently dropped back to rawIcs-only on the next unrelated save. */
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
if (this == null) return null
val isSimple = byDay.isEmpty() && byMonth.isEmpty() && byMonthDay.isEmpty() &&
val isSimple = byMonth.isEmpty() && byMonthDay.isEmpty() &&
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
if (!isSimple) return null
if (byDay.isNotEmpty() && (frequency != Frequency.WEEKLY || byDay.any { it.num != null })) return null
return when (frequency) {
Frequency.DAILY -> RecurrenceFrequency.DAILY
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
@ -55,6 +61,32 @@ object IcsMapper {
}
}
/** Only meaningful when [toRecurrenceFrequency] didn't return null - a plain (unordinaled)
* BYDAY list, translated from biweekly's own [biweekly.util.DayOfWeek] to [java.time.DayOfWeek]. */
private fun Recurrence?.toByDaySet(): Set<java.time.DayOfWeek> =
this?.byDay?.mapNotNull { it.day?.toJavaDayOfWeek() }?.toSet() ?: emptySet()
private fun biweekly.util.DayOfWeek.toJavaDayOfWeek(): java.time.DayOfWeek? = when (this) {
biweekly.util.DayOfWeek.MONDAY -> java.time.DayOfWeek.MONDAY
biweekly.util.DayOfWeek.TUESDAY -> java.time.DayOfWeek.TUESDAY
biweekly.util.DayOfWeek.WEDNESDAY -> java.time.DayOfWeek.WEDNESDAY
biweekly.util.DayOfWeek.THURSDAY -> java.time.DayOfWeek.THURSDAY
biweekly.util.DayOfWeek.FRIDAY -> java.time.DayOfWeek.FRIDAY
biweekly.util.DayOfWeek.SATURDAY -> java.time.DayOfWeek.SATURDAY
biweekly.util.DayOfWeek.SUNDAY -> java.time.DayOfWeek.SUNDAY
else -> null
}
private fun java.time.DayOfWeek.toBiweeklyDayOfWeek(): biweekly.util.DayOfWeek = when (this) {
java.time.DayOfWeek.MONDAY -> biweekly.util.DayOfWeek.MONDAY
java.time.DayOfWeek.TUESDAY -> biweekly.util.DayOfWeek.TUESDAY
java.time.DayOfWeek.WEDNESDAY -> biweekly.util.DayOfWeek.WEDNESDAY
java.time.DayOfWeek.THURSDAY -> biweekly.util.DayOfWeek.THURSDAY
java.time.DayOfWeek.FRIDAY -> biweekly.util.DayOfWeek.FRIDAY
java.time.DayOfWeek.SATURDAY -> biweekly.util.DayOfWeek.SATURDAY
java.time.DayOfWeek.SUNDAY -> biweekly.util.DayOfWeek.SUNDAY
}
/** Builds the Recurrence to write for [item], or null to clear any existing RRULE. Only
* reachable for the 4 frequencies the picker supports - anything else in the original
* document was already filtered out by [Recurrence.toRecurrenceFrequency] on parse, so this
@ -70,6 +102,9 @@ object IcsMapper {
val builder = Recurrence.Builder(bwFreq).interval(item.recurrenceInterval.coerceAtLeast(1))
item.recurrenceCount?.let { builder.count(it) }
item.recurrenceUntil?.let { builder.until(Date(it), true) }
if (freq == RecurrenceFrequency.WEEKLY && item.recurrenceByDay.isNotEmpty()) {
builder.byDay(item.recurrenceByDay.map { it.toBiweeklyDayOfWeek() })
}
return builder.build()
}
@ -81,30 +116,109 @@ object IcsMapper {
(-duration.toMillis() / 60_000L).toInt()
}.distinct().sorted()
/** Parses a raw .ics blob (one VEVENT or VTODO) fetched from the server into our model. */
/**
* Splits a multi-component .ics document (e.g. exported from another calendar app, or from
* NCal's own export) into one freshly-created [CalendarItem] per VEVENT/VTODO, for bulk
* import into [calendarUrl]. Each result is a brand-new creation - no href/etag, and always a
* *fresh* UID rather than the source file's own, since reusing an existing UID risks a
* collision with the target calendar's own trashbin (see the same issue worked around in
* [com.homelab.ncal.data.repository.NextcloudRepository.undoDelete]). A recurring item's
* DTSTART/DTEND/DUE come from its true master date (not "resolved next occurrence") so the
* recreated series starts exactly where the original one did.
*/
fun parseAllForImport(ics: String, calendarUrl: String): List<CalendarItem> {
val ical = runCatching { Biweekly.parse(ics).first() }.getOrNull() ?: return emptyList()
val items = mutableListOf<CalendarItem>()
// RECURRENCE-ID overrides are only meaningful attached to their master's UID/series -
// importing one on its own as an independent event would just create a confusing
// duplicate sitting at that occurrence's time slot, disconnected from any series.
ical.events.filter { it.recurrenceId == null }.forEach { ev ->
val single = ICalendar().apply { addEvent(ev) }
val singleIcs = Biweekly.write(single).go()
parse(singleIcs, href = "", etag = null, calendarUrl = calendarUrl)?.let { parsed ->
items += parsed.copy(
uid = UUID.randomUUID().toString(),
rawIcs = null,
start = if (parsed.isRecurring) parsed.recurrenceMasterStart else parsed.start,
end = if (parsed.isRecurring) parsed.recurrenceMasterEnd else parsed.end
)
}
}
ical.todos.filter { it.recurrenceId == null }.forEach { td ->
val single = ICalendar().apply { addTodo(td) }
val singleIcs = Biweekly.write(single).go()
parse(singleIcs, href = "", etag = null, calendarUrl = calendarUrl)?.let { parsed ->
items += parsed.copy(
uid = UUID.randomUUID().toString(),
rawIcs = null,
start = if (parsed.isRecurring) parsed.recurrenceMasterStart else parsed.start,
due = if (parsed.isRecurring) parsed.recurrenceMasterDue else parsed.due
)
}
}
return items
}
/** Combines every item's own component (reusing its already-fetched [CalendarItem.rawIcs]
* when present, so unmodeled properties survive) into one multi-component .ics document,
* for exporting a whole calendar. */
fun buildCombinedIcs(items: List<CalendarItem>): String {
val ical = ICalendar()
items.forEach { item ->
val singleIcs = item.rawIcs ?: toIcs(item)
val parsed = runCatching { Biweekly.parse(singleIcs).first() }.getOrNull() ?: return@forEach
parsed.events.forEach { ical.addEvent(it) }
parsed.todos.forEach { ical.addTodo(it) }
}
return Biweekly.write(ical).go()
}
/**
* Parses a raw .ics blob fetched from the server into our model. The blob may contain just a
* master VEVENT/VTODO, or a master plus one or more RECURRENCE-ID override components (same
* UID) representing single-occurrence edits ("this event only" - see [buildOccurrenceOverrideIcs]).
* When the resolved occurrence being displayed has a matching override, that override's
* fields (summary/description/etc. and its own possibly-moved start/end/due) are what's
* surfaced - the master's RRULE/recurrence metadata is still what's tracked for the series
* itself, via [CalendarItem.recurrenceMasterStart] etc.
*/
fun parse(ics: String, href: String, etag: String?, calendarUrl: String): CalendarItem? {
val ical = Biweekly.parse(ics).first() ?: return null
ical.events.firstOrNull()?.let { ev ->
(ical.events.firstOrNull { it.recurrenceId == null } ?: ical.events.firstOrNull())?.let { ev ->
val originalStart = ev.dateStart?.value?.time
val originalEnd = ev.dateEnd?.value?.time
val durationMillis = if (originalStart != null && originalEnd != null) originalEnd - originalStart else null
val recurrence = ev.recurrenceRule?.value
val exceptionDates = ev.exceptionDates.flatMap { it.values }.map { it.time }.toSet()
var occurrenceDate: Long? = null
var override: VEvent? = null
val (resolvedStart, resolvedEnd) = if (recurrence != null && originalStart != null) {
val next = RecurrenceUtils.nextOccurrence(originalStart, recurrence, System.currentTimeMillis())
val next = RecurrenceUtils.nextOccurrence(originalStart, recurrence, System.currentTimeMillis(), exceptionDates)
if (next == null) {
android.util.Log.d("NCalParse", "EVENT '${ev.summary?.value}' recurrence ended (freq=${recurrence.frequency}, until=${recurrence.until}, count=${recurrence.count}) - dropping")
return null
}
next to (durationMillis?.let { next + it } ?: next)
occurrenceDate = next
override = ical.events.firstOrNull { it !== ev && it.recurrenceId?.value?.time == next }
val ovStart = override?.dateStart?.value?.time
val ovEnd = override?.dateEnd?.value?.time
if (ovStart != null) {
ovStart to (ovEnd ?: ovStart)
} else {
next to (durationMillis?.let { next + it } ?: next)
}
} else {
originalStart to originalEnd
}
val display = override ?: ev
android.util.Log.d(
"NCalParse",
"EVENT '${ev.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " +
"EVENT '${ev.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} hasOverride=${override != null} " +
"originalStart=${originalStart?.let { java.util.Date(it) }} resolvedStart=${resolvedStart?.let { java.util.Date(it) }}"
)
@ -114,38 +228,50 @@ object IcsMapper {
etag = etag,
calendarUrl = calendarUrl,
type = ItemType.EVENT,
summary = ev.summary?.value ?: "(no title)",
description = ev.description?.value ?: "",
location = ev.location?.value ?: "",
url = ev.url?.value ?: "",
summary = display.summary?.value ?: "(no title)",
description = display.description?.value ?: "",
location = display.location?.value ?: "",
url = display.url?.value ?: "",
start = resolvedStart,
end = resolvedEnd,
allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
priority = ev.priority?.value ?: 0,
reminderMinutes = alarmMinutesBefore(ev.alarms),
allDay = ev.dateStart?.value?.let { !it.hasTime() } ?: false,
priority = display.priority?.value ?: 0,
reminderMinutes = alarmMinutesBefore(display.alarms),
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
recurrenceInterval = recurrence?.interval ?: 1,
recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time,
recurrenceByDay = recurrence.toByDaySet(),
isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterEnd = originalEnd,
occurrenceDate = occurrenceDate,
rawIcs = ics
)
}
ical.todos.firstOrNull()?.let { td ->
(ical.todos.firstOrNull { it.recurrenceId == null } ?: ical.todos.firstOrNull())?.let { td ->
val originalDue = td.dateDue?.value?.time
val originalStart = td.dateStart?.value?.time
val startToDueMillis = if (originalStart != null && originalDue != null) originalDue - originalStart else null
val recurrence = td.recurrenceRule?.value
val exceptionDates = td.exceptionDates.flatMap { it.values }.map { it.time }.toSet()
var occurrenceDate: Long? = null
var override: VTodo? = null
val resolvedDue = if (recurrence != null && originalDue != null) {
RecurrenceUtils.nextOccurrence(originalDue, recurrence, System.currentTimeMillis())
val next = RecurrenceUtils.nextOccurrence(originalDue, recurrence, System.currentTimeMillis(), exceptionDates)
occurrenceDate = next
if (next != null) override = ical.todos.firstOrNull { it !== td && it.recurrenceId?.value?.time == next }
override?.dateDue?.value?.time ?: next
} else {
originalDue
}
val resolvedStart = if (recurrence != null && originalStart != null && resolvedDue != null) {
val display = override ?: td
val resolvedStart = if (override != null) {
display.dateStart?.value?.time
} else if (recurrence != null && originalStart != null && resolvedDue != null) {
startToDueMillis?.let { resolvedDue - it }
} else {
originalStart
@ -153,13 +279,13 @@ object IcsMapper {
android.util.Log.d(
"NCalParse",
"TASK '${td.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " +
"TASK '${td.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} hasOverride=${override != null} " +
"originalDue=${originalDue?.let { java.util.Date(it) }} resolvedDue=${resolvedDue?.let { java.util.Date(it) }} " +
"originalStart=${originalStart?.let { java.util.Date(it) }} completed=${td.status?.isCompleted}"
)
val status = td.status.toTaskStatus()
val percentComplete = td.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
val status = display.status.toTaskStatus()
val percentComplete = display.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
return CalendarItem(
uid = td.uid?.value ?: UUID.randomUUID().toString(),
@ -167,25 +293,27 @@ object IcsMapper {
etag = etag,
calendarUrl = calendarUrl,
type = ItemType.TASK,
summary = td.summary?.value ?: "(no title)",
description = td.description?.value ?: "",
location = td.location?.value ?: "",
url = td.url?.value ?: "",
summary = display.summary?.value ?: "(no title)",
description = display.description?.value ?: "",
location = display.location?.value ?: "",
url = display.url?.value ?: "",
start = resolvedStart,
due = resolvedDue,
completed = status == TaskStatus.COMPLETED || percentComplete >= 100,
status = status,
percentComplete = percentComplete,
priority = td.priority?.value ?: 0,
parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
reminderMinutes = alarmMinutesBefore(td.alarms),
priority = display.priority?.value ?: 0,
parentUid = display.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
reminderMinutes = alarmMinutesBefore(display.alarms),
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
recurrenceInterval = recurrence?.interval ?: 1,
recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time,
recurrenceByDay = recurrence.toByDaySet(),
isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterDue = originalDue,
occurrenceDate = occurrenceDate,
rawIcs = ics
)
}
@ -222,7 +350,9 @@ object IcsMapper {
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
when (item.type) {
ItemType.EVENT -> {
val ev = ical.events.firstOrNull() ?: VEvent().also { ical.addEvent(it) }
// "All events" edits always target the master, never a RECURRENCE-ID override
// that might otherwise be sitting first in the list.
val ev = ical.events.firstOrNull { it.recurrenceId == null } ?: ical.events.firstOrNull() ?: VEvent().also { ical.addEvent(it) }
val originalRecurrence = ev.recurrenceRule?.value
val wasRecurring = originalRecurrence != null
@ -249,7 +379,7 @@ object IcsMapper {
}
}
ItemType.TASK -> {
val td = ical.todos.firstOrNull() ?: VTodo().also { ical.addTodo(it) }
val td = ical.todos.firstOrNull { it.recurrenceId == null } ?: ical.todos.firstOrNull() ?: VTodo().also { ical.addTodo(it) }
val originalRecurrence = td.recurrenceRule?.value
val wasRecurring = originalRecurrence != null
@ -285,9 +415,97 @@ object IcsMapper {
return Biweekly.write(ical).go()
}
/** Crude but effective: DTSTART/DTEND with a VALUE=DATE param (no time) means an all-day item. */
private fun hasTimeComponent(ics: String, property: String): Boolean {
val line = ics.lines().firstOrNull { it.startsWith(property) } ?: return true
return !line.contains("VALUE=DATE")
/**
* Builds the full .ics document for "save changes to this event only" on a recurring [item] -
* adds or replaces a RECURRENCE-ID override VEVENT/VTODO for [CalendarItem.occurrenceDate]
* inside the existing document ([CalendarItem.rawIcs]), leaving the master component (and
* its RRULE) completely untouched. The override carries the item's *current* fields in full
* (RFC 5545 allows a full-component override, not just the properties that differ from the
* master - simpler and just as valid).
*/
fun buildOccurrenceOverrideIcs(item: CalendarItem): String {
val occurrenceDate = requireNotNull(item.occurrenceDate) {
"buildOccurrenceOverrideIcs requires a recurring item with occurrenceDate set"
}
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
when (item.type) {
ItemType.EVENT -> {
ical.events.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
val override = VEvent().apply {
setUid(item.uid)
setRecurrenceId(RecurrenceId(Date(occurrenceDate), !item.allDay))
setSummary(item.summary)
setDescription(item.description.takeIf { it.isNotBlank() })
setLocation(item.location.takeIf { it.isNotBlank() })
setUrl(item.url.takeIf { it.isNotBlank() })
setDateStart(item.start?.let { Date(it) }, !item.allDay)
setDateEnd(item.end?.let { Date(it) }, !item.allDay)
setPriority(item.priority.takeIf { it > 0 })
}
item.reminderMinutes.forEach { minutes ->
val duration = Duration.builder().prior(true).minutes(minutes).build()
override.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary))
}
ical.addEvent(override)
}
ItemType.TASK -> {
ical.todos.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
val override = VTodo().apply {
setUid(item.uid)
setRecurrenceId(RecurrenceId(Date(occurrenceDate), true))
setSummary(item.summary)
setDescription(item.description.takeIf { it.isNotBlank() })
setLocation(item.location.takeIf { it.isNotBlank() })
setUrl(item.url.takeIf { it.isNotBlank() })
setDateStart(item.start?.let { Date(it) })
setDateDue(item.due?.let { Date(it) })
setPriority(item.priority.takeIf { it > 0 })
setStatus(item.status.toIcsStatus())
setPercentComplete(item.percentComplete.takeIf { it > 0 })
}
item.parentUid?.takeIf { it.isNotBlank() }?.let { override.addRelatedTo(RelatedTo(it)) }
if (item.due != null) {
item.reminderMinutes.forEach { minutes ->
val duration = Duration.builder().prior(true).minutes(minutes).build()
override.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary))
}
}
ical.addTodo(override)
}
}
return Biweekly.write(ical).go()
}
/**
* Builds the full .ics document for "delete this event only" on a recurring [item] - adds
* [CalendarItem.occurrenceDate] to the master's EXDATE list (so [RecurrenceUtils.nextOccurrence]
* skips straight over it on every future sync) and removes any existing RECURRENCE-ID
* override for that same date, since there's nothing left for it to override.
*/
fun buildOccurrenceDeletionIcs(item: CalendarItem): String {
val occurrenceDate = requireNotNull(item.occurrenceDate) {
"buildOccurrenceDeletionIcs requires a recurring item with occurrenceDate set"
}
val rawIcs = requireNotNull(item.rawIcs?.takeIf { it.isNotBlank() }) {
"buildOccurrenceDeletionIcs requires the item's existing rawIcs"
}
val ical = Biweekly.parse(rawIcs).first()
when (item.type) {
ItemType.EVENT -> {
ical.events.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
ical.events.firstOrNull { it.recurrenceId == null }?.addExceptionDates(
ExceptionDates().apply { values.add(ICalDate(Date(occurrenceDate), !item.allDay)) }
)
}
ItemType.TASK -> {
ical.todos.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
ical.todos.firstOrNull { it.recurrenceId == null }?.addExceptionDates(
ExceptionDates().apply { values.add(ICalDate(Date(occurrenceDate), true)) }
)
}
}
return Biweekly.write(ical).go()
}
}

View File

@ -2,9 +2,12 @@ package com.homelab.ncal.util
import biweekly.util.Frequency
import biweekly.util.Recurrence
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAdjusters
/**
* Minimal RRULE evaluator. CalDAV servers return the *master* VEVENT/VTODO for a
@ -13,21 +16,32 @@ import java.time.ZonedDateTime
* yearly birthday would show once, forever, on the date it was first created.
*
* This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
* and UNTIL. It intentionally does not implement BYDAY/BYMONTH/BYSETPOS/etc - those cover
* a small minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday").
* Anything using those rule parts still round-trips correctly on save/edit (the raw ICS
* is preserved via [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be
* *re-dated* for display, and will show at its original occurrence like before.
* UNTIL, (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), and EXDATE (single-occurrence
* deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It intentionally
* does not implement BYMONTH/BYMONTHDAY/BYSETPOS/ordinaled BYDAY/etc - those cover a small
* minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday"). Anything using
* those rule parts still round-trips correctly on save/edit (the raw ICS is preserved via
* [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be *re-dated* for
* display, and will show at its original occurrence like before.
*/
object RecurrenceUtils {
private const val MAX_ITERATIONS = 10_000
private const val MAX_BYDAY_DAYS = 3660 // ~10 years of daily steps, an upper bound on the walk-forward search
/**
* Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis],
* or null if the series has already ended (past UNTIL/COUNT) or uses an unsupported rule.
* Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis]
* that isn't in [exceptionDates] (EXDATE - a single occurrence deleted via "delete this
* event only"), or null if the series has already ended (past UNTIL/COUNT) or uses an
* unsupported rule.
*/
fun nextOccurrence(originalStartMillis: Long, recurrence: Recurrence, referenceMillis: Long): Long? {
fun nextOccurrence(
originalStartMillis: Long,
recurrence: Recurrence,
referenceMillis: Long,
exceptionDates: Set<Long> = emptySet()
): Long? {
val zone = ZoneId.systemDefault()
val freq = recurrence.frequency ?: return null
if (freq !in SUPPORTED_FREQUENCIES) return originalStartMillis // fall back to showing the master as-is
@ -38,14 +52,27 @@ object RecurrenceUtils {
}
val maxCount = recurrence.count
var current = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
val byDayOfWeek: Set<DayOfWeek> = if (freq == Frequency.WEEKLY) {
recurrence.byDay.mapNotNull { it.day?.toJavaDayOfWeek() }.toSet()
} else {
emptySet()
}
val originalStart = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
val reference = Instant.ofEpochMilli(referenceMillis).atZone(zone)
if (!current.isBefore(reference)) return current.toInstant().toEpochMilli()
if (byDayOfWeek.isNotEmpty()) {
return nextWeeklyByDayOccurrence(originalStart, reference, interval, byDayOfWeek, until, maxCount, exceptionDates)
}
var current = originalStart
var occurrenceIndex = 1
var iterations = 0
while (current.isBefore(reference)) {
// Keep advancing while the candidate is either before the reference point, or lands on
// an excluded (EXDATE'd) date - the latter is what makes "delete this occurrence" skip
// straight to the following one instead of getting stuck offering the deleted date back.
while (current.isBefore(reference) || current.toInstant().toEpochMilli() in exceptionDates) {
if (iterations++ > MAX_ITERATIONS) return null
current = when (freq) {
@ -63,5 +90,58 @@ object RecurrenceUtils {
return current.toInstant().toEpochMilli()
}
/**
* Walks forward day-by-day from [originalStart]'s date (RFC 5545 occurrences never precede
* DTSTART, so this is the correct starting point even when DTSTART's own weekday isn't in
* [byDayOfWeek]), counting only days that are both a selected weekday and in a week that's a
* multiple of [interval] weeks from DTSTART's week - matching RFC 5545's WEEKLY+BYDAY+INTERVAL
* semantics (default WKST=MO). [exceptionDates] are counted toward COUNT (they were still
* generated by the rule) but never returned as "the next occurrence".
*/
private fun nextWeeklyByDayOccurrence(
originalStart: ZonedDateTime,
reference: ZonedDateTime,
interval: Int,
byDayOfWeek: Set<DayOfWeek>,
until: ZonedDateTime?,
maxCount: Int?,
exceptionDates: Set<Long>
): Long? {
val startWeek = originalStart.toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
var occurrenceIndex = 0
var day = originalStart.toLocalDate()
repeat(MAX_BYDAY_DAYS) {
if (day.dayOfWeek in byDayOfWeek) {
val dayWeek = day.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val weeksFromStart = ChronoUnit.WEEKS.between(startWeek, dayWeek)
if (weeksFromStart % interval == 0L) {
occurrenceIndex++
if (maxCount != null && occurrenceIndex > maxCount) return null
val occurrence = originalStart.withLocalDate(day)
if (until != null && occurrence.isAfter(until)) return null
val occurrenceMillis = occurrence.toInstant().toEpochMilli()
if (occurrenceMillis !in exceptionDates && !occurrence.isBefore(reference)) return occurrenceMillis
}
}
day = day.plusDays(1)
}
return null
}
private fun ZonedDateTime.withLocalDate(date: java.time.LocalDate): ZonedDateTime =
date.atTime(toLocalTime()).atZone(zone)
private fun biweekly.util.DayOfWeek.toJavaDayOfWeek(): DayOfWeek? = when (this) {
biweekly.util.DayOfWeek.MONDAY -> DayOfWeek.MONDAY
biweekly.util.DayOfWeek.TUESDAY -> DayOfWeek.TUESDAY
biweekly.util.DayOfWeek.WEDNESDAY -> DayOfWeek.WEDNESDAY
biweekly.util.DayOfWeek.THURSDAY -> DayOfWeek.THURSDAY
biweekly.util.DayOfWeek.FRIDAY -> DayOfWeek.FRIDAY
biweekly.util.DayOfWeek.SATURDAY -> DayOfWeek.SATURDAY
biweekly.util.DayOfWeek.SUNDAY -> DayOfWeek.SUNDAY
else -> null
}
private val SUPPORTED_FREQUENCIES = setOf(Frequency.DAILY, Frequency.WEEKLY, Frequency.MONTHLY, Frequency.YEARLY)
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>

View File

@ -0,0 +1,77 @@
package com.homelab.ncal.data.network
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.IOException
/**
* The offline-save/delete fallback in NextcloudRepository depends entirely on being able to
* tell "the server rejected this" (CalDavException, has an HTTP status) apart from "there is no
* server to talk to right now" (a plain IOException - UnknownHostException/ConnectException/
* SocketTimeoutException/etc). This locks that distinction in against a real socket, not just an
* assumption about what OkHttp throws.
*/
class CalDavClientTest {
private val server = MockWebServer()
@After
fun tearDown() {
runCatching { server.shutdown() }
}
@Test
fun `an HTTP error response throws CalDavException carrying the status code`() {
server.enqueue(MockResponse().setResponseCode(412).setBody("Precondition Failed"))
server.start()
val client = CalDavClient(server.url("/").toString(), "user", "pass")
val href = server.url("/cal/item1.ics").toString()
try {
client.updateItem(href, "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "\"some-etag\"")
fail("expected CalDavException")
} catch (e: CalDavException) {
assertEquals(412, e.httpCode)
}
}
@Test
fun `a connection failure throws a plain IOException, not CalDavException`() {
server.start()
val href = server.url("/cal/item1.ics").toString()
server.shutdown() // nothing is listening on this port anymore
val client = CalDavClient(href, "user", "pass")
try {
client.updateItem(href, "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "\"some-etag\"")
fail("expected an IOException")
} catch (e: CalDavException) {
fail("a connection failure must NOT be a CalDavException, or the offline-save fallback would never trigger - got httpCode=${e.httpCode}")
} catch (e: IOException) {
assertFalse("must not accidentally be the CalDavException subtype", e is CalDavException)
}
}
@Test
fun `deleteItem also distinguishes connection failure from an HTTP error`() {
server.start()
val href = server.url("/cal/item1.ics").toString()
server.shutdown()
val client = CalDavClient(href, "user", "pass")
try {
client.deleteItem(href, "\"some-etag\"")
fail("expected an IOException")
} catch (e: CalDavException) {
fail("a connection failure must NOT be a CalDavException")
} catch (e: IOException) {
assertTrue(true)
}
}
}

View File

@ -84,14 +84,54 @@ class IcsMapperTest {
}
@Test
fun `unsupported BYDAY-based rule is left completely untouched when the picker fields are null`() {
fun `a plain unordinaled WEEKLY BYDAY rule is representable by the picker`() {
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
assertEquals("a BYDAY-based rule isn't representable by the picker", null, item.recurrenceFrequency)
assertEquals("WEEKLY+BYDAY=MO is a plain rule the picker can represent", RecurrenceFrequency.WEEKLY, item.recurrenceFrequency)
assertEquals(setOf(java.time.DayOfWeek.MONDAY), item.recurrenceByDay)
assertTrue(item.isRecurring)
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
assertTrue("BYDAY must still be present after a round-trip", rebuiltIcs.contains("BYDAY=MO"))
}
@Test
fun `editing which weekdays a WEEKLY BYDAY rule repeats on regenerates the RRULE`() {
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val edited = item.copy(recurrenceByDay = setOf(java.time.DayOfWeek.MONDAY, java.time.DayOfWeek.WEDNESDAY, java.time.DayOfWeek.FRIDAY))
val rebuiltIcs = IcsMapper.toIcs(edited)
assertTrue("RRULE must contain all three selected days", rebuiltIcs.contains("RRULE"))
val rruleLine = rebuiltIcs.lines().first { it.startsWith("RRULE") }
assertTrue(rruleLine.contains("MO"))
assertTrue(rruleLine.contains("WE"))
assertTrue(rruleLine.contains("FR"))
}
@Test
fun `an ordinaled BYDAY like 'the 2nd Monday' is still left completely untouched`() {
val ordinaledIcs = ics(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Nextcloud//Test//EN",
"BEGIN:VEVENT",
"UID:test-event-ordinaled@example.com",
"DTSTAMP:20200106T090000Z",
"DTSTART:20200106T090000Z",
"DTEND:20200106T100000Z",
"SUMMARY:Monthly board meeting",
"RRULE:FREQ=MONTHLY;BYDAY=2MO",
"END:VEVENT",
"END:VCALENDAR"
)
val item = IcsMapper.parse(ordinaledIcs, href = "https://example.com/e6.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
assertEquals("an ordinaled BYDAY isn't representable by the picker", null, item.recurrenceFrequency)
assertTrue("but the item is still recognized as recurring", item.isRecurring)
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
assertTrue("original BYDAY must be preserved verbatim", rebuiltIcs.contains("BYDAY=MO"))
assertTrue("original ordinaled BYDAY must be preserved verbatim", rebuiltIcs.contains("BYDAY=2MO"))
}
@Test
@ -244,4 +284,205 @@ class IcsMapperTest {
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
assertTrue("PRIORITY must reflect the edit", rebuiltIcs.contains("PRIORITY:3"))
}
@Test
fun `parseAllForImport splits a multi-component document into one item each, with fresh UIDs`() {
val multiIcs = ics(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Nextcloud//Test//EN",
"BEGIN:VEVENT",
"UID:imported-event-1@example.com",
"DTSTAMP:20260101T090000Z",
"DTSTART:20260101T090000Z",
"DTEND:20260101T100000Z",
"SUMMARY:Imported Event",
"END:VEVENT",
"BEGIN:VTODO",
"UID:imported-task-1@example.com",
"DTSTAMP:20260102T090000Z",
"DUE:20260102T090000Z",
"SUMMARY:Imported Task",
"STATUS:NEEDS-ACTION",
"END:VTODO",
"END:VCALENDAR"
)
val items = IcsMapper.parseAllForImport(multiIcs, calendarUrl = "https://example.com/target/")
assertEquals(2, items.size)
val event = items.first { it.type == ItemType.EVENT }
val task = items.first { it.type == ItemType.TASK }
assertEquals("Imported Event", event.summary)
assertEquals("Imported Task", task.summary)
assertTrue("imported items must be brand new (no href)", event.isNew && task.isNew)
assertTrue("imported items must get a fresh UID, not the source file's", event.uid != "imported-event-1@example.com")
assertTrue("imported items must get a fresh UID, not the source file's", task.uid != "imported-task-1@example.com")
assertEquals("https://example.com/target/", event.calendarUrl)
}
@Test
fun `parseAllForImport preserves a recurring item's true master date, not a resolved next occurrence`() {
val recurringIcs = ics(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Nextcloud//Test//EN",
"BEGIN:VEVENT",
"UID:imported-recurring@example.com",
"DTSTAMP:20200106T090000Z",
"DTSTART:20200106T090000Z",
"DTEND:20200106T100000Z",
"SUMMARY:Imported Weekly Standup",
"RRULE:FREQ=WEEKLY",
"END:VEVENT",
"END:VCALENDAR"
)
val items = IcsMapper.parseAllForImport(recurringIcs, calendarUrl = "https://example.com/target/")
assertEquals(1, items.size)
val item = items.first()
assertEquals("the recreated series must start on the ORIGINAL anchor date", 1578301200000L, item.start) // 2020-01-06T09:00:00Z
}
@Test
fun `buildCombinedIcs merges multiple cached items into one multi-component document`() {
val eventIcs = ics(
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Nextcloud//Test//EN",
"BEGIN:VEVENT", "UID:e1@example.com", "DTSTAMP:20260101T090000Z",
"DTSTART:20260101T090000Z", "DTEND:20260101T100000Z", "SUMMARY:Event One",
"END:VEVENT", "END:VCALENDAR"
)
val taskIcs = ics(
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Nextcloud//Test//EN",
"BEGIN:VTODO", "UID:t1@example.com", "DTSTAMP:20260101T090000Z",
"DUE:20260101T090000Z", "SUMMARY:Task One", "STATUS:NEEDS-ACTION",
"END:VTODO", "END:VCALENDAR"
)
val event = IcsMapper.parse(eventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val task = IcsMapper.parse(taskIcs, href = "https://example.com/t1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val combined = IcsMapper.buildCombinedIcs(listOf(event, task))
assertTrue(combined.contains("SUMMARY:Event One"))
assertTrue(combined.contains("SUMMARY:Task One"))
assertTrue(combined.contains("BEGIN:VEVENT"))
assertTrue(combined.contains("BEGIN:VTODO"))
}
// ---------- Single-occurrence edits (RECURRENCE-ID overrides + EXDATE) ----------
private fun utc(millis: Long): String {
val fmt = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", java.util.Locale.US)
fmt.timeZone = java.util.TimeZone.getTimeZone("UTC")
return fmt.format(java.util.Date(millis))
}
private val dailyEventIcs = ics(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Nextcloud//Test//EN",
"BEGIN:VEVENT",
"UID:daily-series@example.com",
"DTSTAMP:20200106T090000Z",
"DTSTART:20200106T090000Z",
"DTEND:20200106T100000Z",
"SUMMARY:Daily Standup",
"RRULE:FREQ=DAILY",
"END:VEVENT",
"END:VCALENDAR"
)
@Test
fun `a RECURRENCE-ID override's fields are surfaced for the matching occurrence`() {
val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val occurrenceDate = master.occurrenceDate!!
val withOverride = ics(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Nextcloud//Test//EN",
"BEGIN:VEVENT",
"UID:daily-series@example.com",
"DTSTAMP:20200106T090000Z",
"DTSTART:20200106T090000Z",
"DTEND:20200106T100000Z",
"SUMMARY:Daily Standup",
"RRULE:FREQ=DAILY",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:daily-series@example.com",
"DTSTAMP:20200106T090000Z",
"RECURRENCE-ID:${utc(occurrenceDate)}",
"DTSTART:${utc(occurrenceDate + 3600_000)}", // moved 1 hour later
"DTEND:${utc(occurrenceDate + 7200_000)}",
"SUMMARY:Daily Standup (moved)",
"END:VEVENT",
"END:VCALENDAR"
)
val item = IcsMapper.parse(withOverride, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
assertEquals("the override's summary must be shown, not the master's", "Daily Standup (moved)", item.summary)
assertEquals("the override's own (moved) start must be shown", occurrenceDate + 3600_000, item.start)
assertEquals("occurrenceDate stays anchored to the RRULE slot, not the override's moved time", occurrenceDate, item.occurrenceDate)
assertEquals(RecurrenceFrequency.DAILY, item.recurrenceFrequency)
}
@Test
fun `buildOccurrenceOverrideIcs adds an override without touching the master's RRULE`() {
val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val edited = master.copy(summary = "Daily Standup (just today)", start = master.occurrenceDate!! + 1800_000)
val rebuilt = IcsMapper.buildOccurrenceOverrideIcs(edited)
assertEquals("master RRULE must be untouched", 1, rebuilt.lines().count { it.startsWith("RRULE") })
assertTrue(rebuilt.contains("RECURRENCE-ID"))
assertTrue(rebuilt.contains("SUMMARY:Daily Standup (just today)"))
assertTrue("the master's own summary must still be present, untouched", rebuilt.lines().any { it == "SUMMARY:Daily Standup" })
// Re-parsing confirms the override round-trips and is picked up as the current occurrence.
val reparsed = IcsMapper.parse(rebuilt, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
assertEquals("Daily Standup (just today)", reparsed.summary)
}
@Test
fun `saving this occurrence twice replaces the override instead of duplicating it`() {
val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val firstEdit = master.copy(summary = "First edit")
val onceEdited = IcsMapper.buildOccurrenceOverrideIcs(firstEdit)
val reparsed = IcsMapper.parse(onceEdited, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val secondEdit = reparsed.copy(summary = "Second edit")
val twiceEdited = IcsMapper.buildOccurrenceOverrideIcs(secondEdit)
assertEquals("only one override VEVENT should exist for this occurrence", 2, twiceEdited.lines().count { it.startsWith("BEGIN:VEVENT") })
assertFalse(twiceEdited.contains("First edit"))
assertTrue(twiceEdited.contains("Second edit"))
}
@Test
fun `buildOccurrenceDeletionIcs excludes just that date and the next sync skips to the following occurrence`() {
val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val deleted = IcsMapper.buildOccurrenceDeletionIcs(master)
assertTrue(deleted.contains("EXDATE"))
// the master's RRULE is untouched - only an EXDATE was added alongside it.
assertEquals(1, deleted.lines().count { it.startsWith("RRULE") })
val next = IcsMapper.parse(deleted, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
assertTrue("the next occurrence must be strictly after the deleted one", next.occurrenceDate!! > master.occurrenceDate!!)
}
@Test
fun `deleting an already-overridden occurrence removes the stale override too`() {
val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val overridden = IcsMapper.buildOccurrenceOverrideIcs(master.copy(summary = "Moved just for today"))
val reparsed = IcsMapper.parse(overridden, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
val deleted = IcsMapper.buildOccurrenceDeletionIcs(reparsed)
assertFalse("the override VEVENT must be gone, not just excluded", deleted.contains("Moved just for today"))
assertEquals(1, deleted.lines().count { it.startsWith("BEGIN:VEVENT") })
}
}

View File

@ -0,0 +1,99 @@
package com.homelab.ncal.util
import biweekly.util.DayOfWeek
import biweekly.util.Frequency
import biweekly.util.Recurrence
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* Covers the WEEKLY+BYDAY branch of [RecurrenceUtils.nextOccurrence] added alongside the
* Repeats picker's new day-of-week toggles - a plain period-jump (the pre-existing behavior for
* non-BYDAY rules) is the wrong algorithm for "every Mon/Wed/Fri": it would just keep recurring
* on DTSTART's own single weekday forever, silently ignoring the other selected days.
*/
class RecurrenceUtilsTest {
private val zone = ZoneId.systemDefault()
private fun millis(iso: String): Long = ZonedDateTime.parse(iso).withZoneSameInstant(zone).toInstant().toEpochMilli()
@Test
fun `weekly MonWedFri rule advances to the next selected weekday, not just 7 days later`() {
// DTSTART is a Monday 2024-01-01T09:00. Reference is the following Tuesday - the next
// occurrence should be Wednesday of that same week, not the following Monday.
val start = millis("2024-01-01T09:00:00Z")
val reference = millis("2024-01-02T12:00:00Z") // Tuesday noon
val recurrence = Recurrence.Builder(Frequency.WEEKLY)
.byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
.build()
val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
assertEquals(millis("2024-01-03T09:00:00Z"), next) // Wednesday same week
}
@Test
fun `weekly MonWedFri rule rolls into the following week once the current week is exhausted`() {
val start = millis("2024-01-01T09:00:00Z") // Monday
val reference = millis("2024-01-06T12:00:00Z") // Saturday, past Fri's occurrence
val recurrence = Recurrence.Builder(Frequency.WEEKLY)
.byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
.build()
val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
assertEquals(millis("2024-01-08T09:00:00Z"), next) // next Monday
}
@Test
fun `biweekly BYDAY rule skips the off week entirely`() {
// Every OTHER week, on Mon/Wed. DTSTART Monday 2024-01-01 is week 0 (active).
// Week of 2024-01-08 is week 1 (inactive, skipped). Week of 2024-01-15 is week 2 (active).
val start = millis("2024-01-01T09:00:00Z")
val reference = millis("2024-01-09T00:00:00Z") // during the inactive week
val recurrence = Recurrence.Builder(Frequency.WEEKLY)
.interval(2)
.byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY))
.build()
val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
assertEquals(millis("2024-01-15T09:00:00Z"), next) // Monday of the next ACTIVE week
}
@Test
fun `BYDAY rule respects COUNT even when counting by individual weekday occurrences`() {
// Mon+Wed, COUNT=3: Mon(1), Wed(2), next Mon(3) - then the series is over.
val start = millis("2024-01-01T09:00:00Z") // Monday, occurrence #1
val recurrence = Recurrence.Builder(Frequency.WEEKLY)
.byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY))
.count(3)
.build()
val thirdOccurrence = millis("2024-01-08T09:00:00Z") // occurrence #3 (next Monday)
assertEquals(thirdOccurrence, RecurrenceUtils.nextOccurrence(start, recurrence, thirdOccurrence))
val pastTheEnd = millis("2024-01-08T09:00:01Z")
assertNull("the series only has 3 occurrences", RecurrenceUtils.nextOccurrence(start, recurrence, pastTheEnd))
}
@Test
fun `BYDAY rule starting mid-week only counts days at or after DTSTART`() {
// DTSTART is Wednesday, BYDAY=Mon,Wed,Fri - the Monday of DTSTART's own week must NOT
// count (it's before DTSTART), but Wed and Fri of that same week should.
val start = millis("2024-01-03T09:00:00Z") // Wednesday
val recurrence = Recurrence.Builder(Frequency.WEEKLY)
.byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
.build()
val firstOccurrence = RecurrenceUtils.nextOccurrence(start, recurrence, start)
assertEquals("the first occurrence is DTSTART itself, not the preceding Monday", start, firstOccurrence)
val afterWednesday = millis("2024-01-03T10:00:00Z")
assertEquals(millis("2024-01-05T09:00:00Z"), RecurrenceUtils.nextOccurrence(start, recurrence, afterWednesday)) // Friday
}
}