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:
makiolaj committed 2026-11-06 18:02:43 +01:00
1 parent 9e5e98ccee
commit 5ffd740050
5 files changed
+508 -135

No files matched your search

@@ -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))
@@ -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()
}
}