Fix remaining known limitations: recurrence editing, subtask tree parity, conflict resolution
- Recurrence picker (Daily/Weekly/Monthly/Yearly, interval, end condition) for both events and tasks, backed by a real RRULE. Fixes a latent bug from the prior RRULE-preservation change: editing a recurring item was writing the resolved *next occurrence* back as the master DTSTART, silently shifting the whole series forward on every edit - start/end/due are now correctly read-only for recurring items instead. - Periodic background sync (WorkManager, ~30 min, network-gated) so widgets stay fresh without opening the app; auto-refresh on app open too. - Tasks widget now renders the same parent/child tree as the in-app Tasks screen (util/TaskTree.kt shared by both) instead of a flat due-date list. - Conflict resolution: a stale save (HTTP 412) shows both versions and lets you keep your edit or take theirs, instead of just an error banner. - First test suite for the project (IcsMapperTest, TaskTreeTest).
This commit is contained in:
parent
75f5e6024b
commit
14ee70d71d
|
|
@ -4,3 +4,6 @@
|
|||
# Release signing - never commit these (see ncal/.gitignore for the enforced copy)
|
||||
/ncal/keystore/
|
||||
/ncal/keystore.properties
|
||||
|
||||
# Built APKs don't belong in source control
|
||||
*.apk
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -51,7 +51,14 @@ is the fourth option: one app, one account, one sync engine.
|
|||
- **Subtasks** — assign any task a parent task (stored as the standard iCalendar
|
||||
`RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other
|
||||
CalDAV clients). The parent picker excludes the task itself and its own descendants
|
||||
to prevent cycles.
|
||||
to prevent cycles. The Tasks screen *and* the Tasks widget both render the real
|
||||
parent/child tree (`util/TaskTree.kt` is shared by both).
|
||||
- **Recurrence** — a Repeats picker (Daily/Weekly/Monthly/Yearly, interval, and an end
|
||||
condition of never/after N times/on a date) for both events and tasks, backed by a
|
||||
real iCal `RRULE`.
|
||||
- **Conflict handling** — a stale save (someone else changed or deleted the item since
|
||||
you opened it) surfaces both versions in a dialog and lets you keep your edit or
|
||||
take theirs, instead of just failing with an error.
|
||||
- **Reminders** — add one or more reminders to any event or task (at the scheduled
|
||||
time, or a preset offset before it: 5 min up to 1 week). Reminders fire as real
|
||||
system notifications via `AlarmManager` exact alarms — not a best-effort background
|
||||
|
|
@ -85,10 +92,11 @@ compiles down to plain `RemoteViews`/`AppWidgetProvider` — no Google dependenc
|
|||
each row (color-coded by priority) and tap-to-open on the row itself.
|
||||
|
||||
Both widgets refresh automatically whenever you sync, save, or delete something in
|
||||
the app — no manual refresh needed. Note this means a widget's content is only as
|
||||
fresh as the last time the app itself synced (on open, on manual pull-to-refresh, or
|
||||
after an edit) — the widgets read from the local cache on their own ~30-minute OS
|
||||
redraw timer, they don't independently hit the network.
|
||||
the app, and independently of the app being open at all: a `WorkManager` periodic job
|
||||
(`sync/SyncScheduler.kt`, `sync/SyncWorker.kt`) calls `repository.fullSync()` roughly
|
||||
every 30 minutes whenever the device has network - matching the widgets' own OS-level
|
||||
redraw floor, since syncing more often than a widget can even show an update buys
|
||||
nothing.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -127,24 +135,26 @@ redraw timer, they don't independently hit the network.
|
|||
6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month"
|
||||
and/or "Tasks".
|
||||
|
||||
Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator
|
||||
needed.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Editing an existing recurring item drops its `RRULE`.** Saving rebuilds the
|
||||
`.ics` from the edit form's fields, so an edited recurring item becomes a one-off.
|
||||
Viewing/completing/deleting a recurring item is safe; editing its fields is not,
|
||||
if you want the series to survive the save. `CalendarItem.rawIcs` retains the
|
||||
original document for exactly this reason — wiring `toIcs()` to mutate the parsed
|
||||
original in place is the natural fix if this turns out to matter in practice.
|
||||
- **No recurrence-editing UI** — no picker to *set* a recurrence rule on a new item.
|
||||
- **Subtask tree is app-side only.** The Month view and both widgets show tasks
|
||||
flat/chronological, not nested — only the in-app Tasks screen renders the parent/
|
||||
child tree.
|
||||
- **No background sync while the app is closed.** Sync happens on app open, manual
|
||||
pull-to-refresh, or after an edit — there's no periodic `WorkManager` job calling
|
||||
`repository.fullSync()` yet, so a widget can go stale if you never open the app.
|
||||
- **Last-write-wins conflict handling.** A genuinely stale write fails loudly (a
|
||||
`CalDavException` with the HTTP code) rather than silently clobbering a change made
|
||||
elsewhere — but there's no merge UI, just an error surfaced to the edit screen.
|
||||
- **No recurrence editing for a recurring item's start/end time.** You can set/change
|
||||
*whether* something repeats and how (Daily/Weekly/Monthly/Yearly, interval, end
|
||||
condition) via the Repeats picker, and edit all of a recurring item's other fields
|
||||
freely - but the Starts/Ends/Due date fields become read-only the moment an item is
|
||||
recurring, because [CalendarItem.start]/`end`/`due` hold the *resolved next
|
||||
occurrence* for a recurring item, not the master date, and writing that back would
|
||||
silently shift the whole series forward on every edit. Change the actual start date
|
||||
of a recurring series in Nextcloud's web UI. A rule the picker can't represent
|
||||
(`BYDAY`/`BYMONTH`/etc, or anything outside Daily/Weekly/Monthly/Yearly) is left
|
||||
completely untouched rather than flattened into something simpler. Both guarantees
|
||||
are regression-tested in `IcsMapperTest`.
|
||||
- **Conflict resolution is a two-choice dialog, not a field-by-field merge.** A stale
|
||||
write (HTTP 412 - the item changed or was deleted elsewhere since you opened it)
|
||||
shows both versions and lets you keep your edit or take theirs; there's no way to
|
||||
merge individual fields from each.
|
||||
|
||||
## Where to look first if you want to extend it
|
||||
|
||||
|
|
@ -156,6 +166,10 @@ redraw timer, they don't independently hit the network.
|
|||
- `data/repository/NextcloudRepository.kt` — the sync/cache/reminder/widget
|
||||
orchestration.
|
||||
- `notifications/ReminderScheduler.kt` — reminder alarm scheduling.
|
||||
- `sync/SyncWorker.kt` / `sync/SyncScheduler.kt` — the periodic background sync job.
|
||||
- `util/TaskTree.kt` — the parent/child ordering shared by the Tasks screen and widget.
|
||||
- `util/RecurrenceUtils.kt` — re-dates a recurring item to its next occurrence for display.
|
||||
- `data/repository/SaveConflictException.kt` — the 412-conflict data carried to the edit screen's dialog.
|
||||
- `widget/` — the two Glance home screen widgets.
|
||||
|
||||
## Building a signed release
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
// IcsMapper.parse() logs via android.util.Log, which throws "not mocked" in a plain
|
||||
// JVM unit test otherwise - return defaults instead of pulling in Robolectric just
|
||||
// for that.
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
|
|
@ -107,4 +116,6 @@ dependencies {
|
|||
|
||||
// Encrypts the local Room cache at rest (calendar/task content), same as credentials already are
|
||||
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class NcalApplication : Application() {
|
|||
createReminderNotificationChannel()
|
||||
|
||||
if (repository.isLoggedIn) {
|
||||
repository.scheduleBackgroundSync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems())
|
||||
MonthGridWidget().updateAll(this@NcalApplication)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
|
|||
|
||||
@Database(
|
||||
entities = [CollectionEntity::class, ItemEntity::class],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ data class ItemEntity(
|
|||
val priority: Int,
|
||||
val parentUid: String?,
|
||||
val reminderMinutes: String, // comma-separated minutes-before list, "" if none
|
||||
val recurrenceFrequency: String?, // RecurrenceFrequency name, null if not recurring/unsupported rule
|
||||
val recurrenceInterval: Int,
|
||||
val recurrenceCount: Int?,
|
||||
val recurrenceUntil: Long?,
|
||||
val isRecurring: Boolean,
|
||||
val rawIcs: String?
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,15 @@ enum class ItemType { EVENT, TASK }
|
|||
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
|
||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||
|
||||
/** The RRULE FREQ values the app's recurrence picker and [com.homelab.ncal.util.RecurrenceUtils]
|
||||
* support. Anything else (BYDAY/BYMONTH/etc-based rules, or SECONDLY/MINUTELY/HOURLY) still
|
||||
* round-trips fine via [CalendarItem.rawIcs] - it just isn't editable through the picker. */
|
||||
enum class RecurrenceFrequency { DAILY, WEEKLY, MONTHLY, YEARLY }
|
||||
|
||||
/**
|
||||
* 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
|
||||
* explicitly model (RRULE, alarms, attendees, etc.) survive an edit-and-save cycle.
|
||||
* explicitly model (attendees, X- properties, etc.) survive an edit-and-save cycle.
|
||||
*/
|
||||
data class CalendarItem(
|
||||
val uid: String,
|
||||
|
|
@ -34,6 +39,14 @@ data class CalendarItem(
|
|||
val parentUid: String? = null, // tasks only: UID of the parent task (iCal RELATED-TO)
|
||||
val reminderMinutes: List<Int> = emptyList(), // minutes before start (events) / due (tasks) to notify
|
||||
|
||||
val recurrenceFrequency: RecurrenceFrequency? = null, // null if not recurring, or a rule the picker can't represent
|
||||
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 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, so the edit screen can't let those be edited here
|
||||
|
||||
val rawIcs: String? = null
|
||||
) {
|
||||
val isNew: Boolean get() = href.isEmpty()
|
||||
|
|
|
|||
|
|
@ -166,6 +166,27 @@ class CalDavClient(
|
|||
return executeRawEtag(builder.build(), expectBodyOnError = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current server copy of a single item - used for conflict resolution after a
|
||||
* 412 on [updateItem]. Returns null if the item no longer exists on the server (404), which
|
||||
* is itself meaningful (deleted elsewhere) rather than an error.
|
||||
*/
|
||||
fun getItem(href: String): Pair<String, String?>? {
|
||||
val request = Request.Builder()
|
||||
.url(href)
|
||||
.header("Authorization", authHeader)
|
||||
.get()
|
||||
.build()
|
||||
http.newCall(request).execute().use { resp ->
|
||||
if (resp.code == 404) return null
|
||||
if (!resp.isSuccessful) {
|
||||
throw CalDavException("HTTP ${resp.code} for GET ${request.url}: ${resp.message}", resp.code)
|
||||
}
|
||||
val body = resp.body?.string() ?: return null
|
||||
return body to resp.header("ETag")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(href: String, etag: String?) {
|
||||
val builder = Request.Builder()
|
||||
.url(href)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import com.homelab.ncal.data.db.ItemEntity
|
|||
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.TaskStatus
|
||||
import com.homelab.ncal.data.network.CalDavClient
|
||||
import com.homelab.ncal.data.network.CalDavException
|
||||
import com.homelab.ncal.data.prefs.SecurePrefs
|
||||
import com.homelab.ncal.notifications.ReminderScheduler
|
||||
import com.homelab.ncal.sync.SyncScheduler
|
||||
import com.homelab.ncal.util.IcsMapper
|
||||
import com.homelab.ncal.widget.MonthGridWidget
|
||||
import com.homelab.ncal.widget.NextTasksWidget
|
||||
|
|
@ -45,6 +48,12 @@ class NextcloudRepository(context: Context) {
|
|||
|
||||
fun logout() {
|
||||
prefs.clear()
|
||||
SyncScheduler.cancel(appContext)
|
||||
}
|
||||
|
||||
/** Starts (or confirms) the ~30-minute periodic background sync - see [SyncScheduler]. */
|
||||
fun scheduleBackgroundSync() {
|
||||
SyncScheduler.schedule(appContext)
|
||||
}
|
||||
|
||||
// ---------- Collections ----------
|
||||
|
|
@ -144,17 +153,34 @@ class NextcloudRepository(context: Context) {
|
|||
syncItems()
|
||||
}
|
||||
|
||||
/** Creates or updates an item both on the server and in the local cache. */
|
||||
/**
|
||||
* Creates or updates an item both on the server and in the local cache.
|
||||
*
|
||||
* @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) {
|
||||
val c = client()
|
||||
val ics = IcsMapper.toIcs(item)
|
||||
|
||||
val saved = if (item.isNew) {
|
||||
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
|
||||
item.copy(href = href, etag = etag, rawIcs = ics)
|
||||
} else {
|
||||
val etag = c.updateItem(item.href, ics, item.etag)
|
||||
item.copy(etag = etag, rawIcs = ics)
|
||||
val saved = try {
|
||||
if (item.isNew) {
|
||||
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
|
||||
item.copy(href = href, etag = etag, rawIcs = ics)
|
||||
} else {
|
||||
val etag = c.updateItem(item.href, ics, item.etag)
|
||||
item.copy(etag = etag, rawIcs = ics)
|
||||
}
|
||||
} catch (e: CalDavException) {
|
||||
if (e.httpCode == 412 && !item.isNew) {
|
||||
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)
|
||||
|
|
@ -192,7 +218,10 @@ class NextcloudRepository(context: Context) {
|
|||
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
||||
status = runCatching { TaskStatus.valueOf(status) }.getOrDefault(TaskStatus.NEEDS_ACTION),
|
||||
percentComplete = percentComplete, priority = priority,
|
||||
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(), rawIcs = rawIcs
|
||||
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
|
||||
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
|
||||
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
||||
isRecurring = isRecurring, rawIcs = rawIcs
|
||||
)
|
||||
|
||||
private fun CalendarItem.toEntity() = ItemEntity(
|
||||
|
|
@ -200,7 +229,9 @@ class NextcloudRepository(context: Context) {
|
|||
summary = summary, description = description, location = location, url = url,
|
||||
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
||||
status = status.name, percentComplete = percentComplete,
|
||||
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(), rawIcs = rawIcs
|
||||
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
|
||||
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
|
||||
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, isRecurring = isRecurring, rawIcs = rawIcs
|
||||
)
|
||||
|
||||
private fun String.toMinutesList(): List<Int> =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.homelab.ncal.data.repository
|
||||
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
|
||||
/**
|
||||
* Thrown by [NextcloudRepository.save] when the server rejects a PUT with HTTP 412 (the item was
|
||||
* changed - or deleted - elsewhere since this edit started). Carries both versions so the UI can
|
||||
* offer a real choice instead of just an error banner.
|
||||
*/
|
||||
class SaveConflictException(
|
||||
val localEdit: CalendarItem,
|
||||
val serverVersion: CalendarItem?, // null if the item was deleted on the server
|
||||
cause: Throwable
|
||||
) : Exception(cause)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.homelab.ncal.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Schedules [SyncWorker] to run roughly every 30 minutes - matches the widgets' own OS-level
|
||||
* redraw floor (`updatePeriodMillis`), since syncing more often than a widget can even show an
|
||||
* update buys nothing.
|
||||
*/
|
||||
object SyncScheduler {
|
||||
private const val WORK_NAME = "ncal_periodic_sync"
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(30, TimeUnit.MINUTES)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.homelab.ncal.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.homelab.ncal.NcalApplication
|
||||
|
||||
/** Periodic background sync so widgets/reminders stay fresh even if the app is never opened. */
|
||||
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val repo = (applicationContext as NcalApplication).repository
|
||||
if (!repo.isLoggedIn) return Result.success()
|
||||
return try {
|
||||
repo.fullSync()
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalSyncWorker", "Periodic background sync failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,10 @@ 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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
|
|
@ -45,8 +47,11 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.TaskStatus
|
||||
import com.homelab.ncal.util.priorityColor
|
||||
import com.homelab.ncal.util.priorityLabel
|
||||
|
|
@ -141,15 +146,23 @@ fun ItemEditScreen(
|
|||
|
||||
if (item.type == ItemType.EVENT) {
|
||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
DateTimePicker(
|
||||
millis = item.start,
|
||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||
)
|
||||
if (item.isRecurring) {
|
||||
RecurringDateNotice(item.start)
|
||||
} else {
|
||||
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) {
|
||||
RecurringDateNotice(item.end)
|
||||
} else {
|
||||
DateTimePicker(
|
||||
millis = item.end,
|
||||
onChange = { v -> viewModel.update { it.copy(end = v) } }
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -163,15 +176,23 @@ fun ItemEditScreen(
|
|||
}
|
||||
} else {
|
||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
DateTimePicker(
|
||||
millis = item.start,
|
||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||
)
|
||||
if (item.isRecurring) {
|
||||
RecurringDateNotice(item.start)
|
||||
} else {
|
||||
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) {
|
||||
RecurringDateNotice(item.due)
|
||||
} else {
|
||||
DateTimePicker(
|
||||
millis = item.due,
|
||||
onChange = { v -> viewModel.update { it.copy(due = v) } }
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Status",
|
||||
|
|
@ -220,6 +241,16 @@ fun ItemEditScreen(
|
|||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Repeats",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
RecurrencePicker(
|
||||
item = item,
|
||||
onUpdate = viewModel::update
|
||||
)
|
||||
|
||||
Text(
|
||||
"Reminders",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
|
|
@ -288,6 +319,39 @@ fun ItemEditScreen(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
state.conflict?.let { conflict ->
|
||||
AlertDialog(
|
||||
onDismissRequest = {}, // force an explicit choice - this isn't safely dismissible
|
||||
title = { Text("This ${if (item.type == ItemType.TASK) "task" else "event"} changed elsewhere") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (conflict.serverVersion != null) {
|
||||
"It was edited somewhere else (another device, or Nextcloud's own web UI) since you opened it here."
|
||||
} else {
|
||||
"It was deleted somewhere else since you opened it here."
|
||||
}
|
||||
)
|
||||
Text(
|
||||
"Your version: “${conflict.localEdit.summary}”",
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
conflict.serverVersion?.let {
|
||||
Text("Their version: “${it.summary}”", modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::keepMyChanges) { Text("Keep my changes") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::discardMyChanges) {
|
||||
Text(if (conflict.serverVersion != null) "Use their version" else "Discard mine")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -380,6 +444,175 @@ private fun PriorityPicker(selected: Int, onSelect: (Int) -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecurringDateNotice(millis: Long?) {
|
||||
val display = millis?.let { SimpleDateFormat("EEE, MMM d yyyy h:mm a", Locale.getDefault()).format(Date(it)) } ?: "Not set"
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(display)
|
||||
Text(
|
||||
"This is a recurring item - changing when the series starts isn't supported here yet. Edit it in Nextcloud's web UI if you need to.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val recurrenceFrequencyLabels = linkedMapOf<RecurrenceFrequency?, String>(
|
||||
null to "Does not repeat",
|
||||
RecurrenceFrequency.DAILY to "Daily",
|
||||
RecurrenceFrequency.WEEKLY to "Weekly",
|
||||
RecurrenceFrequency.MONTHLY to "Monthly",
|
||||
RecurrenceFrequency.YEARLY to "Yearly"
|
||||
)
|
||||
|
||||
private fun intervalUnitLabel(freq: RecurrenceFrequency, interval: Int): String {
|
||||
val plural = interval != 1
|
||||
return when (freq) {
|
||||
RecurrenceFrequency.DAILY -> if (plural) "days" else "day"
|
||||
RecurrenceFrequency.WEEKLY -> if (plural) "weeks" else "week"
|
||||
RecurrenceFrequency.MONTHLY -> if (plural) "months" else "month"
|
||||
RecurrenceFrequency.YEARLY -> if (plural) "years" else "year"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RecurrencePicker(
|
||||
item: CalendarItem,
|
||||
onUpdate: ((CalendarItem) -> CalendarItem) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = recurrenceFrequencyLabels[item.recurrenceFrequency] ?: "Does not repeat",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
recurrenceFrequencyLabels.forEach { (freq, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
onUpdate { cur ->
|
||||
if (freq == null) {
|
||||
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null)
|
||||
} else {
|
||||
cur.copy(recurrenceFrequency = freq)
|
||||
}
|
||||
}
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val freq = item.recurrenceFrequency
|
||||
if (freq != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text("Every", modifier = Modifier.padding(end = 8.dp))
|
||||
OutlinedTextField(
|
||||
value = item.recurrenceInterval.toString(),
|
||||
onValueChange = { v ->
|
||||
v.toIntOrNull()?.takeIf { it in 1..999 }?.let { n -> onUpdate { it.copy(recurrenceInterval = n) } }
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(80.dp)
|
||||
)
|
||||
Text(intervalUnitLabel(freq, item.recurrenceInterval), modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
||||
RecurrenceEndPicker(
|
||||
count = item.recurrenceCount,
|
||||
until = item.recurrenceUntil,
|
||||
onNever = { onUpdate { it.copy(recurrenceCount = null, recurrenceUntil = null) } },
|
||||
onAfterCount = { n -> onUpdate { it.copy(recurrenceCount = n, recurrenceUntil = null) } },
|
||||
onOnDate = { millis -> onUpdate { it.copy(recurrenceUntil = millis, recurrenceCount = null) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RecurrenceEndPicker(
|
||||
count: Int?,
|
||||
until: Long?,
|
||||
onNever: () -> Unit,
|
||||
onAfterCount: (Int) -> Unit,
|
||||
onOnDate: (Long?) -> Unit
|
||||
) {
|
||||
val mode = when {
|
||||
count != null -> RecurrenceEndMode.AFTER_COUNT
|
||||
until != null -> RecurrenceEndMode.ON_DATE
|
||||
else -> RecurrenceEndMode.NEVER
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val modeLabel = when (mode) {
|
||||
RecurrenceEndMode.NEVER -> "Never"
|
||||
RecurrenceEndMode.AFTER_COUNT -> "After a number of times"
|
||||
RecurrenceEndMode.ON_DATE -> "On a date"
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = modeLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(text = { Text("Never") }, onClick = { onNever(); expanded = false })
|
||||
DropdownMenuItem(text = { Text("After a number of times") }, onClick = { onAfterCount(count ?: 10); expanded = false })
|
||||
DropdownMenuItem(text = { Text("On a date") }, onClick = { onOnDate(until ?: System.currentTimeMillis()); expanded = false })
|
||||
}
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
RecurrenceEndMode.AFTER_COUNT -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = (count ?: 1).toString(),
|
||||
onValueChange = { v -> v.toIntOrNull()?.takeIf { it > 0 }?.let(onAfterCount) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(80.dp)
|
||||
)
|
||||
Text("time${if ((count ?: 1) != 1) "s" else ""}", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
RecurrenceEndMode.ON_DATE -> {
|
||||
DateTimePicker(millis = until, onChange = onOnDate)
|
||||
}
|
||||
RecurrenceEndMode.NEVER -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLabel(status: TaskStatus): String = when (status) {
|
||||
TaskStatus.NEEDS_ACTION -> "Not started"
|
||||
TaskStatus.IN_PROCESS -> "In progress"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,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.repository.NextcloudRepository
|
||||
import com.homelab.ncal.data.repository.SaveConflictException
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -23,7 +24,8 @@ data class ItemEditUiState(
|
|||
val saving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val done: Boolean = false,
|
||||
val deleted: Boolean = false
|
||||
val deleted: Boolean = false,
|
||||
val conflict: SaveConflictException? = null
|
||||
) {
|
||||
/** Tasks eligible to be set as this task's parent: same calendar, not itself, not one of its own descendants. */
|
||||
val availableParentTasks: List<CalendarItem> get() {
|
||||
|
|
@ -119,6 +121,9 @@ class ItemEditViewModel(
|
|||
try {
|
||||
repo.save(item)
|
||||
_state.value = _state.value.copy(saving = false, done = true)
|
||||
} catch (e: SaveConflictException) {
|
||||
android.util.Log.d("NCalConflict", "save() 412 conflict for href=${item.href}, server version present=${e.serverVersion != null}")
|
||||
_state.value = _state.value.copy(saving = false, conflict = e)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "save() failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
|
||||
|
|
@ -126,6 +131,28 @@ class ItemEditViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
/** Conflict resolution: retry the write with the server's current etag, overwriting whatever
|
||||
* changed there with this edit. */
|
||||
fun keepMyChanges() {
|
||||
val conflict = _state.value.conflict ?: return
|
||||
_state.value = _state.value.copy(
|
||||
conflict = null,
|
||||
item = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
|
||||
)
|
||||
save()
|
||||
}
|
||||
|
||||
/** Conflict resolution: discard this edit and load whatever's on the server now (or leave
|
||||
* the screen entirely if it was deleted there) instead. */
|
||||
fun discardMyChanges() {
|
||||
val conflict = _state.value.conflict ?: return
|
||||
_state.value = if (conflict.serverVersion != null) {
|
||||
_state.value.copy(conflict = null, item = conflict.serverVersion)
|
||||
} else {
|
||||
_state.value.copy(conflict = null, deleted = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val item = _state.value.item ?: return
|
||||
if (item.isNew) return
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
|||
val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://")
|
||||
repo.login(normalized, s.username, s.appPassword)
|
||||
repo.fullSync()
|
||||
repo.scheduleBackgroundSync()
|
||||
_state.value = _state.value.copy(loading = false, success = true)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalLogin", "Login/sync failed", e)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ 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.TaskNode
|
||||
import com.homelab.ncal.util.buildTaskTree
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
|
|
@ -13,9 +15,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A task plus how many levels deep it is nested under its ancestors (0 = top-level). */
|
||||
data class TaskNode(val item: CalendarItem, val depth: Int)
|
||||
|
||||
data class TasksUiState(
|
||||
val tasks: List<TaskNode> = emptyList(),
|
||||
val refreshing: Boolean = false,
|
||||
|
|
@ -34,32 +33,9 @@ class TasksViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
|||
}
|
||||
|
||||
val state: StateFlow<TasksUiState> = combine(tasks, refreshing, error) { taskList, r, e ->
|
||||
TasksUiState(buildTree(taskList), r, e)
|
||||
TasksUiState(buildTaskTree(taskList), r, e)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TasksUiState())
|
||||
|
||||
/** Orders tasks depth-first under their parent (via [CalendarItem.parentUid]), preserving each
|
||||
* level's original sort order. Broken/cyclic parent references fall back to top-level. */
|
||||
private fun buildTree(tasks: List<CalendarItem>): List<TaskNode> {
|
||||
val allUids = tasks.map { it.uid }.toSet()
|
||||
val childrenByParent = tasks.groupBy { it.parentUid }
|
||||
val visited = mutableSetOf<String>()
|
||||
val result = mutableListOf<TaskNode>()
|
||||
|
||||
fun addWithChildren(task: CalendarItem, depth: Int) {
|
||||
if (!visited.add(task.uid)) return
|
||||
result += TaskNode(task, depth)
|
||||
childrenByParent[task.uid].orEmpty().forEach { addWithChildren(it, depth + 1) }
|
||||
}
|
||||
|
||||
tasks.forEach { task ->
|
||||
val isRoot = task.parentUid == null || task.parentUid !in allUids
|
||||
if (isRoot) addWithChildren(task, 0)
|
||||
}
|
||||
tasks.forEach { task -> if (task.uid !in visited) addWithChildren(task, 0) }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun toggle(item: CalendarItem) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.network.CalDavException
|
||||
import com.homelab.ncal.data.repository.SaveConflictException
|
||||
|
||||
/**
|
||||
* Short, user-facing summary for an error banner. [CalDavException] messages carry the full
|
||||
|
|
@ -8,6 +9,13 @@ import com.homelab.ncal.data.network.CalDavException
|
|||
* crash dump if shown directly in the UI) - this collapses that down to one line.
|
||||
*/
|
||||
fun Throwable.toUserMessage(): String = when (this) {
|
||||
// The edit screen has a real conflict-resolution dialog for this; other callers (e.g. the
|
||||
// quick-toggle-complete flows) don't, so they fall back to this summary in their error banner.
|
||||
is SaveConflictException -> if (serverVersion != null) {
|
||||
"Couldn't save - this item was changed elsewhere. Reopen it to see the latest version."
|
||||
} else {
|
||||
"Couldn't save - this item was deleted elsewhere."
|
||||
}
|
||||
is CalDavException -> when (httpCode) {
|
||||
404 -> "Server couldn't find that item - it may have changed or been removed elsewhere."
|
||||
401 -> "Login rejected - your stored credentials may have expired. Try logging in again."
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import biweekly.property.RelatedTo
|
|||
import biweekly.property.Status
|
||||
import biweekly.property.Trigger
|
||||
import biweekly.util.Duration
|
||||
import biweekly.util.Frequency
|
||||
import biweekly.util.Recurrence
|
||||
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.TaskStatus
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
|
@ -33,6 +36,43 @@ 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. */
|
||||
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
|
||||
if (this == null) return null
|
||||
val isSimple = byDay.isEmpty() && byMonth.isEmpty() && byMonthDay.isEmpty() &&
|
||||
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
|
||||
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
|
||||
if (!isSimple) return null
|
||||
return when (frequency) {
|
||||
Frequency.DAILY -> RecurrenceFrequency.DAILY
|
||||
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
|
||||
Frequency.MONTHLY -> RecurrenceFrequency.MONTHLY
|
||||
Frequency.YEARLY -> RecurrenceFrequency.YEARLY
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
* never accidentally overwrites a BYDAY-based rule the picker can't represent. */
|
||||
private fun buildRecurrence(item: CalendarItem): Recurrence? {
|
||||
val freq = item.recurrenceFrequency ?: return null
|
||||
val bwFreq = when (freq) {
|
||||
RecurrenceFrequency.DAILY -> Frequency.DAILY
|
||||
RecurrenceFrequency.WEEKLY -> Frequency.WEEKLY
|
||||
RecurrenceFrequency.MONTHLY -> Frequency.MONTHLY
|
||||
RecurrenceFrequency.YEARLY -> Frequency.YEARLY
|
||||
}
|
||||
val builder = Recurrence.Builder(bwFreq).interval(item.recurrenceInterval.coerceAtLeast(1))
|
||||
item.recurrenceCount?.let { builder.count(it) }
|
||||
item.recurrenceUntil?.let { builder.until(Date(it), true) }
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/** Reads the "minutes before" values out of any duration-relative VALARMs on the component. */
|
||||
private fun alarmMinutesBefore(alarms: List<VAlarm>): List<Int> =
|
||||
alarms.mapNotNull { alarm ->
|
||||
|
|
@ -83,6 +123,11 @@ object IcsMapper {
|
|||
allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
|
||||
priority = ev.priority?.value ?: 0,
|
||||
reminderMinutes = alarmMinutesBefore(ev.alarms),
|
||||
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
||||
recurrenceInterval = recurrence?.interval ?: 1,
|
||||
recurrenceCount = recurrence?.count,
|
||||
recurrenceUntil = recurrence?.until?.time,
|
||||
isRecurring = recurrence != null,
|
||||
rawIcs = ics
|
||||
)
|
||||
}
|
||||
|
|
@ -132,6 +177,11 @@ object IcsMapper {
|
|||
priority = td.priority?.value ?: 0,
|
||||
parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
|
||||
reminderMinutes = alarmMinutesBefore(td.alarms),
|
||||
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
||||
recurrenceInterval = recurrence?.interval ?: 1,
|
||||
recurrenceCount = recurrence?.count,
|
||||
recurrenceUntil = recurrence?.until?.time,
|
||||
isRecurring = recurrence != null,
|
||||
rawIcs = ics
|
||||
)
|
||||
}
|
||||
|
|
@ -140,46 +190,85 @@ object IcsMapper {
|
|||
return null
|
||||
}
|
||||
|
||||
/** Builds a full .ics document ready to PUT to the server for the given item. */
|
||||
/**
|
||||
* Builds a full .ics document ready to PUT to the server for the given item.
|
||||
*
|
||||
* Mutates the original parsed document ([CalendarItem.rawIcs]) in place rather than
|
||||
* rebuilding a fresh VEVENT/VTODO from scratch, so properties this app doesn't model -
|
||||
* attendees, X- properties, etc. - survive an edit-and-save cycle instead of being silently
|
||||
* dropped. Only the fields the app actually edits are touched; everything else in the
|
||||
* original document is left untouched. `setXxx(null)` on a biweekly single-cardinality
|
||||
* property removes it, which is what lets a cleared form field (e.g. description) actually
|
||||
* clear the property here too, instead of just leaving the old value in place.
|
||||
*
|
||||
* Recurring items get two extra guards, both load-bearing:
|
||||
* - [CalendarItem.start]/[due]/[end] hold the *resolved next occurrence* for a recurring
|
||||
* item (see [parse]), not the master DTSTART/DTEND/DUE. Writing them back verbatim would
|
||||
* shift the whole series forward on every single edit. So for an item that was *already*
|
||||
* recurring going in, the master date(s) are left exactly as parsed - only a freshly
|
||||
* recurring-for-the-first-time item (or a plain non-recurring one) takes its date from
|
||||
* the model.
|
||||
* - A rule the picker can't fully represent (BYDAY/BYMONTH/etc, or a frequency other than
|
||||
* the 4 simple ones - see [Recurrence.toRecurrenceFrequency]) is left completely alone
|
||||
* whenever the model's `recurrenceFrequency` is null, rather than being replaced by
|
||||
* `buildRecurrence`'s (simpler) reconstruction or cleared outright.
|
||||
*/
|
||||
fun toIcs(item: CalendarItem): String {
|
||||
val ical = ICalendar()
|
||||
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
|
||||
when (item.type) {
|
||||
ItemType.EVENT -> {
|
||||
val ev = VEvent()
|
||||
val ev = ical.events.firstOrNull() ?: VEvent().also { ical.addEvent(it) }
|
||||
val originalRecurrence = ev.recurrenceRule?.value
|
||||
val wasRecurring = originalRecurrence != null
|
||||
|
||||
ev.setUid(item.uid)
|
||||
ev.setSummary(item.summary)
|
||||
if (item.description.isNotBlank()) ev.setDescription(item.description)
|
||||
if (item.location.isNotBlank()) ev.setLocation(item.location)
|
||||
if (item.url.isNotBlank()) ev.setUrl(item.url)
|
||||
item.start?.let { ev.setDateStart(Date(it), !item.allDay) }
|
||||
item.end?.let { ev.setDateEnd(Date(it), !item.allDay) }
|
||||
if (item.priority > 0) ev.setPriority(item.priority)
|
||||
ev.setDescription(item.description.takeIf { it.isNotBlank() })
|
||||
ev.setLocation(item.location.takeIf { it.isNotBlank() })
|
||||
ev.setUrl(item.url.takeIf { it.isNotBlank() })
|
||||
if (!wasRecurring) {
|
||||
ev.setDateStart(item.start?.let { Date(it) }, !item.allDay)
|
||||
ev.setDateEnd(item.end?.let { Date(it) }, !item.allDay)
|
||||
}
|
||||
ev.setPriority(item.priority.takeIf { it > 0 })
|
||||
if (!(originalRecurrence.toRecurrenceFrequency() == null && wasRecurring && item.recurrenceFrequency == null)) {
|
||||
ev.setRecurrenceRule(buildRecurrence(item))
|
||||
}
|
||||
ev.alarms.clear()
|
||||
item.reminderMinutes.forEach { minutes ->
|
||||
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
||||
ev.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary))
|
||||
}
|
||||
ical.addEvent(ev)
|
||||
}
|
||||
ItemType.TASK -> {
|
||||
val td = VTodo()
|
||||
val td = ical.todos.firstOrNull() ?: VTodo().also { ical.addTodo(it) }
|
||||
val originalRecurrence = td.recurrenceRule?.value
|
||||
val wasRecurring = originalRecurrence != null
|
||||
|
||||
td.setUid(item.uid)
|
||||
td.setSummary(item.summary)
|
||||
if (item.description.isNotBlank()) td.setDescription(item.description)
|
||||
if (item.location.isNotBlank()) td.setLocation(item.location)
|
||||
if (item.url.isNotBlank()) td.setUrl(item.url)
|
||||
item.start?.let { td.setDateStart(Date(it)) }
|
||||
item.due?.let { td.setDateDue(Date(it)) }
|
||||
if (item.priority > 0) td.setPriority(item.priority)
|
||||
td.setDescription(item.description.takeIf { it.isNotBlank() })
|
||||
td.setLocation(item.location.takeIf { it.isNotBlank() })
|
||||
td.setUrl(item.url.takeIf { it.isNotBlank() })
|
||||
if (!wasRecurring) {
|
||||
td.setDateStart(item.start?.let { Date(it) })
|
||||
td.setDateDue(item.due?.let { Date(it) })
|
||||
}
|
||||
td.setPriority(item.priority.takeIf { it > 0 })
|
||||
td.setStatus(item.status.toIcsStatus())
|
||||
if (item.percentComplete > 0) td.setPercentComplete(item.percentComplete)
|
||||
td.setPercentComplete(item.percentComplete.takeIf { it > 0 })
|
||||
if (!(originalRecurrence.toRecurrenceFrequency() == null && wasRecurring && item.recurrenceFrequency == null)) {
|
||||
td.setRecurrenceRule(buildRecurrence(item))
|
||||
}
|
||||
td.relatedTo.clear()
|
||||
item.parentUid?.takeIf { it.isNotBlank() }?.let { td.addRelatedTo(RelatedTo(it)) }
|
||||
td.alarms.clear()
|
||||
if (item.due != null) {
|
||||
item.reminderMinutes.forEach { minutes ->
|
||||
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
||||
td.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary))
|
||||
}
|
||||
}
|
||||
ical.addTodo(td)
|
||||
}
|
||||
}
|
||||
return Biweekly.write(ical).go()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
|
||||
/** A task plus how many levels deep it is nested under its ancestors (0 = top-level). */
|
||||
data class TaskNode(val item: CalendarItem, val depth: Int)
|
||||
|
||||
/** Orders tasks depth-first under their parent (via [CalendarItem.parentUid]), preserving each
|
||||
* level's original sort order. Broken/cyclic parent references fall back to top-level. Shared
|
||||
* by the in-app Tasks screen and the "Tasks" home screen widget so both render the same tree. */
|
||||
fun buildTaskTree(tasks: List<CalendarItem>): List<TaskNode> {
|
||||
val allUids = tasks.map { it.uid }.toSet()
|
||||
val childrenByParent = tasks.groupBy { it.parentUid }
|
||||
val visited = mutableSetOf<String>()
|
||||
val result = mutableListOf<TaskNode>()
|
||||
|
||||
fun addWithChildren(task: CalendarItem, depth: Int) {
|
||||
if (!visited.add(task.uid)) return
|
||||
result += TaskNode(task, depth)
|
||||
childrenByParent[task.uid].orEmpty().forEach { addWithChildren(it, depth + 1) }
|
||||
}
|
||||
|
||||
tasks.forEach { task ->
|
||||
val isRoot = task.parentUid == null || task.parentUid !in allUids
|
||||
if (isRoot) addWithChildren(task, 0)
|
||||
}
|
||||
tasks.forEach { task -> if (task.uid !in visited) addWithChildren(task, 0) }
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -36,41 +36,37 @@ import androidx.glance.text.TextStyle
|
|||
import com.homelab.ncal.MainActivity
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.util.TaskNode
|
||||
import com.homelab.ncal.util.buildTaskTree
|
||||
import com.homelab.ncal.util.priorityColor
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
internal val taskHrefKey = ActionParameters.Key<String>("task_href")
|
||||
|
||||
/** Home screen widget listing incomplete tasks, grouped and ordered by due date - the widget
|
||||
* equivalent of the in-app Tasks list, minus the subtask tree (kept flat/chronological here
|
||||
* since the point of a widget is a quick "what's next" glance, not full hierarchy browsing). */
|
||||
/** Home screen widget listing incomplete tasks as a parent/child tree, ordered by due date at
|
||||
* each level - the widget equivalent of the in-app Tasks list ([buildTaskTree] is shared by
|
||||
* both, so they always render the same tree). Each row still shows its own due time inline. */
|
||||
class NextTasksWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val repo = (context.applicationContext as NcalApplication).repository
|
||||
val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url }
|
||||
val zone = ZoneId.systemDefault()
|
||||
|
||||
val groups = repo.observeTasks(urls).first()
|
||||
.filter { !it.completed }
|
||||
.sortedWith(compareBy({ it.due == null }, { it.due }))
|
||||
.groupBy { it.due?.let { d -> Instant.ofEpochMilli(d).atZone(zone).toLocalDate() } }
|
||||
.toList()
|
||||
.sortedWith(compareBy(nullsLast()) { it.first })
|
||||
// observeTasks() already orders by due date, which buildTaskTree preserves at each level.
|
||||
val nodes = buildTaskTree(repo.observeTasks(urls).first().filter { !it.completed })
|
||||
|
||||
provideContent {
|
||||
NextTasksContent(groups)
|
||||
NextTasksContent(nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>) {
|
||||
private fun NextTasksContent(nodes: List<TaskNode>) {
|
||||
val context = LocalContext.current
|
||||
GlanceTheme {
|
||||
Column(
|
||||
|
|
@ -85,30 +81,15 @@ private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>)
|
|||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp),
|
||||
modifier = GlanceModifier.padding(bottom = 4.dp)
|
||||
)
|
||||
if (groups.isEmpty()) {
|
||||
if (nodes.isEmpty()) {
|
||||
Text(
|
||||
"Nothing due",
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
groups.forEach { (date, tasksForDate) ->
|
||||
item(itemId = date?.toEpochDay() ?: -1L) {
|
||||
Text(
|
||||
text = date?.let {
|
||||
it.format(DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()))
|
||||
} ?: "No due date",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
modifier = GlanceModifier.padding(top = 6.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
items(tasksForDate, itemId = { it.href.hashCode().toLong() }) { task ->
|
||||
TaskRow(task)
|
||||
}
|
||||
items(nodes, itemId = { it.item.href.hashCode().toLong() }) { node ->
|
||||
TaskRow(node.item, node.depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +98,7 @@ private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>)
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(item: CalendarItem) {
|
||||
private fun TaskRow(item: CalendarItem, depth: Int) {
|
||||
val context = LocalContext.current
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name)
|
||||
|
|
@ -129,7 +110,7 @@ private fun TaskRow(item: CalendarItem) {
|
|||
.fillMaxWidth()
|
||||
.background(GlanceTheme.colors.background)
|
||||
.clickable(actionStartActivity(intent))
|
||||
.padding(vertical = 3.dp),
|
||||
.padding(start = (depth * 12).dp, top = 3.dp, bottom = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.data.model.RecurrenceFrequency
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression coverage for the "editing a recurring item drops its RRULE" bug: toIcs() used to
|
||||
* always rebuild a fresh VEVENT/VTODO from the app's modeled fields, silently discarding RRULE,
|
||||
* attendees, and any other property the app doesn't model. It now mutates the originally-parsed
|
||||
* document in place instead.
|
||||
*/
|
||||
class IcsMapperTest {
|
||||
|
||||
private fun ics(vararg lines: String) = lines.joinToString("\r\n") + "\r\n"
|
||||
|
||||
private val recurringEventIcs = ics(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Nextcloud//Test//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:test-event-1@example.com",
|
||||
"DTSTAMP:20200106T090000Z",
|
||||
"DTSTART:20200106T090000Z",
|
||||
"DTEND:20200106T100000Z",
|
||||
"SUMMARY:Weekly Standup",
|
||||
"DESCRIPTION:Team sync",
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=MO",
|
||||
"ATTENDEE:mailto:someone@example.com",
|
||||
"X-CUSTOM-PROP:keep-me",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event preserves its RRULE and unmodeled properties`() {
|
||||
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = "\"abc\"", calendarUrl = "https://example.com/cal/")
|
||||
assertNotNull("parse() should resolve an ongoing weekly recurrence", item)
|
||||
|
||||
// Simulate a real edit made through the app's edit form.
|
||||
val edited = item!!.copy(summary = "Weekly Standup (renamed)")
|
||||
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||
|
||||
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||
assertTrue("unmodeled ATTENDEE property must survive the edit", rebuiltIcs.contains("ATTENDEE"))
|
||||
assertTrue("unmodeled X- property must survive the edit", rebuiltIcs.contains("X-CUSTOM-PROP"))
|
||||
assertTrue("edited summary must be applied", rebuiltIcs.contains("Weekly Standup (renamed)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event does NOT shift its master DTSTART to the resolved next occurrence`() {
|
||||
// item.start after parse() is the *resolved next occurrence* (could be years after the
|
||||
// original 2020-01-06 anchor by the time this test runs) - saving it back verbatim would
|
||||
// silently shift the whole series forward on every single edit. Regression test for
|
||||
// exactly that bug.
|
||||
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||
assertTrue("resolved start should have moved past the 2020 anchor", item.start!! > 1578297600000L) // 2020-01-06
|
||||
|
||||
val edited = item.copy(summary = "renamed")
|
||||
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||
|
||||
assertTrue("master DTSTART must stay exactly as originally authored", rebuiltIcs.contains("DTSTART:20200106T090000Z"))
|
||||
assertTrue("master DTEND must stay exactly as originally authored", rebuiltIcs.contains("DTEND:20200106T100000Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported BYDAY-based rule is left completely untouched when the picker fields are null`() {
|
||||
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)
|
||||
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"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a simple weekly recurrence via the picker to a previously non-recurring event`() {
|
||||
val plainEventIcs = ics(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Nextcloud//Test//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:test-event-3@example.com",
|
||||
"DTSTAMP:20260101T090000Z",
|
||||
"DTSTART:20260101T090000Z",
|
||||
"DTEND:20260101T100000Z",
|
||||
"SUMMARY:One-off meeting",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
val item = IcsMapper.parse(plainEventIcs, href = "https://example.com/e3.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||
assertFalse(item.isRecurring)
|
||||
|
||||
val madeRecurring = item.copy(recurrenceFrequency = RecurrenceFrequency.WEEKLY, recurrenceInterval = 2, recurrenceCount = 5)
|
||||
val rebuiltIcs = IcsMapper.toIcs(madeRecurring)
|
||||
|
||||
assertTrue(rebuiltIcs.contains("RRULE"))
|
||||
assertTrue(rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||
assertTrue(rebuiltIcs.contains("INTERVAL=2"))
|
||||
assertTrue(rebuiltIcs.contains("COUNT=5"))
|
||||
// a brand new recurrence has no prior master date to preserve - the model's date applies.
|
||||
assertTrue(rebuiltIcs.contains("DTSTART:20260101T090000Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing recurrence interval on a simple recurring event updates RRULE and keeps master DTSTART`() {
|
||||
val simpleRecurringIcs = ics(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Nextcloud//Test//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:test-event-4@example.com",
|
||||
"DTSTAMP:20200106T090000Z",
|
||||
"DTSTART:20200106T090000Z",
|
||||
"DTEND:20200106T100000Z",
|
||||
"SUMMARY:Plain weekly sync",
|
||||
"RRULE:FREQ=WEEKLY",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
val item = IcsMapper.parse(simpleRecurringIcs, href = "https://example.com/e4.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||
assertEquals(RecurrenceFrequency.WEEKLY, item.recurrenceFrequency)
|
||||
|
||||
val edited = item.copy(recurrenceInterval = 3)
|
||||
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||
|
||||
assertTrue(rebuiltIcs.contains("INTERVAL=3"))
|
||||
assertTrue("master DTSTART must stay exactly as originally authored", rebuiltIcs.contains("DTSTART:20200106T090000Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing recurrence via the picker on a simple recurring event removes the RRULE`() {
|
||||
val simpleRecurringIcs = ics(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Nextcloud//Test//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:test-event-5@example.com",
|
||||
"DTSTAMP:20200106T090000Z",
|
||||
"DTSTART:20200106T090000Z",
|
||||
"DTEND:20200106T100000Z",
|
||||
"SUMMARY:Plain weekly sync",
|
||||
"RRULE:FREQ=WEEKLY",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
val item = IcsMapper.parse(simpleRecurringIcs, href = "https://example.com/e5.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||
|
||||
val stoppedRepeating = item.copy(recurrenceFrequency = null)
|
||||
val rebuiltIcs = IcsMapper.toIcs(stoppedRepeating)
|
||||
|
||||
assertFalse("RRULE must be removed once the picker is set back to 'Does not repeat'", rebuiltIcs.contains("RRULE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing a field on edit actually removes the property, not just leaves it unset`() {
|
||||
val plainEventIcs = ics(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Nextcloud//Test//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:test-event-2@example.com",
|
||||
"DTSTAMP:20260101T090000Z",
|
||||
"DTSTART:20260101T090000Z",
|
||||
"DTEND:20260101T100000Z",
|
||||
"SUMMARY:One-off meeting",
|
||||
"DESCRIPTION:Has a description",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
|
||||
val item = IcsMapper.parse(plainEventIcs, href = "https://example.com/e2.ics", etag = null, calendarUrl = "https://example.com/cal/")
|
||||
assertNotNull(item)
|
||||
assertEquals("Has a description", item!!.description)
|
||||
|
||||
val cleared = item.copy(description = "")
|
||||
val rebuiltIcs = IcsMapper.toIcs(cleared)
|
||||
|
||||
assertFalse("DESCRIPTION property must be removed when the field is cleared", rebuiltIcs.contains("DESCRIPTION"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a brand new item with no rawIcs still builds a fresh document`() {
|
||||
val item = CalendarItem(
|
||||
uid = "new-uid",
|
||||
href = "",
|
||||
etag = null,
|
||||
calendarUrl = "https://example.com/cal/",
|
||||
type = ItemType.EVENT,
|
||||
summary = "Brand new event",
|
||||
start = 1750000000000L,
|
||||
end = 1750003600000L
|
||||
)
|
||||
val builtIcs = IcsMapper.toIcs(item)
|
||||
|
||||
assertTrue(builtIcs.contains("BEGIN:VEVENT"))
|
||||
assertTrue(builtIcs.contains("SUMMARY:Brand new event"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a task preserves its RRULE too`() {
|
||||
val recurringTaskIcs = ics(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Nextcloud//Test//EN",
|
||||
"BEGIN:VTODO",
|
||||
"UID:test-task-1@example.com",
|
||||
"DTSTAMP:20200106T090000Z",
|
||||
"DUE:20200106T090000Z",
|
||||
"SUMMARY:Take out recycling",
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=TU",
|
||||
"STATUS:NEEDS-ACTION",
|
||||
"END:VTODO",
|
||||
"END:VCALENDAR"
|
||||
)
|
||||
|
||||
val item = IcsMapper.parse(recurringTaskIcs, href = "https://example.com/t1.ics", etag = null, calendarUrl = "https://example.com/cal/")
|
||||
assertNotNull("parse() should resolve an ongoing weekly recurrence", item)
|
||||
|
||||
val edited = item!!.copy(priority = 3)
|
||||
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||
|
||||
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||
assertTrue("PRIORITY must reflect the edit", rebuiltIcs.contains("PRIORITY:3"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class TaskTreeTest {
|
||||
|
||||
private fun task(uid: String, parentUid: String? = null) = CalendarItem(
|
||||
uid = uid,
|
||||
href = "https://example.com/$uid.ics",
|
||||
etag = null,
|
||||
calendarUrl = "https://example.com/cal/",
|
||||
type = ItemType.TASK,
|
||||
summary = uid,
|
||||
parentUid = parentUid
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `children are ordered depth-first right after their parent`() {
|
||||
val tasks = listOf(
|
||||
task("root-1"),
|
||||
task("child-1a", parentUid = "root-1"),
|
||||
task("root-2"),
|
||||
task("child-1b", parentUid = "root-1"),
|
||||
task("grandchild", parentUid = "child-1a")
|
||||
)
|
||||
|
||||
val nodes = buildTaskTree(tasks)
|
||||
|
||||
assertEquals(listOf("root-1", "child-1a", "grandchild", "child-1b", "root-2"), nodes.map { it.item.uid })
|
||||
assertEquals(listOf(0, 1, 2, 1, 0), nodes.map { it.depth })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parentUid pointing at a task that doesn't exist falls back to top-level`() {
|
||||
val tasks = listOf(task("orphan", parentUid = "does-not-exist"))
|
||||
|
||||
val nodes = buildTaskTree(tasks)
|
||||
|
||||
assertEquals(listOf("orphan"), nodes.map { it.item.uid })
|
||||
assertEquals(listOf(0), nodes.map { it.depth })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cyclic parent reference doesn't infinite-loop and still surfaces every task once`() {
|
||||
val tasks = listOf(
|
||||
task("a", parentUid = "b"),
|
||||
task("b", parentUid = "a")
|
||||
)
|
||||
|
||||
val nodes = buildTaskTree(tasks)
|
||||
|
||||
assertEquals(setOf("a", "b"), nodes.map { it.item.uid }.toSet())
|
||||
assertEquals(2, nodes.size)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue