Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d30c0e0a4 | ||
|
|
d9dc358883 |
No files matched your search
+4
-7
@@ -7,13 +7,10 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Sub-tasks of a repeating task start over when ticking off its current
|
||||
occurrence moves it on to its next date, as in tasks.org: completed and
|
||||
half-done ones open again, and dated ones move along with the task. Before,
|
||||
the next occurrence showed them already ticked. Unticking gives them back
|
||||
their state and dates, and repeating sub-tasks keep their own schedule
|
||||
(#160).
|
||||
### Added
|
||||
- A "Description in reminders" setting under Notifications sets how much of a
|
||||
task's description a reminder shows: none, a short preview of up to three
|
||||
lines (the new default), or all of it as before (#156).
|
||||
|
||||
## [1.3.1] - 2026-11-09
|
||||
|
||||
|
||||
@@ -1,775 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "6c8058a22084f0af602bc2f23c949b3c",
|
||||
"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, `rule_after` TEXT, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, `undoable` INTEGER NOT NULL DEFAULT 1, `settled` INTEGER NOT NULL DEFAULT 0, `reset_subtasks` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"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": "ruleAfter",
|
||||
"columnName": "rule_after",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "droppedExdates",
|
||||
"columnName": "dropped_exdates",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fromOverride",
|
||||
"columnName": "from_override",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "undoable",
|
||||
"columnName": "undoable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "settled",
|
||||
"columnName": "settled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "resetSubtasks",
|
||||
"columnName": "reset_subtasks",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"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, '6c8058a22084f0af602bc2f23c949b3c')"
|
||||
]
|
||||
}
|
||||
}
|
||||
-139
@@ -14,7 +14,6 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -701,144 +700,6 @@ class RoomTasksDataSourceTest {
|
||||
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingARoutineOnReopensItsCompletedSubtasks() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val done = source.insertTask(form(title = "Step").copy(parentId = id))
|
||||
val nested = source.insertTask(form(title = "Nested").copy(parentId = done))
|
||||
val cancelled = source.insertTask(form(title = "Skipped").copy(parentId = id))
|
||||
val daily = source.insertTask(form(title = "Daily part").copy(parentId = id))
|
||||
makeRecurring(daily, at, rule = "FREQ=DAILY")
|
||||
source.setCompleted(done, completed = true)
|
||||
source.setCompleted(nested, completed = true)
|
||||
source.setCancelled(cancelled, cancelled = true)
|
||||
source.setCompletedInstance(daily, at, completed = true)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days)
|
||||
assertThat(db.tasks().entity(done)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(done)!!.completedAt).isNull()
|
||||
assertThat(db.tasks().entity(nested)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(cancelled)!!.status).isEqualTo(TaskStatus.CANCELLED)
|
||||
// A recurring subtask has its own series, and its closed occurrence stays closed.
|
||||
assertThat(db.tasks().overrides(daily).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHalfDoneSubtaskStartsOverToo() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val step = source.insertTask(form(title = "Step", percentComplete = 40).copy(parentId = id))
|
||||
db.tasks().update(db.tasks().entity(step)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 40))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(step)!!.percentComplete).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingARoutineBackRestoresTheSubtasksItReset() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val done = source.insertTask(form(title = "Step").copy(parentId = id))
|
||||
val touched = source.insertTask(form(title = "Touched since").copy(parentId = id))
|
||||
source.setCompleted(done, completed = true)
|
||||
source.setCompleted(touched, completed = true)
|
||||
val completedAt = db.tasks().entity(done)!!.completedAt
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
db.tasks().update(db.tasks().entity(touched)!!.copy(percentComplete = 30))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at)
|
||||
assertThat(db.tasks().entity(done)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(db.tasks().entity(done)!!.completedAt).isEqualTo(completedAt)
|
||||
// Changed after the reset: the user's state wins.
|
||||
assertThat(db.tasks().entity(touched)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(touched)!!.percentComplete).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDatedSubtaskMovesWithItsRoutineAndBack() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
|
||||
val undated = source.insertTask(form(title = "Undated").copy(parentId = id))
|
||||
source.setCompleted(step, completed = true)
|
||||
val due = db.tasks().entity(step)!!.due!!
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
// A week of wall-clock time: an hour either way across a DST change.
|
||||
assertThat(db.tasks().entity(step)!!.due!! - due).isIn(com.google.common.collect.Range.closed(7.days - 1.hours, 7.days + 1.hours))
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().entity(undated)!!.due).isNull()
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(step)!!.due).isEqualTo(due)
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarkedCopyUnderARoutineIsLeftAlone() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val routineUid = db.tasks().entity(id)!!.uid
|
||||
val copy = source.insertTask(form(title = "Daily part").copy(parentId = id))
|
||||
val elsewhere = source.insertTask(form(title = "Moved here").copy(parentId = id))
|
||||
source.setCompleted(copy, completed = true)
|
||||
source.setCompleted(elsewhere, completed = true)
|
||||
db.tasks().update(db.tasks().entity(copy)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:$routineUid"))
|
||||
db.tasks().update(db.tasks().entity(elsewhere)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:another-parent"))
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(copy)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
// Its marker names a parent it no longer sits under: an ordinary sub-task here.
|
||||
assertThat(db.tasks().entity(elsewhere)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLegacyResetRecordRestoresTheStateAndLeavesTheDates() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Routine"))
|
||||
makeRecurring(id, at)
|
||||
val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id))
|
||||
source.setCompleted(step, completed = true)
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
val record = db.advancedOccurrences().forMaster(id).single()
|
||||
db.advancedOccurrences().insert(record.copy(resetSubtasks = "$step,COMPLETED,100,"))
|
||||
val movedDue = db.tasks().entity(step)!!.due
|
||||
|
||||
source.setCompletedInstance(id, at, completed = false)
|
||||
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(db.tasks().entity(step)!!.due).isEqualTo(movedDue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRoutineTickedOnItsLastOccurrenceLeavesItsSubtasksDone() {
|
||||
val at = now
|
||||
val id = source.insertTask(form(title = "Once more"))
|
||||
makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=1")
|
||||
val step = source.insertTask(form(title = "Step").copy(parentId = id))
|
||||
source.setCompleted(step, completed = true)
|
||||
|
||||
source.setCompletedInstance(id, at, completed = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSeriesReshapedSinceIsNotMovedBack() {
|
||||
val at = now
|
||||
|
||||
-19
@@ -174,25 +174,6 @@ class TasksDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addsAnEmptyResetSubtasksRecord() {
|
||||
helper.createDatabase(TEST_DB, 5).use { db ->
|
||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Personal', 0)")
|
||||
db.execSQL("INSERT INTO tasks (id, list_id, uid) VALUES (1, 1, 'a'), (2, 1, 'b')")
|
||||
db.execSQL(
|
||||
"INSERT INTO advanced_occurrences (copy_id, master_id, anchor, advanced_to, prior_status, spent, from_override) " +
|
||||
"VALUES (2, 1, 0, 1, 0, 1, 0)",
|
||||
)
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(TEST_DB, 6, true, TasksDatabase.MIGRATION_5_6).use { db ->
|
||||
db.query("SELECT reset_subtasks FROM advanced_occurrences").use { c ->
|
||||
assertThat(c.moveToFirst()).isTrue()
|
||||
assertThat(c.getString(0)).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
|
||||
@@ -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, TasksDatabase.MIGRATION_4_5, TasksDatabase.MIGRATION_5_6)
|
||||
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4, TasksDatabase.MIGRATION_4_5)
|
||||
.build()
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,9 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||
}
|
||||
|
||||
/** How much of a task's description a due reminder shows when expanded. */
|
||||
enum class ReminderDescription { HIDDEN, SHORT, FULL }
|
||||
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
|
||||
|
||||
@@ -103,6 +106,7 @@ data class Settings(
|
||||
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||
/** How long a reminder's "Snooze" action puts it off. */
|
||||
val snoozeMinutes: Int = DEFAULT_SNOOZE_MINUTES,
|
||||
val reminderDescription: ReminderDescription = ReminderDescription.SHORT,
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** The first day of the week; `null` follows the locale. */
|
||||
val weekStart: DayOfWeek? = null,
|
||||
@@ -171,6 +175,9 @@ class SettingsPrefs @Inject constructor(
|
||||
sortOrder = p[SORT_ORDER]?.let { runCatching { TaskSortOrder.valueOf(it) }.getOrNull() }
|
||||
?: TaskSortOrder.DUE,
|
||||
snoozeMinutes = p[SNOOZE_MINUTES]?.takeIf { it > 0 } ?: DEFAULT_SNOOZE_MINUTES,
|
||||
reminderDescription = p[REMINDER_DESCRIPTION]
|
||||
?.let { runCatching { ReminderDescription.valueOf(it) }.getOrNull() }
|
||||
?: ReminderDescription.SHORT,
|
||||
timeFormat = p[TIME_FORMAT]?.let { runCatching { TimeFormatPref.valueOf(it) }.getOrNull() }
|
||||
?: TimeFormatPref.AUTO,
|
||||
autofocusTitle = p[AUTOFOCUS_TITLE] ?: true,
|
||||
@@ -199,6 +206,9 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
|
||||
|
||||
suspend fun setReminderDescription(pref: ReminderDescription) =
|
||||
dataStore.edit { it[REMINDER_DESCRIPTION] = pref.name }
|
||||
|
||||
suspend fun setSyncIntervalMinutes(minutes: Int) = dataStore.edit { it[SYNC_INTERVAL] = minutes }
|
||||
|
||||
suspend fun setPushEnabled(enabled: Boolean) = dataStore.edit { it[PUSH_ENABLED] = enabled }
|
||||
@@ -345,6 +355,7 @@ class SettingsPrefs @Inject constructor(
|
||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||
val SORT_ORDER = stringPreferencesKey("sort_order")
|
||||
val SNOOZE_MINUTES = intPreferencesKey("snooze_minutes")
|
||||
val REMINDER_DESCRIPTION = stringPreferencesKey("reminder_description")
|
||||
val TIME_FORMAT = stringPreferencesKey("time_format")
|
||||
val WEEK_START = stringPreferencesKey("week_start")
|
||||
val AUTOFOCUS_TITLE = booleanPreferencesKey("autofocus_title")
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
import de.jeanlucmakiola.agendula.MainActivity
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||
@@ -86,7 +87,11 @@ class TaskNotifier @Inject constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
|
||||
val description = reminderBody(
|
||||
task.description?.readableDescription(),
|
||||
settings.reminderDescription,
|
||||
lineChars = (NOTIFICATION_LINE_CHARS / context.resources.configuration.fontScale).toInt(),
|
||||
)
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
task.taskId.toInt(),
|
||||
@@ -277,3 +282,96 @@ internal fun expandedText(dueLine: String?, description: String?): String? = whe
|
||||
dueLine == null -> description
|
||||
else -> "$dueLine\n\n$description"
|
||||
}
|
||||
|
||||
/** Rough characters per line of an expanded notification on a phone, at the default font size. */
|
||||
private const val NOTIFICATION_LINE_CHARS = 40
|
||||
|
||||
/** The description a reminder shows, as [mode] asks; null when there is none to show. */
|
||||
internal fun reminderBody(
|
||||
description: String?,
|
||||
mode: ReminderDescription,
|
||||
lineChars: Int = NOTIFICATION_LINE_CHARS,
|
||||
): String? {
|
||||
val text = description?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return when (mode) {
|
||||
ReminderDescription.HIDDEN -> null
|
||||
ReminderDescription.SHORT -> shortDescription(text, lineChars = lineChars)
|
||||
ReminderDescription.FULL -> text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [description] cut to about [maxLines] lines of an expanded notification,
|
||||
* ending in an ellipsis when anything was left out. Blank lines are dropped,
|
||||
* and a long line counts as the lines it wraps to.
|
||||
*/
|
||||
internal fun shortDescription(
|
||||
description: String,
|
||||
maxLines: Int = 3,
|
||||
lineChars: Int = NOTIFICATION_LINE_CHARS,
|
||||
): String {
|
||||
val perLine = lineChars.coerceAtLeast(2)
|
||||
val lines = description.lines().map { it.trimEnd() }.filter { it.isNotBlank() }
|
||||
val kept = mutableListOf<String>()
|
||||
var rowsLeft = maxLines
|
||||
for ((index, line) in lines.withIndex()) {
|
||||
val width = displayWidth(line)
|
||||
val rows = (width + perLine - 1) / perLine
|
||||
val last = index == lines.lastIndex
|
||||
// The ellipsis after a line that ends the budget needs a cell of its own.
|
||||
val fits = rows < rowsLeft || (rows == rowsLeft && (last || width < rows * perLine))
|
||||
if (fits) {
|
||||
kept += line
|
||||
rowsLeft -= rows
|
||||
if (rowsLeft == 0 && !last) return ellipsize(kept)
|
||||
continue
|
||||
}
|
||||
if (rowsLeft > 0) kept += cutAtWord(line, rowsLeft * perLine - 1)
|
||||
return ellipsize(kept)
|
||||
}
|
||||
return kept.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ellipsize(lines: List<String>): String =
|
||||
lines.joinToString("\n").trimEnd { it.isWhitespace() || it in ".,;:" } + "\u2026"
|
||||
|
||||
/** [line] cut to at most [max] cells, at a word break when there is one, never inside a character. */
|
||||
private fun cutAtWord(line: String, max: Int): String {
|
||||
var end = 0
|
||||
var width = 0
|
||||
while (end < line.length) {
|
||||
val codePoint = line.codePointAt(end)
|
||||
width += cellsOf(codePoint)
|
||||
if (width > max) break
|
||||
end += Character.charCount(codePoint)
|
||||
}
|
||||
if (end >= line.length || line[end].isWhitespace()) return line.substring(0, end)
|
||||
val cut = line.substring(0, end)
|
||||
val space = cut.indexOfLast { it.isWhitespace() }
|
||||
return if (space > end / 2) cut.substring(0, space) else cut
|
||||
}
|
||||
|
||||
/** How many cells [text] takes, wide characters (CJK, emoji) counting two. */
|
||||
private fun displayWidth(text: String): Int {
|
||||
var width = 0
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val codePoint = text.codePointAt(i)
|
||||
width += cellsOf(codePoint)
|
||||
i += Character.charCount(codePoint)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private fun cellsOf(codePoint: Int): Int = when {
|
||||
Character.isSupplementaryCodePoint(codePoint) -> 2
|
||||
Character.UnicodeScript.of(codePoint) in WIDE_SCRIPTS -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
private val WIDE_SCRIPTS = setOf(
|
||||
Character.UnicodeScript.HAN,
|
||||
Character.UnicodeScript.HIRAGANA,
|
||||
Character.UnicodeScript.KATAKANA,
|
||||
Character.UnicodeScript.HANGUL,
|
||||
)
|
||||
+48
-185
@@ -25,8 +25,6 @@ import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.domain.toInt
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
@@ -137,8 +135,21 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
* the window still counts. All columns and a lenient `_deleted`, as in
|
||||
* [exportTasks], since the table's shape varies across providers.
|
||||
*/
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||
childRows(authority(), parentTaskId, projection = null) { it.getLong(Tasks.ID) }
|
||||
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
|
||||
TasksContract.tasksUri(authority()),
|
||||
null,
|
||||
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||
arrayOf(parentTaskId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
|
||||
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* All columns and no sort: the instances view differs across provider
|
||||
@@ -196,7 +207,12 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
filterArgs: List<String> = emptyList(),
|
||||
sortOrder: String? = null,
|
||||
read: (ColumnReader) -> T?,
|
||||
): List<T> = inSelections(idColumn, ids, filter, filterArgs).flatMap { (selection, args) ->
|
||||
): List<T> = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk ->
|
||||
val selection = buildString {
|
||||
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||
if (filter != null) append(" AND ($filter)")
|
||||
}
|
||||
val args = (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||
resolver.query(uri, projection, selection, args, sortOrder)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
buildList { while (c.moveToNext()) read(reader)?.let(::add) }
|
||||
@@ -329,7 +345,18 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
arrayOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ),
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) TaskMapper.seriesSpec(CursorColumnReader(c)) else null
|
||||
if (!c.moveToFirst()) return@use null
|
||||
val r = CursorColumnReader(c)
|
||||
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null
|
||||
val rule = TaskMapper.seriesRule(r)
|
||||
RecurrenceSpec(
|
||||
rrule = rule?.rrule,
|
||||
rdate = rule?.rdate,
|
||||
exdate = r.getString(Tasks.EXDATE),
|
||||
anchor = Instant.fromEpochMilliseconds(anchor),
|
||||
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||
timeZone = r.getString(Tasks.TZ),
|
||||
)
|
||||
}
|
||||
|
||||
/** End the master's rule before [occurrenceStart], and drop the exceptions past it. */
|
||||
@@ -726,8 +753,6 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
* Runs [write], and when it closed an occurrence the provider then detached
|
||||
* into a task of its own, remembers that copy, so the occurrence can still be
|
||||
* read and reopened, and how the series moved, so reopening can move it back.
|
||||
* When the series moved on, its sub-tasks start over for the next occurrence,
|
||||
* and [resets] remembers how they were (#160), with or without a copy.
|
||||
*/
|
||||
private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int {
|
||||
if (!closing) return write()
|
||||
@@ -736,18 +761,9 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
val before = runCatching { seriesState(authority, taskId) }.getOrNull()
|
||||
return write().also { rows ->
|
||||
if (rows > 0) {
|
||||
val after = before?.let {
|
||||
runCatching { seriesState(authority, taskId) }
|
||||
.onFailure { Log.w(TAG, "Reading the series after the tick failed", it) }
|
||||
.getOrNull()?.takeIf { it != before }
|
||||
}
|
||||
if (before != null && after != null && SubtaskReset.movedOn(before, after)) {
|
||||
runCatching { resetSubtasks(authority, taskId, before, after) }
|
||||
.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }
|
||||
}
|
||||
runCatching {
|
||||
detachedSince(authority, lastId)?.let { copy ->
|
||||
markCopy(authority, copy, taskId)
|
||||
val after = before?.let { seriesState(authority, taskId) }?.takeIf { it != before }
|
||||
val tick = Tick(copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis())
|
||||
ticks.put(ticks.key(authority, taskId, occurrenceStart), tick)
|
||||
}
|
||||
@@ -756,143 +772,12 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks [copyId], left by ticking the recurring sub-task [seriesId], with the
|
||||
* UID of the parent it sits under, where DAVx5 keeps and uploads unknown
|
||||
* properties, so other installs know it too. A top-level series' copy sits
|
||||
* under nothing a reset walks, so it is left unmarked.
|
||||
*/
|
||||
private fun markCopy(authority: String, copyId: Long, seriesId: Long) {
|
||||
runCatching {
|
||||
val parentId = resolver.query(taskUri(authority, seriesId), arrayOf(Tasks.PARENT_ID), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null } ?: return
|
||||
val parentUid = uidOf(authority, parentId) ?: return
|
||||
val values = mapOf(
|
||||
Properties.TASK_ID to copyId,
|
||||
Properties.MIMETYPE to SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE,
|
||||
Properties.DATA0 to SubtaskReset.copyMarkerJson(parentUid),
|
||||
)
|
||||
resolver.insert(TasksContract.propertiesUri(authority), values.toContentValues())
|
||||
}.onFailure { Log.w(TAG, "Marking the ticked copy failed", it) }
|
||||
}
|
||||
|
||||
private fun uidOf(authority: String, taskId: Long): String? =
|
||||
resolver.query(taskUri(authority, taskId), arrayOf(Tasks.UID), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
|
||||
/** The parent UID each of [taskIds] carries a copy marker for. */
|
||||
private fun markerParents(authority: String, taskIds: List<Long>): Map<Long, String> = runCatching {
|
||||
queryByIds(
|
||||
TasksContract.propertiesUri(authority),
|
||||
arrayOf(Properties.TASK_ID, Properties.DATA0),
|
||||
Properties.TASK_ID,
|
||||
taskIds,
|
||||
filter = "${Properties.MIMETYPE} = ?",
|
||||
filterArgs = listOf(SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE),
|
||||
) { r ->
|
||||
val id = r.getLong(Properties.TASK_ID) ?: return@queryByIds null
|
||||
SubtaskReset.markerParentFromJson(r.getString(Properties.DATA0))?.let { id to it }
|
||||
}.toMap()
|
||||
}.onFailure { Log.w(TAG, "Reading the copy markers failed", it) }.getOrDefault(emptyMap())
|
||||
|
||||
/**
|
||||
* Resets what [SubtaskReset] picks under the series [seriesId], dated ones
|
||||
* moved as the series moved from [before] to [after], and records it in
|
||||
* [resets] first, under the anchor the series moved to.
|
||||
*/
|
||||
private fun resetSubtasks(authority: String, seriesId: Long, before: Map<String, Any?>, after: Map<String, Any?>) {
|
||||
val copies = ticks.copies(authority)
|
||||
val uids = HashMap<Long, String?>().apply { put(seriesId, uidOf(authority, seriesId)) }
|
||||
val reset = SubtaskReset.toReset(seriesId, SubtaskReset.shiftOf(before, after)) { id ->
|
||||
val rows = childRows(authority, id, SUBTASK_STATE) { r ->
|
||||
r.getLong(Tasks.ID)?.let { rowId ->
|
||||
uids[rowId] = r.getString(Tasks.UID)
|
||||
// Read now: the cursor is closed by the time the markers are in.
|
||||
val rrule = r.getString(Tasks.RRULE)
|
||||
val rdate = r.getString(Tasks.RDATE)
|
||||
val state = subtaskState(r)
|
||||
val dates = SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE))
|
||||
val allDay = r.getBoolean(Tasks.IS_ALLDAY)
|
||||
val tz = r.getString(Tasks.TZ)
|
||||
rowId to { markerParent: String? ->
|
||||
SubtaskReset.providerNode(
|
||||
rowId, rrule, rdate, copies, markerParent = markerParent, parentUid = uids[id],
|
||||
state = state, dates = dates, isAllDay = allDay, timeZone = tz,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val markers = markerParents(authority, rows.map { it.first })
|
||||
rows.map { (rowId, node) -> node(markers[rowId]) }
|
||||
}
|
||||
if (reset.isEmpty()) return
|
||||
SubtaskReset.anchor(after)?.let { resets.put(authority, seriesId, it, SubtaskReset.encode(reset), System.currentTimeMillis()) }
|
||||
val ops = reset.map { subtaskOp(authority, it.id, SubtaskState.FRESH, it.dates?.takeIf { d -> d != it.priorDates }) }
|
||||
resolver.applyBatch(authority, ArrayList(ops))
|
||||
}
|
||||
|
||||
private fun subtaskState(r: ColumnReader) =
|
||||
SubtaskState(statusFromInt(r.getInt(Tasks.STATUS)), r.getInt(Tasks.PERCENT_COMPLETE), r.getLong(Tasks.COMPLETED))
|
||||
|
||||
/** [state] onto sub-task [id], and [dates] when they change. */
|
||||
private fun subtaskOp(authority: String, id: Long, state: SubtaskState, dates: SubtaskDates?): ContentProviderOperation {
|
||||
val values = buildMap<String, Any?> {
|
||||
put(Tasks.STATUS, state.status.toInt())
|
||||
put(Tasks.PERCENT_COMPLETE, state.percent)
|
||||
put(Tasks.COMPLETED, state.completedAt)
|
||||
if (dates != null) {
|
||||
put(Tasks.DTSTART, dates.start)
|
||||
put(Tasks.DUE, dates.due)
|
||||
}
|
||||
}
|
||||
return ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build()
|
||||
}
|
||||
|
||||
/** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state and dates. */
|
||||
private fun subtaskRestore(authority: String, reset: String?): List<ContentProviderOperation> {
|
||||
val entries = SubtaskReset.decode(reset)
|
||||
if (entries.isEmpty()) return emptyList()
|
||||
val current = queryByIds(
|
||||
TasksContract.tasksUri(authority),
|
||||
SUBTASK_STATE,
|
||||
Tasks.ID,
|
||||
entries.map { it.id },
|
||||
filter = "${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0",
|
||||
) { r ->
|
||||
r.getLong(Tasks.ID)?.let {
|
||||
it to SubtaskNow(subtaskState(r), SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE)), r.getBoolean(Tasks.IS_ALLDAY))
|
||||
}
|
||||
}.toMap()
|
||||
val outcome = SubtaskReset.toRestore(entries, current::get)
|
||||
if (outcome.changed.isNotEmpty()) Log.i(TAG, "Sub-tasks ${outcome.changed} changed since the reset and keep their state")
|
||||
return outcome.restore.map { subtaskOp(authority, it.id, it.prior, it.priorDates?.takeIf { d -> d != it.dates }) }
|
||||
}
|
||||
|
||||
/** Walks [parentId]'s direct sub-task rows, overrides left out, with [read] on each. */
|
||||
private fun <T : Any> childRows(authority: String, parentId: Long, projection: Array<String>?, read: (ColumnReader) -> T?): List<T> =
|
||||
resolver.query(
|
||||
TasksContract.tasksUri(authority),
|
||||
projection,
|
||||
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||
arrayOf(parentId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
val r = CursorColumnReader(c)
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null || r.getLong(Tasks.ID) == parentId) continue
|
||||
read(r)?.let(::add)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* Undoes the tick that left [taskId]'s occurrence at [occurrenceStart] — or,
|
||||
* with no occurrence, the tick that left the copy [taskId]: the series goes
|
||||
* back where it was and the copy goes, as long as the series is still where
|
||||
* the tick moved it and the copy has nothing filed under it. The sub-tasks
|
||||
* the tick reset get their state back from [resets]. False when it cannot,
|
||||
* so the caller reopens the copy as a task of its own.
|
||||
* the tick moved it and the copy has nothing filed under it. False when it
|
||||
* cannot, so the caller reopens the copy as a task of its own.
|
||||
*/
|
||||
private fun movedBack(taskId: Long, occurrenceStart: Instant?): Boolean = runCatching {
|
||||
val authority = authority()
|
||||
@@ -901,29 +786,21 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
?: return false
|
||||
val series = tick.seriesId ?: return false
|
||||
val before = tick.before ?: return false
|
||||
val after = tick.after ?: return false
|
||||
// Compared on what the tick recorded: one from before IS_ALLDAY and TZ were read has neither.
|
||||
val now = seriesState(authority, series) ?: return false
|
||||
if (after.any { (column, value) -> now[column] != value } || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||
val restore = before.filter { (column, value) -> after[column] != value }
|
||||
if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false
|
||||
val restore = before.filter { (column, value) -> tick.after?.get(column) != value }
|
||||
val ops = arrayListOf(
|
||||
ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(),
|
||||
ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(),
|
||||
)
|
||||
val anchor = SubtaskReset.anchor(after)
|
||||
anchor?.let { at ->
|
||||
runCatching { ops += subtaskRestore(authority, resets.get(authority, series, at)) }
|
||||
.onFailure { Log.w(TAG, "Reading the reset sub-tasks failed", it) }
|
||||
}
|
||||
resolver.applyBatch(authority, ops)
|
||||
ticks.remove(key)
|
||||
anchor?.let { resets.remove(authority, series, it) }
|
||||
true
|
||||
}.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false)
|
||||
|
||||
/** The columns a tick moves a series by, or null when [taskId] is no series. */
|
||||
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? =
|
||||
resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
|
||||
private fun seriesState(authority: String, taskId: Long): Map<String, Any?>? {
|
||||
if (seriesRules(listOf(taskId)).isEmpty()) return null
|
||||
return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
SERIES_STATE.indices.associate { i ->
|
||||
SERIES_STATE[i] to when (c.getType(i)) {
|
||||
@@ -932,7 +809,8 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
else -> c.getString(i)
|
||||
}
|
||||
}
|
||||
}?.takeIf { state -> listOf(Tasks.RRULE, Tasks.RDATE).any { !(state[it] as? String).isNullOrBlank() } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun lastTaskId(authority: String): Long =
|
||||
resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC")
|
||||
@@ -952,12 +830,7 @@ class AndroidTasksDataSource @Inject constructor(
|
||||
}
|
||||
}?.singleOrNull()
|
||||
|
||||
private val ticks = TickedCopies(
|
||||
prefs = { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) },
|
||||
known = { context.getSharedPreferences(KNOWN_COPIES, Context.MODE_PRIVATE) },
|
||||
)
|
||||
|
||||
private val resets = TickResets { context.getSharedPreferences(TICK_RESETS, Context.MODE_PRIVATE) }
|
||||
private val ticks = TickedCopies { context.getSharedPreferences(TICKED_COPIES, Context.MODE_PRIVATE) }
|
||||
|
||||
/** The copy a tick left for [taskId]'s occurrence at [occurrenceStart], if one was remembered. */
|
||||
private fun tickedCopy(taskId: Long, occurrenceStart: Instant): Long? =
|
||||
@@ -1066,22 +939,10 @@ private const val MIGRATIONS = "provider_migrations"
|
||||
|
||||
private const val TICKED_COPIES = "provider_ticked_copies"
|
||||
|
||||
/** Every copy a tick on this install left, kept past [TickedCopies.HORIZON_MS] alongside the marker on the row. */
|
||||
private const val KNOWN_COPIES = "provider_known_copies"
|
||||
|
||||
/** The sub-tasks each tick reset, kept apart from its copy. */
|
||||
private const val TICK_RESETS = "provider_tick_resets"
|
||||
|
||||
/** What resetting a sub-task reads: whether it recurs, its progress and its dates. */
|
||||
private val SUBTASK_STATE = arrayOf(
|
||||
Tasks.ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.RRULE, Tasks.RDATE, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED,
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.UID,
|
||||
)
|
||||
|
||||
/** What a tick changes on a series, and an undo changes back; with what its rule is read in. */
|
||||
/** What a tick changes on a series, and an undo changes back. */
|
||||
private val SERIES_STATE = arrayOf(
|
||||
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, Tasks.TZ,
|
||||
Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE,
|
||||
)
|
||||
|
||||
private const val TAG = "AndroidTasksDataSource"
|
||||
@@ -1089,3 +950,5 @@ private const val TAG = "AndroidTasksDataSource"
|
||||
/** No rule to look up: the provider says the row does not recur. */
|
||||
private const val NO_RULE = -1L
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
private const val MAX_SQL_ARGS = 999
|
||||
@@ -1,23 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
/** SQLite's default bound-argument limit on older Android releases. */
|
||||
internal const val MAX_SQL_ARGS = 999
|
||||
|
||||
/**
|
||||
* `idColumn IN (…)` selections over [ids], each with its arguments and
|
||||
* [filterArgs] after them, in chunks that keep every query within [limit]
|
||||
* bound arguments.
|
||||
*/
|
||||
internal fun inSelections(
|
||||
idColumn: String,
|
||||
ids: Collection<Long>,
|
||||
filter: String? = null,
|
||||
filterArgs: List<String> = emptyList(),
|
||||
limit: Int = MAX_SQL_ARGS,
|
||||
): List<Pair<String, Array<String>>> = ids.distinct().chunked(limit - filterArgs.size).map { chunk ->
|
||||
val selection = buildString {
|
||||
append("$idColumn IN (${chunk.joinToString(",") { "?" }})")
|
||||
if (filter != null) append(" AND ($filter)")
|
||||
}
|
||||
selection to (chunk.map(Long::toString) + filterArgs).toTypedArray()
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.isClosed
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.dateZone
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
|
||||
/** A sub-task's progress, which a routine's next occurrence starts without. */
|
||||
internal data class SubtaskState(val status: TaskStatus, val percent: Int?, val completedAt: Long?) {
|
||||
/** Open with nothing done yet: what a reset leaves. */
|
||||
val isFresh: Boolean get() = status == TaskStatus.NEEDS_ACTION && (percent ?: 0) == 0 && completedAt == null
|
||||
|
||||
companion object {
|
||||
val FRESH = SubtaskState(TaskStatus.NEEDS_ACTION, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** A sub-task's own start and due, as epoch millis. */
|
||||
internal data class SubtaskDates(val start: Long?, val due: Long?) {
|
||||
val isEmpty: Boolean get() = start == null && due == null
|
||||
|
||||
/** The same dates as a store keeps them: to the second, or for an all-day task to the day. */
|
||||
fun sameAs(other: SubtaskDates, isAllDay: Boolean): Boolean {
|
||||
fun unit(at: Long?) = at?.let { Math.floorDiv(it, if (isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND) }
|
||||
return unit(start) == unit(other.start) && unit(due) == unit(other.due)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NONE = SubtaskDates(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of
|
||||
* a recurring one left. Its dates are read in [dateZone] of [isAllDay] and [timeZone].
|
||||
*/
|
||||
internal class SubtaskNode(
|
||||
val id: Long,
|
||||
val recurring: Boolean,
|
||||
val state: SubtaskState,
|
||||
val dates: SubtaskDates = SubtaskDates.NONE,
|
||||
val isAllDay: Boolean = false,
|
||||
val timeZone: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A sub-task [SubtaskReset] reset: its state and dates before, and the dates the
|
||||
* reset gave it. A record from before dates were kept has neither, and its dates
|
||||
* are neither compared nor restored.
|
||||
*/
|
||||
internal data class ResetSubtask(
|
||||
val id: Long,
|
||||
val prior: SubtaskState,
|
||||
val priorDates: SubtaskDates? = SubtaskDates.NONE,
|
||||
val dates: SubtaskDates? = priorDates,
|
||||
)
|
||||
|
||||
/** A reset sub-task as it is now, for [SubtaskReset.toRestore]. */
|
||||
internal class SubtaskNow(val state: SubtaskState, val dates: SubtaskDates, val isAllDay: Boolean)
|
||||
|
||||
/** What moving a series back does with its reset sub-tasks: who gets their state back, and who changed since. */
|
||||
internal class SubtaskRestore(val restore: List<ResetSubtask>, val changed: List<Long>)
|
||||
|
||||
/**
|
||||
* How far a series moved in [seriesZone]: wall-clock time for a timed sub-task,
|
||||
* so it keeps its place relative to the routine across a DST change, and the
|
||||
* change of calendar date for an all-day one. [floating] reads a sub-task with
|
||||
* no zone of its own.
|
||||
*/
|
||||
internal class SeriesShift(from: Long, to: Long, seriesZone: ZoneId, private val floating: ZoneId = ZoneId.systemDefault()) {
|
||||
private val delta = Duration.between(local(from, seriesZone), local(to, seriesZone))
|
||||
private val days = ChronoUnit.DAYS.between(local(from, seriesZone).toLocalDate(), local(to, seriesZone).toLocalDate())
|
||||
|
||||
/** [at] moved along. */
|
||||
fun apply(at: Long, isAllDay: Boolean, timeZone: String? = null): Long {
|
||||
val zone = dateZone(isAllDay, timeZone, floating)
|
||||
val moved = if (isAllDay) local(at, zone).plusDays(days) else local(at, zone).plus(delta)
|
||||
return moved.atZone(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
private fun local(at: Long, zone: ZoneId) = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(at), zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a routine's sub-tasks do when its series moves on to the next occurrence
|
||||
* (#160): the ones completed or in progress start over, as in tasks.org, dated
|
||||
* ones move along with the routine, and moving the series back gives them their
|
||||
* state and dates again.
|
||||
*/
|
||||
internal object SubtaskReset {
|
||||
|
||||
/** Only bounds the search for a next occurrence. */
|
||||
private val LOOKAHEAD = (366 * 1000).days
|
||||
|
||||
/**
|
||||
* The `X-` property Agendula puts on the copy a provider tick of a recurring
|
||||
* sub-task left, valued with the UID of the parent it sits under, so any
|
||||
* device knows it for one while it stays there.
|
||||
*/
|
||||
const val COPY_MARKER = "X-AGENDULA-TICK-COPY"
|
||||
|
||||
/** The provider `properties` mimetype DAVx5 keeps unknown properties under, and round-trips. */
|
||||
const val UNKNOWN_PROPERTY_MIMETYPE = "vnd.android.cursor.item/vnd.ical4android.unknown-property"
|
||||
|
||||
private val MARKER_JSON = Regex("""^\s*\[\s*"$COPY_MARKER"\s*,\s*"((?:[^"\\]|\\.)*)"""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** The marker for a copy under the parent [parentUid], as DAVx5 stores an unknown property: a JSON array of name and value. */
|
||||
fun copyMarkerJson(parentUid: String): String =
|
||||
"[\"$COPY_MARKER\",\"${parentUid.replace("\\", "\\\\").replace("\"", "\\\"")}\"]"
|
||||
|
||||
/** The parent UID an unknown-property row's JSON marks its task a copy under, or null when it is no marker. */
|
||||
fun markerParentFromJson(json: String?): String? =
|
||||
json?.let(MARKER_JSON::find)?.groupValues?.get(1)?.replace(Regex("""\\(.)"""), "$1")
|
||||
|
||||
/** The parent UID a stored residue's marker names, as a copy synced in from a provider install carries it. */
|
||||
fun markerParentFromResidue(residue: String?): String? = residue?.lines()?.firstNotNullOfOrNull {
|
||||
val line = it.trim()
|
||||
val next = line.getOrNull(COPY_MARKER.length)
|
||||
if (!line.startsWith(COPY_MARKER, ignoreCase = true) || (next != ':' && next != ';')) return@firstNotNullOfOrNull null
|
||||
line.substringAfter(':').replace(Regex("""\\([\\;,])"""), "$1")
|
||||
}
|
||||
|
||||
/** Whether a marker naming [markerParent] makes a task under [parentUid] a copy: only under the parent it came from. */
|
||||
fun isCopyOf(markerParent: String?, parentUid: String?): Boolean = markerParent != null && markerParent == parentUid
|
||||
|
||||
/**
|
||||
* The sub-tasks under [parentId], all the way down, to reset: started ones,
|
||||
* and with a [shift], dated ones too. A recurring one has a series of its
|
||||
* own and is left alone with everything under it; a cancelled one stays as
|
||||
* it is.
|
||||
*/
|
||||
fun toReset(parentId: Long, shift: SeriesShift? = null, children: (Long) -> List<SubtaskNode>): List<ResetSubtask> {
|
||||
val seen = mutableSetOf(parentId)
|
||||
val reset = mutableListOf<ResetSubtask>()
|
||||
fun walk(id: Long) {
|
||||
children(id).filter { !it.recurring && seen.add(it.id) }.forEach {
|
||||
val moves = shift != null && !it.dates.isEmpty
|
||||
if (it.state.status != TaskStatus.CANCELLED && (!it.state.isFresh || moves)) {
|
||||
val dates = if (shift == null) it.dates else SubtaskDates(
|
||||
it.dates.start?.let { at -> shift.apply(at, it.isAllDay, it.timeZone) },
|
||||
it.dates.due?.let { at -> shift.apply(at, it.isAllDay, it.timeZone) },
|
||||
)
|
||||
reset += ResetSubtask(it.id, it.state, it.dates, dates)
|
||||
}
|
||||
walk(it.id)
|
||||
}
|
||||
}
|
||||
walk(parentId)
|
||||
return reset
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider row as a node. Nothing the provider sets on a detached copy marks
|
||||
* it as one, so a copy is known by the [copies] ticks on this install left, or
|
||||
* by a [markerParent] naming the [parentUid] it sits under: it belongs to a
|
||||
* recurring sub-task and counts as recurring.
|
||||
*/
|
||||
fun providerNode(
|
||||
id: Long,
|
||||
rrule: String?,
|
||||
rdate: String?,
|
||||
copies: Set<Long>,
|
||||
markerParent: String? = null,
|
||||
parentUid: String? = null,
|
||||
state: SubtaskState,
|
||||
dates: SubtaskDates = SubtaskDates.NONE,
|
||||
isAllDay: Boolean = false,
|
||||
timeZone: String? = null,
|
||||
) = SubtaskNode(
|
||||
id,
|
||||
!rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies || isCopyOf(markerParent, parentUid),
|
||||
state, dates, isAllDay, timeZone,
|
||||
)
|
||||
|
||||
/**
|
||||
* The reset sub-tasks still as the reset left them, so moving back may
|
||||
* restore them, and the ones changed since, which keep the user's state.
|
||||
* A deleted one is neither.
|
||||
*/
|
||||
fun toRestore(reset: List<ResetSubtask>, current: (Long) -> SubtaskNow?): SubtaskRestore {
|
||||
val restore = mutableListOf<ResetSubtask>()
|
||||
val changed = mutableListOf<Long>()
|
||||
reset.forEach { entry ->
|
||||
val now = current(entry.id) ?: return@forEach
|
||||
val unchanged = now.state.isFresh && (entry.dates == null || now.dates.sameAs(entry.dates, now.isAllDay))
|
||||
if (unchanged) restore += entry else changed += entry.id
|
||||
}
|
||||
return SubtaskRestore(restore, changed)
|
||||
}
|
||||
|
||||
fun encode(reset: List<ResetSubtask>): String = reset.joinToString(";") {
|
||||
listOf(
|
||||
it.id, it.prior.status.name, it.prior.percent, it.prior.completedAt,
|
||||
it.priorDates?.start, it.priorDates?.due, it.dates?.start, it.dates?.due,
|
||||
).joinToString(",") { value -> value?.toString().orEmpty() }
|
||||
}
|
||||
|
||||
fun decode(raw: String?): List<ResetSubtask> = raw.orEmpty().split(';').mapNotNull { entry ->
|
||||
val parts = entry.split(',')
|
||||
if (parts.size != 4 && parts.size != 8) return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
val status = TaskStatus.entries.firstOrNull { it.name == parts[1] } ?: return@mapNotNull null
|
||||
val state = SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull())
|
||||
if (parts.size == 4) return@mapNotNull ResetSubtask(id, state, priorDates = null, dates = null)
|
||||
ResetSubtask(
|
||||
id, state,
|
||||
SubtaskDates(parts[4].toLongOrNull(), parts[5].toLongOrNull()),
|
||||
SubtaskDates(parts[6].toLongOrNull(), parts[7].toLongOrNull()),
|
||||
)
|
||||
}
|
||||
|
||||
/** A provider series' rule set from its columns. */
|
||||
fun specOf(state: Map<String, Any?>): RecurrenceSpec? = TaskMapper.seriesSpec(MapColumnReader(state))
|
||||
|
||||
/** How the series moved from [before] to [after], read in the zone its rule is expanded in. */
|
||||
fun shiftOf(before: Map<String, Any?>, after: Map<String, Any?>, floating: ZoneId = ZoneId.systemDefault()): SeriesShift? {
|
||||
val from = anchor(before) ?: return null
|
||||
val to = anchor(after) ?: return null
|
||||
val reader = MapColumnReader(after)
|
||||
return SeriesShift(from, to, dateZone(reader.getBoolean(Tasks.IS_ALLDAY), reader.getString(Tasks.TZ), floating), floating)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider tick moved the series on, judged by its columns
|
||||
* [before] and [after]: its start (or due) moved later and it is still open,
|
||||
* with [next], its rule set as it now stands, generating an occurrence from
|
||||
* there. An `EXDATE` or a status alone is no move; a series whose rule ran
|
||||
* out is finished.
|
||||
*/
|
||||
fun movedOn(before: Map<String, Any?>?, after: Map<String, Any?>?, next: RecurrenceSpec? = after?.let(::specOf)): Boolean {
|
||||
val was = anchor(before ?: return false) ?: return false
|
||||
val now = anchor(after ?: return false) ?: return false
|
||||
if (now <= was) return false
|
||||
if (statusFromInt((after[Tasks.STATUS] as? Number)?.toInt()).isClosed) return false
|
||||
val spec = next ?: return false
|
||||
if (spec.rrule == null && spec.rdate == null) return false
|
||||
val parts = spec.rrule?.split(';')?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
val count = parts?.firstOrNull { it.startsWith("COUNT=", ignoreCase = true) }?.substringAfter('=')?.toIntOrNull()
|
||||
if (count != null && count <= 0) return false
|
||||
// Expanded from the old start, as an expansion always yields its own start
|
||||
// even past UNTIL. COUNT is left out: it was spent before the new start.
|
||||
val probe = spec.copy(
|
||||
rrule = parts?.filterNot { it.startsWith("COUNT=", ignoreCase = true) }?.joinToString(";"),
|
||||
anchor = Instant.fromEpochMilliseconds(was),
|
||||
)
|
||||
val from = Instant.fromEpochMilliseconds(now)
|
||||
return RecurrenceExpander.expand(probe, ExpansionWindow(from = from, until = from + LOOKAHEAD, maxOccurrences = 1)).isNotEmpty()
|
||||
}
|
||||
|
||||
/** The anchor a series' columns put it at: its start, or else its due date. */
|
||||
fun anchor(state: Map<String, Any?>): Long? =
|
||||
(state[Tasks.DTSTART] as? Number)?.toLong() ?: (state[Tasks.DUE] as? Number)?.toLong()
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */
|
||||
@@ -78,20 +77,6 @@ object TaskMapper {
|
||||
return if (rrule == null && rdate == null) null else SeriesRule(rrule, rdate)
|
||||
}
|
||||
|
||||
/** The rule set of a `tasks` row, anchored on its start or else its due date; null without either date. */
|
||||
fun seriesSpec(r: ColumnReader): RecurrenceSpec? {
|
||||
val rule = seriesRule(r)
|
||||
val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return null
|
||||
return RecurrenceSpec(
|
||||
rrule = rule?.rrule,
|
||||
rdate = rule?.rdate,
|
||||
exdate = r.getString(Tasks.EXDATE),
|
||||
anchor = Instant.fromEpochMilliseconds(anchor),
|
||||
isAllDay = r.getBoolean(Tasks.IS_ALLDAY),
|
||||
timeZone = r.getString(Tasks.TZ),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence's `RECURRENCE-ID` anchor.
|
||||
*
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* The sub-tasks a provider tick reset, as [SubtaskReset.encode] writes them,
|
||||
* kept for [TickedCopies.HORIZON_MS] under the series and the anchor the tick
|
||||
* moved it to: whether or not the provider left a copy that [TickedCopies]
|
||||
* could record, moving the series back from there finds them.
|
||||
*/
|
||||
internal class TickResets(private val prefs: () -> SharedPreferences) {
|
||||
|
||||
fun put(authority: String, seriesId: Long, anchor: Long, reset: String, at: Long) {
|
||||
val stale = expire(prefs().all, at)
|
||||
prefs().edit().apply {
|
||||
stale.forEach(::remove)
|
||||
putString(key(authority, seriesId, anchor), encode(at, reset))
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun get(authority: String, seriesId: Long, anchor: Long): String? =
|
||||
prefs().getString(key(authority, seriesId, anchor), null)?.let(::decode)
|
||||
|
||||
fun remove(authority: String, seriesId: Long, anchor: Long) {
|
||||
prefs().edit().remove(key(authority, seriesId, anchor)).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun key(authority: String, seriesId: Long, anchor: Long) = "$authority|$seriesId|$anchor"
|
||||
|
||||
fun encode(at: Long, reset: String) = "$at|$reset"
|
||||
|
||||
/** The reset an entry holds, or null when it is not one. */
|
||||
fun decode(raw: String): String? = raw.substringBefore('|', "").toLongOrNull()?.let { raw.substringAfter('|') }
|
||||
|
||||
/** The keys of [entries] past the horizon at [now], unreadable ones included. */
|
||||
fun expire(entries: Map<String, *>, now: Long): Set<String> = entries.filter { (_, value) ->
|
||||
val at = (value as? String)?.substringBefore('|', "")?.toLongOrNull() ?: 0L
|
||||
now - at > TickedCopies.HORIZON_MS
|
||||
}.keys
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import kotlin.time.Instant
|
||||
/**
|
||||
* What ticking a provider occurrence left: the copy the provider detached, and
|
||||
* the series' columns before and after the tick, so an undo can move it back.
|
||||
* The sub-tasks it reset are kept apart, in [TickResets].
|
||||
*/
|
||||
internal class Tick(
|
||||
val copyId: Long,
|
||||
@@ -17,12 +16,8 @@ internal class Tick(
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* The provider keeps no record of its ticks, so they are kept here for
|
||||
* [HORIZON_MS]. The copies' ids are kept for good in [known]: a detached copy
|
||||
* carries nothing that marks it as one.
|
||||
*/
|
||||
internal class TickedCopies(private val prefs: () -> SharedPreferences, private val known: () -> SharedPreferences) {
|
||||
/** The provider keeps no record of its ticks, so they are kept here for [HORIZON_MS]. */
|
||||
internal class TickedCopies(private val prefs: () -> SharedPreferences) {
|
||||
|
||||
fun key(authority: String, taskId: Long, occurrenceStart: Instant) =
|
||||
"$authority|$taskId|${wholeSecond(occurrenceStart).epochSeconds}"
|
||||
@@ -36,40 +31,21 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private
|
||||
(value as? String)?.let(::decode)?.takeIf { it.copyId == copyId }?.let { key to it }
|
||||
}
|
||||
|
||||
/** Every copy a tick in Agendula left in [authority]. */
|
||||
fun copies(authority: String): Set<Long> {
|
||||
val kept = knownIds(authority).mapNotNullTo(HashSet()) { it.toLongOrNull() }
|
||||
prefs().all.forEach { (key, value) ->
|
||||
if (key.startsWith("$authority|")) (value as? String)?.let(::decode)?.let { kept += it.copyId }
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** Records [tick] under [key]; ticks past [HORIZON_MS] go, their copies stay known. */
|
||||
fun put(key: String, tick: Tick) {
|
||||
val (stale, keep) = expire(prefs().all.mapValues { (_, value) -> (value as? String)?.let(::decode) }, tick.at)
|
||||
val known = keep + (key.substringBefore('|') to (keep[key.substringBefore('|')].orEmpty() + tick.copyId))
|
||||
known().edit().apply {
|
||||
known.forEach { (authority, ids) -> putStringSet(authority, knownIds(authority) + ids.map(Long::toString)) }
|
||||
}.apply()
|
||||
val stale = prefs().all.filter { (_, value) ->
|
||||
val at = (value as? String)?.let(::decode)?.at ?: 0L
|
||||
tick.at - at > HORIZON_MS
|
||||
}.keys
|
||||
prefs().edit().apply {
|
||||
stale.forEach(::remove)
|
||||
putString(key, encode(tick))
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/** Drops the tick under [key], and its copy from the known ones: moving back deleted it, and its id may be reused. */
|
||||
fun remove(key: String) {
|
||||
val copy = get(key)?.copyId
|
||||
prefs().edit().remove(key).apply()
|
||||
if (copy != null) {
|
||||
val authority = key.substringBefore('|')
|
||||
known().edit().putStringSet(authority, knownIds(authority) - copy.toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun knownIds(authority: String): Set<String> = known().getStringSet(authority, emptySet()).orEmpty().toSet()
|
||||
|
||||
private fun encode(tick: Tick): String = JSONObject().apply {
|
||||
put("copy", tick.copyId)
|
||||
put("at", tick.at)
|
||||
@@ -104,16 +80,5 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private
|
||||
companion object {
|
||||
/** As long as the own store keeps a tick undoable. */
|
||||
const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The keys of [ticks] past the horizon at [now], unreadable ones included,
|
||||
* and per authority the copies they leave, which stay known after the tick goes.
|
||||
*/
|
||||
fun expire(ticks: Map<String, Tick?>, now: Long): Pair<Set<String>, Map<String, Set<Long>>> {
|
||||
val stale = ticks.filter { (_, tick) -> now - (tick?.at ?: 0L) > HORIZON_MS }
|
||||
val keep = stale.entries.mapNotNull { (key, tick) -> tick?.let { key.substringBefore('|') to it.copyId } }
|
||||
.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
|
||||
return stale.keys to keep
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,6 @@ data class AdvancedOccurrenceEntity(
|
||||
@ColumnInfo(name = "undoable", defaultValue = "1") val undoable: Boolean = true,
|
||||
/** Set once the moved series reached the server, which then holds the move itself. */
|
||||
@ColumnInfo(name = "settled", defaultValue = "0") val settled: Boolean = false,
|
||||
/** The series' sub-tasks the move reset, with their state before, as [de.jeanlucmakiola.agendula.data.tasks.SubtaskReset.encode] writes them. */
|
||||
@ColumnInfo(name = "reset_subtasks", defaultValue = "") val resetSubtasks: String = "",
|
||||
) {
|
||||
val dropped: List<Instant>
|
||||
get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) }
|
||||
|
||||
+2
-67
@@ -1,14 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import android.util.Log
|
||||
import androidx.room.InvalidationTracker
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ResetSubtask
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SeriesShift
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskDates
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskNow
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskReset
|
||||
import de.jeanlucmakiola.agendula.data.tasks.SubtaskState
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||
@@ -26,7 +18,6 @@ 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 de.jeanlucmakiola.agendula.domain.recurrence.dateZone
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -100,8 +91,6 @@ private val TASK_TABLES = arrayOf("tasks", "task_lists", "task_alarms")
|
||||
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
||||
private const val ACK_CACHE_SIZE = 512
|
||||
|
||||
private const val TAG = "RoomTasksDataSource"
|
||||
|
||||
/**
|
||||
* [TasksDataSource] over Agendula's own Room store.
|
||||
*
|
||||
@@ -649,7 +638,6 @@ class RoomTasksDataSource @Inject constructor(
|
||||
isDirty = true,
|
||||
),
|
||||
)
|
||||
val (reset, resetLists) = resetSubtasks(master, newAnchor, now)
|
||||
val copyId = if (override != null) {
|
||||
detach(override, now, close)
|
||||
} else {
|
||||
@@ -675,66 +663,13 @@ class RoomTasksDataSource @Inject constructor(
|
||||
ruleAfter = ruleAfter,
|
||||
droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)),
|
||||
fromOverride = override != null,
|
||||
resetSubtasks = SubtaskReset.encode(reset),
|
||||
),
|
||||
)
|
||||
advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) })
|
||||
writes.onWritten(setOf(master.listId) + resetLists)
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets what [SubtaskReset] picks under [master] for the series' next
|
||||
* occurrence at [newAnchor], dated ones moved along; the lists it wrote to.
|
||||
* A copy a provider tick of a recurring sub-task left, synced in, is known by
|
||||
* its marker while it stays under the parent the marker names.
|
||||
*/
|
||||
private fun resetSubtasks(master: TaskEntity, newAnchor: Instant, now: Instant): Pair<List<ResetSubtask>, Set<Long>> {
|
||||
val floating = ZoneId.of(zone())
|
||||
val shift = master.recurrenceAnchor?.let {
|
||||
SeriesShift(it.toEpochMilliseconds(), newAnchor.toEpochMilliseconds(), dateZone(master.isAllDay, master.timezone, floating), floating)
|
||||
}
|
||||
val loaded = HashMap<Long, TaskEntity>().apply { put(master.id, master) }
|
||||
val reset = SubtaskReset.toReset(master.id, shift) { id ->
|
||||
val parentUid = loaded[id]?.uid
|
||||
tasks.subtasks(id).map { row ->
|
||||
val sub = row.task.also { loaded[it.id] = it }
|
||||
val copy = SubtaskReset.isCopyOf(SubtaskReset.markerParentFromResidue(sub.unknownProperties), parentUid)
|
||||
SubtaskNode(sub.id, sub.isRecurring || copy, sub.progress, sub.dates, sub.isAllDay, sub.timezone)
|
||||
}
|
||||
}
|
||||
val lists = reset.mapNotNull { entry ->
|
||||
loaded[entry.id]?.also { tasks.update(it.withProgress(SubtaskState.FRESH, entry.dates, now)) }?.listId
|
||||
}
|
||||
return reset to lists.toSet()
|
||||
}
|
||||
|
||||
/** What [resetSubtasks] reset for [record], back as it was where nothing changed it since. */
|
||||
private fun restoreSubtasks(record: AdvancedOccurrenceEntity, now: Instant): Set<Long> {
|
||||
val entries = SubtaskReset.decode(record.resetSubtasks)
|
||||
val current = entries.mapNotNull { entry -> tasks.entity(entry.id)?.takeUnless { it.isDeleted } }.associateBy { it.id }
|
||||
val outcome = SubtaskReset.toRestore(entries) { id -> current[id]?.let { SubtaskNow(it.progress, it.dates, it.isAllDay) } }
|
||||
if (outcome.changed.isNotEmpty()) Log.i(TAG, "Sub-tasks ${outcome.changed} changed since the reset and keep their state")
|
||||
return outcome.restore.mapNotNull { entry ->
|
||||
current[entry.id]?.also { tasks.update(it.withProgress(entry.prior, entry.priorDates, now)) }?.listId
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private val TaskEntity.progress get() = SubtaskState(status, percentComplete, completedAt?.toEpochMilliseconds())
|
||||
|
||||
private val TaskEntity.dates get() = SubtaskDates(dtstart?.toEpochMilliseconds(), due?.toEpochMilliseconds())
|
||||
|
||||
/** [state] onto the row, and [dates] unless they are unknown. */
|
||||
private fun TaskEntity.withProgress(state: SubtaskState, dates: SubtaskDates?, now: Instant) = copy(
|
||||
status = state.status,
|
||||
percentComplete = state.percent,
|
||||
completedAt = state.completedAt?.let(Instant::fromEpochMilliseconds),
|
||||
dtstart = if (dates == null) dtstart else dates.start?.let(Instant::fromEpochMilliseconds),
|
||||
due = if (dates == null) due else dates.due?.let(Instant::fromEpochMilliseconds),
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
|
||||
/** 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),
|
||||
@@ -819,7 +754,7 @@ class RoomTasksDataSource @Inject constructor(
|
||||
val plain = reopened.status == TaskStatus.NEEDS_ACTION && reopened.percentComplete == null
|
||||
if (record.fromOverride || !plain) toOverride(reopened, back, record.anchor, now)
|
||||
else deleteTask(row.id)
|
||||
writes.onWritten(setOf(master.listId) + restoreSubtasks(record, now))
|
||||
writes.onWritten(setOf(master.listId))
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import kotlin.time.Instant
|
||||
AdvancedOccurrenceEntity::class,
|
||||
AdvancedDetachedEntity::class,
|
||||
],
|
||||
version = 6,
|
||||
version = 5,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -204,12 +204,5 @@ abstract class TasksDatabase : RoomDatabase() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** v6 remembers which sub-tasks a moved series reset, so moving it back restores them (#160). */
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE advanced_occurrences ADD COLUMN `reset_subtasks` TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-12
@@ -12,16 +12,6 @@ import kotlin.time.Instant
|
||||
private const val MILLIS_PER_SECOND = 1000L
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The zone a task's dates are read in: UTC for an all-day one, whose dates are
|
||||
* midnights there; else its [timeZone], or [floating] when it has none or an
|
||||
* unknown one.
|
||||
*/
|
||||
fun dateZone(isAllDay: Boolean, timeZone: String?, floating: ZoneId = ZoneId.systemDefault()): ZoneId {
|
||||
if (isAllDay) return ZoneId.of("UTC")
|
||||
return timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: floating
|
||||
}
|
||||
|
||||
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
|
||||
data class RecurrenceSpec(
|
||||
val rrule: String?,
|
||||
@@ -143,8 +133,11 @@ object RecurrenceExpander {
|
||||
return occurrences.indices.map { it - current }
|
||||
}
|
||||
|
||||
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone =
|
||||
TimeZone.getTimeZone(dateZone(spec.isAllDay, spec.timeZone, floatingZone))
|
||||
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
|
||||
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
|
||||
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
|
||||
return TimeZone.getTimeZone(stored ?: floatingZone)
|
||||
}
|
||||
|
||||
/**
|
||||
* All-day series are date-anchored: pin the anchor to UTC midnight, as it is
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS
|
||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||
@@ -113,6 +114,7 @@ internal fun NotificationsScreen(
|
||||
var showOffset by remember { mutableStateOf(false) }
|
||||
var showAllDay by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var showDescription by remember { mutableStateOf(false) }
|
||||
var showAllDayTime by remember { mutableStateOf(false) }
|
||||
var perListExpanded by remember { mutableStateOf(false) }
|
||||
var expandedLists by remember { mutableStateOf(emptySet<Long>()) }
|
||||
@@ -178,9 +180,15 @@ internal fun NotificationsScreen(
|
||||
R.string.settings_allday_reminder_time_summary,
|
||||
formatMinuteOfDay(state.settings.allDayReminderMinuteOfDay, LocalUse24HourFormat.current),
|
||||
),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
onClick = { showAllDayTime = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminder_description),
|
||||
summary = reminderDescriptionLabel(state.settings.reminderDescription),
|
||||
position = Position.Bottom,
|
||||
onClick = { showDescription = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val batteryExempt = rememberBatteryOptimizationExempt(context)
|
||||
@@ -318,6 +326,17 @@ internal fun NotificationsScreen(
|
||||
onDismiss = { showSnooze = false },
|
||||
)
|
||||
}
|
||||
if (showDescription) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_reminder_description),
|
||||
options = ReminderDescription.entries,
|
||||
selected = state.settings.reminderDescription,
|
||||
label = { reminderDescriptionLabel(it) },
|
||||
summary = { reminderDescriptionSummary(it) },
|
||||
onSelect = viewModel::setReminderDescription,
|
||||
onDismiss = { showDescription = false },
|
||||
)
|
||||
}
|
||||
if (showAllDayTime) {
|
||||
val minute = state.settings.allDayReminderMinuteOfDay
|
||||
TimePickerAlert(
|
||||
@@ -354,6 +373,24 @@ private fun snoozeLabel(minutes: Int): String =
|
||||
if (minutes % 60 == 0) pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||
else pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||
|
||||
@Composable
|
||||
private fun reminderDescriptionLabel(pref: ReminderDescription): String = stringResource(
|
||||
when (pref) {
|
||||
ReminderDescription.HIDDEN -> R.string.settings_reminder_description_hidden
|
||||
ReminderDescription.SHORT -> R.string.settings_reminder_description_short
|
||||
ReminderDescription.FULL -> R.string.settings_reminder_description_full
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun reminderDescriptionSummary(pref: ReminderDescription): String = stringResource(
|
||||
when (pref) {
|
||||
ReminderDescription.HIDDEN -> R.string.settings_reminder_description_hidden_summary
|
||||
ReminderDescription.SHORT -> R.string.settings_reminder_description_short_summary
|
||||
ReminderDescription.FULL -> R.string.settings_reminder_description_full_summary
|
||||
},
|
||||
)
|
||||
|
||||
/** Which list's override picker is open, and for which kind of task. */
|
||||
private data class OverrideTarget(val listId: Long, val allDay: Boolean)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
@@ -259,6 +260,7 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setTimeFormat(pref: TimeFormatPref) = viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
fun setWeekStart(day: DayOfWeek?) = viewModelScope.launch { prefs.setWeekStart(day) }
|
||||
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||
fun setReminderDescription(pref: ReminderDescription) = viewModelScope.launch { prefs.setReminderDescription(pref) }
|
||||
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
||||
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
||||
fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) }
|
||||
|
||||
@@ -470,6 +470,13 @@
|
||||
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_snooze_duration">Snooze duration</string>
|
||||
<string name="settings_reminder_description">Description in reminders</string>
|
||||
<string name="settings_reminder_description_hidden">Hidden</string>
|
||||
<string name="settings_reminder_description_short">Short</string>
|
||||
<string name="settings_reminder_description_full">Full</string>
|
||||
<string name="settings_reminder_description_hidden_summary">Only the title and due time</string>
|
||||
<string name="settings_reminder_description_short_summary">A short preview of the description</string>
|
||||
<string name="settings_reminder_description_full_summary">The whole description</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Agendula so they arrive on time.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||
<string name="settings_default_list">Default list</string>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ShortDescriptionTest {
|
||||
|
||||
@Test
|
||||
fun `short text is left alone`() {
|
||||
assertThat(shortDescription("Buy oat milk")).isEqualTo("Buy oat milk")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three short lines fit`() {
|
||||
assertThat(shortDescription("one\ntwo\nthree")).isEqualTo("one\ntwo\nthree")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lines past the third are cut with an ellipsis`() {
|
||||
assertThat(shortDescription("one\ntwo\nthree\nfour\nfive")).isEqualTo("one\ntwo\nthree…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank lines take no room`() {
|
||||
assertThat(shortDescription("one\n\n\ntwo\n \nthree")).isEqualTo("one\ntwo\nthree")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long paragraph is cut at a word within the line budget`() {
|
||||
val text = List(40) { "word" }.joinToString(" ")
|
||||
val short = shortDescription(text, maxLines = 3, lineChars = 40)
|
||||
assertThat(short).endsWith("word…")
|
||||
assertThat(short.length).isAtMost(3 * 40)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrapping line counts as the lines it takes`() {
|
||||
val long = "a".repeat(70)
|
||||
val short = shortDescription("$long\nsecond\nthird", maxLines = 3, lineChars = 40)
|
||||
assertThat(short).isEqualTo("$long\nsecond…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a word with no break is cut hard`() {
|
||||
val short = shortDescription("x".repeat(200), maxLines = 3, lineChars = 40)
|
||||
assertThat(short).isEqualTo("x".repeat(119) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing punctuation gives way to the ellipsis`() {
|
||||
assertThat(shortDescription("one.\ntwo,\nthree.\nfour")).isEqualTo("one.\ntwo,\nthree…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a line that fills the budget exactly leaves room for the ellipsis`() {
|
||||
val full = "a".repeat(120)
|
||||
val short = shortDescription("$full\nmore", maxLines = 3, lineChars = 40)
|
||||
assertThat(short).isEqualTo("a".repeat(119) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cut never splits an emoji`() {
|
||||
val short = shortDescription("x".repeat(118) + "😀" + "y".repeat(10), maxLines = 3, lineChars = 40)
|
||||
assertThat(short).isEqualTo("x".repeat(118) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a word that ends right at the cut is kept`() {
|
||||
val short = shortDescription("aaaa bbbb cccc", maxLines = 1, lineChars = 10)
|
||||
assertThat(short).isEqualTo("aaaa bbbb…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tabs count as word breaks`() {
|
||||
val short = shortDescription("Meeting\tagenda\tnotes", maxLines = 1, lineChars = 12)
|
||||
assertThat(short).isEqualTo("Meeting…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no space is left before the ellipsis`() {
|
||||
assertThat(shortDescription("see list :\nmore", maxLines = 1, lineChars = 40)).isEqualTo("see list…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wide characters take two cells`() {
|
||||
val short = shortDescription("中".repeat(30), maxLines = 1, lineChars = 20)
|
||||
assertThat(short).isEqualTo("中".repeat(9) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the setting picks what the reminder shows`() {
|
||||
val long = List(60) { "word" }.joinToString(" ")
|
||||
assertThat(reminderBody(long, ReminderDescription.HIDDEN)).isNull()
|
||||
assertThat(reminderBody(long, ReminderDescription.FULL)).isEqualTo(long)
|
||||
assertThat(reminderBody(long, ReminderDescription.SHORT)).isEqualTo(shortDescription(long))
|
||||
assertThat(reminderBody(" ", ReminderDescription.FULL)).isNull()
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,24 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SqlChunksTest {
|
||||
|
||||
@Test
|
||||
fun `ids are split so no query binds more than the limit`() {
|
||||
val ids = (1L..2_100L).toList()
|
||||
val chunks = inSelections("_id", ids, filter = "deleted = ?", filterArgs = listOf("0"))
|
||||
|
||||
assertThat(chunks.map { it.second.size }).containsExactly(999, 999, 105).inOrder()
|
||||
assertThat(chunks.flatMap { (_, args) -> args.dropLast(1) }).isEqualTo(ids.map(Long::toString))
|
||||
assertThat(chunks.all { (_, args) -> args.last() == "0" }).isTrue()
|
||||
assertThat(chunks.first().first).endsWith("?) AND (deleted = ?)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated ids are bound once, and none give no query`() {
|
||||
assertThat(inSelections("_id", listOf(3L, 3L, 4L)).single().second).asList().containsExactly("3", "4")
|
||||
assertThat(inSelections("_id", emptyList())).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SubtaskResetTest {
|
||||
|
||||
private val done = SubtaskState(TaskStatus.COMPLETED, 100, 1_000L)
|
||||
private val halfway = SubtaskState(TaskStatus.IN_PROCESS, 50, null)
|
||||
private val cancelled = SubtaskState(TaskStatus.CANCELLED, null, null)
|
||||
|
||||
private fun node(id: Long, state: SubtaskState = done, recurring: Boolean = false) = SubtaskNode(id, recurring, state)
|
||||
|
||||
private fun reset(vararg tree: Pair<Long, List<SubtaskNode>>): List<Long> {
|
||||
val children = tree.toMap()
|
||||
return SubtaskReset.toReset(1L) { children[it].orEmpty() }.map { it.id }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed and half-done sub-tasks of a routine start over, fresh ones are left (#160)`() {
|
||||
val reopened = reset(1L to listOf(node(2), node(3, SubtaskState.FRESH), node(4, halfway)))
|
||||
assertThat(reopened).containsExactly(2L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an open sub-task with progress counts as started`() {
|
||||
assertThat(reset(1L to listOf(node(2, SubtaskState(TaskStatus.NEEDS_ACTION, 20, null))))).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled sub-task stays cancelled, what is under it still starts over`() {
|
||||
assertThat(reset(1L to listOf(node(2, cancelled)), 2L to listOf(node(3)))).containsExactly(3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested sub-tasks start over too, also under a fresh one`() {
|
||||
val reopened = reset(
|
||||
1L to listOf(node(2, SubtaskState.FRESH)),
|
||||
2L to listOf(node(3)),
|
||||
3L to listOf(node(4)),
|
||||
)
|
||||
assertThat(reopened).containsExactly(3L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring sub-task, or a copy a tick of one left, is left alone with what is under it`() {
|
||||
val reopened = reset(
|
||||
1L to listOf(node(2, recurring = true), node(3), node(5, recurring = true)),
|
||||
2L to listOf(node(4)),
|
||||
)
|
||||
assertThat(reopened).containsExactly(3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider copy a tick in Agendula left is left alone, a plain sub-task is not`() {
|
||||
val copies = setOf(3L)
|
||||
val rows = listOf(
|
||||
SubtaskReset.providerNode(2, rrule = "FREQ=DAILY", rdate = null, copies = copies, state = done),
|
||||
SubtaskReset.providerNode(3, rrule = null, rdate = null, copies = copies, state = done),
|
||||
SubtaskReset.providerNode(4, rrule = null, rdate = "20261110T080000Z", copies = copies, state = done),
|
||||
SubtaskReset.providerNode(5, rrule = " ", rdate = null, copies = copies, state = done),
|
||||
)
|
||||
assertThat(SubtaskReset.toReset(1L) { if (it == 1L) rows else emptyList() }.map { it.id }).containsExactly(5L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cycle or the parent listed under itself is not walked twice`() {
|
||||
val reopened = reset(
|
||||
1L to listOf(node(1), node(2)),
|
||||
2L to listOf(node(3)),
|
||||
3L to listOf(node(2)),
|
||||
)
|
||||
assertThat(reopened).containsExactly(2L, 3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what was reset survives encoding`() {
|
||||
val reset = listOf(
|
||||
ResetSubtask(2, done),
|
||||
ResetSubtask(3, halfway, SubtaskDates(1L, 2L), SubtaskDates(11L, 12L)),
|
||||
ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null), SubtaskDates(null, 5L), SubtaskDates(null, 15L)),
|
||||
)
|
||||
assertThat(SubtaskReset.decode(SubtaskReset.encode(reset))).isEqualTo(reset)
|
||||
assertThat(SubtaskReset.decode("")).isEmpty()
|
||||
assertThat(SubtaskReset.decode(null)).isEmpty()
|
||||
assertThat(SubtaskReset.decode("x,COMPLETED,,;2,NOPE,,;3,IN_PROCESS")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a record from before dates were kept restores the state and leaves the dates`() {
|
||||
val legacy = SubtaskReset.decode("2,COMPLETED,100,1000").single()
|
||||
assertThat(legacy).isEqualTo(ResetSubtask(2, done, priorDates = null, dates = null))
|
||||
|
||||
val dated = SubtaskNow(SubtaskState.FRESH, SubtaskDates(at("2026-11-09T10:00:00Z"), null), isAllDay = false)
|
||||
assertThat(SubtaskReset.toRestore(listOf(legacy)) { dated }.restore).containsExactly(legacy)
|
||||
assertThat(SubtaskReset.encode(listOf(legacy))).isEqualTo("2,COMPLETED,100,1000,,,,")
|
||||
}
|
||||
|
||||
private fun now(state: SubtaskState, dates: SubtaskDates = SubtaskDates.NONE, allDay: Boolean = false) = SubtaskNow(state, dates, allDay)
|
||||
|
||||
@Test
|
||||
fun `moving back restores only the sub-tasks still as the reset left them`() {
|
||||
val moved = SubtaskDates(20_000L, null)
|
||||
val reset = listOf(
|
||||
ResetSubtask(2, done),
|
||||
ResetSubtask(3, halfway),
|
||||
ResetSubtask(4, done),
|
||||
ResetSubtask(5, done, SubtaskDates(10_000L, null), moved),
|
||||
ResetSubtask(6, done, SubtaskDates(10_000L, null), moved),
|
||||
)
|
||||
val current = mapOf(
|
||||
2L to now(SubtaskState.FRESH),
|
||||
3L to now(done),
|
||||
5L to now(SubtaskState.FRESH, moved),
|
||||
// Re-dated since: the user's dates win.
|
||||
6L to now(SubtaskState.FRESH, SubtaskDates(30_000L, null)),
|
||||
)
|
||||
val outcome = SubtaskReset.toRestore(reset, current::get)
|
||||
assertThat(outcome.restore.map { it.id }).containsExactly(2L, 5L)
|
||||
// 4 is gone: neither restored nor reported as changed.
|
||||
assertThat(outcome.changed).containsExactly(3L, 6L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dates compare as a store keeps them, to the second or the day`() {
|
||||
val reset = ResetSubtask(2, done, SubtaskDates.NONE, SubtaskDates(at("2026-11-09T10:00:00.250Z"), null))
|
||||
val allDay = ResetSubtask(3, done, SubtaskDates.NONE, SubtaskDates(null, at("2026-11-10T00:00:00Z")))
|
||||
fun restores(entry: ResetSubtask, stored: Long, isAllDay: Boolean) =
|
||||
SubtaskReset.toRestore(listOf(entry)) { now(SubtaskState.FRESH, SubtaskDates(if (isAllDay) null else stored, if (isAllDay) stored else null), isAllDay) }
|
||||
.restore.isNotEmpty()
|
||||
|
||||
assertThat(restores(reset, at("2026-11-09T10:00:00Z"), isAllDay = false)).isTrue()
|
||||
assertThat(restores(reset, at("2026-11-09T10:00:01Z"), isAllDay = false)).isFalse()
|
||||
assertThat(restores(allDay, at("2026-11-10T12:00:00Z"), isAllDay = true)).isTrue()
|
||||
assertThat(restores(allDay, at("2026-11-11T00:00:00Z"), isAllDay = true)).isFalse()
|
||||
}
|
||||
|
||||
private val berlin = ZoneId.of("Europe/Berlin")
|
||||
|
||||
private fun at(text: String) = java.time.Instant.parse(text).toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `a dated sub-task keeps its wall-clock place to the routine across a DST change`() {
|
||||
// A weekly routine at 09:00 Berlin moves from the last Monday of summer time to the first of winter time.
|
||||
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin, floating = ZoneOffset.UTC)
|
||||
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, timeZone = "Europe/Berlin")).isEqualTo(at("2026-10-27T09:00:00Z"))
|
||||
assertThat(shift.apply(at("2026-10-21T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-10-28T00:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day sub-task follows the routine's change of date, also on a step under a day`() {
|
||||
// An eight-hourly routine moves from 20:00 to 04:00 the next day in Berlin.
|
||||
val shift = SeriesShift(at("2026-11-09T19:00:00Z"), at("2026-11-10T03:00:00Z"), berlin, floating = ZoneOffset.UTC)
|
||||
assertThat(shift.apply(at("2026-11-09T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-11-10T00:00:00Z"))
|
||||
// From 04:00 to 12:00 the date stays.
|
||||
val sameDay = SeriesShift(at("2026-11-10T03:00:00Z"), at("2026-11-10T11:00:00Z"), berlin, floating = ZoneOffset.UTC)
|
||||
assertThat(sameDay.apply(at("2026-11-10T00:00:00Z"), isAllDay = true)).isEqualTo(at("2026-11-10T00:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed sub-task without a zone of its own is read in the floating zone`() {
|
||||
val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin, floating = berlin)
|
||||
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false)).isEqualTo(at("2026-10-27T09:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving on shifts dated sub-tasks, fresh ones included, and leaves undated fresh ones`() {
|
||||
val shift = SeriesShift(at("2026-11-02T08:00:00Z"), at("2026-11-09T08:00:00Z"), ZoneOffset.UTC, floating = ZoneOffset.UTC)
|
||||
val nodes = listOf(
|
||||
SubtaskNode(2, false, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
|
||||
SubtaskNode(3, false, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), isAllDay = true),
|
||||
SubtaskNode(4, false, SubtaskState.FRESH),
|
||||
SubtaskNode(5, false, cancelled, SubtaskDates(at("2026-11-02T10:00:00Z"), null)),
|
||||
)
|
||||
val reset = SubtaskReset.toReset(1L, shift) { if (it == 1L) nodes else emptyList() }
|
||||
assertThat(reset).containsExactly(
|
||||
ResetSubtask(2, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null), SubtaskDates(at("2026-11-09T10:00:00Z"), null)),
|
||||
ResetSubtask(3, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), SubtaskDates(null, at("2026-11-10T00:00:00Z"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider series' shift is read in its own zone`() {
|
||||
val before = mapOf<String, Any?>(Tasks.DTSTART to at("2026-10-19T07:00:00Z"), Tasks.TZ to "Europe/Berlin", Tasks.IS_ALLDAY to 0L)
|
||||
val after = before + (Tasks.DTSTART to at("2026-10-26T08:00:00Z"))
|
||||
val shift = SubtaskReset.shiftOf(before, after, floating = ZoneOffset.UTC)!!
|
||||
assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, timeZone = "Europe/Berlin")).isEqualTo(at("2026-10-27T09:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider series' rule set is read from its columns`() {
|
||||
val state = mapOf<String, Any?>(
|
||||
Tasks.DTSTART to at("2026-11-02T08:00:00Z"), Tasks.RRULE to "FREQ=WEEKLY", Tasks.EXDATE to null,
|
||||
Tasks.IS_ALLDAY to 1L, Tasks.TZ to null,
|
||||
)
|
||||
val spec = SubtaskReset.specOf(state)!!
|
||||
assertThat(spec.rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(spec.isAllDay).isTrue()
|
||||
assertThat(SubtaskReset.specOf(state + (Tasks.RRULE to " "))!!.rrule).isNull()
|
||||
assertThat(SubtaskReset.specOf(state + (Tasks.DTSTART to null))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the copy marker names the parent it was left under`() {
|
||||
val json = SubtaskReset.copyMarkerJson("parent-\"1\"")
|
||||
assertThat(SubtaskReset.markerParentFromJson(json)).isEqualTo("parent-\"1\"")
|
||||
assertThat(SubtaskReset.markerParentFromJson(" [\"x-agendula-tick-copy\", \"p\"]")).isEqualTo("p")
|
||||
assertThat(SubtaskReset.markerParentFromJson("[\"X-AGENDULA-TICK-COPYRIGHT\",\"p\"]")).isNull()
|
||||
assertThat(SubtaskReset.markerParentFromJson(null)).isNull()
|
||||
assertThat(SubtaskReset.markerParentFromResidue("X-OTHER:1\r\nX-AGENDULA-TICK-COPY:p\\,1")).isEqualTo("p,1")
|
||||
assertThat(SubtaskReset.markerParentFromResidue("X-AGENDULA-TICK-COPY;X-P=1:p")).isEqualTo("p")
|
||||
assertThat(SubtaskReset.markerParentFromResidue("X-AGENDULA-TICK-COPYRIGHT:p")).isNull()
|
||||
assertThat(SubtaskReset.markerParentFromResidue(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a marked copy counts only under the parent it came from`() {
|
||||
fun node(markerParent: String?, parentUid: String?) =
|
||||
SubtaskReset.providerNode(2, null, null, emptySet(), markerParent = markerParent, parentUid = parentUid, state = done)
|
||||
assertThat(node("routine", "routine").recurring).isTrue()
|
||||
// Moved under another parent since: an ordinary completed sub-task there.
|
||||
assertThat(node("routine", "other").recurring).isFalse()
|
||||
assertThat(node(null, "routine").recurring).isFalse()
|
||||
assertThat(node("routine", null).recurring).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a moved-on provider series resets with or without a copy, and its record is found by series and anchor`() {
|
||||
val before = state(start) + (Tasks.RRULE to "FREQ=WEEKLY")
|
||||
val after = state(start + week) + (Tasks.RRULE to "FREQ=WEEKLY")
|
||||
assertThat(SubtaskReset.movedOn(before, after)).isTrue()
|
||||
assertThat(SubtaskReset.movedOn(before, before)).isFalse()
|
||||
|
||||
val anchor = SubtaskReset.anchor(after)!!
|
||||
val key = TickResets.key("org.dmfs.tasks", 4, anchor)
|
||||
val record = SubtaskReset.encode(listOf(ResetSubtask(2, done)))
|
||||
assertThat(key).isEqualTo("org.dmfs.tasks|4|${start + week}")
|
||||
assertThat(TickResets.decode(TickResets.encode(1L, record))).isEqualTo(record)
|
||||
assertThat(SubtaskReset.toRestore(SubtaskReset.decode(record)) { now(SubtaskState.FRESH) }.restore.map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reset record expires with the tick horizon`() {
|
||||
val now = 100L * 24 * 60 * 60 * 1000
|
||||
val entries = mapOf(
|
||||
"a|1|1" to TickResets.encode(now - 31L * 24 * 60 * 60 * 1000, "2,COMPLETED,,,,,,"),
|
||||
"a|1|2" to TickResets.encode(now - 1, "3,COMPLETED,,,,,,"),
|
||||
"garbage" to "x",
|
||||
)
|
||||
assertThat(TickResets.expire(entries, now)).containsExactly("a|1|1", "garbage")
|
||||
assertThat(TickResets.decode("x")).isNull()
|
||||
}
|
||||
|
||||
private val week = 7L * 24 * 60 * 60 * 1000
|
||||
private val start = Instant.parse("2026-11-02T08:00:00Z").toEpochMilliseconds()
|
||||
|
||||
private fun state(dtstart: Long?, status: Int = TasksContract.STATUS_NEEDS_ACTION, exdate: String? = null, due: Long? = null) =
|
||||
mapOf<String, Any?>(Tasks.DTSTART to dtstart, Tasks.DUE to due, Tasks.STATUS to status.toLong(), Tasks.EXDATE to exdate)
|
||||
|
||||
private fun spec(at: Long, rrule: String = "FREQ=WEEKLY") =
|
||||
RecurrenceSpec(rrule = rrule, rdate = null, exdate = null, anchor = Instant.fromEpochMilliseconds(at), isAllDay = false, timeZone = "UTC")
|
||||
|
||||
@Test
|
||||
fun `a provider series whose start moved later has moved on`() {
|
||||
assertThat(SubtaskReset.movedOn(state(start), state(start + week), spec(start + week))).isTrue()
|
||||
assertThat(SubtaskReset.movedOn(state(null, due = start), state(null, due = start + week), spec(start + week))).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ticking a later occurrence only adds an exception and moves nothing`() {
|
||||
val after = state(start, exdate = "20261109T080000Z")
|
||||
assertThat(SubtaskReset.movedOn(state(start), after, spec(start))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider series ticked on its last occurrence is finished`() {
|
||||
// OpenTasks deletes the master or drops its rule: there is no series state after.
|
||||
assertThat(SubtaskReset.movedOn(state(start), null, null)).isFalse()
|
||||
// Closed through its status.
|
||||
assertThat(SubtaskReset.movedOn(state(start), state(start + week, TasksContract.STATUS_COMPLETED), spec(start + week))).isFalse()
|
||||
// Dates moved, but the rule has nothing left from there.
|
||||
val ended = spec(start + week, rrule = "FREQ=WEEKLY;UNTIL=20261103T080000Z")
|
||||
assertThat(SubtaskReset.movedOn(state(start), state(start + week), ended)).isFalse()
|
||||
assertThat(SubtaskReset.movedOn(state(start), state(start + week), null)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider series moved earlier or without dates has not moved on`() {
|
||||
assertThat(SubtaskReset.movedOn(state(start), state(start - week), spec(start - week))).isFalse()
|
||||
assertThat(SubtaskReset.movedOn(state(null), state(null), spec(start))).isFalse()
|
||||
assertThat(SubtaskReset.movedOn(null, state(start + week), spec(start + week))).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TickedCopiesTest {
|
||||
|
||||
private val now = 100L * 24 * 60 * 60 * 1000
|
||||
|
||||
private fun tick(copy: Long, daysAgo: Long) = Tick(copy, seriesId = 1, before = null, after = null, at = now - daysAgo * 24 * 60 * 60 * 1000)
|
||||
|
||||
@Test
|
||||
fun `a tick past the horizon goes, and its copy stays known`() {
|
||||
val ticks = mapOf(
|
||||
"org.dmfs.tasks|1|10" to tick(copy = 7, daysAgo = 31),
|
||||
"org.dmfs.tasks|1|20" to tick(copy = 8, daysAgo = 2),
|
||||
"org.tasks.opentasks|5|10" to tick(copy = 9, daysAgo = 40),
|
||||
"garbage" to null,
|
||||
)
|
||||
|
||||
val (stale, known) = TickedCopies.expire(ticks, now)
|
||||
|
||||
assertThat(stale).containsExactly("org.dmfs.tasks|1|10", "org.tasks.opentasks|5|10", "garbage")
|
||||
assertThat(known).containsExactly("org.dmfs.tasks", setOf(7L), "org.tasks.opentasks", setOf(9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing expires inside the horizon`() {
|
||||
val (stale, known) = TickedCopies.expire(mapOf("a|1|1" to tick(copy = 3, daysAgo = 29)), now)
|
||||
assertThat(stale).isEmpty()
|
||||
assertThat(known).isEmpty()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user