Fix the two remaining known limitations: recurring item date editing, field-by-field conflict merge

- CalendarItem gains recurrenceMasterStart/End/Due, the true unresolved
  master date, tracked separately from start/end/due (which hold the
  resolved next occurrence for a recurring item). The edit screen now shows
  and edits the real master date for recurring items instead of disabling
  those fields outright, with a note that the change applies to the whole
  series. Verified against a real recurring event on-device.
- New util/ConflictDiff.kt computes exactly which user-editable fields
  differ between a local edit and the server's version after a 412. The
  conflict dialog is now a per-field mine/theirs picker (with "All
  mine"/"All theirs" shortcuts) instead of an all-or-nothing choice.
- Extracted statusLabel() to util/StatusLabels.kt so both the edit screen
  and the new conflict diff can share it.
- Two more test files (ConflictDiffTest, extended IcsMapperTest).
This commit is contained in:
RomanNum3ral 2026-07-17 08:51:35 -04:00
parent 14ee70d71d
commit 79cc6925e8
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
12 changed files with 413 additions and 83 deletions

View File

@ -57,8 +57,9 @@ is the fourth option: one app, one account, one sync engine.
condition of never/after N times/on a date) for both events and tasks, backed by a condition of never/after N times/on a date) for both events and tasks, backed by a
real iCal `RRULE`. real iCal `RRULE`.
- **Conflict handling** — a stale save (someone else changed or deleted the item since - **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 you opened it) surfaces every field that actually differs and lets you pick, per
take theirs, instead of just failing with an error. field, whether your edit or the server's current value wins ("All mine"/"All
theirs" shortcuts too), instead of just failing with an error.
- **Reminders** — add one or more reminders to any event or task (at the scheduled - **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 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 system notifications via `AlarmManager` exact alarms — not a best-effort background
@ -75,7 +76,7 @@ is the fourth option: one app, one account, one sync engine.
Nextcloud's own sidebar. Nextcloud's own sidebar.
- **Recurring events and tasks**`RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with - **Recurring events and tasks**`RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with
`INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its `INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its
next upcoming occurrence. See "Known limitations" below for what this doesn't cover. next upcoming occurrence. See "Editing recurring items" below for what this doesn't cover.
## Home screen widgets ## Home screen widgets
@ -138,23 +139,21 @@ nothing.
Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator
needed. needed.
## Known limitations ## Editing recurring items
- **No recurrence editing for a recurring item's start/end time.** You can set/change Recurring items get two guarantees, both regression-tested in `IcsMapperTest`:
*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 - **Editing the series' actual start/end/due time is safe.** [CalendarItem.start]/`end`/
freely - but the Starts/Ends/Due date fields become read-only the moment an item is `due` hold the *resolved next occurrence* for a recurring item (what every list/grid
recurring, because [CalendarItem.start]/`end`/`due` hold the *resolved next displays), not the master date - so the edit screen shows/edits a separate
occurrence* for a recurring item, not the master date, and writing that back would `recurrenceMasterStart`/`End`/`Due` for a recurring item instead, with a note that
silently shift the whole series forward on every edit. Change the actual start date the change applies to the whole series. Editing them writes the true master
of a recurring series in Nextcloud's web UI. A rule the picker can't represent `DTSTART`/`DTEND`/`DUE`; leaving them untouched round-trips the original value
(`BYDAY`/`BYMONTH`/etc, or anything outside Daily/Weekly/Monthly/Yearly) is left exactly, so an unrelated edit (e.g. just the title) can never silently shift a
completely untouched rather than flattened into something simpler. Both guarantees series forward the way it used to.
are regression-tested in `IcsMapperTest`. - **A rule the Repeats picker can't represent is left completely alone.** `BYDAY`/
- **Conflict resolution is a two-choice dialog, not a field-by-field merge.** A stale `BYMONTH`/etc-based rules (or anything outside Daily/Weekly/Monthly/Yearly) show as
write (HTTP 412 - the item changed or was deleted elsewhere since you opened it) "Does not repeat" in the picker but are never flattened or overwritten by 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 ## Where to look first if you want to extend it
@ -170,6 +169,7 @@ needed.
- `util/TaskTree.kt` — the parent/child ordering shared by the Tasks screen and widget. - `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. - `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. - `data/repository/SaveConflictException.kt` — the 412-conflict data carried to the edit screen's dialog.
- `util/ConflictDiff.kt` — computes which fields actually differ for the merge dialog.
- `widget/` — the two Glance home screen widgets. - `widget/` — the two Glance home screen widgets.
## Building a signed release ## Building a signed release

View File

@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
@Database( @Database(
entities = [CollectionEntity::class, ItemEntity::class], entities = [CollectionEntity::class, ItemEntity::class],
version = 5, version = 6,
exportSchema = false exportSchema = false
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {

View File

@ -33,6 +33,9 @@ data class ItemEntity(
val recurrenceCount: Int?, val recurrenceCount: Int?,
val recurrenceUntil: Long?, val recurrenceUntil: Long?,
val isRecurring: Boolean, val isRecurring: Boolean,
val recurrenceMasterStart: Long?,
val recurrenceMasterEnd: Long?,
val recurrenceMasterDue: Long?,
val rawIcs: String? val rawIcs: String?
) )

View File

@ -45,7 +45,15 @@ data class CalendarItem(
val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount
val isRecurring: Boolean = false, // true for ANY RRULE, even ones recurrenceFrequency can't represent - 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 // [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 // the master date - see recurrenceMaster* below for that
// The *true*, unresolved master DTSTART/DTEND/DUE for a recurring item - only meaningful
// when isRecurring. The edit screen shows/edits these instead of start/end/due for a
// recurring item, so editing the series' actual start time is possible without the bug
// where saving would've overwritten the master date with the resolved next occurrence.
val recurrenceMasterStart: Long? = null,
val recurrenceMasterEnd: Long? = null, // events only
val recurrenceMasterDue: Long? = null, // tasks only
val rawIcs: String? = null val rawIcs: String? = null
) { ) {

View File

@ -221,7 +221,9 @@ class NextcloudRepository(context: Context) {
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(), parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() }, recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
isRecurring = isRecurring, rawIcs = rawIcs isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
rawIcs = rawIcs
) )
private fun CalendarItem.toEntity() = ItemEntity( private fun CalendarItem.toEntity() = ItemEntity(
@ -231,7 +233,9 @@ class NextcloudRepository(context: Context) {
status = status.name, percentComplete = percentComplete, status = status.name, percentComplete = percentComplete,
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(), priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval, recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, isRecurring = isRecurring, rawIcs = rawIcs recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
rawIcs = rawIcs
) )
private fun String.toMinutesList(): List<Int> = private fun String.toMinutesList(): List<Int> =

View File

@ -32,6 +32,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -55,6 +56,7 @@ import com.homelab.ncal.data.model.RecurrenceFrequency
import com.homelab.ncal.data.model.TaskStatus import com.homelab.ncal.data.model.TaskStatus
import com.homelab.ncal.util.priorityColor import com.homelab.ncal.util.priorityColor
import com.homelab.ncal.util.priorityLabel import com.homelab.ncal.util.priorityLabel
import com.homelab.ncal.util.statusLabel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@ -147,7 +149,10 @@ fun ItemEditScreen(
if (item.type == ItemType.EVENT) { if (item.type == ItemType.EVENT) {
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp)) Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
if (item.isRecurring) { if (item.isRecurring) {
RecurringDateNotice(item.start) RecurringDateTimePicker(
millis = item.recurrenceMasterStart,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
)
} else { } else {
DateTimePicker( DateTimePicker(
millis = item.start, millis = item.start,
@ -156,7 +161,10 @@ fun ItemEditScreen(
} }
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
if (item.isRecurring) { if (item.isRecurring) {
RecurringDateNotice(item.end) RecurringDateTimePicker(
millis = item.recurrenceMasterEnd,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterEnd = v) } }
)
} else { } else {
DateTimePicker( DateTimePicker(
millis = item.end, millis = item.end,
@ -177,7 +185,10 @@ fun ItemEditScreen(
} else { } else {
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp)) Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
if (item.isRecurring) { if (item.isRecurring) {
RecurringDateNotice(item.start) RecurringDateTimePicker(
millis = item.recurrenceMasterStart,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
)
} else { } else {
DateTimePicker( DateTimePicker(
millis = item.start, millis = item.start,
@ -186,7 +197,10 @@ fun ItemEditScreen(
} }
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
if (item.isRecurring) { if (item.isRecurring) {
RecurringDateNotice(item.due) RecurringDateTimePicker(
millis = item.recurrenceMasterDue,
onChange = { v -> viewModel.update { it.copy(recurrenceMasterDue = v) } }
)
} else { } else {
DateTimePicker( DateTimePicker(
millis = item.due, millis = item.due,
@ -321,40 +335,102 @@ fun ItemEditScreen(
} }
state.conflict?.let { conflict -> state.conflict?.let { conflict ->
AlertDialog( if (conflict.serverVersion != null && state.conflictFields.isNotEmpty()) {
onDismissRequest = {}, // force an explicit choice - this isn't safely dismissible ConflictMergeDialog(
title = { Text("This ${if (item.type == ItemType.TASK) "task" else "event"} changed elsewhere") }, itemTypeLabel = if (item.type == ItemType.TASK) "task" else "event",
text = { fields = state.conflictFields,
Column { choices = state.conflictChoices,
Text( onChoiceChange = viewModel::setConflictFieldChoice,
if (conflict.serverVersion != null) { onSetAll = viewModel::setAllConflictFields,
"It was edited somewhere else (another device, or Nextcloud's own web UI) since you opened it here." onConfirm = viewModel::resolveConflictMerge
} else { )
"It was deleted somewhere else since you opened it here." } else {
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, though nothing you can edit here actually differs."
} 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))
} }
) }
Text( },
"Your version: “${conflict.localEdit.summary}", confirmButton = {
modifier = Modifier.padding(top = 12.dp) TextButton(onClick = viewModel::keepMyChanges) { Text("Keep my changes") }
) },
conflict.serverVersion?.let { dismissButton = {
Text("Their version: “${it.summary}", modifier = Modifier.padding(top = 4.dp)) TextButton(onClick = viewModel::discardMyChanges) {
Text(if (conflict.serverVersion != null) "Use their version" else "Discard mine")
} }
} }
}, )
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")
}
}
)
} }
} }
} }
@Composable
private fun ConflictMergeDialog(
itemTypeLabel: String,
fields: List<com.homelab.ncal.util.ConflictField>,
choices: Map<String, Boolean>,
onChoiceChange: (key: String, takeTheirs: Boolean) -> Unit,
onSetAll: (takeTheirs: Boolean) -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = {}, // force an explicit choice - this isn't safely dismissible
title = { Text("This $itemTypeLabel changed elsewhere") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text("It was edited somewhere else since you opened it here. Pick which version wins for each field that differs:")
Row(modifier = Modifier.padding(top = 8.dp)) {
TextButton(onClick = { onSetAll(false) }) { Text("All mine") }
TextButton(onClick = { onSetAll(true) }) { Text("All theirs") }
}
fields.forEach { field ->
val takeTheirs = choices[field.key] == true
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(field.label, style = MaterialTheme.typography.labelLarge)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onChoiceChange(field.key, false) }
) {
RadioButton(selected = !takeTheirs, onClick = { onChoiceChange(field.key, false) })
Text("Mine: ${field.mineDisplay}")
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onChoiceChange(field.key, true) }
) {
RadioButton(selected = takeTheirs, onClick = { onChoiceChange(field.key, true) })
Text("Theirs: ${field.theirsDisplay}")
}
}
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text("Save merged version") }
}
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CalendarPicker( private fun CalendarPicker(
@ -444,15 +520,18 @@ 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 @Composable
private fun RecurringDateNotice(millis: Long?) { private fun RecurringDateTimePicker(millis: Long?, onChange: (Long?) -> Unit) {
val display = millis?.let { SimpleDateFormat("EEE, MMM d yyyy h:mm a", Locale.getDefault()).format(Date(it)) } ?: "Not set" Column {
Column(modifier = Modifier.padding(vertical = 8.dp)) { DateTimePicker(millis = millis, onChange = onChange)
Text(display)
Text( 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.", "This changes the whole series, not just one occurrence.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp)
) )
} }
} }
@ -613,12 +692,6 @@ private fun RecurrenceEndPicker(
} }
} }
private fun statusLabel(status: TaskStatus): String = when (status) {
TaskStatus.NEEDS_ACTION -> "Not started"
TaskStatus.IN_PROCESS -> "In progress"
TaskStatus.COMPLETED -> "Completed"
TaskStatus.CANCELLED -> "Cancelled"
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@ -8,6 +8,8 @@ import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType import com.homelab.ncal.data.model.ItemType
import com.homelab.ncal.data.repository.NextcloudRepository import com.homelab.ncal.data.repository.NextcloudRepository
import com.homelab.ncal.data.repository.SaveConflictException import com.homelab.ncal.data.repository.SaveConflictException
import com.homelab.ncal.util.ConflictField
import com.homelab.ncal.util.diffConflictFields
import com.homelab.ncal.util.toUserMessage import com.homelab.ncal.util.toUserMessage
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -25,7 +27,9 @@ data class ItemEditUiState(
val error: String? = null, val error: String? = null,
val done: Boolean = false, val done: Boolean = false,
val deleted: Boolean = false, val deleted: Boolean = false,
val conflict: SaveConflictException? = null val conflict: SaveConflictException? = null,
val conflictFields: List<ConflictField> = emptyList(),
val conflictChoices: Map<String, Boolean> = emptyMap() // field key -> true means "take theirs"
) { ) {
/** Tasks eligible to be set as this task's parent: same calendar, not itself, not one of its own descendants. */ /** 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() { val availableParentTasks: List<CalendarItem> get() {
@ -122,8 +126,12 @@ class ItemEditViewModel(
repo.save(item) repo.save(item)
_state.value = _state.value.copy(saving = false, done = true) _state.value = _state.value.copy(saving = false, done = true)
} catch (e: SaveConflictException) { } catch (e: SaveConflictException) {
android.util.Log.d("NCalConflict", "save() 412 conflict for href=${item.href}, server version present=${e.serverVersion != null}") val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
_state.value = _state.value.copy(saving = false, conflict = e) android.util.Log.d(
"NCalConflict",
"save() 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) { } catch (e: Exception) {
android.util.Log.e("NCalError", "save() failed for href=${item.href} calendar=${item.calendarUrl}", e) 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()) _state.value = _state.value.copy(saving = false, error = e.toUserMessage())
@ -131,28 +139,56 @@ class ItemEditViewModel(
} }
} }
/** Conflict resolution: retry the write with the server's current etag, overwriting whatever /** Conflict resolution (simple path - used when there's nothing to diff, e.g. the item was
* changed there with this edit. */ * deleted server-side): retry the write with the server's current etag, overwriting
* whatever changed there with this edit. */
fun keepMyChanges() { fun keepMyChanges() {
val conflict = _state.value.conflict ?: return val conflict = _state.value.conflict ?: return
_state.value = _state.value.copy( _state.value = _state.value.copy(
conflict = null, conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(),
item = conflict.localEdit.copy(etag = conflict.serverVersion?.etag) item = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
) )
save() save()
} }
/** Conflict resolution: discard this edit and load whatever's on the server now (or leave /** Conflict resolution (simple path): discard this edit and load whatever's on the server
* the screen entirely if it was deleted there) instead. */ * now (or leave the screen entirely if it was deleted there) instead. */
fun discardMyChanges() { fun discardMyChanges() {
val conflict = _state.value.conflict ?: return val conflict = _state.value.conflict ?: return
_state.value = if (conflict.serverVersion != null) { _state.value = if (conflict.serverVersion != null) {
_state.value.copy(conflict = null, item = conflict.serverVersion) _state.value.copy(conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(), item = conflict.serverVersion)
} else { } else {
_state.value.copy(conflict = null, deleted = true) _state.value.copy(conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(), deleted = true)
} }
} }
/** Per-field conflict resolution: toggle whether [key] should take the server's value
* ([takeTheirs] = true) or keep the local edit's value (false, the default). */
fun setConflictFieldChoice(key: String, takeTheirs: Boolean) {
_state.value = _state.value.copy(conflictChoices = _state.value.conflictChoices + (key to takeTheirs))
}
fun setAllConflictFields(takeTheirs: Boolean) {
_state.value = _state.value.copy(
conflictChoices = _state.value.conflictFields.associate { it.key to takeTheirs }
)
}
/** Applies the chosen per-field picks onto the local edit and retries the save with the
* server's current etag. */
fun resolveConflictMerge() {
val conflict = _state.value.conflict ?: return
val theirs = conflict.serverVersion ?: return
var merged = conflict.localEdit.copy(etag = theirs.etag)
_state.value.conflictFields.forEach { field ->
if (_state.value.conflictChoices[field.key] == true) {
merged = field.takeTheirs(merged, theirs)
}
}
_state.value = _state.value.copy(conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(), item = merged)
save()
}
fun delete() { fun delete() {
val item = _state.value.item ?: return val item = _state.value.item ?: return
if (item.isNew) return if (item.isNew) return

View File

@ -0,0 +1,89 @@
package com.homelab.ncal.util
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* One user-editable field that differs between a local edit and the server's current version
* after a save conflict (HTTP 412). [takeTheirs] applies just this field from `theirs` onto
* `target`, leaving everything else in `target` untouched - so a set of chosen fields can be
* folded onto the local edit one at a time to build a real per-field merge.
*/
data class ConflictField(
val key: String,
val label: String,
val mineDisplay: String,
val theirsDisplay: String,
val takeTheirs: (target: CalendarItem, theirs: CalendarItem) -> CalendarItem
)
private fun formatConflictDate(millis: Long?): String =
millis?.let { SimpleDateFormat("EEE, MMM d yyyy h:mm a", Locale.getDefault()).format(Date(it)) } ?: "Not set"
private fun displayOrEmpty(value: String): String = value.ifBlank { "(empty)" }
/**
* Every user-editable field that differs between [mine] (the edit that just failed to save) and
* [theirs] (the server's current version), in the same order they appear on the edit form. Only
* differing fields are included, so the merge UI stays focused on what actually needs a decision.
*/
fun diffConflictFields(mine: CalendarItem, theirs: CalendarItem): List<ConflictField> {
val fields = mutableListOf<ConflictField>()
fun add(key: String, label: String, mineDisplay: String, theirsDisplay: String, takeTheirs: (CalendarItem, CalendarItem) -> CalendarItem) {
fields += ConflictField(key, label, mineDisplay, theirsDisplay, takeTheirs)
}
if (mine.summary != theirs.summary) {
add("summary", "Title", mine.summary, theirs.summary) { t, th -> t.copy(summary = th.summary) }
}
if (mine.description != theirs.description) {
add("description", "Description", displayOrEmpty(mine.description), displayOrEmpty(theirs.description)) { t, th -> t.copy(description = th.description) }
}
if (mine.location != theirs.location) {
add("location", "Location", displayOrEmpty(mine.location), displayOrEmpty(theirs.location)) { t, th -> t.copy(location = th.location) }
}
if (mine.url != theirs.url) {
add("url", "URL", displayOrEmpty(mine.url), displayOrEmpty(theirs.url)) { t, th -> t.copy(url = th.url) }
}
if (mine.priority != theirs.priority) {
add("priority", "Priority", priorityLabel(mine.priority), priorityLabel(theirs.priority)) { t, th -> t.copy(priority = th.priority) }
}
if (mine.type == ItemType.EVENT) {
if (mine.start != theirs.start) {
add("start", "Starts", formatConflictDate(mine.start), formatConflictDate(theirs.start)) { t, th -> t.copy(start = th.start) }
}
if (mine.end != theirs.end) {
add("end", "Ends", formatConflictDate(mine.end), formatConflictDate(theirs.end)) { t, th -> t.copy(end = th.end) }
}
if (mine.allDay != theirs.allDay) {
add("allDay", "All day", if (mine.allDay) "Yes" else "No", if (theirs.allDay) "Yes" else "No") { t, th -> t.copy(allDay = th.allDay) }
}
} else {
if (mine.start != theirs.start) {
add("start", "Starts", formatConflictDate(mine.start), formatConflictDate(theirs.start)) { t, th -> t.copy(start = th.start) }
}
if (mine.due != theirs.due) {
add("due", "Due", formatConflictDate(mine.due), formatConflictDate(theirs.due)) { t, th -> t.copy(due = th.due) }
}
if (mine.status != theirs.status) {
add("status", "Status", statusLabel(mine.status), statusLabel(theirs.status)) { t, th -> t.withStatus(th.status) }
}
if (mine.percentComplete != theirs.percentComplete) {
add("percentComplete", "% complete", "${mine.percentComplete}%", "${theirs.percentComplete}%") { t, th -> t.copy(percentComplete = th.percentComplete) }
}
if (mine.parentUid != theirs.parentUid) {
add("parentUid", "Parent task", mine.parentUid ?: "None", theirs.parentUid ?: "None") { t, th -> t.copy(parentUid = th.parentUid) }
}
}
if (mine.reminderMinutes.toSet() != theirs.reminderMinutes.toSet()) {
add("reminders", "Reminders", "${mine.reminderMinutes.size} set", "${theirs.reminderMinutes.size} set") { t, th -> t.copy(reminderMinutes = th.reminderMinutes) }
}
return fields
}

View File

@ -128,6 +128,8 @@ object IcsMapper {
recurrenceCount = recurrence?.count, recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time, recurrenceUntil = recurrence?.until?.time,
isRecurring = recurrence != null, isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterEnd = originalEnd,
rawIcs = ics rawIcs = ics
) )
} }
@ -182,6 +184,8 @@ object IcsMapper {
recurrenceCount = recurrence?.count, recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time, recurrenceUntil = recurrence?.until?.time,
isRecurring = recurrence != null, isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterDue = originalDue,
rawIcs = ics rawIcs = ics
) )
} }
@ -203,11 +207,12 @@ object IcsMapper {
* *
* Recurring items get two extra guards, both load-bearing: * Recurring items get two extra guards, both load-bearing:
* - [CalendarItem.start]/[due]/[end] hold the *resolved next occurrence* for a recurring * - [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 * 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* * shift the whole series forward on every single edit. So an item that was *already*
* recurring going in, the master date(s) are left exactly as parsed - only a freshly * recurring going in takes its master date from [CalendarItem.recurrenceMasterStart]/
* recurring-for-the-first-time item (or a plain non-recurring one) takes its date from * `End`/`Due` instead (what the edit screen actually shows/edits for a recurring item);
* the model. * a freshly recurring-for-the-first-time item (or a plain non-recurring one) takes it
* from the model's start/end/due as usual, since there's no prior master date to preserve.
* - A rule the picker can't fully represent (BYDAY/BYMONTH/etc, or a frequency other than * - 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 * the 4 simple ones - see [Recurrence.toRecurrenceFrequency]) is left completely alone
* whenever the model's `recurrenceFrequency` is null, rather than being replaced by * whenever the model's `recurrenceFrequency` is null, rather than being replaced by
@ -226,7 +231,10 @@ object IcsMapper {
ev.setDescription(item.description.takeIf { it.isNotBlank() }) ev.setDescription(item.description.takeIf { it.isNotBlank() })
ev.setLocation(item.location.takeIf { it.isNotBlank() }) ev.setLocation(item.location.takeIf { it.isNotBlank() })
ev.setUrl(item.url.takeIf { it.isNotBlank() }) ev.setUrl(item.url.takeIf { it.isNotBlank() })
if (!wasRecurring) { if (wasRecurring) {
ev.setDateStart(item.recurrenceMasterStart?.let { Date(it) }, !item.allDay)
ev.setDateEnd(item.recurrenceMasterEnd?.let { Date(it) }, !item.allDay)
} else {
ev.setDateStart(item.start?.let { Date(it) }, !item.allDay) ev.setDateStart(item.start?.let { Date(it) }, !item.allDay)
ev.setDateEnd(item.end?.let { Date(it) }, !item.allDay) ev.setDateEnd(item.end?.let { Date(it) }, !item.allDay)
} }
@ -250,7 +258,10 @@ object IcsMapper {
td.setDescription(item.description.takeIf { it.isNotBlank() }) td.setDescription(item.description.takeIf { it.isNotBlank() })
td.setLocation(item.location.takeIf { it.isNotBlank() }) td.setLocation(item.location.takeIf { it.isNotBlank() })
td.setUrl(item.url.takeIf { it.isNotBlank() }) td.setUrl(item.url.takeIf { it.isNotBlank() })
if (!wasRecurring) { if (wasRecurring) {
td.setDateStart(item.recurrenceMasterStart?.let { Date(it) })
td.setDateDue(item.recurrenceMasterDue?.let { Date(it) })
} else {
td.setDateStart(item.start?.let { Date(it) }) td.setDateStart(item.start?.let { Date(it) })
td.setDateDue(item.due?.let { Date(it) }) td.setDateDue(item.due?.let { Date(it) })
} }

View File

@ -0,0 +1,10 @@
package com.homelab.ncal.util
import com.homelab.ncal.data.model.TaskStatus
fun statusLabel(status: TaskStatus): String = when (status) {
TaskStatus.NEEDS_ACTION -> "Not started"
TaskStatus.IN_PROCESS -> "In progress"
TaskStatus.COMPLETED -> "Completed"
TaskStatus.CANCELLED -> "Cancelled"
}

View File

@ -0,0 +1,81 @@
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.TaskStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConflictDiffTest {
private fun event(summary: String = "Standup", location: String = "", start: Long? = 1000L, end: Long? = 2000L) = CalendarItem(
uid = "u1", href = "https://example.com/e1.ics", etag = "\"1\"", calendarUrl = "https://example.com/cal/",
type = ItemType.EVENT, summary = summary, location = location, start = start, end = end
)
@Test
fun `identical items produce no diff fields`() {
val mine = event()
val theirs = event()
assertTrue(diffConflictFields(mine, theirs).isEmpty())
}
@Test
fun `only the fields that actually differ are reported`() {
val mine = event(summary = "Standup", location = "Room A")
val theirs = event(summary = "Standup", location = "Room B")
val fields = diffConflictFields(mine, theirs)
assertEquals(listOf("location"), fields.map { it.key })
assertEquals("Room A", fields.single().mineDisplay)
assertEquals("Room B", fields.single().theirsDisplay)
}
@Test
fun `takeTheirs applies only that one field, not the whole item`() {
val mine = event(summary = "Mine title", location = "Mine location")
val theirs = event(summary = "Theirs title", location = "Theirs location")
val fields = diffConflictFields(mine, theirs)
val locationField = fields.first { it.key == "location" }
val merged = locationField.takeTheirs(mine, theirs)
assertEquals("Theirs location", merged.location)
assertEquals("Mine title", merged.summary) // untouched - only "location" was applied
}
@Test
fun `task-only fields are compared for tasks and not events`() {
val mine = CalendarItem(
uid = "t1", href = "https://example.com/t1.ics", etag = "\"1\"", calendarUrl = "https://example.com/cal/",
type = ItemType.TASK, summary = "Task", status = TaskStatus.NEEDS_ACTION, percentComplete = 0
)
val theirs = mine.withStatus(TaskStatus.COMPLETED)
val fields = diffConflictFields(mine, theirs)
assertTrue(fields.any { it.key == "status" })
assertTrue(fields.any { it.key == "percentComplete" })
// event-only fields must never appear for a task, even if irrelevant values differ
assertTrue(fields.none { it.key == "allDay" })
}
@Test
fun `applying a status takeTheirs keeps completed and percentComplete in sync`() {
val mine = CalendarItem(
uid = "t1", href = "https://example.com/t1.ics", etag = "\"1\"", calendarUrl = "https://example.com/cal/",
type = ItemType.TASK, summary = "Task", status = TaskStatus.NEEDS_ACTION, percentComplete = 0
)
val theirs = mine.withStatus(TaskStatus.COMPLETED)
val statusField = diffConflictFields(mine, theirs).first { it.key == "status" }
val merged = statusField.takeTheirs(mine, theirs)
assertEquals(TaskStatus.COMPLETED, merged.status)
assertTrue(merged.completed)
assertEquals(100, merged.percentComplete)
}
}

View File

@ -68,6 +68,21 @@ class IcsMapperTest {
assertTrue("master DTEND must stay exactly as originally authored", rebuiltIcs.contains("DTEND:20200106T100000Z")) assertTrue("master DTEND must stay exactly as originally authored", rebuiltIcs.contains("DTEND:20200106T100000Z"))
} }
@Test
fun `deliberately changing a recurring event's master start via recurrenceMasterStart is applied`() {
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
assertEquals(1578301200000L, item.recurrenceMasterStart) // 2020-01-06T09:00:00Z, parsed from the fixture
// Simulate the user actually moving the Repeats picker's "Starts" field, not just editing
// an unrelated field - this is the one case where the master date SHOULD change.
val newMasterStart = 1578304800000L // 2020-01-06T10:00:00Z, one hour later
val edited = item.copy(recurrenceMasterStart = newMasterStart)
val rebuiltIcs = IcsMapper.toIcs(edited)
assertTrue("master DTSTART must reflect the deliberate change", rebuiltIcs.contains("DTSTART:20200106T100000Z"))
assertTrue("RRULE must still survive", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
}
@Test @Test
fun `unsupported BYDAY-based rule is left completely untouched when the picker fields are null`() { 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/")!! val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!