Make advancing a series exact and reversible (#137)
Anchor on the next occurrence, spend COUNT before EXDATE, keep the series' prior state on the copy so reopening it from anywhere moves the series back, detach occurrences already closed ahead of it, and never fork an override before the series start.
This commit is contained in:
1 parent
9e5e98ccee
commit
5ffd740050
5 files changed
+508
-135
No files matched your search
+152
@@ -356,6 +356,158 @@ class RoomTasksDataSourceTest {
|
||||
assertThat(db.tasks().allIn(listId).single { it.id != id }.status).isEqualTo(TaskStatus.CANCELLED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesWhoseStartWasDeletedMovesPastTheTickedOne() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.deleteOccurrence(id, at)
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countSpendsExcludedOccurrencesAndReopeningRestoresThem() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Daily"))
|
||||
makeRecurring(id, at, rule = "FREQ=DAILY;COUNT=4")
|
||||
source.deleteOccurrence(id, at + 1.days)
|
||||
val before = db.tasks().entity(id)!!
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val moved = db.tasks().entity(id)!!
|
||||
assertThat(moved.dtstart).isEqualTo(at + 2.days)
|
||||
assertThat(moved.rrule).isEqualTo("FREQ=DAILY;COUNT=2")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
val back = db.tasks().entity(id)!!
|
||||
assertThat(back.dtstart).isEqualTo(before.dtstart)
|
||||
assertThat(back.rrule).isEqualTo(before.rrule)
|
||||
assertThat(back.exdate).isEqualTo(before.exdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningTheCopyItselfMovesTheSeriesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val copy = db.tasks().allIn(listId).single { it.id != id }
|
||||
|
||||
source.setCompleted(copy.id, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undoingAnEditedOccurrenceBringsItsOverrideBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.updateInstance(id, at, TaskForm(title = "Just this once", listId = listId, start = at, due = at + 1.days), reminders = null)
|
||||
val override = db.tasks().override(id, at)!!
|
||||
source.setCompletedInstance(override.id, at, completed = true)
|
||||
|
||||
source.setCompletedInstance(override.id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
val back = db.tasks().override(id, at)!!
|
||||
assertThat(back.title).isEqualTo("Just this once")
|
||||
assertThat(back.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleTickOfAnOccurrenceMovedPastForksNothing() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun occurrencesClosedBeforeTheUpdateNoLongerHoldTheSeriesBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
db.tasks().insert(
|
||||
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED, completedAt = at),
|
||||
)
|
||||
|
||||
source.setCompletedInstance(id, at + 7.days, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().allIn(listId).count { it.id != id && it.status == TaskStatus.COMPLETED }).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTickedOccurrenceStillResolvesToItsCopy() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val occurrence = source.task(id, at)!!.occurrenceStart!!
|
||||
|
||||
source.setCompletedInstance(id, occurrence, completed = true)
|
||||
|
||||
val found = source.task(id, occurrence)!!
|
||||
assertThat(found.taskId).isNotEqualTo(id)
|
||||
assertThat(found.isCompleted).isTrue()
|
||||
assertThat(found.occurrenceStart).isEqualTo(occurrence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNextOccurrenceStartsWithoutTheLastOnesProgress() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
db.tasks().update(db.tasks().entity(id)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 60))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(master.percentComplete).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCopyOfAnEditedOccurrenceKeepsItsOwnProperties() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Weekly"))
|
||||
makeRecurring(id, at)
|
||||
val master = db.tasks().entity(id)!!
|
||||
val override = db.tasks().insert(
|
||||
master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, unknownProperties = "COMMENT:only this one"),
|
||||
)
|
||||
|
||||
source.setCompletedInstance(override, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("COMMENT:only this one")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRuleWithAGapOfMoreThanTenYearsKeepsGoing() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Rare"))
|
||||
makeRecurring(id, at, rule = "FREQ=YEARLY;INTERVAL=11")
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
val master = db.tasks().entity(id)!!
|
||||
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(master.dtstart!!).isGreaterThan(at + (366 * 10).days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
||||
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
||||
|
||||
+202
-135
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -28,10 +29,9 @@ private val WINDOW_FORWARD = 730.days
|
||||
/** Residue lines that describe the old series and must not follow the new one. */
|
||||
private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID")
|
||||
|
||||
private const val OCCURRENCE_MARK = "X-AGENDULA-OCCURRENCE-OF:"
|
||||
|
||||
/** How far ahead to look for a series' next occurrence: past any yearly gap, with room to spare. */
|
||||
private val SERIES_LOOKAHEAD = (366 * 10).days
|
||||
/** Only bounds the search: the occurrence count is what stops expansion, so no real gap is cut off. */
|
||||
private val SERIES_LOOKAHEAD = (366 * 1000).days
|
||||
|
||||
/** Enough to count every occurrence before a split point without bounding a real series. */
|
||||
internal const val SPLIT_COUNT_CEILING = 100_000
|
||||
@@ -137,6 +137,9 @@ class RoomTasksDataSource @Inject constructor(
|
||||
if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId)
|
||||
return occurrencesOf(row, tasks.overrides(taskId), clock.now())
|
||||
.firstOrNull { it.occurrenceStart == occurrenceStart }
|
||||
?: copyOf(row.task, occurrenceStart)?.let { copy ->
|
||||
tasks.task(copy.id)?.let { RoomTaskMapper.task(it).copy(occurrenceStart = occurrenceStart) }
|
||||
}
|
||||
?: task(taskId)
|
||||
}
|
||||
|
||||
@@ -355,9 +358,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
percentComplete = null,
|
||||
completedAt = null,
|
||||
// Recurrence lines belong to the old series; everything else carries over.
|
||||
unknownProperties = master.unknownProperties?.lines()
|
||||
?.filterNot { line -> SERIES_LINES.any { line.startsWith(it, ignoreCase = true) } }
|
||||
?.joinToString("\r\n")?.ifBlank { null },
|
||||
unknownProperties = seriesFree(master.unknownProperties).joinToString("\r\n").ifBlank { null },
|
||||
)
|
||||
val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone())
|
||||
val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } }
|
||||
@@ -471,141 +472,225 @@ class RoomTasksDataSource @Inject constructor(
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = tx {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId")
|
||||
tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
if (cancelled || !reopenCopy(current)) {
|
||||
tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
/** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = tx {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
if (completed || !reopenCopy(current)) {
|
||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
||||
writes.onWritten(setOf(current.listId))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticking a series' first open occurrence moves the series on to its next
|
||||
* one and keeps a completed copy, as tasks.org and Apple Reminders do — a
|
||||
* completed `RECURRENCE-ID` override alone leaves the series showing as open
|
||||
* in most other clients (#137). Unticking that copy's occurrence moves the
|
||||
* series back. Any other occurrence forks an override carrying the completion,
|
||||
* the same model [updateInstance] writes. Writing the status onto the master
|
||||
* instead would close the series.
|
||||
* in most other clients (#137). Occurrences already closed as overrides ahead
|
||||
* of it become copies too. Reopening the copy moves the series back. Any other
|
||||
* occurrence forks an override carrying the completion, the same model
|
||||
* [updateInstance] writes. Writing the status onto the master instead would
|
||||
* close the series.
|
||||
*/
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = tx {
|
||||
val close = { it: TaskEntity -> TaskFormWriter.completed(it, completed, clock.now()) }
|
||||
if (!moveSeries(taskId, occurrenceStart, closing = completed, close)) {
|
||||
writeInstance(taskId, occurrenceStart, "complete", close)
|
||||
}
|
||||
closeOccurrence(taskId, occurrenceStart, completed, "complete") { TaskFormWriter.completed(it, completed, clock.now()) }
|
||||
}
|
||||
|
||||
/** Same model as [setCompletedInstance]. */
|
||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx {
|
||||
val close = { it: TaskEntity -> TaskFormWriter.cancelled(it, cancelled, clock.now()) }
|
||||
if (!moveSeries(taskId, occurrenceStart, closing = cancelled, close)) {
|
||||
writeInstance(taskId, occurrenceStart, "cancel", close)
|
||||
closeOccurrence(taskId, occurrenceStart, cancelled, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
|
||||
}
|
||||
|
||||
private fun closeOccurrence(
|
||||
taskId: Long,
|
||||
occurrenceStart: Instant,
|
||||
closing: Boolean,
|
||||
what: String,
|
||||
close: (TaskEntity) -> TaskEntity,
|
||||
) {
|
||||
val row = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId")
|
||||
val master = row.masterId?.let { tasks.entity(it) } ?: row
|
||||
if (master.recurrenceSpec() == null) {
|
||||
// A plain task, or the copy a ticked occurrence left: this row is the occurrence.
|
||||
if (closing || !reopenCopy(row)) {
|
||||
tasks.update(close(row))
|
||||
writes.onWritten(setOf(row.listId))
|
||||
}
|
||||
return
|
||||
}
|
||||
val override = if (row.masterId != null) row else tasks.override(master.id, occurrenceStart)
|
||||
val anchor = override?.recurrenceId ?: occurrenceStart
|
||||
val seriesStart = master.recurrenceAnchor ?: return
|
||||
if (anchor < seriesStart && !sameSecond(anchor, seriesStart)) {
|
||||
// The series has already moved past it: a stale tick does nothing, a reopen takes it back.
|
||||
if (override != null) writeInstance(master, override, anchor, close)
|
||||
else if (!closing) copyOf(master, anchor)?.let(::reopenCopy)
|
||||
return
|
||||
}
|
||||
if (isClosed(master)) {
|
||||
// Only its last occurrence closes a series; reopening that one reopens it.
|
||||
if (!closing && sameSecond(anchor, seriesStart)) closeSeries(master, override, close)
|
||||
else writeInstance(master, override, anchor, close)
|
||||
return
|
||||
}
|
||||
if (!closing || !advance(master, anchor, override, close)) writeInstance(master, override, anchor, close)
|
||||
}
|
||||
|
||||
private fun isClosed(task: TaskEntity) = task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED
|
||||
|
||||
/** [close] applied to the master — and to [override], its last occurrence — which ends the series. */
|
||||
private fun closeSeries(master: TaskEntity, override: TaskEntity?, close: (TaskEntity) -> TaskEntity) {
|
||||
tasks.update(close(master))
|
||||
override?.let { tasks.update(close(it)) }
|
||||
writes.onWritten(setOf(master.listId))
|
||||
}
|
||||
|
||||
/** Moves [master] past [anchor] when that is its first open occurrence; false when it is not. */
|
||||
private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean {
|
||||
val spec = master.recurrenceSpec() ?: return false
|
||||
val overrides = tasks.overrides(master.id).associateBy { it.recurrenceId?.epochSeconds }
|
||||
val closedAhead = overrides.values.count(::isClosed)
|
||||
val leading = expandFrom(spec, maxOccurrences = closedAhead + 2)
|
||||
val index = leading.indexOfFirst { sameSecond(it, anchor) }
|
||||
if (index < 0 || leading.take(index).any { overrides[it.epochSeconds]?.let(::isClosed) != true }) return false
|
||||
val next = leading.getOrNull(index + 1) ?: return true.also { closeSeries(master, override, close) }
|
||||
|
||||
val now = clock.now()
|
||||
val detached = leading.take(index).map { earlier -> earlier to detach(overrides.getValue(earlier.epochSeconds), now) }
|
||||
val prior = Prior(master, fromOverride = override != null, detached = detached)
|
||||
val copyId = if (override != null) {
|
||||
detach(override, now, close)
|
||||
} else {
|
||||
val (start, due) = occurrenceTimes(master, anchor)
|
||||
val id = tasks.insert(close(asCopy(master, now).copy(dtstart = start, due = due)))
|
||||
alarms.copyToTask(master.id, id, sameResource = false)
|
||||
id
|
||||
}
|
||||
// Keeps the anchor's own sub-second part, which expansion drops.
|
||||
val newAnchor = next + (spec.anchor - Instant.fromEpochSeconds(spec.anchor.epochSeconds))
|
||||
val spent = RecurrenceExpander.expand(
|
||||
spec.copy(exdate = null),
|
||||
ExpansionWindow(from = floorSecond(spec.anchor), until = next, maxOccurrences = SPLIT_COUNT_CEILING),
|
||||
).size
|
||||
val (start, due) = occurrenceTimes(master, newAnchor)
|
||||
val zone = master.timezone ?: zone()
|
||||
tasks.update(
|
||||
master.copy(
|
||||
dtstart = start,
|
||||
due = due,
|
||||
rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, spent) },
|
||||
exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone),
|
||||
rdate = TaskFormWriter.valuesFrom(master.rdate, newAnchor, zone),
|
||||
status = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = null,
|
||||
completedAt = null,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
),
|
||||
)
|
||||
val copy = tasks.entity(copyId)!!
|
||||
val mark = Mark(master.id, master.uid, anchor, newAnchor.toEpochMilliseconds(), prior)
|
||||
tasks.update(copy.copy(unknownProperties = (seriesFree(copy.unknownProperties) + mark.line()).joinToString("\r\n")))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the series behind [taskId] past [occurrenceStart] when [closing] its
|
||||
* first occurrence, or back onto it when reopening the one it last moved past.
|
||||
* False when neither applies, so the caller writes an override instead.
|
||||
* Turns the override [row] into a task of its own, in place so its id keeps
|
||||
* working: a new UID, unlinked from the series, and a resource of its own.
|
||||
*/
|
||||
private fun moveSeries(taskId: Long, occurrenceStart: Instant, closing: Boolean, close: (TaskEntity) -> TaskEntity): Boolean {
|
||||
val row = tasks.entity(taskId) ?: return false
|
||||
val master = row.masterId?.let { tasks.entity(it) } ?: row
|
||||
if (master.recurrenceSpec() == null) return false
|
||||
val override = if (row.masterId != null) row else tasks.override(master.id, occurrenceStart)
|
||||
val anchor = override?.recurrenceId ?: occurrenceStart
|
||||
val closed = master.status == TaskStatus.COMPLETED || master.status == TaskStatus.CANCELLED
|
||||
if (closed) {
|
||||
// Only its last occurrence closes a series; reopening that one reopens it.
|
||||
if (closing || master.recurrenceAnchor?.let { sameSecond(it, anchor) } != true) return false
|
||||
tasks.update(close(master))
|
||||
override?.let { tasks.update(close(it)) }
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
return if (closing) advance(master, anchor, override, close) else rewind(master, anchor)
|
||||
private fun detach(row: TaskEntity, now: Instant, close: (TaskEntity) -> TaskEntity = { it }): Long {
|
||||
tasks.update(close(asCopy(row, now).copy(id = row.id, unknownProperties = seriesFree(row.unknownProperties).joinToString("\r\n").ifBlank { null })))
|
||||
return row.id
|
||||
}
|
||||
|
||||
private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean {
|
||||
val (first, next) = firstTwo(master) ?: return false
|
||||
if (!sameSecond(first, anchor)) return false
|
||||
if (next == null) {
|
||||
tasks.update(close(master))
|
||||
override?.let { tasks.update(close(it)) }
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
/** [source] as a standalone task: no series fields, never uploaded. */
|
||||
private fun asCopy(source: TaskEntity, now: Instant): TaskEntity = source.copy(
|
||||
id = 0,
|
||||
uid = UUID.randomUUID().toString(),
|
||||
masterId = null,
|
||||
recurrenceId = null,
|
||||
href = null,
|
||||
etag = null,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
exdate = null,
|
||||
createdAt = now,
|
||||
sequence = 0,
|
||||
isDeleted = false,
|
||||
isDirty = true,
|
||||
unknownProperties = seriesFree(source.unknownProperties).joinToString("\r\n").ifBlank { null },
|
||||
)
|
||||
|
||||
/**
|
||||
* Undoes [advance] for the copy [row], when the series has not changed since:
|
||||
* the master takes back its anchor and rule, and the occurrences it moved past
|
||||
* become its overrides or generated occurrences again. False when [row] is no
|
||||
* such copy, so the caller reopens it as a task of its own.
|
||||
*/
|
||||
private fun reopenCopy(row: TaskEntity): Boolean {
|
||||
val mark = Mark.of(row) ?: return false
|
||||
val master = tasks.entity(mark.masterId)?.takeIf { it.uid == mark.masterUid } ?: return false
|
||||
if (master.recurrenceAnchor?.toEpochMilliseconds() != mark.advancedTo) return false
|
||||
val now = clock.now()
|
||||
val (start, due) = override?.let { it.dtstart to it.due } ?: occurrenceTimes(master, anchor)
|
||||
val copy = close(
|
||||
(override ?: master).copy(
|
||||
id = 0,
|
||||
uid = UUID.randomUUID().toString(),
|
||||
masterId = null,
|
||||
recurrenceId = null,
|
||||
val prior = mark.prior
|
||||
tasks.update(prior.restore(master, now))
|
||||
prior.detached.forEach { (anchor, id) -> tasks.entity(id)?.let { toOverride(it, master, anchor, now) { r -> r } } }
|
||||
if (prior.fromOverride) {
|
||||
toOverride(row, master, mark.anchor, now) { TaskFormWriter.completed(it, false, now) }
|
||||
} else {
|
||||
deleteTask(row.id)
|
||||
}
|
||||
writes.onWritten(setOf(master.listId, row.listId))
|
||||
return true
|
||||
}
|
||||
|
||||
/** [row], a detached occurrence, back as [master]'s override for [anchor] — reopened by [reopen]. */
|
||||
private fun toOverride(row: TaskEntity, master: TaskEntity, anchor: Instant, now: Instant, reopen: (TaskEntity) -> TaskEntity) {
|
||||
val back = reopen(
|
||||
row.copy(
|
||||
uid = master.uid,
|
||||
masterId = master.id,
|
||||
recurrenceId = anchor,
|
||||
listId = master.listId,
|
||||
href = null,
|
||||
etag = null,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
exdate = null,
|
||||
dtstart = start,
|
||||
due = due,
|
||||
createdAt = now,
|
||||
sequence = 0,
|
||||
isDeleted = false,
|
||||
unknownProperties = (seriesFree(master.unknownProperties) + occurrenceMark(master, anchor)).joinToString("\r\n"),
|
||||
isDirty = true,
|
||||
lastModified = now,
|
||||
unknownProperties = seriesFree(row.unknownProperties).joinToString("\r\n").ifBlank { null },
|
||||
),
|
||||
)
|
||||
val copyId = tasks.insert(copy)
|
||||
alarms.copyToTask(override?.id ?: master.id, copyId, sameResource = false)
|
||||
override?.let { tasks.delete(it.id) }
|
||||
// Keeps the anchor's own sub-second part, which expansion drops.
|
||||
tasks.update(shifted(master, master.recurrenceAnchor!! + (next - first), spent = 1, now))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
if (row.href == null) {
|
||||
tasks.update(back)
|
||||
} else {
|
||||
// Already uploaded as a resource of its own: that one goes, a fresh override comes.
|
||||
val id = tasks.insert(back.copy(id = 0))
|
||||
alarms.copyToTask(row.id, id, sameResource = true)
|
||||
deleteTask(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewind(master: TaskEntity, anchor: Instant): Boolean {
|
||||
val anchorNow = master.recurrenceAnchor ?: return false
|
||||
if (anchor >= anchorNow || sameSecond(anchor, anchorNow)) return false
|
||||
val back = shifted(master, anchor, spent = -1, clock.now())
|
||||
val (first, next) = firstTwo(back) ?: return false
|
||||
if (!sameSecond(first, anchor) || next == null || !sameSecond(next, anchorNow)) return false
|
||||
tasks.update(back)
|
||||
val mark = occurrenceMark(master, anchor)
|
||||
tasks.allIn(master.listId)
|
||||
.filter { !it.isDeleted && it.unknownProperties?.lines()?.contains(mark) == true }
|
||||
.forEach { deleteTask(it.id) }
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
/** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */
|
||||
private fun copyOf(master: TaskEntity, anchor: Instant): TaskEntity? =
|
||||
tasks.withUnknownLike("%$OCCURRENCE_MARK${master.id};%").firstOrNull { copy ->
|
||||
Mark.of(copy)?.let { it.masterUid == master.uid && sameSecond(it.anchor, anchor) } == true
|
||||
}
|
||||
|
||||
/** The series' occurrences from its anchor on, at most [maxOccurrences]. */
|
||||
private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List<Instant> {
|
||||
val from = floorSecond(spec.anchor)
|
||||
return RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = maxOccurrences))
|
||||
}
|
||||
|
||||
/** The series' first occurrence and the one after it (null when it has no other). */
|
||||
private fun firstTwo(master: TaskEntity): Pair<Instant, Instant?>? {
|
||||
val spec = master.recurrenceSpec() ?: return null
|
||||
val from = Instant.fromEpochSeconds(spec.anchor.epochSeconds)
|
||||
val occurrences = RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = 2))
|
||||
val first = occurrences.firstOrNull() ?: return null
|
||||
return first to occurrences.getOrNull(1)
|
||||
}
|
||||
|
||||
/** [master] anchored on [at], its `COUNT` reduced by [spent] and the exceptions before [at] dropped. */
|
||||
private fun shifted(master: TaskEntity, at: Instant, spent: Int, now: Instant): TaskEntity {
|
||||
val (start, due) = occurrenceTimes(master, at)
|
||||
val zone = master.timezone ?: zone()
|
||||
return master.copy(
|
||||
dtstart = start,
|
||||
due = due,
|
||||
rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, spent) },
|
||||
exdate = if (spent > 0) TaskFormWriter.valuesFrom(master.exdate, at, zone) else master.exdate,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
}
|
||||
private fun floorSecond(at: Instant) = Instant.fromEpochSeconds(at.epochSeconds)
|
||||
|
||||
private fun seriesFree(unknown: String?): List<String> =
|
||||
unknown?.lines()?.filterNot { line -> SERIES_LINES.any { line.startsWith(it, ignoreCase = true) } || line.startsWith(OCCURRENCE_MARK) }
|
||||
@@ -614,33 +699,15 @@ class RoomTasksDataSource @Inject constructor(
|
||||
/** Expansion runs in whole seconds; stored anchors may carry milliseconds. */
|
||||
private fun sameSecond(a: Instant, b: Instant) = a.epochSeconds == b.epochSeconds
|
||||
|
||||
/** Links a completed copy to the occurrence it was split off, so reopening it can move the series back. */
|
||||
private fun occurrenceMark(master: TaskEntity, anchor: Instant) = "$OCCURRENCE_MARK${master.uid};${anchor.toEpochMilliseconds()}"
|
||||
|
||||
/** Applies [write] to the override for [occurrenceStart], forking one if there is none yet. */
|
||||
private fun writeInstance(
|
||||
taskId: Long,
|
||||
occurrenceStart: Instant,
|
||||
what: String,
|
||||
write: (TaskEntity) -> TaskEntity,
|
||||
) {
|
||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId")
|
||||
// Not a series master — an override, or a plain task the caller handed an
|
||||
// anchor for. Either way this row *is* the occurrence.
|
||||
if (master.recurrenceSpec() == null) {
|
||||
tasks.update(write(master))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return
|
||||
/** Applies [write] to [override], or to a new one forked from [master] for [occurrenceStart]. */
|
||||
private fun writeInstance(master: TaskEntity, override: TaskEntity?, occurrenceStart: Instant, write: (TaskEntity) -> TaskEntity) {
|
||||
if (override != null) {
|
||||
tasks.update(write(override))
|
||||
} else {
|
||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||
val id = tasks.insert(write(newOverride(master, master.id, occurrenceStart, start, due)))
|
||||
alarms.copyToTask(master.id, id, sameResource = true)
|
||||
}
|
||||
|
||||
tasks.override(taskId, occurrenceStart)?.let {
|
||||
tasks.update(write(it))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return
|
||||
}
|
||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
||||
alarms.copyToTask(taskId, id, sameResource = true)
|
||||
writes.onWritten(setOf(master.listId))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import java.util.Base64
|
||||
import kotlin.time.Instant
|
||||
|
||||
internal const val OCCURRENCE_MARK = "X-AGENDULA-OCCURRENCE-OF:"
|
||||
|
||||
/**
|
||||
* What a series looked like before ticking its first occurrence moved it on, so
|
||||
* reopening the copy can put it back exactly — `COUNT`, `EXDATE` and `RDATE`
|
||||
* included — with the occurrences it moved past as overrides again.
|
||||
*/
|
||||
internal data class Prior(
|
||||
val dtstart: Instant?,
|
||||
val due: Instant?,
|
||||
val rrule: String?,
|
||||
val rdate: String?,
|
||||
val exdate: String?,
|
||||
val status: TaskStatus,
|
||||
val percentComplete: Int?,
|
||||
val completedAt: Instant?,
|
||||
val fromOverride: Boolean,
|
||||
/** Earlier closed occurrences detached alongside: their anchor and the row they became. */
|
||||
val detached: List<Pair<Instant, Long>>,
|
||||
) {
|
||||
constructor(master: TaskEntity, fromOverride: Boolean, detached: List<Pair<Instant, Long>>) : this(
|
||||
master.dtstart, master.due, master.rrule, master.rdate, master.exdate,
|
||||
master.status, master.percentComplete, master.completedAt, fromOverride, detached,
|
||||
)
|
||||
|
||||
fun restore(master: TaskEntity, now: Instant): TaskEntity = master.copy(
|
||||
dtstart = dtstart,
|
||||
due = due,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
|
||||
fun encode(): String {
|
||||
val fields = listOf(
|
||||
dtstart?.toEpochMilliseconds()?.toString(),
|
||||
due?.toEpochMilliseconds()?.toString(),
|
||||
rrule,
|
||||
rdate,
|
||||
exdate,
|
||||
status.name,
|
||||
percentComplete?.toString(),
|
||||
completedAt?.toEpochMilliseconds()?.toString(),
|
||||
fromOverride.toString(),
|
||||
detached.joinToString(",") { "${it.first.toEpochMilliseconds()}:${it.second}" },
|
||||
).joinToString("\n") { it.orEmpty() }
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(fields.toByteArray())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(raw: String): Prior? = runCatching {
|
||||
val f = String(Base64.getUrlDecoder().decode(raw)).split('\n')
|
||||
fun instant(i: Int) = f[i].toLongOrNull()?.let(Instant::fromEpochMilliseconds)
|
||||
Prior(
|
||||
dtstart = instant(0),
|
||||
due = instant(1),
|
||||
rrule = f[2].ifEmpty { null },
|
||||
rdate = f[3].ifEmpty { null },
|
||||
exdate = f[4].ifEmpty { null },
|
||||
status = TaskStatus.valueOf(f[5]),
|
||||
percentComplete = f[6].toIntOrNull(),
|
||||
completedAt = instant(7),
|
||||
fromOverride = f[8].toBooleanStrict(),
|
||||
detached = f[9].split(',').filter { it.isNotEmpty() }.map { pair ->
|
||||
Instant.fromEpochMilliseconds(pair.substringBefore(':').toLong()) to pair.substringAfter(':').toLong()
|
||||
},
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line a completed copy carries back to its series:
|
||||
* `X-AGENDULA-OCCURRENCE-OF:<master id>;<master uid>;<anchor>;<anchor it moved to>;<prior>`.
|
||||
* Another device's copy names a master id that is not this one's, so it never matches.
|
||||
*/
|
||||
internal data class Mark(
|
||||
val masterId: Long,
|
||||
val masterUid: String,
|
||||
val anchor: Instant,
|
||||
val advancedTo: Long,
|
||||
val prior: Prior,
|
||||
) {
|
||||
fun line(): String =
|
||||
"$OCCURRENCE_MARK$masterId;$masterUid;${anchor.toEpochMilliseconds()};$advancedTo;${prior.encode()}"
|
||||
|
||||
companion object {
|
||||
fun of(task: TaskEntity): Mark? = task.unknownProperties?.lines()
|
||||
?.firstOrNull { it.startsWith(OCCURRENCE_MARK) }
|
||||
?.let { parse(it.removePrefix(OCCURRENCE_MARK)) }
|
||||
|
||||
fun parse(value: String): Mark? {
|
||||
val parts = value.split(';')
|
||||
if (parts.size < 5) return null
|
||||
return Mark(
|
||||
masterId = parts.first().toLongOrNull() ?: return null,
|
||||
masterUid = parts.subList(1, parts.size - 3).joinToString(";"),
|
||||
anchor = parts[parts.size - 3].toLongOrNull()?.let(Instant::fromEpochMilliseconds) ?: return null,
|
||||
advancedTo = parts[parts.size - 2].toLongOrNull() ?: return null,
|
||||
prior = Prior.decode(parts.last()) ?: return null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,10 @@ interface TaskDao {
|
||||
@Query("SELECT * FROM tasks WHERE list_id = :listId")
|
||||
fun allIn(listId: Long): List<TaskEntity>
|
||||
|
||||
/** Live rows whose unmodelled properties match the `LIKE` [pattern]. */
|
||||
@Query("SELECT * FROM tasks WHERE unknown_properties LIKE :pattern AND is_deleted = 0")
|
||||
fun withUnknownLike(pattern: String): List<TaskEntity>
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
@Insert
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SeriesCopyMarkTest {
|
||||
|
||||
private val prior = Prior(
|
||||
dtstart = Instant.fromEpochMilliseconds(1_000),
|
||||
due = null,
|
||||
rrule = "FREQ=DAILY;COUNT=4",
|
||||
rdate = null,
|
||||
exdate = "20261107T090000Z,20261108T090000Z",
|
||||
status = TaskStatus.IN_PROCESS,
|
||||
percentComplete = 40,
|
||||
completedAt = null,
|
||||
fromOverride = true,
|
||||
detached = listOf(Instant.fromEpochMilliseconds(500) to 12L),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a mark survives its own line`() {
|
||||
val mark = Mark(7, "uid;with;semicolons", Instant.fromEpochMilliseconds(1_000), 2_000, prior)
|
||||
assertThat(Mark.parse(mark.line().removePrefix(OCCURRENCE_MARK))).isEqualTo(mark)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a damaged mark reads as none`() {
|
||||
assertThat(Mark.parse("7;uid;1000;2000;!!!")).isNull()
|
||||
assertThat(Mark.parse("x;uid;1000;2000;")).isNull()
|
||||
assertThat(Mark.parse("7;uid")).isNull()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user