Undo a move only for an unchanged copy, keep its record local and tidy (#137)

Reopening a copy moves the series back from any save path when nothing but its
state changed; an edited copy, or one with subtasks, stays a task of its own.
Recurring subtasks keep the override model. A server version replacing a moved
series takes its copies along. COUNT changed since and EXDATE shapes are kept.
This commit is contained in:
makiolaj committed 2026-11-06 18:55:08 +01:00
1 parent 4335f65980
commit a399b25079
14 files changed
+418 -132

No files matched your search

+2 -2
View File
@@ -11,8 +11,8 @@ All notable changes to this project are documented here. The format follows
- Ticking off the current occurrence of a repeating task moves the series on
to its next date and keeps a completed copy, as tasks.org and Apple
Reminders do. Other apps and the web views of servers such as Infomaniak now
see it as done instead of still open. Unticking it moves the series back
(#137).
see it as done instead of still open. Unticking it moves the series back,
as long as the series hasn't moved on or changed its start since (#137).
## [1.3.0] - 2026-11-02
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "319fd369dabf77ad0743bec42d6aab1d",
"identityHash": "64dfff356aece57e8f1b81b82b84406b",
"entities": [
{
"tableName": "accounts",
@@ -546,7 +546,7 @@
},
{
"tableName": "advanced_occurrences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `dropped_exdate` TEXT, `from_override` INTEGER NOT NULL, `detached` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `count_after` INTEGER, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "copyId",
@@ -605,22 +605,22 @@
"notNull": true
},
{
"fieldPath": "droppedExdate",
"columnName": "dropped_exdate",
"affinity": "TEXT"
"fieldPath": "countAfter",
"columnName": "count_after",
"affinity": "INTEGER"
},
{
"fieldPath": "droppedExdates",
"columnName": "dropped_exdates",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "fromOverride",
"columnName": "from_override",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "detached",
"columnName": "detached",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
@@ -665,11 +665,90 @@
]
}
]
},
{
"tableName": "advanced_detached",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "copyId",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_advanced_detached_copy_id",
"unique": false,
"columnNames": [
"copy_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)"
},
{
"name": "index_advanced_detached_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "advanced_occurrences",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"copy_id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '319fd369dabf77ad0743bec42d6aab1d')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '64dfff356aece57e8f1b81b82b84406b')"
]
}
}
@@ -566,6 +566,8 @@ class RoomTasksDataSourceTest {
source.setCompletedInstance(id, at + 14.days, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
// Nothing to move past: the closed occurrences stay the series' own.
assertThat(db.tasks().overrides(id)).hasSize(2)
source.setCompletedInstance(id, at + 14.days, completed = false)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
@@ -636,6 +638,97 @@ class RoomTasksDataSourceTest {
assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("UID:alarm-1")
}
@Test
fun reopeningTheCopyInTheEditorMovesTheSeriesBack() {
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.updateTask(
copy.id,
TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0),
reminders = null,
)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
}
@Test
fun anEditedCopyReopensAsATaskOfItsOwn() {
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 }
db.tasks().update(copy.copy(description = "notes"))
source.updateTask(copy.id, TaskForm(title = "Renamed", listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 100), null)
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(copy.id)!!.title).isEqualTo("Renamed")
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aCopyWithASubtaskReopensOnItsOwn() {
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.insertTask(form(title = "Part").copy(parentId = copy.id))
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun aRecurringSubtaskKeepsTheOverrideModel() {
val at = now
val parent = source.insertTask(form(title = "Parent"))
val id = source.insertTask(form(title = "Daily part").copy(parentId = parent))
makeRecurring(id, at, rule = "FREQ=DAILY")
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().overrides(id)).hasSize(1)
}
@Test
fun aCountChangedSinceIsKept() {
val at = now
val id = source.insertTask(form(title = "Ten"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10")
source.setCompletedInstance(id, at, completed = true)
db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "FREQ=WEEKLY;COUNT=5"))
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().entity(id)!!.rrule).isEqualTo("FREQ=WEEKLY;COUNT=5")
}
@Test
fun tickingTheSameOccurrenceAgainKeepsOneRecord() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at, completed = false)
source.setCompletedInstance(id, at, completed = true)
assertThat(db.advancedOccurrences().forMaster(id)).hasSize(1)
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
}
@Test
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Clock
import kotlin.time.Instant
/** A server version replacing a moved series takes the moves' copies with it (#137). */
@RunWith(AndroidJUnit4::class)
class RoomSyncStoreAdvancesTest {
private val db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Context>(), TasksDatabase::class.java)
.allowMainThreadQueries().build()
@After
fun tearDown() = db.close()
@Test
fun droppingAdvancesDeletesTheirCopies() {
val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds)
val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY"))
RoomTasksDataSource(db).setCompletedInstance(series, at, true)
val copy = db.tasks().allIn(list).single { it.id != series }
db.tasks().update(copy.copy(href = "copy.ics"))
RoomSyncStore(db).dropAdvances(series)
assertThat(db.advancedOccurrences().forMaster(series)).isEmpty()
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isTrue()
}
}
@@ -825,6 +825,7 @@ class CollectionSyncer(
}
pendingDiscard.remove(href)?.let { cause ->
existing[null]?.let { store.dropAdvances(it.id) }
report = report.copy(
discardedEdits = report.discardedEdits +
DiscardedEdit(uid, losing?.title, cause),
@@ -41,6 +41,13 @@ interface SyncStore {
fun deleteAll(taskIds: List<Long>)
/**
* Forgets how ticking occurrences moved [masterId]'s series, and deletes the
* copies that left: the server's version replaced the moved series, and its
* own overrides stand for those occurrences again.
*/
fun dropAdvances(masterId: Long) = Unit
/**
* Records href and ETag on a resource's rows, clearing `is_dirty`.
*
@@ -97,6 +104,19 @@ class RoomSyncStore @Inject constructor(
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
}
override fun dropAdvances(masterId: Long) = database.runInTransaction {
val advanced = database.advancedOccurrences()
advanced.forMaster(masterId).forEach { record ->
val rows = advanced.detachedFor(record.copyId).mapNotNull { it.taskId } + record.copyId
advanced.delete(record.copyId)
rows.mapNotNull { database.tasks().entity(it) }.forEach { row ->
// Never uploaded: nobody to tell. Uploaded: a tombstone the next run deletes.
if (row.href == null) database.tasks().delete(row.id)
else database.tasks().update(row.copy(isDeleted = true, isDirty = true))
}
}
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
}
@@ -68,9 +68,11 @@ class TasksRepositoryImpl @Inject constructor(
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
dataSource.task(taskId, occurrenceStart)?.let { task ->
// The row the read resolved: a past occurrence may have become a task of its own.
val owner = if (task.seriesId == null) task.taskId else taskId
TaskDetail(
task = task,
subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId },
subtasks = SeriesCollapse.visible(dataSource.subtasks(owner)).filter { it.taskId != owner },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
}
@@ -12,7 +12,6 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.isClosed
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
@@ -108,7 +107,6 @@ class ProviderWriter internal constructor(
}
}
/** A read-only table is refused as such, whichever store is active. */
/** Refused as read-only whichever store is active, before the store is even looked at. */
private fun readOnly(table: ProviderTable): Nothing = throw UnsupportedOperationException("${table.path} is read-only")
@@ -149,8 +147,6 @@ class ProviderWriter internal constructor(
requireWritableList(current.listId)
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
if (edited == current) return true
// Reopening the copy of a ticked occurrence moves its series back, as in the app.
if (current.isClosed && !edited.isClosed && store.reopenAdvanced(taskId)) return true
val moved = edited.listId != current.listId
if (moved) {
requireWritableList(edited.listId)
@@ -39,35 +39,69 @@ data class AdvancedOccurrenceEntity(
@ColumnInfo(name = "prior_completed_at") val priorCompletedAt: Instant?,
/** Occurrences `COUNT` lost to the move. */
@ColumnInfo(name = "spent") val spent: Int,
/** `EXDATE` values the move dropped, as they were written. */
@ColumnInfo(name = "dropped_exdate") val droppedExdate: String?,
/** The `COUNT` the move left; one changed since is the user's and stays. */
@ColumnInfo(name = "count_after") val countAfter: Int?,
/** The `EXDATE` occurrences the move dropped, as epoch millis, comma-separated. */
@ColumnInfo(name = "dropped_exdates", defaultValue = "") val droppedExdates: String = "",
/** The copy was an override before, and goes back to being one. */
@ColumnInfo(name = "from_override") val fromOverride: Boolean,
/** Closed overrides detached with it: `anchor:taskId` pairs, comma-separated. */
@ColumnInfo(name = "detached", defaultValue = "") val detached: String = "",
) {
val detachedRows: List<Pair<Instant, Long>>
get() = detached.split(',').filter { it.isNotEmpty() }.map {
Instant.fromEpochMilliseconds(it.substringBefore(':').toLong()) to it.substringAfter(':').toLong()
}
val dropped: List<Instant>
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
companion object {
fun encodeDetached(rows: List<Pair<Instant, Long>>) =
rows.joinToString(",") { "${it.first.toEpochMilliseconds()}:${it.second}" }
fun encode(instants: List<Instant>) = instants.joinToString(",") { it.toEpochMilliseconds().toString() }
}
}
/** A closed override detached along with an advance; [taskId] is null once that row is gone. */
@Entity(
tableName = "advanced_detached",
foreignKeys = [
ForeignKey(
entity = AdvancedOccurrenceEntity::class,
parentColumns = ["copy_id"],
childColumns = ["copy_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["task_id"], onDelete = ForeignKey.SET_NULL),
],
indices = [Index(value = ["copy_id"]), Index(value = ["task_id"])],
)
data class AdvancedDetachedEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
@ColumnInfo(name = "copy_id") val copyId: Long,
@ColumnInfo(name = "anchor") val anchor: Instant,
@ColumnInfo(name = "task_id") val taskId: Long?,
)
@Dao
interface AdvancedOccurrenceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(row: AdvancedOccurrenceEntity)
@Insert
fun insertDetached(rows: List<AdvancedDetachedEntity>)
@Query("SELECT * FROM advanced_occurrences WHERE copy_id = :copyId")
fun forCopy(copyId: Long): AdvancedOccurrenceEntity?
@Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId AND anchor / 1000 = :anchorSecond")
fun at(masterId: Long, anchorSecond: Long): AdvancedOccurrenceEntity?
@Query("SELECT * FROM advanced_detached WHERE copy_id = :copyId")
fun detachedFor(copyId: Long): List<AdvancedDetachedEntity>
@Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId")
fun forMaster(masterId: Long): List<AdvancedOccurrenceEntity>
/** The record for [masterId]'s occurrence within the second starting at [secondStart]. */
@Query(
"SELECT * FROM advanced_occurrences WHERE master_id = :masterId " +
"AND anchor >= :secondStart AND anchor < :secondStart + 1000",
)
fun at(masterId: Long, secondStart: Instant): AdvancedOccurrenceEntity?
@Query("DELETE FROM advanced_occurrences WHERE copy_id = :copyId")
fun delete(copyId: Long)
@Query("DELETE FROM advanced_occurrences WHERE master_id = :masterId")
fun deleteForMaster(masterId: Long)
}
@@ -1,11 +1,10 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.LocalAccount
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportTask
import de.jeanlucmakiola.agendula.domain.isClosed
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
import kotlin.time.Instant
@@ -104,7 +103,7 @@ val TaskEntity.isRecurring: Boolean
/** Completed or cancelled. */
val TaskEntity.isClosed: Boolean
get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
get() = status.isClosed
/**
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
@@ -29,7 +29,6 @@ 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")
/** 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
@@ -265,7 +264,8 @@ class RoomTasksDataSource @Inject constructor(
// in both (the task query skips non-null `master_id`, and the override
// query finds no master in B) while still uploading as part of A's
// resource.
tasks.update(
saveRow(
current,
if (current.masterId == null) {
TaskFormWriter.withRule(edited, form.rrule)
} else {
@@ -472,88 +472,90 @@ class RoomTasksDataSource @Inject constructor(
/** 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")
if (cancelled || !reopenCopy(current)) {
tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now()))
writes.onWritten(setOf(current.listId))
}
saveRow(current, TaskFormWriter.cancelled(current, cancelled, clock.now()))
writes.onWritten(setOf(current.listId))
}
/** 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")
if (completed || !reopenCopy(current)) {
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
writes.onWritten(setOf(current.listId))
}
saveRow(current, TaskFormWriter.completed(current, completed, clock.now()))
writes.onWritten(setOf(current.listId))
}
/**
* Ticking a series' first open occurrence moves the series on to its next
* open 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). Occurrences already closed as overrides on
* either side of it become copies too. Reopening the copy moves the series
* in most other clients (#137). Closed overrides between become copies too.
* Reopening the copy while the series is still where that left it moves it
* 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 {
closeOccurrence(taskId, occurrenceStart, completed, "complete") { TaskFormWriter.completed(it, completed, clock.now()) }
closeOccurrence(taskId, occurrenceStart, completed) { TaskFormWriter.completed(it, completed, clock.now()) }
}
/** Same model as [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx {
closeOccurrence(taskId, occurrenceStart, cancelled, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
closeOccurrence(taskId, occurrenceStart, cancelled) { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
}
/** Moves the series behind the copy [taskId] back onto its occurrence; false when it is no such copy. */
fun reopenAdvanced(taskId: Long): Boolean {
var reopened = false
tx { reopened = tasks.entity(taskId)?.let(::reopenCopy) == true }
return reopened
}
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
private fun closeOccurrence(taskId: Long, occurrenceStart: Instant, closing: Boolean, close: (TaskEntity) -> TaskEntity) {
val master = seriesMaster(taskId)
val row = tasks.entity(taskId)!!
if (master.recurrenceSpec() == null) {
// A plain task, or the copy a ticked occurrence left: this row is the occurrence.
if (closing || !reopenCopy(row)) writeRow(row, close)
saveRow(row, 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 moved past it: a stale tick does nothing, a reopen takes its copy back.
// The series has moved past it, so a tick has nothing left to close;
// a reopen goes to the copy that occurrence left.
when {
override != null -> writeInstance(master, override, anchor, close)
!closing -> copyAt(master, anchor)?.let { copy -> if (!reopenCopy(copy)) writeRow(copy, close) }
!closing -> copyAt(master, anchor)?.let { copy ->
saveRow(copy, close(copy))
writes.onWritten(setOf(copy.listId))
}
}
return
}
if (master.isClosed) {
// 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)
// A series ends with its last occurrence; reopening any of it reopens the series.
if (!closing) 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 writeRow(row: TaskEntity, close: (TaskEntity) -> TaskEntity) {
tasks.update(close(row))
// Reopened on its own, it no longer stands for the series' occurrence.
if (!close(row).isClosed) advanced.delete(row.id)
writes.onWritten(setOf(row.listId))
/**
* Writes [edited] over [current]. A copy whose only change is that it
* reopens moves its series back instead; any other edit makes it a task of
* its own, and it no longer stands for the series' occurrence.
*/
private fun saveRow(current: TaskEntity, edited: TaskEntity) {
val record = advanced.forCopy(current.id)
if (record != null) {
val reopenedOnly = current.isClosed && !edited.isClosed && sameApartFromState(current, edited)
if (reopenedOnly && reopenCopy(current, record)) return
if (!sameApartFromState(current, edited) || !edited.isClosed) advanced.delete(current.id)
}
tasks.update(edited)
}
/** Nothing a user sees changed but its state — what saving a reopened copy from the editor amounts to. */
private fun sameApartFromState(a: TaskEntity, b: TaskEntity) =
a.title == b.title && a.description == b.description && a.location == b.location && a.url == b.url &&
a.priority == b.priority && a.dtstart == b.dtstart && a.due == b.due && a.isAllDay == b.isAllDay &&
a.listId == b.listId && a.parentId == b.parentId && a.rrule == b.rrule && a.rdate == b.rdate &&
a.categories == b.categories && a.color == b.color
/** [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))
@@ -563,11 +565,13 @@ class RoomTasksDataSource @Inject constructor(
/**
* Moves [master] past [anchor] when that is its first open occurrence; false
* when it is not. A series with `RDATE`s is left to the override model:
* anchoring on an `RDATE` would re-align its `RRULE`.
* when it is not, so the caller writes an override. Series with `RDATE`s keep
* the override model (anchoring on one would re-align the `RRULE`), and so do
* recurring subtasks, whose copies would pile up under the parent.
*/
private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean {
if (master.rrule.isNullOrBlank() || !master.rdate.isNullOrBlank()) return false
val rule = master.rrule?.takeIf { it.isNotBlank() } ?: return false
if (!master.rdate.isNullOrBlank() || master.parentId != null) return false
val spec = master.recurrenceSpec() ?: return false
val overrides = tasks.overrides(master.id).associateBy { it.recurrenceId?.epochSeconds }
fun closedAt(at: Instant) = overrides[at.epochSeconds]?.isClosed == true
@@ -576,7 +580,7 @@ class RoomTasksDataSource @Inject constructor(
if (index < 0 || leading.take(index).any { !closedAt(it) }) return false
var nextIndex = index + 1
while (nextIndex < leading.size && closedAt(leading[nextIndex])) nextIndex++
val next = leading.getOrNull(nextIndex)
val next = leading.getOrNull(nextIndex) ?: return true.also { closeSeries(master, override, close) }
val now = clock.now()
val zone = master.timezone ?: zone()
@@ -584,23 +588,23 @@ class RoomTasksDataSource @Inject constructor(
at to detach(overrides.getValue(at.epochSeconds), now)
}
// Expansion drops the anchor's sub-second part; the series keeps it.
val offset = spec.anchor - floorSecond(spec.anchor)
val newAnchor = (next ?: leading[index]) + offset
val spent = spentBefore(spec, next ?: leading[index])
val newAnchor = next + (spec.anchor - floorSecond(spec.anchor))
val spent = spentBefore(spec, next)
val (start, due) = occurrenceTimes(master, newAnchor)
val moved = master.copy(
dtstart = start,
due = due,
rrule = master.rrule.let { TaskFormWriter.remainingCount(it, spent) },
exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone),
lastModified = now,
isDirty = true,
val ruleAfter = TaskFormWriter.remainingCount(rule, spent)
tasks.update(
master.copy(
dtstart = start,
due = due,
rrule = ruleAfter,
exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone),
status = TaskStatus.NEEDS_ACTION,
percentComplete = null,
completedAt = null,
lastModified = now,
isDirty = true,
),
)
if (next == null) {
// Its last occurrence: the series ends there, and reopening it reopens the series.
closeSeries(moved, override, close)
return true
}
val copyId = if (override != null) {
detach(override, now, close)
} else {
@@ -609,7 +613,7 @@ class RoomTasksDataSource @Inject constructor(
alarms.copyToTask(master.id, id, sameResource = false)
id
}
tasks.update(moved.copy(status = TaskStatus.NEEDS_ACTION, percentComplete = null, completedAt = null))
advanced.at(master.id, floorSecond(anchor))?.let { advanced.delete(it.copyId) }
advanced.insert(
AdvancedOccurrenceEntity(
copyId = copyId,
@@ -622,15 +626,20 @@ class RoomTasksDataSource @Inject constructor(
priorPercentComplete = master.percentComplete,
priorCompletedAt = master.completedAt,
spent = spent,
droppedExdate = TaskFormWriter.valuesBefore(master.exdate, newAnchor, zone),
countAfter = countOf(ruleAfter),
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
fromOverride = override != null,
detached = AdvancedOccurrenceEntity.encodeDetached(detached),
),
)
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
writes.onWritten(setOf(master.listId))
return true
}
private fun countOf(rule: String?): Int? = rule?.split(';')
?.firstOrNull { it.substringBefore('=').trim().equals("COUNT", ignoreCase = true) }
?.substringAfter('=')?.trim()?.toIntOrNull()
/** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */
private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand(
spec.copy(exdate = null),
@@ -674,47 +683,50 @@ class RoomTasksDataSource @Inject constructor(
/**
* Undoes [advance] for the copy [row] while the series still sits where it
* moved it: the anchor goes back, `COUNT` gets back what it spent and the
* dropped `EXDATE`s return; any other edit made since stays. The occurrences
* it moved past become overrides again — or, where their copy is gone, are
* excluded. False when [row] is no such copy, so the caller reopens it as a
* task of its own.
* moved it, and the copy is still just that — same list, nothing filed under
* it. The anchor goes back, `COUNT` gets back what it spent unless it was
* changed since, and the dropped `EXDATE`s return in the list's own shape;
* any other edit made since stays. The occurrences detached with it become
* overrides again, or are excluded where their copy is gone. False when it
* cannot, so the caller reopens the copy as a task of its own.
*/
private fun reopenCopy(row: TaskEntity): Boolean {
val record = advanced.forCopy(row.id) ?: return false
val master = tasks.entity(record.masterId)
?.takeIf { !it.isDeleted && !it.isClosed && it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true }
if (master == null) {
private fun reopenCopy(row: TaskEntity, record: AdvancedOccurrenceEntity): Boolean {
val master = tasks.entity(record.masterId)?.takeIf {
!it.isDeleted && !it.isClosed && row.listId == it.listId &&
it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true
}
if (master == null || tasks.subtasks(row.id).isNotEmpty()) {
advanced.delete(row.id)
return false
}
val now = clock.now()
val zone = master.timezone ?: zone()
val synced = lists.entity(master.listId)?.accountId != null
val (start, due) = occurrenceTimes(master, record.priorDtstart ?: record.priorDue ?: record.anchor)
val rule = master.rrule?.let { if (countOf(it) == record.countAfter) TaskFormWriter.remainingCount(it, -record.spent) else it }
var back = master.copy(
dtstart = start,
due = due,
rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, -record.spent) },
exdate = listOfNotNull(record.droppedExdate, master.exdate).joinToString(",").ifEmpty { null },
rrule = rule,
status = if (master.status == TaskStatus.NEEDS_ACTION) record.priorStatus else master.status,
percentComplete = master.percentComplete ?: record.priorPercentComplete,
completedAt = master.completedAt ?: record.priorCompletedAt,
lastModified = now,
isDirty = true,
)
val synced = lists.entity(master.listId)?.accountId != null
record.detachedRows.forEach { (anchor, id) ->
val detached = tasks.entity(id)?.takeIf { !it.isDeleted && it.listId == master.listId }
if (detached == null) back = TaskFormWriter.excepting(back, anchor, now, zone, dirty = synced)
record.dropped.forEach { back = TaskFormWriter.excepting(back, it, now, zone, dirty = synced) }
val detached = advanced.detachedFor(row.id).map { it.anchor to it.taskId?.let(tasks::entity) }
detached.forEach { (at, task) ->
if (task == null || task.isDeleted || task.listId != master.listId) back = TaskFormWriter.excepting(back, at, now, zone, dirty = synced)
}
tasks.update(back)
record.detachedRows.forEach { (anchor, id) ->
tasks.entity(id)?.takeIf { !it.isDeleted && it.listId == master.listId }?.let { toOverride(it, back, anchor, now) }
detached.forEach { (at, task) ->
if (task != null && !task.isDeleted && task.listId == master.listId) toOverride(task, back, at, now)
}
advanced.delete(row.id)
if (record.fromOverride) toOverride(TaskFormWriter.completed(row, false, now), back, record.anchor, now)
else deleteTask(row.id)
writes.onWritten(setOf(master.listId, row.listId))
writes.onWritten(setOf(master.listId))
return true
}
@@ -724,7 +736,6 @@ class RoomTasksDataSource @Inject constructor(
uid = master.uid,
masterId = master.id,
recurrenceId = anchor,
listId = master.listId,
href = null,
etag = null,
isDirty = true,
@@ -744,7 +755,7 @@ class RoomTasksDataSource @Inject constructor(
/** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */
private fun copyAt(master: TaskEntity, anchor: Instant): TaskEntity? =
advanced.at(master.id, anchor.epochSeconds)?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
advanced.at(master.id, floorSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted }
/** The series' occurrences from its anchor on, at most [maxOccurrences]. */
private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List<Instant> {
@@ -874,7 +885,7 @@ class RoomTasksDataSource @Inject constructor(
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, edited)
}
else -> tasks.update(edited)
else -> saveRow(current, edited)
}
}
writes.onWritten(setOf(current.listId, edited.listId))
@@ -120,6 +120,10 @@ object TaskFormWriter {
fun valuesBefore(values: String?, cut: Instant, zone: String): String? =
mapValues(values, zone) { it.takeIf { at -> at < cut } }
/** The instants of an `EXDATE`/`RDATE` list before [cut]; values it cannot read are left out. */
fun instantsBefore(values: String?, cut: Instant, zone: String): List<Instant> =
values?.split(',')?.map(String::trim)?.mapNotNull { readValue(it, zone) }?.filter { it < cut }.orEmpty()
/** The values of an `EXDATE`/`RDATE` list at or after [cut]. */
fun valuesFrom(values: String?, cut: Instant, zone: String): String? =
mapValues(values, zone) { it.takeIf { at -> at >= cut } }
@@ -9,7 +9,7 @@ import java.util.UUID
import kotlin.time.Instant
/**
* Agendula's own task store. Five tables, designed from
* Agendula's own task store. Six tables, designed from
* what the app actually reads and writes plus RFC 5545's `VTODO`.
*
* Schemas are exported to `app/schemas/` and committed, so a future version can
@@ -22,6 +22,7 @@ import kotlin.time.Instant
TaskEntity::class,
TaskAlarmEntity::class,
AdvancedOccurrenceEntity::class,
AdvancedDetachedEntity::class,
],
version = 5,
exportSchema = true,
@@ -187,18 +188,19 @@ abstract class TasksDatabase : RoomDatabase() {
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, " +
"`master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, " +
"`prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, " +
"`prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, " +
"`dropped_exdate` TEXT, `from_override` INTEGER NOT NULL, `detached` TEXT NOT NULL DEFAULT '', " +
"PRIMARY KEY(`copy_id`), " +
"FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `count_after` INTEGER, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` " +
"ON `advanced_occurrences` (`master_id`, `anchor`)",
"CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `advanced_occurrences` (`master_id`, `anchor`)",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `advanced_detached` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `advanced_detached` (`copy_id`)",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `advanced_detached` (`task_id`)",
)
}
}
@@ -37,6 +37,9 @@ data class TaskList(
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
/** Completed or cancelled. */
val TaskStatus.isClosed: Boolean get() = this == TaskStatus.COMPLETED || this == TaskStatus.CANCELLED
/** iCalendar priority, bucketed for the UI. */
enum class Priority { NONE, LOW, MEDIUM, HIGH }
@@ -111,7 +114,7 @@ data class Task(
val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
val isClosed: Boolean get() = status.isClosed
val isSubtask: Boolean get() = parentId != null && parentId > 0
/** The task's own colour if set, else the list colour. */
val effectiveColor: Int get() = taskColor ?: listColor