Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a399b25079 | ||
|
|
4335f65980 | ||
|
|
22c57710b7 | ||
|
|
2f314890fb | ||
|
|
5ffd740050 | ||
|
|
9e5e98ccee |
No files matched your search
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
|
||||
+18
-2
@@ -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 }
|
||||
|
||||
+467
-5
@@ -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)
|
||||
|
||||
+12
@@ -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"
|
||||
}
|
||||
|
||||
+42
@@ -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
|
||||
|
||||
+23
-26
@@ -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
|
||||
|
||||
+320
-43
@@ -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
|
||||
|
||||
Reference in new issue
Block a user