Compare commits

...
Author SHA1 Message Date
makiolaj a399b25079 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.
2026-11-06 18:55:08 +01:00
makiolaj 4335f65980 Keep the advance record local and undo only the move (#137)
The record of how a series moved now lives in a local table (schema v5)
instead of an X- property synced with the copy. Reopening undoes the move
alone, keeping later edits; series with RDATEs keep the override model; the
series also moves past closed occurrences after the ticked one.
2026-11-06 18:37:27 +01:00
makiolaj 22c57710b7 Refuse read-only provider tables before the store check (#137)
The provider contract tests failed whenever Agendula ran on an external
provider: the store check fired first. Value checks still need the own store,
so those two tests now skip without it.
2026-11-06 18:21:26 +01:00
makiolaj 2f314890fb Point the right provider test at a later occurrence (#137) 2026-11-06 18:08:31 +01:00
makiolaj 5ffd740050 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.
2026-11-06 18:02:43 +01:00
makiolaj 9e5e98ccee Move a series on when its first occurrence is ticked off (#137)
Agendula's own store kept the series open and wrote a completed RECURRENCE-ID
override, which Infomaniak and Apple Reminders show as still open. The series
now advances to its next occurrence and a completed copy is kept; reopening
that occurrence moves it back.
2026-11-06 17:52:15 +01:00
18 changed files with 1822 additions and 83 deletions

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Changed
- 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,
as long as the series hasn't moved on or changed its start since (#137).
## [1.3.0] - 2026-11-02
### Added
@@ -0,0 +1,754 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "64dfff356aece57e8f1b81b82b84406b",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principalUrl",
"columnName": "principal_url",
"affinity": "TEXT"
},
{
"fieldPath": "homeSetUrl",
"columnName": "home_set_url",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "lastSyncAt",
"columnName": "last_sync_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncError",
"columnName": "last_sync_error",
"affinity": "TEXT"
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isSynced",
"columnName": "is_synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "is_read_only",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "ctag",
"columnName": "ctag",
"affinity": "TEXT"
},
{
"fieldPath": "syncToken",
"columnName": "sync_token",
"affinity": "TEXT"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_lists_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"account_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "listId",
"columnName": "list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT"
},
{
"fieldPath": "etag",
"columnName": "etag",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "categories",
"columnName": "categories",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "percentComplete",
"columnName": "percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "completedAt",
"columnName": "completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "classification",
"columnName": "classification",
"affinity": "INTEGER"
},
{
"fieldPath": "dtstart",
"columnName": "dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "due",
"columnName": "due",
"affinity": "INTEGER"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "TEXT"
},
{
"fieldPath": "isAllDay",
"columnName": "is_all_day",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT"
},
{
"fieldPath": "rrule",
"columnName": "rrule",
"affinity": "TEXT"
},
{
"fieldPath": "rdate",
"columnName": "rdate",
"affinity": "TEXT"
},
{
"fieldPath": "exdate",
"columnName": "exdate",
"affinity": "TEXT"
},
{
"fieldPath": "recurrenceId",
"columnName": "recurrence_id",
"affinity": "INTEGER"
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER"
},
{
"fieldPath": "parentId",
"columnName": "parent_id",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER"
},
{
"fieldPath": "lastModified",
"columnName": "last_modified",
"affinity": "INTEGER"
},
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDirty",
"columnName": "is_dirty",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isDeleted",
"columnName": "is_deleted",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "unknownProperties",
"columnName": "unknown_properties",
"affinity": "TEXT"
},
{
"fieldPath": "remindersOff",
"columnName": "reminders_off",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tasks_list_id_is_deleted",
"unique": false,
"columnNames": [
"list_id",
"is_deleted"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
},
{
"name": "index_tasks_parent_id",
"unique": false,
"columnNames": [
"parent_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
},
{
"name": "index_tasks_master_id_recurrence_id",
"unique": false,
"columnNames": [
"master_id",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
},
{
"name": "index_tasks_is_dirty",
"unique": false,
"columnNames": [
"is_dirty"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
},
{
"name": "index_tasks_list_id_uid_recurrence_id",
"unique": true,
"columnNames": [
"list_id",
"uid",
"recurrence_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
}
],
"foreignKeys": [
{
"table": "task_lists",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"list_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"parent_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "task_alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskId",
"columnName": "task_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minutesBefore",
"columnName": "minutes_before",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DUE'"
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT"
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_task_alarms_task_id",
"unique": false,
"columnNames": [
"task_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"task_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"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, `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",
"columnName": "copy_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "masterId",
"columnName": "master_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "anchor",
"columnName": "anchor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "advancedTo",
"columnName": "advanced_to",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorDtstart",
"columnName": "prior_dtstart",
"affinity": "INTEGER"
},
{
"fieldPath": "priorDue",
"columnName": "prior_due",
"affinity": "INTEGER"
},
{
"fieldPath": "priorStatus",
"columnName": "prior_status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priorPercentComplete",
"columnName": "prior_percent_complete",
"affinity": "INTEGER"
},
{
"fieldPath": "priorCompletedAt",
"columnName": "prior_completed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "spent",
"columnName": "spent",
"affinity": "INTEGER",
"notNull": true
},
{
"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
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"copy_id"
]
},
"indices": [
{
"name": "index_advanced_occurrences_master_id_anchor",
"unique": false,
"columnNames": [
"master_id",
"anchor"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `${TABLE_NAME}` (`master_id`, `anchor`)"
}
],
"foreignKeys": [
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"copy_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "tasks",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"master_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"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, '64dfff356aece57e8f1b81b82b84406b')"
]
}
}
@@ -124,6 +124,7 @@ class AgendulaTaskProviderTest {
@Test
fun aBadWriteReachesTheClientAsIllegalArgument() {
assumeTrue("Agendula is running on an external provider", ownStore())
// No list has this id, so nothing in the app's real store is touched.
val values = ContentValues().apply {
put("list_id", Long.MAX_VALUE)
@@ -135,6 +136,7 @@ class AgendulaTaskProviderTest {
@Test
fun aFailingBatchWritesNothing() {
assumeTrue("Agendula is running on an external provider", ownStore())
val missingList = Long.MAX_VALUE
val operations = arrayListOf(
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
@@ -145,6 +147,9 @@ class AgendulaTaskProviderTest {
.use { assertThat(it.count).isEqualTo(0) }
}
/** Whether the app's own store is active: only then does the provider take writes at all. */
private fun ownStore() = runCatching { resolver.delete(uri("tasks"), "_id = -1", null) }.isSuccess
/** stdout and stderr of [command]; the denial lands on stderr. */
private fun shell(command: String): String {
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
@@ -734,9 +734,11 @@ class ProviderWriterTest {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
)
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
// The second occurrence: ticking the first moves the series on instead.
val second = today + 1.days
val row = readRow(ProviderTable.INSTANCES, instanceId(second))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(second)), null)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
@@ -795,6 +797,20 @@ class ProviderWriterTest {
private fun store() = RoomTasksDataSource(db)
@Test
fun reopeningATickedOccurrencesCopyMovesTheSeriesBack() {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "w", title = "Weekly", dtstart = today, rrule = "FREQ=WEEKLY", timezone = "UTC"),
)
store().setCompletedInstance(series, today, true)
val copy = db.tasks().allIn(home).single { it.id != series }
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 0), byId(ProviderTable.TASKS, copy.id), null)
assertThat(task(series)!!.dtstart).isEqualTo(today)
assertThat(task(copy.id)).isNull()
}
/** Every column of one published row, as a client that read it would write it back. */
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
val columns = table.columns.map { it.first }
@@ -211,7 +211,8 @@ class RoomTasksDataSourceTest {
@Test
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// Overdue occurrences before it, so it is not the first and forks an override.
makeRecurring(id, now - 14.days)
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
val before = open()
val target = before.first { it.distanceFromCurrent == 0 }
@@ -237,7 +238,8 @@ class RoomTasksDataSourceTest {
@Test
fun reopeningACompletedOccurrenceReusesItsOverride() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// Overdue occurrences before it, so it is not the first and forks an override.
makeRecurring(id, now - 14.days)
val target = source.tasks(TaskQuery(listId = listId))
.first { it.taskId == id && it.distanceFromCurrent == 0 }
@@ -254,7 +256,8 @@ class RoomTasksDataSourceTest {
@Test
fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, now)
// Overdue occurrences before it, so it is not the first and forks an override.
makeRecurring(id, now - 14.days)
val target = source.tasks(TaskQuery(listId = listId))
.first { it.taskId == id && it.distanceFromCurrent == 0 }
@@ -269,6 +272,463 @@ class RoomTasksDataSourceTest {
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun completingTheFirstOccurrenceMovesTheSeriesOnAndKeepsACopy() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
source.setCompletedInstance(id, at, completed = true)
val master = db.tasks().entity(id)!!
assertThat(master.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(master.dtstart).isEqualTo(at + 7.days)
assertThat(master.due).isEqualTo(at + 8.days)
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=3")
assertThat(master.isDirty).isTrue()
assertThat(db.tasks().overrides(id)).isEmpty()
val copy = db.tasks().allIn(listId).single { it.id != id }
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(copy.uid).isNotEqualTo(master.uid)
assertThat(copy.rrule).isNull()
assertThat(copy.masterId).isNull()
assertThat(copy.dtstart).isEqualTo(at)
assertThat(copy.title).isEqualTo("Water the plants")
}
@Test
fun reopeningTheOccurrenceMovesTheSeriesBack() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=4")
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at, completed = false)
val master = db.tasks().entity(id)!!
assertThat(master.dtstart).isEqualTo(at)
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;COUNT=4")
assertThat(db.tasks().allIn(listId).filter { !it.isDeleted }.map { it.id }).containsExactly(id)
}
@Test
fun completingAnEditedFirstOccurrenceKeepsItsEdits() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at)
source.updateInstance(id, at, TaskForm(title = "Water the cactus", listId = listId, start = at, due = at + 1.days), reminders = null)
val override = db.tasks().override(id, at)!!
source.setCompletedInstance(override.id, at, completed = true)
assertThat(db.tasks().overrides(id)).isEmpty()
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
val copy = db.tasks().allIn(listId).single { it.id != id }
assertThat(copy.title).isEqualTo("Water the cactus")
assertThat(copy.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun completingTheLastOccurrenceClosesTheSeries() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(db.tasks().allIn(listId).map { it.id }).containsExactly(id)
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun cancellingTheFirstOccurrenceMovesTheSeriesOn() {
val at = now
val id = source.insertTask(form(title = "Water the plants"))
makeRecurring(id, at)
source.setCancelledInstance(id, at, cancelled = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
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 reopeningKeepsWhatChangedOnTheSeriesSince() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.deleteOccurrence(id, at + 28.days)
source.setCompletedInstance(id, at, completed = false)
val master = db.tasks().entity(id)!!
assertThat(master.dtstart).isEqualTo(at)
assertThat(master.exdate).isNotNull()
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
.doesNotContain((at + 28.days).epochSeconds)
}
@Test
fun aSeriesWithExtraDatesKeepsTheOverrideModel() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
db.tasks().update(db.tasks().entity(id)!!.copy(rdate = "20991231T090000Z"))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(db.tasks().overrides(id).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aDeletedSeriesIsNotBroughtBackByItsCopy() {
val at = now
val work = syncedList()
val id = source.insertTask(form(title = "Weekly").copy(listId = work))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
val copy = db.tasks().allIn(work).single { it.id != id }
source.deleteWithSubtasks(id)
source.setCompleted(copy.id, completed = false)
assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.tasks().entity(copy.id)!!.isDeleted).isFalse()
}
@Test
fun aSeriesEndedOnItsLastOpenOccurrenceReopens() {
val at = now
val id = source.insertTask(form(title = "Three"))
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=3")
val master = db.tasks().entity(id)!!
listOf(at, at + 7.days).forEach { done ->
db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = done, rrule = null, status = TaskStatus.COMPLETED))
}
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)
}
@Test
fun reopeningAnOlderCopyReopensItOnItsOwn() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
source.setCompletedInstance(id, at, completed = true)
source.setCompletedInstance(id, at + 7.days, completed = true)
source.setCompletedInstance(id, at, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
val reopened = db.tasks().allIn(listId).single { it.id != id && it.dtstart == at }
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(db.advancedOccurrences().forCopy(reopened.id)).isNull()
}
@Test
fun aSeriesMovesPastOccurrencesAlreadyClosedAfterIt() {
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 + 7.days, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at, completed = true)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 14.days)
assertThat(db.tasks().overrides(id)).isEmpty()
}
@Test
fun aDetachedOccurrenceDeletedSinceStaysExcluded() {
val at = now
val id = source.insertTask(form(title = "Weekly"))
makeRecurring(id, at)
val master = db.tasks().entity(id)!!
val earlier = db.tasks().insert(master.copy(id = 0, masterId = id, recurrenceId = at, rrule = null, status = TaskStatus.COMPLETED))
source.setCompletedInstance(id, at + 7.days, completed = true)
source.deleteTask(earlier)
source.setCompletedInstance(id, at + 7.days, completed = false)
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart?.epochSeconds })
.doesNotContain(at.epochSeconds)
}
@Test
fun aNestedComponentKeepsItsOwnUid() {
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 = "BEGIN:VALARM\r\nUID:alarm-1\r\nACTION:AUDIO\r\nEND:VALARM",
),
)
source.setCompletedInstance(override, at, completed = true)
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))
@@ -657,10 +1117,12 @@ class RoomTasksDataSourceTest {
val outOfWindow = db.tasks().insert(
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
)
val at = now
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = at, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
)
source.setCompletedInstance(series, now, true)
// Not the first occurrence, so it forks an override rather than a copy.
source.setCompletedInstance(series, at + 1.days, true)
val plain = source.insertTask(form("plain").copy(parentId = parent))
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
@@ -162,6 +162,18 @@ class TasksDatabaseMigrationTest {
}
}
@Test
fun addsTheAdvancedOccurrencesTable() {
helper.createDatabase(TEST_DB, 4).close()
helper.runMigrationsAndValidate(TEST_DB, 5, true, TasksDatabase.MIGRATION_4_5).use { db ->
db.query("SELECT COUNT(*) FROM advanced_occurrences").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getInt(0)).isEqualTo(0)
}
}
}
private companion object {
const val TEST_DB = "migration-test.db"
}
@@ -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)
}
@@ -89,7 +89,7 @@ object DataProvideModule {
// backed-up `.db` does not. The backup rules carry all three files and
// the app checkpoints on ON_STOP.
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4, TasksDatabase.MIGRATION_4_5)
.build()
/**
@@ -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) },
)
}
@@ -201,7 +203,8 @@ class TasksRepositoryImpl @Inject constructor(
// Going through the occurrence forks an override instead, which takes
// the reminders on its own row and leaves the series' alone.
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
if (instance == null) dataSource.updateTask(taskId, form, reminders)
// The row the read resolved: a past occurrence may have become a task of its own.
if (instance == null) dataSource.updateTask(current?.taskId ?: taskId, form, reminders)
else dataSource.updateInstance(taskId, instance, form, reminders)
}
@@ -235,7 +238,7 @@ class TasksRepositoryImpl @Inject constructor(
val current = dataSource.task(taskId, occurrenceStart)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(current?.taskId ?: taskId)
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
@@ -62,39 +62,33 @@ class ProviderWriter internal constructor(
private var batchWrote = false
/** Inserts one row and returns its published id. */
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
when (table) {
ProviderTable.TASKS -> insertTask(values)
ProviderTable.PROPERTIES -> insertProperty(values)
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
fun insert(table: ProviderTable, values: ProviderValues): Long = when (table) {
ProviderTable.TASKS -> write { insertTask(values) }
ProviderTable.PROPERTIES -> write { insertProperty(values) }
ProviderTable.LISTS, ProviderTable.INSTANCES -> readOnly(table)
}
/** Updates every row [selection] matches and returns how many changed. */
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
val matched = ids(table, selection, args)
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
}
ProviderTable.INSTANCES -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
instances(selection, args).count { updateInstance(it, writable) }
}
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = when (table) {
ProviderTable.TASKS -> write {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
val matched = ids(table, selection, args)
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
}
ProviderTable.INSTANCES -> write {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
instances(selection, args).count { updateInstance(it, writable) }
}
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { updateProperty(it, values) } }
ProviderTable.LISTS -> readOnly(table)
}
/** Deletes every row [selection] matches and returns how many went. */
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = when (table) {
ProviderTable.TASKS -> write { ids(table, selection, args).count { deleteTask(it) } }
ProviderTable.INSTANCES -> write { instances(selection, args).count { deleteInstance(it) } }
ProviderTable.PROPERTIES -> write { ids(table, selection, args).count { deleteProperty(it) } }
ProviderTable.LISTS -> readOnly(table)
}
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
@@ -113,6 +107,9 @@ class ProviderWriter internal constructor(
}
}
/** 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")
private fun <T> write(block: () -> T): T = source.locked {
check(resolver.mode() == StorageMode.OWN) {
"Agendula is working on top of an external tasks provider; write to that one instead"
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import de.jeanlucmakiola.agendula.domain.TaskStatus
import kotlin.time.Instant
/**
* How a series was moved past one of its occurrences, kept so reopening the
* completed copy can move it back (#137). Local only: never part of a `VTODO`.
* Holds the change, not the old series, so edits made since are kept.
*/
@Entity(
tableName = "advanced_occurrences",
foreignKeys = [
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["copy_id"], onDelete = ForeignKey.CASCADE),
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["master_id"], onDelete = ForeignKey.CASCADE),
],
indices = [Index(value = ["master_id", "anchor"])],
)
data class AdvancedOccurrenceEntity(
/** The completed copy of the occurrence. */
@PrimaryKey @ColumnInfo(name = "copy_id") val copyId: Long,
@ColumnInfo(name = "master_id") val masterId: Long,
@ColumnInfo(name = "anchor") val anchor: Instant,
/** The anchor the series moved to; it is only moved back while it still has it. */
@ColumnInfo(name = "advanced_to") val advancedTo: Instant,
@ColumnInfo(name = "prior_dtstart") val priorDtstart: Instant?,
@ColumnInfo(name = "prior_due") val priorDue: Instant?,
@ColumnInfo(name = "prior_status") val priorStatus: TaskStatus,
@ColumnInfo(name = "prior_percent_complete") val priorPercentComplete: Int?,
@ColumnInfo(name = "prior_completed_at") val priorCompletedAt: Instant?,
/** Occurrences `COUNT` lost to the move. */
@ColumnInfo(name = "spent") val spent: Int,
/** 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,
) {
val dropped: List<Instant>
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
companion object {
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_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)
}
@@ -4,6 +4,7 @@ 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
@@ -100,6 +101,10 @@ object RoomTaskMapper {
val TaskEntity.isRecurring: Boolean
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/** Completed or cancelled. */
val TaskEntity.isClosed: Boolean
get() = status.isClosed
/**
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
@@ -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,6 +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")
/** 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
@@ -103,6 +107,7 @@ class RoomTasksDataSource @Inject constructor(
private val tasks get() = database.tasks()
private val lists get() = database.taskLists()
private val alarms get() = database.alarms()
private val advanced get() = database.advancedOccurrences()
// --- reads ----------------------------------------------------------------
@@ -132,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 }
?: copyAt(row.task, occurrenceStart)?.let { copy ->
tasks.task(copy.id)?.let { RoomTaskMapper.task(it).copy(occurrenceStart = occurrenceStart) }
}
?: task(taskId)
}
@@ -256,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 {
@@ -323,11 +332,7 @@ class RoomTasksDataSource @Inject constructor(
return@tx
}
val now = clock.now()
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
val spent = RecurrenceExpander.expand(
spec.copy(exdate = null),
ExpansionWindow(from = anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING),
).size
val spent = spentBefore(spec, occurrenceStart)
val (start, due) = resolvedTimes(master, occurrenceStart)
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, now, zone()))
@@ -350,9 +355,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 = seriesFreeText(master.unknownProperties),
)
val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone())
val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } }
@@ -466,56 +469,330 @@ 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()))
saveRow(current, 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()))
saveRow(current, TaskFormWriter.completed(current, completed, clock.now()))
writes.onWritten(setOf(current.listId))
}
/**
* Ticking one occurrence forks a `RECURRENCE-ID` override carrying the
* completion — the same model [updateInstance] writes. Writing the status onto
* the master instead would close the series: the master is what
* [TaskDao.tasks] filters on, so every occurrence, past and future, would
* leave every list at once.
* 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). 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) =
writeInstance(taskId, occurrenceStart, "complete") { TaskFormWriter.completed(it, completed, clock.now()) }
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = tx {
closeOccurrence(taskId, occurrenceStart, completed) { TaskFormWriter.completed(it, completed, clock.now()) }
}
/** Same override model as [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) =
writeInstance(taskId, occurrenceStart, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
/** Same model as [setCompletedInstance]. */
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx {
closeOccurrence(taskId, occurrenceStart, cancelled) { TaskFormWriter.cancelled(it, cancelled, clock.now()) }
}
/** 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.
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) {
tasks.update(write(master))
writes.onWritten(setOf(master.listId))
// A plain task, or the copy a ticked occurrence left: this row is the occurrence.
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, 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 ->
saveRow(copy, close(copy))
writes.onWritten(setOf(copy.listId))
}
}
return
}
if (master.isClosed) {
// 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)
}
tasks.override(taskId, occurrenceStart)?.let {
tasks.update(write(it))
writes.onWritten(setOf(master.listId))
return
/**
* 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))
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, 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 {
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
val leading = expandFrom(spec, maxOccurrences = overrides.values.count { it.isClosed } + 2)
val index = leading.indexOfFirst { sameSecond(it, anchor) }
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) ?: return true.also { closeSeries(master, override, close) }
val now = clock.now()
val zone = master.timezone ?: zone()
val detached = (leading.take(index) + leading.subList(index + 1, nextIndex)).map { at ->
at to detach(overrides.getValue(at.epochSeconds), now)
}
// Expansion drops the anchor's sub-second part; the series keeps it.
val newAnchor = next + (spec.anchor - floorSecond(spec.anchor))
val spent = spentBefore(spec, next)
val (start, due) = occurrenceTimes(master, newAnchor)
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,
),
)
val copyId = if (override != null) {
detach(override, now, close)
} else {
val (copyStart, copyDue) = occurrenceTimes(master, anchor)
val id = tasks.insert(close(asCopy(master, now).copy(dtstart = copyStart, due = copyDue)))
alarms.copyToTask(master.id, id, sameResource = false)
id
}
advanced.at(master.id, floorSecond(anchor))?.let { advanced.delete(it.copyId) }
advanced.insert(
AdvancedOccurrenceEntity(
copyId = copyId,
masterId = master.id,
anchor = anchor,
advancedTo = newAnchor,
priorDtstart = master.dtstart,
priorDue = master.due,
priorStatus = master.status,
priorPercentComplete = master.percentComplete,
priorCompletedAt = master.completedAt,
spent = spent,
countAfter = countOf(ruleAfter),
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
fromOverride = override != null,
),
)
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),
ExpansionWindow(from = floorSecond(spec.anchor), until = cut, maxOccurrences = SPLIT_COUNT_CEILING),
).size
/**
* Turns the override [row] into a task of its own, in place so its id keeps
* working: a new UID, unlinked from the series, a resource of its own — and
* so its alarms are fresh copies, not the series' ones.
*/
private fun detach(row: TaskEntity, now: Instant, close: (TaskEntity) -> TaskEntity = { it }): Long {
tasks.update(close(asCopy(row, now).copy(id = row.id)))
refreshAlarms(row.id)
return row.id
}
private fun refreshAlarms(taskId: Long) {
val current = alarms.forTask(taskId)
alarms.deleteForTask(taskId)
current.forEach { alarms.insert(it.freshCopy(taskId)) }
}
/** [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 = seriesFreeText(source.unknownProperties),
)
/**
* Undoes [advance] for the copy [row] while the series still sits where it
* 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, 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 = 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,
)
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)
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))
return true
}
/** [row], a detached occurrence, back as [master]'s override for [anchor]. */
private fun toOverride(row: TaskEntity, master: TaskEntity, anchor: Instant, now: Instant) {
val back = row.copy(
uid = master.uid,
masterId = master.id,
recurrenceId = anchor,
href = null,
etag = null,
isDirty = true,
lastModified = now,
unknownProperties = seriesFreeText(row.unknownProperties),
)
if (row.href == null) {
tasks.update(back)
refreshAlarms(row.id)
} 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 = false)
deleteTask(row.id)
}
}
/** 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, 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> {
val from = floorSecond(spec.anchor)
return RecurrenceExpander.expand(spec, ExpansionWindow(from = from, until = from + SERIES_LOOKAHEAD, maxOccurrences = maxOccurrences))
}
private fun floorSecond(at: Instant) = Instant.fromEpochSeconds(at.epochSeconds)
/**
* The residue without the series' own lines, as a new resource must not
* carry them. Lines inside a nested component (a `VALARM`'s `UID`) are its
* own and stay.
*/
private fun seriesFreeText(unknown: String?): String? {
var depth = 0
return unknown?.lines()?.filter { line ->
when {
line.startsWith("BEGIN:", ignoreCase = true) -> { depth++; true }
line.startsWith("END:", ignoreCase = true) -> { depth--; true }
else -> depth > 0 || (line.isNotBlank() && SERIES_LINES.none { line.startsWith(it, ignoreCase = true) })
}
}?.joinToString("\r\n")?.ifBlank { null }
}
/** Expansion runs in whole seconds; stored anchors may carry milliseconds. */
private fun sameSecond(a: Instant, b: Instant) = a.epochSeconds == b.epochSeconds
/** 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)
}
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))
}
@@ -608,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. Four 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
@@ -21,8 +21,10 @@ import kotlin.time.Instant
TaskListEntity::class,
TaskEntity::class,
TaskAlarmEntity::class,
AdvancedOccurrenceEntity::class,
AdvancedDetachedEntity::class,
],
version = 4,
version = 5,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -31,6 +33,7 @@ abstract class TasksDatabase : RoomDatabase() {
abstract fun tasks(): TaskDao
abstract fun alarms(): TaskAlarmDao
abstract fun accounts(): AccountDao
abstract fun advancedOccurrences(): AdvancedOccurrenceDao
companion object {
const val NAME = "agendula-tasks.db"
@@ -180,5 +183,26 @@ abstract class TasksDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
}
}
/** v5 remembers how ticking an occurrence moved its series, so reopening it can move it back. */
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, `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`)",
)
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