diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index 068864e..c33e051 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -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)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index cd0d242..f3bdb0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -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 { + 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? { - 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 = 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)) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/SeriesCopyMark.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/SeriesCopyMark.kt new file mode 100644 index 0000000..5a5eec3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/SeriesCopyMark.kt @@ -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>, +) { + constructor(master: TaskEntity, fromOverride: Boolean, detached: List>) : 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:;;;;`. + * 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, + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index 747ef2d..58fc44a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -122,6 +122,10 @@ interface TaskDao { @Query("SELECT * FROM tasks WHERE list_id = :listId") fun allIn(listId: Long): List + /** 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 + // --- writes --------------------------------------------------------------- @Insert diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/SeriesCopyMarkTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/SeriesCopyMarkTest.kt new file mode 100644 index 0000000..96b9f63 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/SeriesCopyMarkTest.kt @@ -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() + } +}