From 53209226a029c4ad77d11c42167f7e6cfa4c2a26 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 2 Jan 2025 01:09:03 -0600 Subject: [PATCH] Microsoft To Do checklist support --- .../java/com/todoroo/astrid/dao/TaskDao.kt | 3 + .../sync/microsoft/MicrosoftConverter.kt | 39 ++++- .../tasks/sync/microsoft/MicrosoftService.kt | 19 +++ .../sync/microsoft/MicrosoftSynchronizer.kt | 156 ++++++++++++++---- .../java/org/tasks/sync/microsoft/Tasks.kt | 12 +- .../kotlin/org/tasks/data/dao/TaskDao.kt | 3 +- 6 files changed, 192 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt b/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt index 44e207724..9e50cffea 100644 --- a/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt +++ b/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt @@ -23,6 +23,7 @@ import org.tasks.notifications.NotificationManager import org.tasks.preferences.Preferences import org.tasks.preferences.QueryPreferences import org.tasks.sync.SyncAdapters +import timber.log.Timber import javax.inject.Inject class TaskDao @Inject constructor( @@ -98,12 +99,14 @@ class TaskDao @Inject constructor( suspend fun save(task: Task) = save(task, fetch(task.id)) suspend fun save(tasks: List, originals: List) { + Timber.d("Saving $tasks") taskDao.updateInternal(tasks) tasks.forEach { task -> afterUpdate(task, originals.find { it.id == task.id }) } } suspend fun save(task: Task, original: Task?) { if (taskDao.update(task, original)) { + Timber.d("Saved $task") afterUpdate(task, original) workManager.triggerNotifications() workManager.scheduleRefresh() diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt index 495c8eebf..cdf1ac5dd 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftConverter.kt @@ -1,12 +1,12 @@ package org.tasks.sync.microsoft -import org.tasks.data.entity.Task import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.WeekDay import net.fortuna.ical4j.model.WeekDayList import org.tasks.data.createDueDate import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.TagData +import org.tasks.data.entity.Task import org.tasks.date.DateTimeUtils import org.tasks.sync.microsoft.Tasks.Task.RecurrenceDayOfWeek import org.tasks.sync.microsoft.Tasks.Task.RecurrenceType @@ -23,6 +23,22 @@ object MicrosoftConverter { private const val DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS0000" private const val DATE_TIME_UTC_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS0000'Z'" + fun Task.applySubtask( + index: Int, + parent: Long, + checklistItem: Tasks.Task.ChecklistItem, + ) { + this.parent = parent + order = index.toLong() + title = checklistItem.displayName + completionDate = if (checklistItem.isChecked) { + checklistItem.checkedDateTime?.parseDateTime() ?: System.currentTimeMillis() + } else { + 0L + } + creationDate = checklistItem.createdDateTime.parseDateTime() + } + fun Task.applyRemote( remote: Tasks.Task, defaultPriority: Int, @@ -84,7 +100,10 @@ object MicrosoftConverter { // sync files } - fun Task.toRemote(caldavTask: CaldavTask, tags: List): Tasks.Task { + fun Task.toRemote( + caldavTask: CaldavTask, + tags: List, + ): Tasks.Task { return Tasks.Task( id = caldavTask.remoteId, title = title, @@ -168,14 +187,26 @@ object MicrosoftConverter { } } else { null - } - // subtasks to checklist + }, // isReminderOn = // reminders // files ) } + fun Task.toChecklistItem(id: String?) = + Tasks.Task.ChecklistItem( + id = id, + displayName = title ?: "", + createdDateTime = DateTime(creationDate).toUTC().toString(DATE_TIME_UTC_FORMAT), + isChecked = isCompleted, + checkedDateTime = if (isCompleted) { + DateTime(completionDate).toUTC().toString(DATE_TIME_UTC_FORMAT) + } else { + null + }, + ) + private fun String?.parseDateTime(): Long = this ?.let { ZonedDateTime.parse(this).toInstant().toEpochMilli() } diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt index 182082340..1eb246679 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftService.kt @@ -59,4 +59,23 @@ class MicrosoftService( suspend fun deleteTask(listId: String, taskId: String) = client.delete("$baseUrl/lists/$listId/tasks/$taskId") + + suspend fun createChecklistItem(listId: String, taskId: String, body: Tasks.Task.ChecklistItem): Tasks.Task.ChecklistItem = + client + .post("$baseUrl/lists/$listId/tasks/$taskId/checklistItems") { + contentType(ContentType.Application.Json) + setBody(body) + } + .body() + + suspend fun updateChecklistItem(listId: String, taskId: String, body: Tasks.Task.ChecklistItem): Tasks.Task.ChecklistItem = + client + .patch("$baseUrl/lists/$listId/tasks/$taskId/checklistItems/${body.id}") { + contentType(ContentType.Application.Json) + setBody(body.copy(id = null, createdDateTime = null)) + } + .body() + + suspend fun deleteChecklistItem(listId: String, taskId: String, checklistItemId: String) = + client.delete("$baseUrl/lists/$listId/tasks/$taskId/checklistItems/$checklistItemId") } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt index 72ed59a62..02972cf94 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/MicrosoftSynchronizer.kt @@ -31,9 +31,12 @@ import org.tasks.data.entity.CaldavTask import org.tasks.data.entity.TagData import org.tasks.data.entity.Task import org.tasks.http.HttpClientFactory +import org.tasks.http.NotFoundException import org.tasks.preferences.Preferences import org.tasks.sync.microsoft.Error.Companion.toMicrosoftError import org.tasks.sync.microsoft.MicrosoftConverter.applyRemote +import org.tasks.sync.microsoft.MicrosoftConverter.applySubtask +import org.tasks.sync.microsoft.MicrosoftConverter.toChecklistItem import org.tasks.sync.microsoft.MicrosoftConverter.toRemote import timber.log.Timber import java.io.IOException @@ -151,12 +154,8 @@ class MicrosoftSynchronizer @Inject constructor( for (task in caldavDao.getMoved(local.uuid!!)) { deleteRemoteResource(microsoft, local, task) } - for (task in taskDao.getCaldavTasksToPush(local.uuid!!)) { - try { - pushTask(local, task, microsoft) - } catch (e: IOException) { - Timber.e(e) - } + for (task in taskDao.getCaldavTasksToPush(local.uuid!!).sortedBy { it.parent }) { + pushTask(local, task, microsoft) } } @@ -166,13 +165,40 @@ class MicrosoftSynchronizer @Inject constructor( task: CaldavTask, ): Boolean { val listId = list.uuid + val parentId = task.remoteParent val taskId = task.remoteId val success = when { task.lastSync == 0L -> true listId.isNullOrBlank() -> false taskId.isNullOrBlank() -> false + parentId.isNullOrBlank() -> { + try { + microsoft.deleteTask(listId, taskId) + } catch (e: NotFoundException) { + Timber.w(e, "task=$task") + } catch (e: org.tasks.http.HttpException) { + when (e.code) { + 400 -> Timber.w(e, "task=$task") + else -> { + throw e + } + } + } + true + } else -> { - microsoft.deleteTask(listId, taskId) + try { + microsoft.deleteChecklistItem(listId, parentId, taskId) + } catch (e: NotFoundException) { + Timber.w(e, "task=$task") + } catch (e: org.tasks.http.HttpException) { + when (e.code) { + 400 -> Timber.w(e, "task=$task") + else -> { + throw e + } + } + } true } } @@ -190,14 +216,19 @@ class MicrosoftSynchronizer @Inject constructor( ) { val caldavTask = caldavDao.getTask(task.id) ?: return if (task.isDeleted) { + Timber.d("Deleting $task") if (deleteRemoteResource(microsoft, list, caldavTask)) { taskDeleter.delete(taskDao.getChildren(task.id) + task.id) } return } - val remoteTask = task.toRemote(caldavTask, tagDataDao.getTagDataForTask(task.id)) - try { - val result: Tasks.Task = if (caldavTask.lastSync == 0L) { + val isNew = caldavTask.lastSync == 0L + if (task.parent == 0L) { + val remoteTask = task.toRemote( + caldavTask = caldavTask, + tags = tagDataDao.getTagDataForTask(task.id), + ) + val result: Tasks.Task = if (isNew) { Timber.d("Uploading new task: $task") microsoft.createTask(list.uuid!!, remoteTask) } else { @@ -208,18 +239,24 @@ class MicrosoftSynchronizer @Inject constructor( caldavTask.obj = "${result.id}.json" caldavTask.lastSync = task.modificationDate vtodoCache.putVtodo(list, caldavTask, json.encodeToString(result)) - caldavDao.update(caldavTask) - } catch (e: HttpException) { - if (e.code == 404) { - Timber.d("404, deleting $task") - vtodoCache.delete(list, caldavTask) - taskDeleter.delete(taskDao.getChildren(task.id) + task.id) + } else { + val caldavParent = caldavDao.getTask(task.parent)?.remoteId ?: return + val remoteTask = task.toChecklistItem(caldavTask.remoteId) + val result: Tasks.Task.ChecklistItem = if (isNew) { + Timber.d("Uploading new checklist item: $task") + microsoft.createChecklistItem(list.uuid!!, caldavParent, remoteTask) } else { - Timber.e(e) + Timber.d("Updating existing checklist item: $task") + microsoft.updateChecklistItem(list.uuid!!, caldavParent, remoteTask) } - } catch (e: Exception) { - Timber.e(e) + caldavTask.remoteId = result.id + caldavTask.remoteParent = caldavParent + caldavTask.obj = "${result.id}.json" + caldavTask.lastSync = task.modificationDate + vtodoCache.putVtodo(list, caldavTask, json.encodeToString(result)) } + + caldavDao.update(caldavTask) } private suspend fun deltaSync( @@ -235,8 +272,9 @@ class MicrosoftSynchronizer @Inject constructor( updateTask(list, remote) } else { val caldavTasks = caldavDao.getTasksByRemoteId(list.uuid!!, listOf(remote.id!!)) + val taskIds = caldavTasks.map { it.task }.flatMap { taskDao.getChildren(it) + it } + Timber.d("Deleting $remote, taskIds=$taskIds") vtodoCache.delete(list, caldavTasks) - val taskIds = caldavTasks.map { it.id }.flatMap { taskDao.getChildren(it) + it } taskDeleter.delete(taskIds) } } @@ -354,24 +392,27 @@ class MicrosoftSynchronizer @Inject constructor( obj = "${remote.id}.json" ) val dirty = existing != null && task.modificationDate > existing.lastSync + if (dirty) { + // TODO: merge with vtodo cached value, similar to iCalendarMerge.kt + Timber.w("Ignoring update for dirty taskId=${task.id} remote=$remote") + return + } task.applyRemote(remote, preferences.defaultPriority) - val existingSubtasks: List = taskDao.getChildren(task.id).let { caldavDao.getTasks(it) } - val remoteSubtaskIds = remote.checklistItems?.map { it.id } ?: emptyList() - existingSubtasks - .filter { it.remoteId?.isNotBlank() == true && !remoteSubtaskIds.contains(it.remoteId) } - .let { taskDeleter.delete(it.map { it.task }) } task.suppressSync() task.suppressRefresh() taskDao.save(task) vtodoCache.putVtodo(list, caldavTask, json.encodeToString(remote)) tagDao.applyTags(task, tagDataDao, getTags(remote.categories ?: emptyList())) - remote.checklistItems?.forEach { - // TODO: handle checklist items + remote.checklistItems?.let { + syncChecklist( + list = list, + parentId = task.id, + parentRemoteId = caldavTask.remoteId!!, + checklistItems = it, + ) } caldavTask.etag = remote.etag - if (!dirty) { - caldavTask.lastSync = task.modificationDate - } + caldavTask.lastSync = task.modificationDate if (caldavTask.id == Task.NO_ID) { caldavDao.insert(caldavTask) Timber.d("NEW $caldavTask") @@ -381,6 +422,61 @@ class MicrosoftSynchronizer @Inject constructor( } } + private suspend fun syncChecklist( + list: CaldavCalendar, + parentId: Long, + parentRemoteId: String, + checklistItems: List, + ) { + val existingSubtasks: List = taskDao.getChildren(parentId).let { caldavDao.getTasks(it) } + val remoteSubtaskIds = checklistItems.map { it.id } + existingSubtasks + .filter { it.remoteId?.isNotBlank() == true && !remoteSubtaskIds.contains(it.remoteId) } + .let { taskDeleter.delete(it.map { it.task }) } + checklistItems.forEachIndexed { index, item -> + val existing = caldavDao.getTaskByRemoteId(list.uuid!!, item.id!!) + val task = existing?.task?.let { taskDao.fetch(it) } + ?: taskCreator.createWithValues("").apply { + taskDao.createNew(this) + } + val caldavTask = + existing + ?.copy(task = task.id) + ?: CaldavTask( + task = task.id, + calendar = list.uuid, + remoteId = item.id, + remoteParent = parentRemoteId, + obj = "${item.id}.json" + ) + val dirty = existing != null && task.modificationDate > existing.lastSync + if (dirty) { + // TODO: merge with vtodo cached value, similar to iCalendarMerge.kt + task.order = index.toLong() + task.parent = parentId + } else { + task.applySubtask( + index = index, + parent = parentId, + checklistItem = item, + ) + } + task.suppressSync() + task.suppressRefresh() + taskDao.save(task) + if (!dirty) { + caldavTask.lastSync = task.modificationDate + } + if (caldavTask.id == Task.NO_ID) { + caldavDao.insert(caldavTask) + Timber.d("NEW $caldavTask") + } else { + caldavDao.update(caldavTask) + Timber.d("UPDATE $caldavTask") + } + } + } + private suspend fun getTags(categories: List): List { if (categories.isEmpty()) { return emptyList() diff --git a/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt b/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt index f3a821195..71343e7f8 100644 --- a/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt +++ b/app/src/main/java/org/tasks/sync/microsoft/Tasks.kt @@ -1,5 +1,7 @@ package org.tasks.sync.microsoft +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.tasks.data.Redacted @@ -11,13 +13,13 @@ data class Tasks( @SerialName("@odata.deltaLink") val nextDelta: String? = null, ) { @Serializable - data class Task( + data class Task @OptIn(ExperimentalSerializationApi::class) constructor( @SerialName("@odata.etag") val etag: String? = null, val id: String? = null, @Redacted val title: String? = null, val body: Body? = null, - val importance: Importance = Importance.low, - val status: Status = Status.notStarted, + @EncodeDefault val importance: Importance = Importance.low, + @EncodeDefault val status: Status = Status.notStarted, val categories: List? = null, val isReminderOn: Boolean = false, val createdDateTime: String? = null, @@ -100,9 +102,9 @@ data class Tasks( @Serializable data class ChecklistItem( - val id: String, + val id: String? = null, val displayName: String, - val createdDateTime: String, + val createdDateTime: String? = null, val isChecked: Boolean, val checkedDateTime: String? = null, ) diff --git a/data/src/commonMain/kotlin/org/tasks/data/dao/TaskDao.kt b/data/src/commonMain/kotlin/org/tasks/data/dao/TaskDao.kt index 7a9e637c7..62067773f 100644 --- a/data/src/commonMain/kotlin/org/tasks/data/dao/TaskDao.kt +++ b/data/src/commonMain/kotlin/org/tasks/data/dao/TaskDao.kt @@ -88,7 +88,8 @@ FROM ( WHERE caldav_tasks.cd_calendar = :calendar AND cd_deleted = 0 AND (tasks.modified > caldav_tasks.cd_last_sync OR caldav_tasks.cd_last_sync = 0) - ORDER BY created""") + ORDER BY created + """) abstract suspend fun getCaldavTasksToPush(calendar: String): List // --- SQL clause generators